Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a345e70e5a |
@@ -16,7 +16,7 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
|
||||
- Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual
|
||||
- Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel
|
||||
- Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray
|
||||
- Contas de revendedor (reseller) com cota de usuários e escopo próprio
|
||||
- Revendedores hierárquicos com sub-revendas, planos por validade/slots ou créditos, auditoria e escopo próprio
|
||||
- Gerenciamento multi-servidor (master/slave) direto pelo painel
|
||||
- API HTTP completa para bots/automações (ver **HTTP API Reference**)
|
||||
- API pública `/check` para consultar usuário ou UUID
|
||||
@@ -66,6 +66,23 @@ Para configurações XHTTP antigas, carregue a configuração visual e clique em
|
||||
|
||||
A confirmação dessa migração é exibida dentro do próprio painel. Se a gravação falhar, o inbound SSH temporário é removido do rascunho e o inbound antigo permanece intacto, permitindo tentar novamente após corrigir o erro exibido.
|
||||
|
||||
### Revendedores compatíveis com o painel PHP antigo
|
||||
|
||||
A área **Revendedores** mantém o fluxo mais importante do DraconCore PHP, com autorização refeita no servidor:
|
||||
|
||||
- cada revendedor gerencia apenas suas contas e seus sub-revendedores diretos;
|
||||
- o superadmin enxerga toda a hierarquia;
|
||||
- plano **Validade / slots** compartilha a cota entre a capacidade `max_connections` das contas SSH/Xray e os slots reservados aos sub-revendedores;
|
||||
- plano **Créditos** debita no cadastro e na renovação; o custo acompanha `max_connections`, com mínimo de uma conexão, e esse limite fica congelado após a criação;
|
||||
- contas de plano por crédito recebem 31 dias por cadastro ou renovação;
|
||||
- criação, edição, renovação, suspensão, reativação e exclusão de revendedores ficam registradas em auditoria;
|
||||
- suspensão bloqueia a árvore e remove os clientes Xray do runtime sem apagar seus metadados; a reativação restaura os clientes ainda válidos;
|
||||
- exclusão remove a árvore, suas contas SSH/Xray e devolve ao pai os créditos ainda não usados;
|
||||
- a cota considera todos os nós gerenciados, mesmo temporariamente desativados, para impedir liberação artificial de limite;
|
||||
- o estado de acesso é sincronizado com nós gerenciados sem copiar senha ou hash de login.
|
||||
|
||||
Revendedores existentes são migrados automaticamente como contas principais no modo **Validade / slots**. Não é necessário recriá-los. Por segurança, as funções antigas de revelar senha em texto puro e de alterar a sessão para “entrar como revendedor” não foram copiadas.
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Servidor Linux com `systemd`
|
||||
@@ -73,6 +90,8 @@ A confirmação dessa migração é exibida dentro do próprio painel. Se a grav
|
||||
- Gerenciador de pacotes `apt`, `yum` ou `dnf`
|
||||
- Portas liberadas no firewall/security group conforme a configuração usada
|
||||
|
||||
O instalador usa Go 1.25.12 e baixa as dependências fixadas no `go.mod`. As bibliotecas oficiais `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text` e `x/time` estão fixadas nas versões de segurança revisadas em 13/07/2026.
|
||||
|
||||
Distribuições alvo:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -92,6 +111,7 @@ sudo bash install.sh
|
||||
Durante a instalação, o script instala/configura:
|
||||
|
||||
- Go
|
||||
- verificação SHA-256 dos arquivos oficiais de Go e Xray antes da extração
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- Binário do DragonCoreSSH V40
|
||||
@@ -545,7 +565,8 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
|
||||
- Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher
|
||||
- Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations
|
||||
- Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray
|
||||
- Reseller accounts with a user quota and self-scoped access
|
||||
- Full reseller workflow compatible with the useful parts of the legacy PHP panel: direct-child hierarchy, validity/slot and credit plans, weighted SSH/Xray connection quotas, renew, suspend/reactivate, delete, and audit history
|
||||
- Existing reseller-owned SSH/Xray accounts with a legacy zero connection limit are migrated to one slot automatically; they do not need to be recreated
|
||||
- Multi-server (master/slave) management directly from the panel
|
||||
- Full HTTP API for bots/automations (see **HTTP API Reference**)
|
||||
- Public `/check` API for checking username or UUID
|
||||
@@ -602,6 +623,8 @@ The migration confirmation is rendered inside the panel. If saving fails, the te
|
||||
- `apt`, `yum`, or `dnf` package manager
|
||||
- Required ports opened in the firewall/security group
|
||||
|
||||
The installer uses Go 1.25.12 and downloads the versions pinned in `go.mod`. The official `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text`, and `x/time` modules are pinned to the security-reviewed versions current on 2026-07-13.
|
||||
|
||||
Target distributions:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -621,6 +644,7 @@ sudo bash install.sh
|
||||
During installation, the script installs/configures:
|
||||
|
||||
- Go
|
||||
- SHA-256 verification of the official Go and Xray archives before extraction
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- DragonCoreSSH V40 binary
|
||||
@@ -1112,7 +1136,7 @@ curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN"
|
||||
- No body. Deletes the session for the supplied `X-Session-Token`. Returns `200` (empty).
|
||||
|
||||
#### `GET /api/auth/me` — session
|
||||
- `200`: `{ "username": string, "role": string }`. If the role is `reseller`, it also includes `max_users` (int), `used_users` (int, combined SSH+Xray), `used_ssh_users` (int), `used_xray_users` (int), `expires_at` (string RFC3339 or null), `is_active` (bool).
|
||||
- `200`: `{ "username": string, "role": string }`. Reseller responses also include `max_users`, weighted `used_users`, SSH/Xray account counts across managed nodes, `parent_username`, `quota_mode`, `credit_balance`, child allocation/count, expiry, and direct/effective active status.
|
||||
|
||||
---
|
||||
|
||||
@@ -1180,19 +1204,35 @@ Creates or updates (upsert) an SSH user.
|
||||
|
||||
---
|
||||
|
||||
### Resellers (superadmin only)
|
||||
### Resellers (authenticated; hierarchy scoped)
|
||||
|
||||
#### `GET /api/resellers` — superadmin
|
||||
- `200`: array of `{ "id": int, "username": string, "role": string, "max_users": int, "used_users": int, "used_ssh_users": int, "used_xray_users": int, "expires_at": string/null, "is_active": bool, "created_at": string }`.
|
||||
Superadmins manage every reseller. A reseller sees and manages only its direct children; it cannot skip a hierarchy level. Child plans inherit the parent's `slots` or `credits` mode.
|
||||
|
||||
#### `POST /api/resellers/create` — superadmin
|
||||
Creates or updates a reseller (upsert by username).
|
||||
- Body: `username` (string, required); `password` (string, optional — required only when creating; if given on an existing account it is changed); `max_users` (int); `expires_at` (string, optional RFC3339; empty clears expiry); `is_active` (bool).
|
||||
- `201 Created` (empty). Errors: `400 username required`, `400 password required for new account`, `400 invalid expires_at (RFC3339 required)`; `500 db error`.
|
||||
#### `GET /api/resellers`
|
||||
- `200`: direct-child array with hierarchy, plan, weighted quota, account-count breakdown, contact, price, expiry, and effective status fields: `{ "id", "username", "parent_username", "quota_mode", "max_users", "credit_balance", "used_users", "used_ssh_users", "used_xray_users", "child_allocation", "child_count", "available", "usage_incomplete", "whatsapp", "monthly_price_cents", "expires_at", "is_active", "effective_active", "created_at" }`.
|
||||
- `used_users` is weighted by each SSH/Xray account's connection limit and includes every configured managed node. If a node cannot be verified, `usage_incomplete` is true and provisioning remains fail-closed.
|
||||
|
||||
#### `DELETE /api/resellers/delete` — superadmin
|
||||
- Query: `username` (string, required). Also disconnects/removes the reseller's owned SSH users and Xray clients.
|
||||
- `204 No Content`. Errors: `400 username required`; `500 db error`.
|
||||
#### `POST /api/resellers/create`
|
||||
Creates or edits a reseller.
|
||||
- Body: `username` (required); `password` (required only on create); `parent_username`; `quota_mode` (`slots` or `credits`); `max_users`; `credits`; `expires_at` (RFC3339 for slot plans); `is_active`; `whatsapp`; `monthly_price_cents`.
|
||||
- For reseller callers, `parent_username` and `quota_mode` are forced to the signed-in parent. Parent and plan mode are immutable after creation.
|
||||
- Credit transfers are atomic and audited. Slot limits cannot be reduced below direct account use plus reserved child allocation.
|
||||
- `201`: `{ "username": string, "created": bool }`.
|
||||
|
||||
#### `POST /api/resellers/action`
|
||||
- Body: `username`, `action` (`renew`, `suspend`, `reactivate`, or superadmin-only `pull`), and optional `days`.
|
||||
- `renew` extends a validity reseller from the later of now/current expiry. `suspend` and `reactivate` apply to the full descendant tree and owned SSH/Xray access on managed nodes. `pull` safely attaches a nested reseller directly to the main panel without duplicating transferred credits.
|
||||
- `200`: `{ "ok": true, "runtime_warning": string }`.
|
||||
|
||||
#### `DELETE /api/resellers/delete`
|
||||
- Query: `username` (required). Suspends the subtree first, then removes all descendant reseller records and their owned SSH/Xray accounts locally and from every configured managed node. Unused descendant credit balances are returned once to the direct credit parent.
|
||||
- `204 No Content`.
|
||||
|
||||
#### `GET /api/resellers/audit`
|
||||
- Returns the latest 200 lifecycle/credit events. Resellers receive only their own and direct-child activity.
|
||||
|
||||
#### `POST /api/resellers/runtime` — superadmin/internal node synchronization
|
||||
- Password-free master-to-node hierarchy/status synchronization used for managed-server suspension, expiry, reactivation, and cleanup. Login passwords and password hashes are never replicated.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type accountRenewPayload struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Days int `json:"days,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
func renewalDays(owner string, requested int) int {
|
||||
if u, ok := adminUsers.get(owner); ok && normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
||||
return 31
|
||||
}
|
||||
if requested == 0 {
|
||||
return 30
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func renewalExpiry(existing *time.Time, days int) time.Time {
|
||||
base := time.Now()
|
||||
if existing != nil && existing.After(base) {
|
||||
base = *existing
|
||||
}
|
||||
return base.AddDate(0, 0, days)
|
||||
}
|
||||
|
||||
func jsonInt(value interface{}) int {
|
||||
switch value := value.(type) {
|
||||
case int:
|
||||
return value
|
||||
case int64:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
case json.Number:
|
||||
result, _ := strconv.Atoi(value.String())
|
||||
return result
|
||||
default:
|
||||
result, _ := strconv.ParseFloat(fmt.Sprint(value), 64)
|
||||
return int(result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewSSHUser(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_connections"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew SSH account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := userMgr.Get(p.Username)
|
||||
if !ok {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
cfg := state.Cfg
|
||||
existingExpiry := state.ExpiresAt
|
||||
state.mu.Unlock()
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if cfg.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(cfg.MaxConnections)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-ssh:"+p.Username)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(existingExpiry, p.Days)
|
||||
cfg.ExpiresAt = next.UTC().Format(time.RFC3339)
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, cfg.OwnerUsername, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
userMgr.DisconnectUser(p.Username)
|
||||
reloadUsersFromDB(ctx, store)
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, cfg.OwnerUsername, "renewed SSH account",
|
||||
fmt.Sprintf("account=%s days=%d", p.Username, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewXrayClient(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.UUID = strings.TrimSpace(p.UUID)
|
||||
if _, err := parseUUID(p.UUID); err != nil {
|
||||
http.Error(w, "invalid UUID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteXrayClientInfo(ctx, ms, p.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_conns"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew Xray account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := store.GetXrayClientMeta(ctx, p.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if meta.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(meta.MaxConns)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-xray:"+p.UUID)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(meta.ExpiresAt, p.Days)
|
||||
meta.ExpiresAt = &next
|
||||
if err := store.UpsertXrayClientMeta(ctx, *meta); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, meta.OwnerUsername, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runtimeWarning := ""
|
||||
if meta.OwnerUsername != "" {
|
||||
if runtimeErr := restoreOwnerXrayClients(ctx, store, meta.OwnerUsername); runtimeErr != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, runtimeErr)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
} else if err := ensureXrayClientPresent(*meta); err != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, err)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, meta.OwnerUsername, "renewed Xray account",
|
||||
fmt.Sprintf("uuid=%s days=%d", p.UUID, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next, "runtime_warning": runtimeWarning})
|
||||
}
|
||||
}
|
||||
|
||||
func ensureXrayClientPresent(meta XrayClientMeta) error {
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Tag != meta.InboundTag {
|
||||
continue
|
||||
}
|
||||
for _, client := range inbound.Clients {
|
||||
if client.UUID == meta.UUID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
email := strings.TrimSpace(meta.Email)
|
||||
if email == "" {
|
||||
email = meta.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(meta.InboundTag, meta.UUID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inbound %s no longer exists", meta.InboundTag)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var accountUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$`)
|
||||
|
||||
func validateAccountUsername(username string) error {
|
||||
if !accountUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, @, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAccountControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0
|
||||
}
|
||||
|
||||
func validateOptionalAccountExpiry(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, raw); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid expiration date")
|
||||
}
|
||||
|
||||
func validateSSHUserPayload(p *UserPayload) error {
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
p.OwnerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
p.TOTPSecret = normalizeBase32Secret(p.TOTPSecret)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Password != nil && len(*p.Password) > 4096 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
if p.MaxConnections < 0 || p.MaxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
if p.LimitUpMbps < 0 || p.LimitUpMbps > 100000 || p.LimitDownMbps < 0 || p.LimitDownMbps > 100000 {
|
||||
return fmt.Errorf("speed limits must be between 0 and 100000 Mbps")
|
||||
}
|
||||
if err := validateOptionalAccountExpiry(p.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(p.OwnerUsername); err != nil {
|
||||
return fmt.Errorf("invalid owner username")
|
||||
}
|
||||
}
|
||||
if len(p.ServerID) > 32 || hasAccountControlCharacters(p.ServerID) {
|
||||
return fmt.Errorf("invalid server id")
|
||||
}
|
||||
if p.TOTPSecret != "" {
|
||||
if len(p.TOTPSecret) > 256 {
|
||||
return fmt.Errorf("TOTP secret is too long")
|
||||
}
|
||||
if _, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(p.TOTPSecret); err != nil {
|
||||
return fmt.Errorf("invalid TOTP secret")
|
||||
}
|
||||
}
|
||||
if p.TOTPPeriod != 0 && (p.TOTPPeriod < 15 || p.TOTPPeriod > 300) {
|
||||
return fmt.Errorf("TOTP period must be between 15 and 300 seconds")
|
||||
}
|
||||
if p.TOTPWindow < 0 || p.TOTPWindow > 10 {
|
||||
return fmt.Errorf("TOTP window must be between 0 and 10")
|
||||
}
|
||||
if p.TOTPDigits != 0 && (p.TOTPDigits < 6 || p.TOTPDigits > 8) {
|
||||
return fmt.Errorf("TOTP digits must be between 6 and 8")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateXrayClientFields(uuid, inboundTag, email, name, expiresAt string, maxConnections int, requireInbound bool) error {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
if _, err := parseUUID(uuid); err != nil {
|
||||
return fmt.Errorf("invalid UUID")
|
||||
}
|
||||
if requireInbound && strings.TrimSpace(inboundTag) == "" {
|
||||
return fmt.Errorf("inbound_tag required")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"inbound_tag": inboundTag,
|
||||
"email": email,
|
||||
"name": name,
|
||||
} {
|
||||
limit := 256
|
||||
if field == "inbound_tag" {
|
||||
limit = 128
|
||||
}
|
||||
if len(value) > limit || hasAccountControlCharacters(value) {
|
||||
return fmt.Errorf("invalid %s", field)
|
||||
}
|
||||
}
|
||||
if maxConnections < 0 || maxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
return validateOptionalAccountExpiry(expiresAt)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateSSHUserPayloadBounds(t *testing.T) {
|
||||
valid := &UserPayload{
|
||||
Username: "client-01",
|
||||
MaxConnections: 2,
|
||||
TOTPPeriod: 60,
|
||||
TOTPWindow: 1,
|
||||
TOTPDigits: 6,
|
||||
}
|
||||
if err := validateSSHUserPayload(valid); err != nil {
|
||||
t.Fatalf("valid SSH payload rejected: %v", err)
|
||||
}
|
||||
|
||||
invalid := *valid
|
||||
invalid.MaxConnections = -1
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("negative max_connections was accepted")
|
||||
}
|
||||
|
||||
invalid = *valid
|
||||
invalid.TOTPSecret = "not base32!"
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("invalid TOTP secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateXrayClientFields(t *testing.T) {
|
||||
const id = "d9428888-122b-11e1-b85c-61cd3cbb3210"
|
||||
if err := validateXrayClientFields(id, "vless-in", "client@example.test", "Client", "", 2, true); err != nil {
|
||||
t.Fatalf("valid Xray client rejected: %v", err)
|
||||
}
|
||||
if err := validateXrayClientFields("not-a-uuid", "vless-in", "", "", "", 1, true); err == nil {
|
||||
t.Fatal("invalid Xray UUID was accepted")
|
||||
}
|
||||
if err := validateXrayClientFields(id, "vless-in", "", "", "", 1001, true); err == nil {
|
||||
t.Fatal("excessive Xray connection limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditAccountConnectionLimitIsImmutable(t *testing.T) {
|
||||
username := "credit-limit-test"
|
||||
adminUsers.set(&AdminUser{Username: username, Role: RoleReseller, QuotaMode: QuotaModeCredit, IsActive: true})
|
||||
defer adminUsers.delete(username)
|
||||
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 3); err != errCreditLimitImmutable {
|
||||
t.Fatalf("credit limit change error = %v, want %v", err, errCreditLimitImmutable)
|
||||
}
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 2); err != nil {
|
||||
t.Fatalf("unchanged credit limit rejected: %v", err)
|
||||
}
|
||||
if _, _, err := authorizeResellerProvision(nil, nil, username, "ssh:test", 0); err != errResellerConnLimit {
|
||||
t.Fatalf("zero-connection credit account error = %v, want %v", err, errResellerConnLimit)
|
||||
}
|
||||
}
|
||||
@@ -743,6 +743,7 @@ select:disabled {
|
||||
.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;}
|
||||
|
||||
.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);}
|
||||
.reseller-row-actions{min-width:265px;flex-wrap:wrap}.reseller-audit-table{min-width:820px}.reseller-audit-table td:nth-child(1){white-space:nowrap}.reseller-audit-table td:nth-child(4){color:#ffd36d;font-weight:800}.reseller-audit-table td:last-child{max-width:360px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.68rem;white-space:normal}
|
||||
.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);}
|
||||
|
||||
.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;}
|
||||
|
||||
@@ -5,6 +5,8 @@ if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
let currentRole = "";
|
||||
let currentUser = "";
|
||||
let currentQuotaMode = "slots";
|
||||
let currentCreditBalance = 0;
|
||||
let statsTimer = null, usersTimer = null, xrayTimer = null;
|
||||
let tlsForwardersState = [];
|
||||
let managedTlsForwardersState = [];
|
||||
@@ -369,8 +371,13 @@ const resellerFormTitle = document.getElementById("resellerFormTitle");
|
||||
const resellerForm = document.getElementById("resellerForm");
|
||||
const rUsername = document.getElementById("rUsername");
|
||||
const rPassword = document.getElementById("rPassword");
|
||||
const rParent = document.getElementById("rParent");
|
||||
const rQuotaMode = document.getElementById("rQuotaMode");
|
||||
const rMaxUsers = document.getElementById("rMaxUsers");
|
||||
const rCredits = document.getElementById("rCredits");
|
||||
const rExpires = document.getElementById("rExpires");
|
||||
const rWhatsApp = document.getElementById("rWhatsApp");
|
||||
const rMonthlyPrice = document.getElementById("rMonthlyPrice");
|
||||
const rActive = document.getElementById("rActive");
|
||||
|
||||
// Managed servers
|
||||
@@ -391,7 +398,6 @@ const serverFormStatus = document.getElementById("serverFormStatus");
|
||||
const serversListView = document.getElementById("serversListView");
|
||||
const serverConfigSubpage = document.getElementById("serverConfigSubpage");
|
||||
const cfgServerName = document.getElementById("cfgServerName");
|
||||
const managedConfigEditor = document.getElementById("managedConfigEditor");
|
||||
const managedConfigStatus = document.getElementById("managedConfigStatus");
|
||||
const serversStatusGrid = document.getElementById("serversStatusGrid");
|
||||
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
|
||||
@@ -534,7 +540,9 @@ async function api(path, opts = {}) {
|
||||
"X-Session-Token": sessionToken,
|
||||
});
|
||||
const res = await fetch(path, o);
|
||||
if (res.status === 401 || res.status === 403) throw new Error("auth");
|
||||
// A 403 is an in-session permission or quota error; only 401 means the
|
||||
// session is no longer valid and should return to the login screen.
|
||||
if (res.status === 401) throw new Error("auth");
|
||||
return res;
|
||||
}
|
||||
function withServerParam(path, serverID) {
|
||||
|
||||
@@ -153,7 +153,7 @@ function selectTab(tab) {
|
||||
if (tab === "stats" && currentRole === "superadmin") loadStats();
|
||||
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
|
||||
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
|
||||
if (tab === "resellers" && currentRole === "superadmin") loadResellers();
|
||||
if (tab === "resellers") loadResellers();
|
||||
if (tab === "servers" && currentRole === "superadmin") loadServers();
|
||||
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
|
||||
}
|
||||
@@ -218,6 +218,13 @@ function clearTimers() {
|
||||
}
|
||||
|
||||
function initAfterLogin() {
|
||||
if (currentRole === "superadmin") {
|
||||
currentQuotaMode = "slots";
|
||||
currentCreditBalance = 0;
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (input) { input.disabled = false; input.title = ""; }
|
||||
});
|
||||
}
|
||||
meUsername.textContent = currentUser;
|
||||
mainApp.classList.remove("role-superadmin", "role-reseller");
|
||||
mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller");
|
||||
@@ -276,13 +283,28 @@ async function loadMe() {
|
||||
const res = await api("/api/auth/me");
|
||||
const d = await res.json();
|
||||
dashboardCache.me = d;
|
||||
currentQuotaMode = d.quota_mode || "slots";
|
||||
currentCreditBalance = d.credit_balance || 0;
|
||||
const creditPlan = currentQuotaMode === "credits";
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (!input) return;
|
||||
input.disabled = creditPlan;
|
||||
input.title = creditPlan ? "Planos por crédito usam 31 dias e são renovados pelo botão +30d." : "";
|
||||
});
|
||||
const used = d.used_users ?? 0;
|
||||
const max = d.max_users || 0;
|
||||
rUsedMax.textContent = used + " / " + (max || "∞");
|
||||
rUsedMax.textContent = currentQuotaMode === "credits"
|
||||
? `${currentCreditBalance} créditos`
|
||||
: `${used + (d.child_allocation || 0)} / ${max || "∞"}`;
|
||||
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration");
|
||||
rStatus.textContent = d.is_active ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
|
||||
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
const effectiveActive = d.effective_active ?? d.is_active;
|
||||
rStatus.textContent = effectiveActive ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = effectiveActive ? "var(--success)" : "var(--danger)";
|
||||
if (currentQuotaMode === "credits") {
|
||||
updateCreditQuotaCard(currentCreditBalance, d.used_ssh_users || 0, d.used_xray_users || 0, d.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard(used + (d.child_allocation || 0), max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
}
|
||||
renderDashboardCounters();
|
||||
} catch {}
|
||||
}
|
||||
@@ -333,6 +355,24 @@ function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) {
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed});
|
||||
}
|
||||
|
||||
function updateCreditQuotaCard(balance, sshUsed = 0, xrayUsed = 0, childCount = 0) {
|
||||
if (!dashQuotaText) return;
|
||||
dashQuotaChip.textContent = `${balance} Cr`;
|
||||
dashQuotaChip.className = `chip ${balance <= 0 ? "red" : balance <= 5 ? "warn" : "green"}`;
|
||||
dashQuotaText.textContent = `${balance} créditos disponíveis`;
|
||||
dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed} · ${childCount} sub-revendas`;
|
||||
dashQuotaBar.style.width = "100%";
|
||||
if (dashQuotaRemaining) {
|
||||
dashQuotaRemaining.textContent = String(balance);
|
||||
setQuotaTone(dashQuotaRemaining, balance <= 0 ? "quota-danger" : balance <= 5 ? "quota-warn" : "quota-good");
|
||||
}
|
||||
if (dashQuotaSummaryText) dashQuotaSummaryText.textContent = `${balance} créditos no saldo`;
|
||||
if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = balance > 0 ? "100%" : "0%";
|
||||
if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${balance} Cr`;
|
||||
if (xrayResellerQuotaRemaining) xrayResellerQuotaRemaining.textContent = String(balance);
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
|
||||
}
|
||||
|
||||
function flattenXrayClients(inbounds = []) {
|
||||
return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c)));
|
||||
}
|
||||
@@ -391,7 +431,11 @@ function renderDashboardCounters() {
|
||||
|
||||
const me = dashboardCache.me;
|
||||
if (currentRole === "reseller" && me) {
|
||||
updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
if ((me.quota_mode || "slots") === "credits") {
|
||||
updateCreditQuotaCard(me.credit_balance || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length, me.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard((me.used_users ?? total) + (me.child_allocation || 0), me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// ─── SSH Users ────────────────────────────────────────────────────────────────
|
||||
let editingSSHUser = "";
|
||||
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
|
||||
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
|
||||
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
|
||||
@@ -7,8 +8,13 @@ cancelUserBtn.addEventListener("click", () => {
|
||||
setWorkspaceSection("ssh", "users");
|
||||
});
|
||||
function prepareNewSSHUser() {
|
||||
editingSSHUser = "";
|
||||
userForm.reset();
|
||||
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
|
||||
fMaxConn.disabled = false;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.value = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Create user");
|
||||
@@ -87,6 +93,10 @@ function renderUsers(users) {
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const tdA = document.createElement("td");
|
||||
const renewBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:"+30d",
|
||||
onclick: () => renewSSHUser(u),
|
||||
});
|
||||
const editBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillUserForm(u),
|
||||
@@ -96,7 +106,8 @@ function renderUsers(users) {
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteUser(u.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
tdA.className = "bot-row-actions";
|
||||
tdA.append(renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(tdA);
|
||||
usersBody.appendChild(tr);
|
||||
});
|
||||
@@ -113,6 +124,7 @@ function renderUsers(users) {
|
||||
}
|
||||
|
||||
function fillUserForm(u) {
|
||||
editingSSHUser = u.username || "";
|
||||
setWorkspaceSection("ssh", "create");
|
||||
fUsername.value = u.username || "";
|
||||
fPassword.value = "";
|
||||
@@ -122,6 +134,10 @@ function fillUserForm(u) {
|
||||
fTotpDigits.value = u.totp_digits || 6;
|
||||
fAllowStatic.checked = !!u.allow_static_password;
|
||||
fMaxConn.value = u.max_connections || "";
|
||||
const creditLocked = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
fMaxConn.disabled = creditLocked;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = creditLocked ? "Em planos por crédito, altere o limite criando uma nova conta." : "";
|
||||
fUp.value = u.limit_mbps_up || "";
|
||||
fDown.value = u.limit_mbps_down || "";
|
||||
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
|
||||
@@ -145,7 +161,9 @@ userForm.addEventListener("submit", async e => {
|
||||
totp_digits: parseInt(fTotpDigits.value||"6",10),
|
||||
allow_static_password: !!fAllowStatic.checked,
|
||||
max_connections: parseInt(fMaxConn.value||"0",10),
|
||||
expires_at: isoFromLocal(fExpires.value),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits" && editingSSHUser
|
||||
? ""
|
||||
: isoFromLocal(fExpires.value),
|
||||
limit_mbps_up: parseInt(fUp.value||"0",10),
|
||||
limit_mbps_down: parseInt(fDown.value||"0",10),
|
||||
server_id: selectedSSHServer(),
|
||||
@@ -187,3 +205,29 @@ async function deleteUser(username) {
|
||||
else userStatus.textContent = t("Error deleting.");
|
||||
}
|
||||
}
|
||||
|
||||
async function renewSSHUser(user) {
|
||||
const creditCost = Math.max(1, Number(user.max_connections || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar SSH", message:`Renovar “${user.username}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
userStatus.textContent = `Renovando ${user.username}…`;
|
||||
try {
|
||||
const res = await api("/api/users/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ username:user.username, days:30, server_id:selectedSSHServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${user.username} renovado.`, "success", "SSH / SlowDNS");
|
||||
await loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar SSH");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,12 +244,17 @@ function renderInbounds(inbounds, options = {}) {
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = t("Edit");
|
||||
editBtn.onclick = () => openEditXrayClient(ib.tag, c);
|
||||
const renewBtn = document.createElement("button");
|
||||
renewBtn.className = "btn btn-ghost btn-sm";
|
||||
renewBtn.style.marginLeft = "4px";
|
||||
renewBtn.textContent = "+30d";
|
||||
renewBtn.onclick = () => renewXrayClient(c);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = t("Del");
|
||||
delBtn.onclick = () => removeClient(ib.tag, c.id);
|
||||
actTd.append(copyBtn, editBtn, delBtn);
|
||||
actTd.append(copyBtn, renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(actTd);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -324,7 +329,10 @@ function prepareXrayClientCreator(preferredTag = "") {
|
||||
const uuid = document.getElementById("xCreateUUID");
|
||||
if (uuid) uuid.value = genUUID();
|
||||
const maxConns = document.getElementById("xCreateMaxConns");
|
||||
if (maxConns) maxConns.value = "0";
|
||||
if (maxConns) {
|
||||
maxConns.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxConns.value = currentRole === "reseller" ? "1" : "0";
|
||||
}
|
||||
const status = document.getElementById("xCreateClientStatus");
|
||||
if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
|
||||
updateXrayCreatorInboundLabel();
|
||||
@@ -405,6 +413,34 @@ async function removeClient(tag, uuid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renewXrayClient(client) {
|
||||
const creditCost = Math.max(1, Number(client.max_conns || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar Xray", message:`Renovar “${client.name || client.email || client.id.slice(0, 8)}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
xStatus.textContent = "Renovando cliente Xray…";
|
||||
try {
|
||||
const res = await api("/api/xray/clients/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", "Renovar Xray");
|
||||
else showPanelToast("Cliente Xray renovado.", "success", "Xray");
|
||||
await loadInbounds({ force:true });
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar Xray");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadXrayCfg() {
|
||||
if (!xCfgEditor) return;
|
||||
const target = selectedXrayServerLabel();
|
||||
|
||||
+226
-56
@@ -1,127 +1,297 @@
|
||||
// ─── Resellers ────────────────────────────────────────────────────────────────
|
||||
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn").addEventListener("click", () => navigateWorkspaceSection("resellers", "create"));
|
||||
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
|
||||
// ─── Hierarchical resellers ───────────────────────────────────────────────────
|
||||
let resellersCache = [];
|
||||
let editingReseller = "";
|
||||
|
||||
document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
navigateWorkspaceSection("resellers", "create");
|
||||
});
|
||||
document.getElementById("cancelResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
});
|
||||
document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit);
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
document.querySelectorAll("[data-workspace='resellers'][data-workspace-section='audit']").forEach(el => {
|
||||
el.addEventListener("click", loadResellerAudit);
|
||||
});
|
||||
document.querySelector("[data-workspace-select='resellers']")?.addEventListener("change", e => {
|
||||
if (e.target.value === "audit") loadResellerAudit();
|
||||
});
|
||||
rQuotaMode?.addEventListener("change", toggleResellerPlanFields);
|
||||
|
||||
function toggleResellerPlanFields() {
|
||||
const credit = rQuotaMode.value === "credits";
|
||||
document.getElementById("rSlotsField")?.classList.toggle("hidden", credit);
|
||||
document.getElementById("rCreditsField")?.classList.toggle("hidden", !credit);
|
||||
document.getElementById("rExpiresField")?.classList.toggle("hidden", credit);
|
||||
if (credit) rExpires.value = "";
|
||||
}
|
||||
|
||||
function prepareNewReseller() {
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
editingReseller = "";
|
||||
resellerFormTitle.textContent = t("Create Reseller");
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Create reseller");
|
||||
resellerForm.reset();
|
||||
rUsername.disabled = false;
|
||||
rParent.disabled = false;
|
||||
rQuotaMode.disabled = currentRole === "reseller";
|
||||
rQuotaMode.value = currentRole === "reseller" ? currentQuotaMode : "slots";
|
||||
rMaxUsers.min = currentRole === "reseller" ? "1" : "0";
|
||||
rMaxUsers.value = currentRole === "reseller" ? "1" : "30";
|
||||
rCredits.value = "1";
|
||||
rActive.checked = true;
|
||||
resellerStatus.textContent = "New reseller.";
|
||||
populateResellerParents();
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("New reseller.");
|
||||
requestAnimationFrame(() => rUsername.focus());
|
||||
}
|
||||
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
|
||||
async function loadResellers() {
|
||||
resellerStatus.textContent = "Loading…";
|
||||
resellerStatus.textContent = t("Loading…");
|
||||
setResellerLiveStatus("Carregando revendedores…", "is-loading");
|
||||
try {
|
||||
const res = await api("/api/resellers");
|
||||
const data = await res.json();
|
||||
renderResellers(data || []);
|
||||
resellerStatus.textContent = "Loaded.";
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellersCache = await res.json() || [];
|
||||
renderResellers(resellersCache);
|
||||
populateResellerParents();
|
||||
resellerStatus.textContent = t("Loaded.");
|
||||
setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok");
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error loading.";
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim();
|
||||
setResellerLiveStatus("Falha ao carregar revendedores", "is-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setResellerLiveStatus(message, tone) {
|
||||
const el = document.getElementById("resellerLiveStatus");
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.className = `workspace-live-status ${tone || ""}`.trim();
|
||||
}
|
||||
|
||||
function renderResellerMetrics(list) {
|
||||
const active = list.filter(r => r.effective_active).length;
|
||||
const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0);
|
||||
const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0);
|
||||
document.getElementById("resellerMetricTotal").textContent = String(list.length);
|
||||
document.getElementById("resellerMetricActive").textContent = String(active);
|
||||
document.getElementById("resellerMetricAllocated").textContent = String(allocated);
|
||||
document.getElementById("resellerMetricCredits").textContent = String(credits);
|
||||
}
|
||||
|
||||
function renderResellers(list) {
|
||||
resellerCountChip.textContent = list.length;
|
||||
renderResellerMetrics(list);
|
||||
resellersBody.innerHTML = "";
|
||||
if (!list.length) {
|
||||
resellersBody.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhum revendedor direto cadastrado.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
list.forEach(r => {
|
||||
const expired = r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const max = r.max_users || 0;
|
||||
const used = r.used_users || 0;
|
||||
const remaining = max ? Math.max(0, max - used) : "∞";
|
||||
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
|
||||
const expired = !!r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const effective = !!r.effective_active && !expired;
|
||||
const maxUsers = Number(r.max_users || 0);
|
||||
const directUsed = Number(r.used_users || 0);
|
||||
const childAllocation = Number(r.child_allocation || 0);
|
||||
const committed = directUsed + childAllocation;
|
||||
const remaining = maxUsers ? Math.max(0, maxUsers - committed) : "∞";
|
||||
const pct = maxUsers ? Math.min(100, Math.round((committed / maxUsers) * 100)) : 0;
|
||||
const isCredit = r.quota_mode === "credits";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHTML(r.username)}</td>
|
||||
<td>
|
||||
<strong>${used} / ${max || "∞"}</strong>
|
||||
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div>
|
||||
<div class="table-meter"><span style="width:${pct}%"></span></div>
|
||||
<div class="bot-primary-cell"><strong>${escapeHTML(r.username)}</strong>
|
||||
<small>${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""}</small>
|
||||
${r.whatsapp ? `<small>${escapeHTML(r.whatsapp)}</small>` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
|
||||
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
|
||||
<td>
|
||||
<strong>${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`}</strong>
|
||||
<div class="hint">${isCredit ? "31 dias por renovação" : `Disponível ${remaining} · capacidade usada ${directUsed} · reservado ${childAllocation}`} · SSH ${r.used_ssh_users || 0} contas · Xray ${r.used_xray_users || 0} contas</div>
|
||||
${isCredit ? "" : `<div class="table-meter"><span style="width:${pct}%"></span></div>`}
|
||||
</td>
|
||||
<td>${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
|
||||
<td><span class="${effective ? "badge-on" : "badge-off"}">${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"}</span></td>
|
||||
<td></td>`;
|
||||
const tdA = tr.lastElementChild;
|
||||
const editBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillResellerForm(r),
|
||||
});
|
||||
const delBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteReseller(r.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "bot-row-actions reseller-row-actions";
|
||||
actions.appendChild(resellerActionButton(t("Edit"), "btn btn-ghost btn-sm", () => fillResellerForm(r)));
|
||||
if (!isCredit) actions.appendChild(resellerActionButton("+30d", "btn btn-ghost btn-sm", () => runResellerAction(r, "renew")));
|
||||
if (currentRole === "superadmin" && r.parent_username) actions.appendChild(resellerActionButton("Puxar", "btn btn-ghost btn-sm", () => runResellerAction(r, "pull")));
|
||||
actions.appendChild(resellerActionButton(r.is_active ? "Suspender" : "Reativar", r.is_active ? "btn btn-warn btn-sm" : "btn btn-ghost btn-sm", () => runResellerAction(r, r.is_active ? "suspend" : "reactivate")));
|
||||
actions.appendChild(resellerActionButton(t("Del"), "btn btn-danger btn-sm", () => deleteReseller(r)));
|
||||
tr.lastElementChild.appendChild(actions);
|
||||
resellersBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function resellerActionButton(label, className, onclick) {
|
||||
return Object.assign(document.createElement("button"), { type: "button", className, textContent: label, onclick });
|
||||
}
|
||||
|
||||
function populateResellerParents() {
|
||||
if (!rParent) return;
|
||||
const selected = rParent.value;
|
||||
rParent.innerHTML = `<option value="">Principal / sem pai</option>`;
|
||||
resellersCache
|
||||
.filter(r => r.username !== editingReseller && r.effective_active)
|
||||
.forEach(r => {
|
||||
const option = document.createElement("option");
|
||||
option.value = r.username;
|
||||
option.textContent = `${r.username} · ${r.quota_mode === "credits" ? `${r.credit_balance || 0} Cr` : `${r.available < 0 ? "∞" : r.available} slots`}`;
|
||||
rParent.appendChild(option);
|
||||
});
|
||||
if ([...rParent.options].some(o => o.value === selected)) rParent.value = selected;
|
||||
}
|
||||
|
||||
function fillResellerForm(r) {
|
||||
editingReseller = r.username;
|
||||
setWorkspaceSection("resellers", "create");
|
||||
resellerFormTitle.textContent = `Edit: ${r.username}`;
|
||||
resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Edit reseller");
|
||||
rUsername.value = r.username;
|
||||
rUsername.disabled = true;
|
||||
rPassword.value = "";
|
||||
populateResellerParents();
|
||||
rParent.value = r.parent_username || "";
|
||||
rParent.disabled = true;
|
||||
rQuotaMode.value = r.quota_mode || "slots";
|
||||
rQuotaMode.disabled = true;
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rCredits.value = r.credit_balance || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rActive.checked = r.is_active;
|
||||
resellerStatus.textContent = `Editing ${r.username}.`;
|
||||
rWhatsApp.value = r.whatsapp || "";
|
||||
rMonthlyPrice.value = ((r.monthly_price_cents || 0) / 100).toFixed(2);
|
||||
rActive.checked = !!r.is_active;
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("Editing {name}.", {name: r.username});
|
||||
}
|
||||
|
||||
resellerForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("saveResellerBtn");
|
||||
btn.disabled = true;
|
||||
resellerStatus.textContent = "Saving…";
|
||||
resellerStatus.textContent = t("Saving…");
|
||||
const mode = rQuotaMode.value || "slots";
|
||||
const payload = {
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
parent_username: currentRole === "superadmin" ? rParent.value : undefined,
|
||||
quota_mode: mode,
|
||||
max_users: parseInt(rMaxUsers.value || "0", 10),
|
||||
expires_at: isoFromLocal(rExpires.value),
|
||||
credits: parseInt(rCredits.value || "0", 10),
|
||||
expires_at: mode === "slots" ? isoFromLocal(rExpires.value) : "",
|
||||
whatsapp: rWhatsApp.value.trim(),
|
||||
monthly_price_cents: Math.round(Math.max(0, parseFloat(rMonthlyPrice.value || "0")) * 100),
|
||||
is_active: rActive.checked,
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellerStatus.textContent = "Saved.";
|
||||
resellerForm.reset(); rActive.checked = true;
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
loadResellers();
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
|
||||
prepareNewReseller();
|
||||
await loadResellers();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error: "+e.message;
|
||||
} finally { btn.disabled = false; }
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error")}: ${e.message}`;
|
||||
showPanelToast(e.message, "error", t("Resellers"));
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteReseller(username) {
|
||||
async function runResellerAction(reseller, action) {
|
||||
const labels = { renew: "Renovar por 30 dias", suspend: "Suspender revendedor", reactivate: "Reativar revendedor", pull: "Puxar para o painel principal" };
|
||||
const descriptions = {
|
||||
renew: "A validade será estendida a partir da data atual ou da validade existente.",
|
||||
suspend: "A conta, seus descendentes e os acessos SSH/Xray ficarão bloqueados sem apagar os cadastros.",
|
||||
reactivate: "Os acessos preservados serão restaurados nos servidores disponíveis.",
|
||||
pull: "O revendedor deixará a revenda atual e passará a ser administrado diretamente pelo superadmin. Os créditos já transferidos não serão duplicados.",
|
||||
};
|
||||
const accepted = await panelConfirm({
|
||||
tone: action === "suspend" ? "danger" : "default",
|
||||
icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
|
||||
title: labels[action],
|
||||
message: `${labels[action]} “${reseller.username}”?`,
|
||||
detail: descriptions[action],
|
||||
confirmLabel: labels[action],
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = `${labels[action]}…`;
|
||||
try {
|
||||
const res = await api("/api/resellers/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: reseller.username, action, days: action === "renew" ? 30 : undefined }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", labels[action]);
|
||||
else showPanelToast(`${reseller.username}: operação concluída.`, "success", labels[action]);
|
||||
await loadResellers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", labels[action]);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReseller(reseller) {
|
||||
const accepted = await panelConfirm({
|
||||
tone: "danger", icon: "×", title: t("Delete reseller"),
|
||||
message:t("Delete reseller \"{name}\"?", {name:username}),
|
||||
detail:t("Their owned access will be removed and active SSH sessions will be disconnected."),
|
||||
message: t("Delete reseller \"{name}\"?", {name: reseller.username}),
|
||||
detail: `Serão removidos ${reseller.child_count || 0} sub-revendedores e todos os acessos SSH/Xray pertencentes à árvore. Esta ação não pode ser desfeita.`,
|
||||
confirmLabel: t("Delete reseller"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = `Deleting ${username}…`;
|
||||
resellerStatus.textContent = t("Deleting {name}…", {name: reseller.username});
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error("failed");
|
||||
resellerStatus.textContent = "Deleted.";
|
||||
loadResellers();
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(reseller.username)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${reseller.username} removido.`, "success", t("Resellers"));
|
||||
await loadResellers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error deleting.";
|
||||
else showPanelToast(e.message || "Falha ao excluir.", "error", t("Delete reseller"));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResellerAudit() {
|
||||
const body = document.getElementById("resellerAuditBody");
|
||||
if (!body) return;
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Carregando atividade…</td></tr>`;
|
||||
try {
|
||||
const res = await api("/api/resellers/audit");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const rows = await res.json() || [];
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhuma atividade registrada.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
rows.forEach(item => {
|
||||
const tr = document.createElement("tr");
|
||||
[fmtDate(item.created_at), item.actor_username, item.target_username, item.action, item.details || "—"].forEach(value => {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Falha ao carregar a atividade.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ function openEditXrayClient(tag, client) {
|
||||
document.getElementById("editXrayName").value = client.name || "";
|
||||
document.getElementById("editXrayEmail").value = client.email || "";
|
||||
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
||||
document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
|
||||
const maxInput = document.getElementById("editXrayMaxConns");
|
||||
maxInput.value = client.max_conns || 0;
|
||||
maxInput.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxInput.disabled = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
maxInput.title = maxInput.disabled ? "Em planos por crédito, o limite de conexões fica fixo." : "";
|
||||
document.getElementById("editXrayClientStatus").textContent = "";
|
||||
document.getElementById("editXrayClientPanel").classList.remove("hidden");
|
||||
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
@@ -24,7 +28,9 @@ async function saveEditXrayClient() {
|
||||
uuid: editingXrayClientId,
|
||||
name: document.getElementById("editXrayName").value.trim(),
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? ""
|
||||
: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
|
||||
+44
-22
@@ -16,7 +16,7 @@
|
||||
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713sections5"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713reseller10"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -51,7 +51,7 @@
|
||||
<button class="tab-btn" data-tab="ssh"><span class="nav-icon">👥</span><span>SSH / SlowDNS</span></button>
|
||||
<button class="tab-btn" data-tab="xray"><span class="nav-icon">◇</span><span>Xray Users</span></button>
|
||||
<div class="nav-group-label superadmin-only hidden">Administração</div>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon">▣</span><span>Infraestrutura</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon">☰</span><span>Logs</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="bot"><span class="nav-icon">🤖</span><span>Bot / Vendas</span></button>
|
||||
@@ -656,16 +656,26 @@
|
||||
</section>
|
||||
</div><!-- /tab-xray -->
|
||||
|
||||
<!-- ═══════════ Resellers Tab (superadmin only) ═══════════ -->
|
||||
<!-- ═══════════ Hierarchical reseller management ═══════════ -->
|
||||
<div class="tab-pane" id="tab-resellers">
|
||||
<section class="page-hero" data-tone="amber"><div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle cotas, validade e acesso dos parceiros em um só lugar.</p></div></section>
|
||||
<section class="page-hero status-hero" data-tone="amber">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle hierarquia, créditos, cotas, validade e acesso dos parceiros em um só lugar.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status is-loading" id="resellerLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="resellerHeroReloadBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">◎</span><div><small>Revendedores</small><strong id="resellerMetricTotal">--</strong><span class="workspace-card-note">parceiros diretos</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">●</span><div><small>Ativos</small><strong id="resellerMetricActive">--</strong><span class="workspace-card-note">acesso liberado</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">#</span><div><small>Alocação</small><strong id="resellerMetricAllocated">--</strong><span class="workspace-card-note">slots reservados</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">Cr</span><div><small>Créditos</small><strong id="resellerMetricCredits">--</strong><span class="workspace-card-note">saldo nos parceiros</span></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="workspace-nav-shell" data-tone="amber">
|
||||
<nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:2">
|
||||
<nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:3">
|
||||
<button class="active" type="button" data-workspace="resellers" data-workspace-section="users"><span>◎</span> Revendedores</button>
|
||||
<button type="button" data-workspace="resellers" data-workspace-section="create"><span>+</span> Criar revendedor</button>
|
||||
<button type="button" data-workspace="resellers" data-workspace-section="audit"><span>☰</span> Atividade</button>
|
||||
</nav>
|
||||
<select id="resellerSection" class="workspace-section-select" data-workspace-select="resellers" aria-label="Área de revendedores">
|
||||
<option value="users">Revendedores</option><option value="create">Criar revendedor</option>
|
||||
<option value="users">Revendedores</option><option value="create">Criar revendedor</option><option value="audit">Atividade</option>
|
||||
</select>
|
||||
</div>
|
||||
<section class="workspace-section active" data-workspace-panel="resellers" data-workspace-section-panel="users">
|
||||
@@ -682,7 +692,7 @@
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Username</th><th>Users (used/max)</th><th>Expires</th><th>Status</th><th>Actions</th>
|
||||
<th>Conta</th><th>Plano e uso</th><th>Validade</th><th>Status</th><th>Ações</th>
|
||||
</tr></thead>
|
||||
<tbody id="resellersBody"></tbody>
|
||||
</table>
|
||||
@@ -702,9 +712,14 @@
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Username</label><input id="rUsername" required autocomplete="off"/></div>
|
||||
<div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="rPassword" type="password" autocomplete="new-password"/></div>
|
||||
<div class="field"><label>Max SSH users (0 = unlimited)</label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>Active</label><input id="rActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
|
||||
<div class="field superadmin-only" id="rParentField"><label>Revendedor pai <span class="hint">(vazio = principal)</span></label><select id="rParent"><option value="">Principal / sem pai</option></select></div>
|
||||
<div class="field"><label>Modo do plano</label><select id="rQuotaMode"><option value="slots">Validade / slots</option><option value="credits">Créditos</option></select><span class="hint" id="rQuotaModeHint">Sub-revendedores herdam o modo da conta pai.</span></div>
|
||||
<div class="field" id="rSlotsField"><label>Limite compartilhado <span class="hint">(0 = ilimitado só para principal)</span></label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field hidden" id="rCreditsField"><label>Saldo de créditos</label><input id="rCredits" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field" id="rExpiresField"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>WhatsApp</label><input id="rWhatsApp" autocomplete="tel" placeholder="+5511999999999"/></div>
|
||||
<div class="field"><label>Valor mensal</label><input id="rMonthlyPrice" type="number" min="0" step="0.01" placeholder="0,00"/></div>
|
||||
<label class="bot-check-field"><input id="rActive" type="checkbox" checked/> Acesso ativo</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit" id="saveResellerBtn">Save reseller</button>
|
||||
@@ -712,6 +727,13 @@
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="resellers" data-workspace-section-panel="audit">
|
||||
<div class="workspace-section-heading"><div><span>03 · Auditoria</span><h3>Atividade das revendas</h3><p>Veja quem criou, alterou, renovou, suspendeu ou removeu cada conta.</p></div><button class="btn btn-ghost btn-sm" id="reloadResellerAuditBtn" type="button">Atualizar</button></div>
|
||||
<div class="card">
|
||||
<div class="tbl-wrap"><table class="reseller-audit-table"><thead><tr><th>Quando</th><th>Responsável</th><th>Revendedor</th><th>Ação</th><th>Detalhes</th></tr></thead><tbody id="resellerAuditBody"></tbody></table></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="statusbar workspace-section-status"><span id="resellerStatus">Ready.</span></div>
|
||||
</div><!-- /tab-resellers -->
|
||||
|
||||
@@ -1538,17 +1560,17 @@
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution order,
|
||||
so behavior is identical to the old single file. Keep this load order. -->
|
||||
<script defer src="assets/js/01-core.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713sections5"></script>
|
||||
<script defer src="assets/js/01-core.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713reseller10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
QuotaModeSlots = "slots"
|
||||
QuotaModeCredit = "credits"
|
||||
sessionTTL = 12 * time.Hour
|
||||
adminBcryptCost = 12
|
||||
)
|
||||
@@ -38,6 +40,11 @@ type AdminUser struct {
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ParentUsername string
|
||||
QuotaMode string
|
||||
CreditBalance int
|
||||
WhatsApp string
|
||||
MonthlyPriceCents int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
@@ -195,8 +202,7 @@ func sessionMiddleware(next http.Handler) http.Handler {
|
||||
// Re-check the account on every request. This immediately revokes sessions
|
||||
// after an account is suspended, expired, deleted, or has its role changed.
|
||||
u, ok := adminUsers.get(s.Username)
|
||||
if !ok || u.ID != s.UserID || !u.IsActive || u.Role != s.Role ||
|
||||
(u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
if !ok || u.ID != s.UserID || u.Role != s.Role || adminAccountChainActive(s.Username) != nil {
|
||||
sessions.Delete(token)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -345,7 +351,47 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS parent_username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS quota_mode TEXT NOT NULL DEFAULT 'slots'`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS credit_balance INT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS whatsapp TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS monthly_price_cents INT NOT NULL DEFAULT 0`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_users_parent ON admin_users(parent_username)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_username TEXT NOT NULL,
|
||||
target_username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_created ON reseller_audit_log(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_actor ON reseller_audit_log(actor_username, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_target ON reseller_audit_log(target_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_credit_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reseller_username TEXT NOT NULL,
|
||||
actor_username TEXT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_credit_ledger_owner ON reseller_credit_ledger(reseller_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_runtime_state (
|
||||
owner_username TEXT PRIMARY KEY,
|
||||
parent_username TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
// Older reseller-owned accounts used zero to mean "unlimited". The
|
||||
// reseller quota model charges at least one slot per account, so normalize
|
||||
// those rows once during schema setup instead of leaving a quota bypass.
|
||||
`UPDATE ssh_users SET max_connections = 1
|
||||
WHERE owner_username <> '' AND max_connections < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -355,45 +401,51 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
const adminUserSelectColumns = `id, username, password_hash, role, max_users,
|
||||
COALESCE(parent_username, ''), COALESCE(quota_mode, 'slots'), COALESCE(credit_balance, 0),
|
||||
COALESCE(whatsapp, ''), COALESCE(monthly_price_cents, 0), expires_at, is_active, created_at`
|
||||
|
||||
func scanAdminUser(scanner interface{ Scan(...interface{}) error }) (*AdminUser, error) {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users WHERE username = $1`, username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
&u.ParentUsername, &u.QuotaMode, &u.CreditBalance, &u.WhatsApp, &u.MonthlyPriceCents,
|
||||
&expiresAt, &u.IsActive, &u.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
u.QuotaMode = normalizeQuotaMode(u.QuotaMode)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
u, err := scanAdminUser(s.db.QueryRowContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users WHERE username = $1`, username))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAdminUsers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users ORDER BY role, username`)
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users ORDER BY role, username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -406,15 +458,21 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
|
||||
}
|
||||
if u.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, parent_username,
|
||||
quota_mode, credit_balance, whatsapp, monthly_price_cents, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive,
|
||||
).Scan(&u.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4,
|
||||
expires_at=$5, is_active=$6 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive)
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4, parent_username=$5,
|
||||
quota_mode=$6, credit_balance=$7, whatsapp=$8, monthly_price_cents=$9,
|
||||
expires_at=$10, is_active=$11 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -435,8 +493,7 @@ func (s *Store) SetAdminUserActive(ctx context.Context, username string, active
|
||||
|
||||
func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -448,8 +505,7 @@ func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error)
|
||||
|
||||
func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -462,15 +518,10 @@ func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUs
|
||||
func scanAdminUsers(rows *sql.Rows) ([]*AdminUser, error) {
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -516,7 +567,12 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states, err := store.ListResellerRuntimeStates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUsers.replaceAll(users)
|
||||
resellerRuntimeStates.replaceAll(states)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -524,20 +580,10 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
|
||||
// ownerIsActive returns nil if an SSH user's reseller owner is active, or an error if suspended/expired.
|
||||
func ownerIsActive(ownerUsername string) error {
|
||||
if ownerUsername == "" {
|
||||
return nil
|
||||
if _, replicated := resellerRuntimeStates.get(ownerUsername); replicated {
|
||||
return resellerRuntimeChainActive(ownerUsername)
|
||||
}
|
||||
u, ok := adminUsers.get(ownerUsername)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
if !u.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
return nil
|
||||
return adminAccountChainActive(ownerUsername)
|
||||
}
|
||||
|
||||
// disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner.
|
||||
@@ -579,29 +625,38 @@ func startResellerExpiryChecker(store *Store) {
|
||||
}
|
||||
for _, u := range expired {
|
||||
log.Printf("reseller %s expired — suspending", u.Username)
|
||||
resellerLifecycleMu.Lock()
|
||||
all, listErr := store.ListAdminUsers(ctx)
|
||||
if listErr != nil {
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry hierarchy for %s: %v", u.Username, listErr)
|
||||
continue
|
||||
}
|
||||
quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(listResellerSubtree(all, u.Username)))
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, false); err != nil {
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = false
|
||||
adminUsers.set(u)
|
||||
disconnectOwnerUsers(u.Username)
|
||||
removeOwnerXrayClients(ctx, store, u.Username)
|
||||
sessions.DeleteUser(u.ID)
|
||||
if err := applyResellerSubtreeRuntime(ctx, store, u.Username, false); err != nil {
|
||||
log.Printf("reseller expiry runtime for %s: %v", u.Username, err)
|
||||
}
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
}
|
||||
|
||||
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
|
||||
renewed, err := store.ListInactiveButRenewedResellers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller renewal check: %v", err)
|
||||
// Replicated owner records on managed nodes also enforce expiration and
|
||||
// inherited parent suspension without contacting the master on each login.
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) != nil {
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, "suspend"); err != nil {
|
||||
log.Printf("replicated reseller expiry runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
for _, u := range renewed {
|
||||
log.Printf("reseller %s renewed — reactivating", u.Username)
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, true); err != nil {
|
||||
log.Printf("reseller renewal: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = true
|
||||
adminUsers.set(u)
|
||||
}
|
||||
|
||||
sessions.cleanup()
|
||||
@@ -664,12 +719,8 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !u.IsActive {
|
||||
http.Error(w, "account suspended", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
http.Error(w, "account expired", http.StatusForbidden)
|
||||
if adminAccountChainActive(u.Username) != nil {
|
||||
http.Error(w, "account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -720,183 +771,35 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.Role == RoleReseller {
|
||||
if u, ok := adminUsers.get(s.Username); ok {
|
||||
childAllocation, childCount := 0, 0
|
||||
if statsStore != nil {
|
||||
childAllocation, _ = statsStore.directChildAllocation(r.Context(), s.Username, "")
|
||||
childCount = statsStore.directChildCount(r.Context(), s.Username)
|
||||
}
|
||||
resp["max_users"] = u.MaxUsers
|
||||
resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username)
|
||||
resp["used_ssh_users"] = countOwnedUsers(s.Username)
|
||||
resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username)
|
||||
usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), statsStore, s.Username)
|
||||
if usageErr != nil {
|
||||
usage = resellerQuotaUsage{
|
||||
Weighted: countOwnedQuota(r.Context(), statsStore, s.Username),
|
||||
SSHAccounts: countOwnedUsers(s.Username),
|
||||
XrayAccounts: countOwnedXrayClients(r.Context(), statsStore, s.Username),
|
||||
}
|
||||
}
|
||||
resp["used_users"] = usage.Weighted
|
||||
resp["used_ssh_users"] = usage.SSHAccounts
|
||||
resp["used_xray_users"] = usage.XrayAccounts
|
||||
resp["parent_username"] = u.ParentUsername
|
||||
resp["quota_mode"] = normalizeQuotaMode(u.QuotaMode)
|
||||
resp["credit_balance"] = u.CreditBalance
|
||||
resp["child_allocation"] = childAllocation
|
||||
resp["child_count"] = childCount
|
||||
resp["expires_at"] = u.ExpiresAt
|
||||
resp["is_active"] = u.IsActive
|
||||
resp["effective_active"] = adminAccountChainActive(u.Username) == nil
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ---------- Reseller management (superadmin only) ----------
|
||||
|
||||
type ResellerDTO struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
UsedUsers int `json:"used_users"`
|
||||
UsedSSH int `json:"used_ssh_users"`
|
||||
UsedXray int `json:"used_xray_users"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func handleListResellers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
users, err := store.ListAdminUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]ResellerDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, ResellerDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
MaxUsers: u.MaxUsers,
|
||||
UsedUsers: countOwnedQuota(r.Context(), store, u.Username),
|
||||
UsedSSH: countOwnedUsers(u.Username),
|
||||
UsedXray: countOwnedXrayClients(r.Context(), store, u.Username),
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
IsActive: u.IsActive,
|
||||
CreatedAt: u.CreatedAt,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
}
|
||||
|
||||
type ResellerPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p ResellerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAdminUsername(p.Username); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.MaxUsers < 0 || p.MaxUsers > 1000000 {
|
||||
http.Error(w, "max_users must be between 0 and 1000000", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
existing, err := store.GetAdminUserByUsername(ctx, p.Username)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var u *AdminUser
|
||||
if existing != nil {
|
||||
u = existing
|
||||
} else {
|
||||
if p.Password == "" {
|
||||
http.Error(w, "password required for new account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u = &AdminUser{Username: p.Username, Role: RoleReseller}
|
||||
}
|
||||
|
||||
if p.Password != "" {
|
||||
if err := validateAdminPassword(p.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
passwordHash, err := hashAdminPassword(p.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
}
|
||||
u.MaxUsers = p.MaxUsers
|
||||
u.IsActive = p.IsActive
|
||||
u.ExpiresAt = nil
|
||||
if p.ExpiresAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, p.ExpiresAt)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid expires_at (RFC3339 required)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u.ExpiresAt = &t
|
||||
}
|
||||
|
||||
if err := store.UpsertAdminUser(ctx, u); err != nil {
|
||||
log.Printf("upsert reseller: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
adminUsers.set(u)
|
||||
if p.Password != "" && existing != nil {
|
||||
sessions.DeleteUser(u.ID)
|
||||
}
|
||||
|
||||
if u.Role == RoleReseller {
|
||||
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
disconnectOwnerUsers(u.Username)
|
||||
removeOwnerXrayClients(ctx, store, u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if err := validateAdminUsername(username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
u, _ := store.GetAdminUserByUsername(ctx, username)
|
||||
if u != nil && u.Role == RoleSuperAdmin {
|
||||
http.Error(w, "superadmin accounts cannot be deleted from the reseller endpoint", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := store.DeleteAdminUser(ctx, username); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
disconnectOwnerUsers(username)
|
||||
removeOwnerXrayClients(ctx, store, username)
|
||||
adminUsers.delete(username)
|
||||
if u != nil {
|
||||
sessions.DeleteUser(u.ID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
// Reseller management handlers live in reseller_management.go.
|
||||
|
||||
+3
-3
@@ -119,7 +119,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
case http.MethodGet:
|
||||
cfg, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "load bot configuration", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, botConfigDTO{
|
||||
@@ -224,7 +224,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
|
||||
}
|
||||
if err := SaveBotConfig(ctx, store, cfg); err != nil {
|
||||
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "save bot configuration", err)
|
||||
return
|
||||
}
|
||||
reloadBotService(store)
|
||||
@@ -265,7 +265,7 @@ func handleBotPlans(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if err := store.UpsertPlan(ctx, &p); err != nil {
|
||||
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "save bot plan", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, p)
|
||||
|
||||
+8
-10
@@ -1843,20 +1843,18 @@ func handleDnsttGenKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
privkey, err := noise.GeneratePrivkey()
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate DNSTT key", err)
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(dnsttKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
var encoded bytes.Buffer
|
||||
if err := noise.WriteKey(&encoded, privkey); err != nil {
|
||||
writeInternalError(w, "encode DNSTT key", err)
|
||||
return
|
||||
}
|
||||
if err := noise.WriteKey(f, privkey); err != nil {
|
||||
f.Close()
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(dnsttKeyFile, encoded.Bytes(), 0o600); err != nil {
|
||||
writeInternalError(w, "write DNSTT key", err)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -1879,13 +1877,13 @@ func handleDnsttGetPubKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
f, err := os.Open(keyPath)
|
||||
if err != nil {
|
||||
http.Error(w, "open key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "open DNSTT key", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
privkey, err := noise.ReadKey(f)
|
||||
if err != nil {
|
||||
http.Error(w, "read key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read DNSTT key", err)
|
||||
return
|
||||
}
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Trusted Go archives used by install.sh and update.sh.
|
||||
# Format: version architecture sha256
|
||||
1.25.12 amd64 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
1.25.12 arm64 8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2
|
||||
1.25.12 armv6l 6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1
|
||||
@@ -1,14 +1,14 @@
|
||||
module shell2
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.12
|
||||
|
||||
require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61
|
||||
github.com/xtaci/smux v1.5.50
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/time v0.15.0
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0
|
||||
)
|
||||
|
||||
@@ -18,6 +18,6 @@ require (
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
@@ -55,8 +55,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -68,8 +68,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -80,17 +80,17 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
|
||||
+70
-6
@@ -15,6 +15,7 @@ LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
|
||||
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
|
||||
GO_SHA256="${GO_SHA256:-}"
|
||||
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
|
||||
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
|
||||
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
|
||||
@@ -33,6 +34,38 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "$GO_SHA256" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -235,16 +268,21 @@ if command -v go &>/dev/null; then
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
GO_EXPECTED_SHA256=""
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
GO_EXPECTED_SHA256="$(trusted_go_sha256 "$GO_VERSION" "$GOARCH" "$SCRIPT_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$GO_EXPECTED_SHA256" ]] || error "No trusted Go checksum for ${GO_VERSION}/${GOARCH}; set GO_SHA256 explicitly"
|
||||
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||
info " Downloading $GO_URL"
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
|
||||
verify_sha256_file "$GO_EXPECTED_SHA256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
@@ -299,25 +337,51 @@ fi
|
||||
|
||||
# ── 6. Xray binary ──────────────────────────────────────────────────────────
|
||||
info "[6/10] Downloading Xray-core…"
|
||||
XRAY_VER=$(curl -sf "https://api.github.com/repos/XTLS/Xray-core/releases/latest" \
|
||||
| grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30")
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) XRAY_ARCH="64" ;;
|
||||
aarch64) XRAY_ARCH="arm64-v8a" ;;
|
||||
armv7l) XRAY_ARCH="arm32-v7a" ;;
|
||||
*) XRAY_ARCH="64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
XRAY_URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VER}/Xray-linux-${XRAY_ARCH}.zip"
|
||||
PYTHON_BIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
|
||||
[[ -n "$PYTHON_BIN" ]] || error "Python is required to validate Xray release metadata"
|
||||
XRAY_RELEASE_JSON=/tmp/xray-release.json
|
||||
curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \
|
||||
-o "$XRAY_RELEASE_JSON" https://api.github.com/repos/XTLS/Xray-core/releases/latest
|
||||
readarray -t XRAY_META < <("$PYTHON_BIN" -c '
|
||||
import json, re, sys
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
||||
release = json.load(handle)
|
||||
tag = release.get("tag_name", "")
|
||||
name = sys.argv[2]
|
||||
asset = next((item for item in release.get("assets", []) if item.get("name") == name), None)
|
||||
if not tag or not asset:
|
||||
raise SystemExit(2)
|
||||
url = asset.get("browser_download_url", "")
|
||||
digest = asset.get("digest", "")
|
||||
prefix = "https://github.com/XTLS/Xray-core/releases/download/" + tag + "/"
|
||||
if not url.startswith(prefix) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise SystemExit(3)
|
||||
print(tag)
|
||||
print(url)
|
||||
print(digest.split(":", 1)[1])
|
||||
' "$XRAY_RELEASE_JSON" "Xray-linux-${XRAY_ARCH}.zip")
|
||||
[[ ${#XRAY_META[@]} -eq 3 ]] || error "Xray release metadata is missing a trusted asset digest"
|
||||
XRAY_VER="${XRAY_META[0]}"
|
||||
XRAY_URL="${XRAY_META[1]}"
|
||||
XRAY_SHA256="${XRAY_META[2]}"
|
||||
info " Xray ${XRAY_VER} (${XRAY_ARCH})"
|
||||
wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL"
|
||||
verify_sha256_file "$XRAY_SHA256" /tmp/xray.zip
|
||||
info " Xray archive checksum verified"
|
||||
unzip -o /tmp/xray.zip xray -d "$INSTALL_DIR" > /dev/null 2>&1 || {
|
||||
mkdir -p /tmp/xray_extract
|
||||
unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1
|
||||
mv /tmp/xray_extract/xray "$INSTALL_DIR/xray"
|
||||
}
|
||||
chmod +x "$INSTALL_DIR/xray"
|
||||
rm -f /tmp/xray.zip
|
||||
rm -f /tmp/xray.zip "$XRAY_RELEASE_JSON"
|
||||
"$INSTALL_DIR/xray" version
|
||||
|
||||
# ── 7. PostgreSQL ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1612,6 +1612,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
// SSH user management (session required; role-filtered inside handlers)
|
||||
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
|
||||
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
|
||||
mux.Handle("/api/users/renew", sessionMiddleware(http.HandlerFunc(handleRenewSSHUser(store))))
|
||||
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
|
||||
|
||||
// Server stats: visible to authenticated sessions; reset remains superadmin-only.
|
||||
@@ -1625,10 +1626,15 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats)))
|
||||
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
|
||||
|
||||
// Superadmin-only: reseller management
|
||||
mux.Handle("/api/resellers", saSession(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
// Hierarchical reseller management. Scope checks inside each handler limit a
|
||||
// reseller to its direct children; superadmins retain global management.
|
||||
mux.Handle("/api/resellers", sessionMiddleware(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", sessionMiddleware(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/action", sessionMiddleware(http.HandlerFunc(handleResellerAction(store))))
|
||||
mux.Handle("/api/resellers/delete", sessionMiddleware(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
mux.Handle("/api/resellers/audit", sessionMiddleware(http.HandlerFunc(handleResellerAudit(store))))
|
||||
// Called master-to-node with the managed server's superadmin session.
|
||||
mux.Handle("/api/resellers/runtime", saSession(http.HandlerFunc(handleResellerRuntime(store))))
|
||||
|
||||
// Master/slave server management. Superadmins can add slave nodes; all authenticated
|
||||
// users can read the enabled server list to pick where accounts are created.
|
||||
@@ -1648,6 +1654,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
|
||||
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
|
||||
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
|
||||
mux.Handle("/api/xray/clients/renew", sessionMiddleware(http.HandlerFunc(handleRenewXrayClient(store))))
|
||||
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
|
||||
|
||||
// Superadmin-only: TLS certificate generation
|
||||
@@ -1783,6 +1790,7 @@ type UserPayload struct {
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
PreserveExpires bool `json:"preserve_expires,omitempty"`
|
||||
}
|
||||
|
||||
func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
@@ -1796,64 +1804,113 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
var p UserPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
if err := validateSSHUserPayload(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableSSH {
|
||||
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
chargedCredits, creditCost, creditOwner := false, 0, ""
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
currentOwner, exists, ownerErr := remoteSSHUserOwner(ctx, ms, p.Username)
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
row, exists, ownerErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
currentOwner := ""
|
||||
if exists {
|
||||
currentOwner = strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
}
|
||||
if exists && currentOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
if exists {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
p.PreserveExpires = true
|
||||
p.MaxConnections = jsonInt(row["max_connections"])
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, jsonInt(row["max_connections"]), p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
if ownerErr != nil {
|
||||
writeResellerProvisionError(w, ownerErr)
|
||||
return
|
||||
}
|
||||
creditOwner = sess.Username
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
p.OwnerUsername = sess.Username
|
||||
}
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, sess.Username); syncErr != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
writeBadGatewayError(w, "create SSH account on managed server", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(ctx)
|
||||
var existingLocalExpiry string
|
||||
var existingLocalUser bool
|
||||
var existingLocalMax int
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
var existingOwner string
|
||||
err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, p.Username).Scan(&existingOwner)
|
||||
var expiresAt sql.NullTime
|
||||
err := store.db.QueryRowContext(ctx,
|
||||
`SELECT owner_username, expires_at, max_connections FROM ssh_users WHERE username=$1`,
|
||||
p.Username).Scan(&existingOwner, &expiresAt, &existingLocalMax)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1862,6 +1919,31 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
existingLocalUser = err == nil
|
||||
if expiresAt.Valid {
|
||||
existingLocalExpiry = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if existingLocalUser {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" && resellerExpiryExtended(existingLocalExpiry, p.ExpiresAt) {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.ExpiresAt = existingLocalExpiry
|
||||
p.MaxConnections = existingLocalMax
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, existingLocalMax, p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.PreserveExpires && !existingLocalUser {
|
||||
var expiresAt sql.NullTime
|
||||
if err := store.db.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM ssh_users WHERE username=$1`, p.Username).Scan(&expiresAt); err == nil && expiresAt.Valid {
|
||||
p.ExpiresAt = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
// Decide what password to use:
|
||||
@@ -1892,29 +1974,32 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
password = existing
|
||||
password, err = openSSHPassword(existing)
|
||||
if err != nil {
|
||||
log.Printf("failed to decrypt existing password for %s: %v", p.Username, err)
|
||||
http.Error(w, "stored credential is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine owner and enforce reseller quota
|
||||
// Determine owner and enforce reseller quota. Credit accounts spend one
|
||||
// credit per allowed connection (minimum one) and receive 31 days.
|
||||
ownerUsername := ""
|
||||
chargedCredits, creditCost := false, 0
|
||||
isNewUser := false
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
// Enforce user limit — only count on new user creation
|
||||
var existsInDB bool
|
||||
_ = store.db.QueryRowContext(ctx,
|
||||
`SELECT TRUE FROM ssh_users WHERE username=$1`, p.Username,
|
||||
).Scan(&existsInDB)
|
||||
if !existsInDB {
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
|
||||
if !existingLocalUser {
|
||||
isNewUser = true
|
||||
var quotaErr error
|
||||
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
|
||||
@@ -1937,6 +2022,9 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if isNewUser && chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, ownerUsername, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("failed to upsert user: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1961,15 +2049,15 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if err := validateAccountUsername(username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, username, sess.Username) {
|
||||
@@ -1979,7 +2067,7 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "delete SSH account from managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -3041,6 +3129,7 @@ func main() {
|
||||
} else {
|
||||
log.Printf("iface totals persistence disabled: %v", err)
|
||||
}
|
||||
startManagedResellerStateSync(store)
|
||||
}
|
||||
|
||||
// start background collector for CPU + interface stats
|
||||
@@ -3061,6 +3150,9 @@ func main() {
|
||||
|
||||
// Start the integrated Xray-core subprocess if configured.
|
||||
initXrayManager(cfg.Xray)
|
||||
if store != nil {
|
||||
reconcileLocalResellerRuntimeStates(store)
|
||||
}
|
||||
|
||||
// Global banner text (from config or file) — stored in a global so the
|
||||
// admin API can update it on the fly without a restart.
|
||||
|
||||
+128
-42
@@ -35,6 +35,25 @@ func managedServerHTTPClient(timeout time.Duration) *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
func remoteErrorSnippet(data []byte) string {
|
||||
const limit = 4096
|
||||
truncated := len(data) > limit
|
||||
if truncated {
|
||||
data = data[:limit]
|
||||
}
|
||||
value := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, string(data))
|
||||
value = strings.TrimSpace(value)
|
||||
if truncated {
|
||||
value += "…"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
@@ -344,6 +363,36 @@ func managedServerFromID(ctx context.Context, store *Store, id string) (*Managed
|
||||
return ms, true, nil
|
||||
}
|
||||
|
||||
func writeManagedServerSelectionError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
switch err.Error() {
|
||||
case "invalid server id", "server not found", "server is disabled":
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case "database not configured":
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
default:
|
||||
writeInternalError(w, "select managed server", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeManagedServerSaveError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
message := err.Error()
|
||||
safe := message == "server name required" || message == "invalid server name" ||
|
||||
message == "invalid admin username" || message == "invalid admin credential" ||
|
||||
message == "invalid server id" || message == "admin key/password required" ||
|
||||
strings.HasPrefix(message, "base url") || message == "invalid base url"
|
||||
if safe {
|
||||
http.Error(w, message, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "save managed server", err)
|
||||
}
|
||||
|
||||
func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ms.BaseURL+"/api/auth/login", bytes.NewReader(body))
|
||||
@@ -359,7 +408,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("remote login failed: %s", strings.TrimSpace(string(data)))
|
||||
return "", fmt.Errorf("remote login failed with HTTP %d: %q", resp.StatusCode, remoteErrorSnippet(data))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
@@ -407,6 +456,13 @@ func handleManagedProxyOrLocal(store *Store, local http.HandlerFunc) http.Handle
|
||||
}
|
||||
|
||||
func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentType string) {
|
||||
if status >= http.StatusInternalServerError {
|
||||
if len(body) > 0 {
|
||||
log.Printf("managed server returned HTTP %d: %q", status, remoteErrorSnippet(body))
|
||||
}
|
||||
body = []byte("managed server request failed\n")
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
@@ -422,7 +478,7 @@ func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentT
|
||||
func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store *Store, remotePath string, body []byte, filterOwner string) bool {
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return true
|
||||
}
|
||||
if !remote {
|
||||
@@ -444,7 +500,7 @@ func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "proxy managed server request", err)
|
||||
return true
|
||||
}
|
||||
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") {
|
||||
@@ -535,7 +591,7 @@ func handleServers(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, err := store.UpsertManagedServer(r.Context(), p)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSaveError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -604,17 +660,17 @@ func handleServerTest(store *Store) http.HandlerFunc {
|
||||
}
|
||||
token, err := remoteLoginToken(r.Context(), ms)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server login", err)
|
||||
return
|
||||
}
|
||||
_ = token
|
||||
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server session", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
http.Error(w, strings.TrimSpace(string(data)), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server session", fmt.Errorf("HTTP %d: %q", status, remoteErrorSnippet(data)))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -644,7 +700,7 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
}
|
||||
if !remote {
|
||||
@@ -653,67 +709,83 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
|
||||
if err != nil {
|
||||
log.Printf("managed server config proxy %s: %v", ms.BaseURL, err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "proxy managed server configuration", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
func remoteSSHUserInfo(ctx context.Context, ms *ManagedServer, username string) (map[string]interface{}, bool, error) {
|
||||
if username == "" {
|
||||
return "", false, nil
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return "", false, err
|
||||
return nil, false, err
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return "", false, err
|
||||
return nil, false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username {
|
||||
return row, true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteSSHUserInfo(ctx, ms, username)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
func remoteXrayClientInfo(ctx context.Context, ms *ManagedServer, uuid string) (map[string]interface{}, bool, error) {
|
||||
if uuid == "" {
|
||||
return "", false, nil
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return "", false, err
|
||||
return nil, false, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return "", false, err
|
||||
return nil, false, err
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid {
|
||||
return strings.TrimSpace(fmt.Sprint(m["owner_username"])), true, nil
|
||||
m["inbound_tag"] = fmt.Sprint(ib["tag"])
|
||||
return m, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteXrayClientInfo(ctx, ms, uuid)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
@@ -721,59 +793,73 @@ func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner s
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
if store == nil || owner == "" {
|
||||
return 0, nil
|
||||
type resellerQuotaUsage struct {
|
||||
Weighted int
|
||||
SSHAccounts int
|
||||
XrayAccounts int
|
||||
}
|
||||
|
||||
func ownedQuotaUsageAcrossManagedServers(ctx context.Context, store *Store, owner string) (resellerQuotaUsage, error) {
|
||||
usage := resellerQuotaUsage{}
|
||||
if owner == "" {
|
||||
return usage, nil
|
||||
}
|
||||
usage.Weighted = countOwnedQuota(ctx, store, owner)
|
||||
usage.SSHAccounts = countOwnedUsers(owner)
|
||||
usage.XrayAccounts = countOwnedXrayClients(ctx, store, owner)
|
||||
if store == nil {
|
||||
return usage, nil
|
||||
}
|
||||
total := countOwnedQuota(ctx, store, owner)
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, ms := range servers {
|
||||
if !ms.IsActive {
|
||||
continue
|
||||
}
|
||||
if ms.EnableSSH {
|
||||
// Count every configured node and both account types. Temporarily disabling
|
||||
// a node or a protocol must not release its committed reseller quota.
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return 0, err
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
total++
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(user["max_connections"]))
|
||||
usage.SSHAccounts++
|
||||
}
|
||||
}
|
||||
}
|
||||
if ms.EnableXray {
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
status, data, _, err = proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return 0, err
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
total++
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(item["max_conns"]))
|
||||
usage.XrayAccounts++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
return total, nil
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
usage, err := ownedQuotaUsageAcrossManagedServers(ctx, store, owner)
|
||||
return usage.Weighted, err
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) {
|
||||
path := panelLogFilePath()
|
||||
maxBytes := panelLogMaxBytes()
|
||||
if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "clear panel log", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeQuotaMode(t *testing.T) {
|
||||
for input, expected := range map[string]string{
|
||||
"": QuotaModeSlots,
|
||||
"slots": QuotaModeSlots,
|
||||
"Validade": QuotaModeSlots,
|
||||
"credits": QuotaModeCredit,
|
||||
"Credito": QuotaModeCredit,
|
||||
} {
|
||||
if got := normalizeQuotaMode(input); got != expected {
|
||||
t.Fatalf("normalizeQuotaMode(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerProvisionCost(t *testing.T) {
|
||||
for input, expected := range map[int]int{-10: 1, 0: 1, 1: 1, 3: 3} {
|
||||
if got := resellerProvisionCost(input); got != expected {
|
||||
t.Fatalf("resellerProvisionCost(%d) = %d, want %d", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResellerSubtree(t *testing.T) {
|
||||
all := []*AdminUser{
|
||||
{Username: "root", Role: RoleReseller},
|
||||
{Username: "child-a", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "child-b", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "grandchild", Role: RoleReseller, ParentUsername: "child-a"},
|
||||
{Username: "admin", Role: RoleSuperAdmin},
|
||||
}
|
||||
got := listResellerSubtree(all, "root")
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("subtree size = %d, want 4", len(got))
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, user := range got {
|
||||
seen[user.Username] = true
|
||||
}
|
||||
for _, username := range []string{"root", "child-a", "child-b", "grandchild"} {
|
||||
if !seen[username] {
|
||||
t.Fatalf("subtree does not contain %q", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerCanManageOnlyDirectChildren(t *testing.T) {
|
||||
sess := &AdminSession{Username: "parent", Role: RoleReseller}
|
||||
if !resellerCanManage(sess, &AdminUser{Username: "child", Role: RoleReseller, ParentUsername: "parent"}) {
|
||||
t.Fatal("parent could not manage its direct child")
|
||||
}
|
||||
if resellerCanManage(sess, &AdminUser{Username: "grandchild", Role: RoleReseller, ParentUsername: "child"}) {
|
||||
t.Fatal("parent was allowed to skip a hierarchy level")
|
||||
}
|
||||
admin := &AdminSession{Username: "admin", Role: RoleSuperAdmin}
|
||||
if !resellerCanManage(admin, &AdminUser{Username: "any", Role: RoleReseller}) {
|
||||
t.Fatal("superadmin could not manage a reseller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerExpiryExtensionDetection(t *testing.T) {
|
||||
existing := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
|
||||
if resellerExpiryExtended(existing.Format(time.RFC3339), existing.Format(time.RFC3339)) {
|
||||
t.Fatal("unchanged expiration was treated as an extension")
|
||||
}
|
||||
if !resellerExpiryExtended(existing.Format(time.RFC3339), existing.Add(time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("later expiration was not treated as an extension")
|
||||
}
|
||||
if resellerTimeExtended(&existing, existing.Add(-time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("shorter expiration was treated as an extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalExpiryUsesLaterBase(t *testing.T) {
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
got := renewalExpiry(&future, 30)
|
||||
want := future.AddDate(0, 0, 30)
|
||||
if got.Sub(want) > time.Second || want.Sub(got) > time.Second {
|
||||
t.Fatalf("renewal expiry = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountChainUsesPasswordFreeRuntimeState(t *testing.T) {
|
||||
parent := "runtime-parent-test"
|
||||
child := "runtime-child-test"
|
||||
adminUsers.delete(parent)
|
||||
adminUsers.delete(child)
|
||||
defer resellerRuntimeStates.delete(parent)
|
||||
defer resellerRuntimeStates.delete(child)
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: true})
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: child, ParentUsername: parent, IsActive: true})
|
||||
if err := adminAccountChainActive(child); err != nil {
|
||||
t.Fatalf("active replicated hierarchy was rejected: %v", err)
|
||||
}
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: false})
|
||||
if err := adminAccountChainActive(child); err == nil {
|
||||
t.Fatal("child remained active while its replicated parent was suspended")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResellerRuntimeState is a password-free ownership record replicated from a
|
||||
// master panel to its managed nodes. It lets a node enforce reseller
|
||||
// suspension and parent hierarchy locally without copying login credentials.
|
||||
type ResellerRuntimeState struct {
|
||||
OwnerUsername string
|
||||
ParentUsername string
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type resellerRuntimeStateCacheT struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]ResellerRuntimeState
|
||||
}
|
||||
|
||||
var resellerRuntimeStates = &resellerRuntimeStateCacheT{m: make(map[string]ResellerRuntimeState)}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) get(username string) (ResellerRuntimeState, bool) {
|
||||
m.mu.RLock()
|
||||
state, ok := m.m[username]
|
||||
m.mu.RUnlock()
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) set(state ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m[state.OwnerUsername] = state
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) delete(username string) {
|
||||
m.mu.Lock()
|
||||
delete(m.m, username)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) list() []ResellerRuntimeState {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]ResellerRuntimeState, 0, len(m.m))
|
||||
for _, state := range m.m {
|
||||
out = append(out, state)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) replaceAll(states []ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m = make(map[string]ResellerRuntimeState, len(states))
|
||||
for _, state := range states {
|
||||
m.m[state.OwnerUsername] = state
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) ListResellerRuntimeStates(ctx context.Context) ([]ResellerRuntimeState, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT owner_username, parent_username, is_active, expires_at
|
||||
FROM reseller_runtime_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ResellerRuntimeState
|
||||
for rows.Next() {
|
||||
var state ResellerRuntimeState
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&state.OwnerUsername, &state.ParentUsername, &state.IsActive, &expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
state.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertResellerRuntimeState(ctx context.Context, state ResellerRuntimeState) error {
|
||||
var expiresAt interface{}
|
||||
if state.ExpiresAt != nil {
|
||||
expiresAt = *state.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO reseller_runtime_state
|
||||
(owner_username, parent_username, is_active, expires_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (owner_username) DO UPDATE SET
|
||||
parent_username=EXCLUDED.parent_username,
|
||||
is_active=EXCLUDED.is_active,
|
||||
expires_at=EXCLUDED.expires_at,
|
||||
updated_at=NOW()`,
|
||||
state.OwnerUsername, state.ParentUsername, state.IsActive, expiresAt)
|
||||
if err == nil {
|
||||
resellerRuntimeStates.set(state)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteResellerRuntimeState(ctx context.Context, owner string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM reseller_runtime_state WHERE owner_username=$1`, owner); err != nil {
|
||||
return err
|
||||
}
|
||||
resellerRuntimeStates.delete(owner)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeChainActive(username string) error {
|
||||
seen := make(map[string]bool)
|
||||
now := time.Now()
|
||||
for depth := 0; username != "" && depth < 128; depth++ {
|
||||
if seen[username] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[username] = true
|
||||
state, ok := resellerRuntimeStates.get(username)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller runtime state not found")
|
||||
}
|
||||
if !state.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if state.ExpiresAt != nil && now.After(*state.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
username = strings.TrimSpace(state.ParentUsername)
|
||||
}
|
||||
if username != "" {
|
||||
return fmt.Errorf("reseller hierarchy is too deep")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeStateFor(owner string, effectiveActive bool) (ResellerRuntimeState, error) {
|
||||
u, ok := adminUsers.get(owner)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return ResellerRuntimeState{}, fmt.Errorf("reseller account not found")
|
||||
}
|
||||
return ResellerRuntimeState{
|
||||
OwnerUsername: u.Username,
|
||||
ParentUsername: u.ParentUsername,
|
||||
IsActive: effectiveActive,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// syncOwnerChainToManagedServer makes account creation on a managed node safe:
|
||||
// every parent is installed before the child, and no password/hash is sent.
|
||||
func syncOwnerChainToManagedServer(ctx context.Context, ms *ManagedServer, owner string) error {
|
||||
var chain []*AdminUser
|
||||
seen := make(map[string]bool)
|
||||
for current := strings.TrimSpace(owner); current != ""; {
|
||||
if seen[current] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[current] = true
|
||||
u, ok := adminUsers.get(current)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
chain = append(chain, u)
|
||||
current = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
state, err := resellerRuntimeStateFor(chain[i].Username, adminAccountChainActive(chain[i].Username) == nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := resellerRuntimePayloadFromState(state, "sync")
|
||||
if err := sendResellerRuntimeToServer(ctx, ms, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncAllResellerRuntimeStates repairs legacy managed nodes after an upgrade.
|
||||
// It runs asynchronously and never prevents the local panel from starting.
|
||||
func startManagedResellerStateSync(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller state sync: %v", err)
|
||||
return
|
||||
}
|
||||
users := adminUsers.list()
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
return resellerHierarchyDepth(users[i].Username) < resellerHierarchyDepth(users[j].Username)
|
||||
})
|
||||
for _, ms := range servers {
|
||||
for _, u := range users {
|
||||
if u.Role != RoleReseller {
|
||||
continue
|
||||
}
|
||||
action := "suspend"
|
||||
active := adminAccountChainActive(u.Username) == nil
|
||||
if active {
|
||||
action = "reactivate"
|
||||
}
|
||||
state, stateErr := resellerRuntimeStateFor(u.Username, active)
|
||||
if stateErr != nil {
|
||||
continue
|
||||
}
|
||||
if sendErr := sendResellerRuntimeToServer(ctx, ms, resellerRuntimePayloadFromState(state, action)); sendErr != nil {
|
||||
log.Printf("reseller state sync to %s for %s: %v", ms.Name, u.Username, sendErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reconcileLocalResellerRuntimeStates reapplies replicated ownership state
|
||||
// after a managed node restarts.
|
||||
func reconcileLocalResellerRuntimeStates(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
action := "suspend"
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) == nil {
|
||||
action = "reactivate"
|
||||
}
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, action); err != nil {
|
||||
log.Printf("reconcile local reseller runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resellerHierarchyDepth(username string) int {
|
||||
seen := make(map[string]bool)
|
||||
depth := 0
|
||||
for username != "" && depth < 128 {
|
||||
if seen[username] {
|
||||
return 128
|
||||
}
|
||||
seen[username] = true
|
||||
u, ok := adminUsers.get(username)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
username = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
@@ -50,6 +50,23 @@ func TestManagedServerURLValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteErrorSnippetIsBoundedAndSingleLine(t *testing.T) {
|
||||
input := make([]byte, 5000)
|
||||
for i := range input {
|
||||
input[i] = 'x'
|
||||
}
|
||||
copy(input, []byte("first\nsecond\r\tsecret"))
|
||||
got := remoteErrorSnippet(input)
|
||||
if len(got) > 4100 {
|
||||
t.Fatalf("remote error snippet is too long: %d", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
t.Fatalf("remote error snippet retained control character %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMPSignatureRequiresSecretAndValidHMAC(t *testing.T) {
|
||||
const (
|
||||
secret = "test-secret-with-enough-entropy"
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,6 +11,20 @@ import (
|
||||
|
||||
const maxAdminRequestBody = 8 << 20
|
||||
|
||||
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func writeBadGatewayError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "managed server request failed", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// securePanelHandler applies baseline browser protections and a global request
|
||||
// body ceiling. Endpoint-specific handlers may impose a smaller limit.
|
||||
func securePanelHandler(next http.Handler) http.Handler {
|
||||
|
||||
@@ -66,7 +66,7 @@ func serverConfigGet(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
data, err := os.ReadFile(globalCfgPath)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read server configuration", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -115,7 +115,7 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil {
|
||||
http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "write server configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -78,7 +78,7 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
certDir := filepath.Join(tlsCertsDir, dirName)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
@@ -86,7 +86,7 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS private key", err)
|
||||
return
|
||||
}
|
||||
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
@@ -110,22 +110,22 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS certificate", err)
|
||||
return
|
||||
}
|
||||
privDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "encode TLS private key", err)
|
||||
return
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "write TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "write TLS private key", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
"--agree-tos", "-m", email, "-d", domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError)
|
||||
writeInternalError(w, "obtain Let's Encrypt certificate", fmt.Errorf("certbot: %w: %s", err, strings.TrimSpace(string(out))))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -220,17 +220,17 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
certDir := filepath.Join(tlsCertsDir, name)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create uploaded TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
keyFile := filepath.Join(certDir, "key.pem")
|
||||
if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "write uploaded TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "write uploaded TLS private key", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -53,6 +53,38 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "${GO_SHA256:-}" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -249,7 +281,7 @@ prepare_source_from_git() {
|
||||
}
|
||||
|
||||
install_go_if_needed() {
|
||||
local go_version machine goarch go_url current_go need_go
|
||||
local go_version machine goarch go_url go_expected_sha256 current_go need_go
|
||||
go_version="$(awk '$1 == "go" {print $2; exit}' "$SOURCE_DIR/go.mod" 2>/dev/null || echo "1.22.5")"
|
||||
need_go=true
|
||||
|
||||
@@ -270,12 +302,16 @@ install_go_if_needed() {
|
||||
x86_64) goarch="amd64" ;;
|
||||
aarch64) goarch="arm64" ;;
|
||||
armv7l) goarch="armv6l" ;;
|
||||
*) goarch="amd64" ;;
|
||||
*) error "Unsupported CPU architecture: $machine" ;;
|
||||
esac
|
||||
go_expected_sha256="$(trusted_go_sha256 "$go_version" "$goarch" "$SOURCE_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$go_expected_sha256" ]] || error "No trusted Go checksum for ${go_version}/${goarch}; set GO_SHA256 explicitly"
|
||||
go_url="https://go.dev/dl/go${go_version}.linux-${goarch}.tar.gz"
|
||||
info " Downloading Go ${go_version} (${goarch})..."
|
||||
need_cmd wget
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$go_url"
|
||||
verify_sha256_file "$go_expected_sha256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
|
||||
+160
-3
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -46,6 +48,10 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS last_active TIMESTAMPTZ`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS active_connections INT NOT NULL DEFAULT 0`,
|
||||
// Keep legacy reseller-owned Xray accounts aligned with weighted quota
|
||||
// accounting. A reseller account always consumes at least one slot.
|
||||
`UPDATE xray_clients SET max_conns = 1
|
||||
WHERE owner_username <> '' AND max_conns < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -231,8 +237,41 @@ func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername stri
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) SumXrayClientQuotaByOwner(ctx context.Context, ownerUsername string) (int, error) {
|
||||
if s == nil || ownerUsername == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var total int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(GREATEST(max_conns, 1)), 0)
|
||||
FROM xray_clients WHERE owner_username=$1`, ownerUsername).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func countOwnedSSHQuota(ownerUsername string) int {
|
||||
total := 0
|
||||
for _, user := range userMgr.List() {
|
||||
if user.Cfg.OwnerUsername == ownerUsername {
|
||||
total += resellerProvisionCost(user.Cfg.MaxConnections)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedXrayQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return 0
|
||||
}
|
||||
total, err := store.SumXrayClientQuotaByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
log.Printf("sum Xray quota for %s: %v", ownerUsername, err)
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
|
||||
return countOwnedSSHQuota(ownerUsername) + countOwnedXrayQuota(ctx, store, ownerUsername)
|
||||
}
|
||||
|
||||
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
|
||||
@@ -262,8 +301,111 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker runs a background goroutine that removes expired
|
||||
// Xray clients from both the config file and the database every 5 minutes.
|
||||
// suspendOwnerXrayClients removes an owner's clients from the live Xray config
|
||||
// while keeping their metadata. That makes reseller suspension reversible.
|
||||
func suspendOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.InboundTag == "" || !present[client.InboundTag][client.UUID] {
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(client.InboundTag, client.UUID); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("suspend Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreOwnerXrayClients restores metadata-backed clients after a reseller is
|
||||
// reactivated. Existing entries are left untouched, so retries are idempotent.
|
||||
func restoreOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.ExpiresAt != nil && time.Now().After(*client.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
if client.InboundTag == "" {
|
||||
continue
|
||||
}
|
||||
clientsForInbound, ok := present[client.InboundTag]
|
||||
if !ok {
|
||||
failures = append(failures, client.UUID+": inbound "+client.InboundTag+" no longer exists")
|
||||
continue
|
||||
}
|
||||
if clientsForInbound[client.UUID] {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(client.Email)
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(client.Name)
|
||||
}
|
||||
if email == "" {
|
||||
email = client.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(client.InboundTag, client.UUID, email); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
clientsForInbound[client.UUID] = true
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("restore Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker removes expired clients from the live config.
|
||||
// Reseller-owned metadata is retained so a paid renewal can restore access.
|
||||
func startXrayClientExpiryChecker(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
@@ -282,20 +424,35 @@ func startXrayClientExpiryChecker(store *Store) {
|
||||
continue
|
||||
}
|
||||
needRestart := false
|
||||
present := make(map[string]map[string]bool)
|
||||
if inbounds, listErr := xrayMgr.ListInbounds(); listErr == nil {
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range expired {
|
||||
tag := m.InboundTag
|
||||
if tag == "" {
|
||||
if m.OwnerUsername == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if present[tag][m.UUID] {
|
||||
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if m.OwnerUsername == "" {
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
|
||||
}
|
||||
if needRestart {
|
||||
|
||||
+178
-48
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -1883,7 +1884,7 @@ func handleXrayStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Start(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "start Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1898,7 +1899,7 @@ func handleXrayStop(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Stop(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "stop Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1913,7 +1914,7 @@ func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "restart Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1938,7 +1939,7 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
data, err := xrayMgr.GetConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read Xray configuration", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1950,10 +1951,21 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.SetConfig(body); err != nil {
|
||||
var raw map[string]interface{}
|
||||
if !json.Valid(body) || json.Unmarshal(body, &raw) != nil || raw == nil {
|
||||
http.Error(w, "invalid Xray JSON configuration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if xrayMgr.useNativeMode() {
|
||||
if err := validateNativeInboundBindings(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := xrayMgr.SetConfig(body); err != nil {
|
||||
writeInternalError(w, "save Xray configuration", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
@@ -1972,13 +1984,13 @@ func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) {
|
||||
wasRunning := xrayMgr.isRunningSnapshot()
|
||||
changed, err := xrayMgr.EnsureStatsAPIConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeInternalError(w, "repair Xray statistics configuration", err)
|
||||
return
|
||||
}
|
||||
restarted := false
|
||||
if wasRunning {
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, "config repaired but restart failed: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "restart Xray after repairing statistics", err)
|
||||
return
|
||||
}
|
||||
restarted = true
|
||||
@@ -2293,7 +2305,7 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "list Xray inbounds", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2446,22 +2458,40 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.InboundTag == "" || req.UUID == "" {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
req.InboundTag = strings.TrimSpace(req.InboundTag)
|
||||
req.UUID = strings.TrimSpace(req.UUID)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.OwnerUsername = strings.TrimSpace(req.OwnerUsername)
|
||||
if err := validateXrayClientFields(req.UUID, req.InboundTag, req.Email, req.Name, req.ExpiresAt, req.MaxConnections, true); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(req.OwnerUsername); err != nil {
|
||||
http.Error(w, "invalid owner username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableXray {
|
||||
http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
chargedCredits, creditCost, creditOwner := false, 0, ""
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
_, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID)
|
||||
if ownerErr != nil {
|
||||
@@ -2472,25 +2502,44 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "UUID already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
|
||||
if ownerErr != nil {
|
||||
writeResellerProvisionError(w, ownerErr)
|
||||
return
|
||||
}
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
creditOwner = sess.Username
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
req.ExpiresAt = expiry
|
||||
}
|
||||
req.OwnerUsername = sess.Username
|
||||
}
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(r.Context(), ms, sess.Username); syncErr != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
writeBadGatewayError(w, "create Xray account on managed server", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
@@ -2504,26 +2553,17 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ownerUsername := ""
|
||||
chargedCredits, creditCost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
if statsStore == nil {
|
||||
http.Error(w, "storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) {
|
||||
if err := adminAccountChainActive(sess.Username); err != nil {
|
||||
http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(req.OwnerUsername) != "" {
|
||||
ownerUsername = strings.TrimSpace(req.OwnerUsername)
|
||||
}
|
||||
@@ -2533,12 +2573,35 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "UUID already exists in database", http.StatusBadRequest)
|
||||
return
|
||||
} else if err != sql.ErrNoRows {
|
||||
http.Error(w, "database error: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "check Xray client metadata", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
var quotaErr error
|
||||
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
|
||||
if quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
req.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
lowerErr := strings.ToLower(err.Error())
|
||||
if strings.Contains(lowerErr, "already exists") {
|
||||
http.Error(w, "UUID already exists", http.StatusConflict)
|
||||
} else if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
|
||||
http.Error(w, "inbound not found", http.StatusBadRequest)
|
||||
} else {
|
||||
writeInternalError(w, "add Xray client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
@@ -2564,7 +2627,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
xrayLogf("xray: save meta for %s: %v", req.UUID, err)
|
||||
_ = xrayMgr.RemoveXrayClient(req.InboundTag, req.UUID)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
http.Error(w, "could not save Xray client", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
@@ -2585,28 +2653,64 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
PreserveExpires bool `json:"preserve_expires,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.UUID == "" {
|
||||
http.Error(w, "uuid required", http.StatusBadRequest)
|
||||
req.UUID = strings.TrimSpace(req.UUID)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if err := validateXrayClientFields(req.UUID, "", req.Email, req.Name, req.ExpiresAt, req.MaxConnections, false); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, req.UUID, sess.Username) {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
row, exists, infoErr := remoteXrayClientInfo(r.Context(), ms, req.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(row["owner_username"])) != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
oldMaxConnections := jsonInt(row["max_conns"])
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(req.ExpiresAt) != "" {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
req.PreserveExpires = true
|
||||
req.MaxConnections = oldMaxConnections
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, oldMaxConnections, req.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "update Xray account on managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2617,16 +2721,34 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "client metadata not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(req.ExpiresAt) != "" && resellerTimeExtended(existing.ExpiresAt, req.ExpiresAt) {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
req.PreserveExpires = true
|
||||
req.MaxConnections = existing.MaxConns
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, existing.MaxConns, req.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
meta := XrayClientMeta{
|
||||
UUID: req.UUID,
|
||||
@@ -2636,6 +2758,9 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerUsername: existing.OwnerUsername,
|
||||
MaxConns: req.MaxConnections,
|
||||
}
|
||||
if req.PreserveExpires {
|
||||
meta.ExpiresAt = existing.ExpiresAt
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, req.ExpiresAt); err == nil {
|
||||
@@ -2645,7 +2770,7 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "update Xray client metadata", err)
|
||||
return
|
||||
}
|
||||
if req.Email != "" {
|
||||
@@ -2664,13 +2789,13 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
inboundTag := r.URL.Query().Get("inbound_tag")
|
||||
uuid := r.URL.Query().Get("uuid")
|
||||
if inboundTag == "" || uuid == "" {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
uuid := strings.TrimSpace(r.URL.Query().Get("uuid"))
|
||||
if err := validateXrayClientFields(uuid, inboundTag, "", "", "", 0, true); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, uuid, sess.Username) {
|
||||
@@ -2680,7 +2805,7 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
remotePath := "/api/xray/clients/remove?inbound_tag=" + url.QueryEscape(inboundTag) + "&uuid=" + url.QueryEscape(uuid)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "delete Xray account from managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2704,7 +2829,12 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
lowerErr := strings.ToLower(err.Error())
|
||||
if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
|
||||
http.Error(w, "inbound not found", http.StatusBadRequest)
|
||||
} else {
|
||||
writeInternalError(w, "remove Xray client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
|
||||
+1
-1
@@ -616,7 +616,7 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "xhttp session queue failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user