From a345e70e5acd987b4e868891bd256c01c41b747d Mon Sep 17 00:00:00 2001 From: penguinehis Date: Mon, 13 Jul 2026 18:01:39 -0300 Subject: [PATCH] Beta 1 --- README.md | 66 +- account_renewal.go | 340 +++++++ account_validation.go | 110 +++ account_validation_test.go | 57 ++ admin/assets/app.css | 1 + admin/assets/js/01-core.js | 12 +- admin/assets/js/02-shell.js | 56 +- admin/assets/js/03-ssh-users.js | 48 +- admin/assets/js/04-xray.js | 40 +- admin/assets/js/05-resellers.js | 312 +++++-- admin/assets/js/09-xray-wizard.js | 10 +- admin/index.html | 66 +- auth.go | 399 ++++---- bot_api.go | 6 +- dnstt_integration.go | 18 +- go-checksums.txt | 5 + go.mod | 12 +- go.sum | 24 +- install.sh | 76 +- main.go | 168 +++- managed_servers.go | 214 +++-- panel_log_limiter.go | 2 +- reseller_management.go | 1399 +++++++++++++++++++++++++++++ reseller_management_test.go | 107 +++ reseller_runtime_state.go | 265 ++++++ security_hardening_test.go | 17 + security_http.go | 15 + server_config_api.go | 4 +- tls_api.go | 20 +- update.sh | 40 +- xray_clients.go | 177 +++- xray_integration.go | 242 +++-- xray_xhttp.go | 2 +- 33 files changed, 3741 insertions(+), 589 deletions(-) create mode 100644 account_renewal.go create mode 100644 account_validation.go create mode 100644 account_validation_test.go create mode 100644 go-checksums.txt create mode 100644 reseller_management.go create mode 100644 reseller_management_test.go create mode 100644 reseller_runtime_state.go diff --git a/README.md b/README.md index 6d21e26..4dde2d8 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/account_renewal.go b/account_renewal.go new file mode 100644 index 0000000..ae398fa --- /dev/null +++ b/account_renewal.go @@ -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) +} diff --git a/account_validation.go b/account_validation.go new file mode 100644 index 0000000..924608f --- /dev/null +++ b/account_validation.go @@ -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) +} diff --git a/account_validation_test.go b/account_validation_test.go new file mode 100644 index 0000000..fce4424 --- /dev/null +++ b/account_validation_test.go @@ -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) + } +} diff --git a/admin/assets/app.css b/admin/assets/app.css index 8f1eefd..85dfeb6 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -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;} diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index a0f172b..9e2ad03 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -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) { diff --git a/admin/assets/js/02-shell.js b/admin/assets/js/02-shell.js index 63a3051..2749605 100644 --- a/admin/assets/js/02-shell.js +++ b/admin/assets/js/02-shell.js @@ -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); + } } } diff --git a/admin/assets/js/03-ssh-users.js b/admin/assets/js/03-ssh-users.js index 2c46719..f819771 100644 --- a/admin/assets/js/03-ssh-users.js +++ b/admin/assets/js/03-ssh-users.js @@ -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"); + } +} diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js index 80c4262..be0ee8a 100644 --- a/admin/assets/js/04-xray.js +++ b/admin/assets/js/04-xray.js @@ -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(); diff --git a/admin/assets/js/05-resellers.js b/admin/assets/js/05-resellers.js index d3d601b..4f6ba1a 100644 --- a/admin/assets/js/05-resellers.js +++ b/admin/assets/js/05-resellers.js @@ -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."; + const res = await api("/api/resellers"); + 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."; + if (e.message === "auth") doAuthError(); + 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 = `Nenhum revendedor direto cadastrado.`; + 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 = ` - ${escapeHTML(r.username)} - ${used} / ${max || "∞"} -
Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}
-
+
${escapeHTML(r.username)} + ${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""} + ${r.whatsapp ? `${escapeHTML(r.whatsapp)}` : ""} +
- ${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"} - ${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"} + + ${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`} +
${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
+ ${isCredit ? "" : `
`} + + ${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"} + ${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"} `; - 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 = ``; + 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; - rPassword.value = ""; - rMaxUsers.value = r.max_users || 0; - rExpires.value = r.expires_at ? localFromISO(r.expires_at) : ""; - rActive.checked = r.is_active; - resellerStatus.textContent = `Editing ${r.username}.`; + 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) : ""; + 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, - max_users: parseInt(rMaxUsers.value||"0",10), - expires_at: isoFromLocal(rExpires.value), - is_active: rActive.checked, + 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), + 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(); + const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) }); + 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; } + if (e.message === "auth") doAuthError(); + 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:"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."), - confirmLabel:t("Delete reseller"), + 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 = `Deleting ${username}…`; + resellerStatus.textContent = `${labels[action]}…`; 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/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 resellerStatus.textContent = "Error deleting."; + 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: 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 = t("Deleting {name}…", {name: reseller.username}); + try { + 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 showPanelToast(e.message || "Falha ao excluir.", "error", t("Delete reseller")); + } +} + +async function loadResellerAudit() { + const body = document.getElementById("resellerAuditBody"); + if (!body) return; + body.innerHTML = `Carregando atividade…`; + 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 = `Nenhuma atividade registrada.`; + 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 = `Falha ao carregar a atividade.`; } } diff --git a/admin/assets/js/09-xray-wizard.js b/admin/assets/js/09-xray-wizard.js index 08fbd42..ef468b2 100644 --- a/admin/assets/js/09-xray-wizard.js +++ b/admin/assets/js/09-xray-wizard.js @@ -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(), }; diff --git a/admin/index.html b/admin/index.html index b39502d..a5b415d 100644 --- a/admin/index.html +++ b/admin/index.html @@ -16,7 +16,7 @@ setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); })(); - +
@@ -51,7 +51,7 @@ - + @@ -656,16 +656,26 @@
- +
-
Partner operations

Revendedores

Controle cotas, validade e acesso dos parceiros em um só lugar.

+
+
Partner operations

Revendedores

Controle hierarquia, créditos, cotas, validade e acesso dos parceiros em um só lugar.

+
Aguardando dados
+
+
Revendedores--parceiros diretos
+
Ativos--acesso liberado
+
#
Alocação--slots reservados
+
Cr
Créditos--saldo nos parceiros
+
+
-
@@ -682,7 +692,7 @@
- +
UsernameUsers (used/max)ExpiresStatusActionsContaPlano e usoValidadeStatusAções
@@ -702,9 +712,14 @@
-
-
-
+
+
Sub-revendedores herdam o modo da conta pai.
+
+ +
+
+
+
@@ -712,6 +727,13 @@
+ +
+
03 · Auditoria

Atividade das revendas

Veja quem criou, alterou, renovou, suspendeu ou removeu cada conta.

+
+
QuandoResponsávelRevendedorAçãoDetalhes
+
+
Ready.
@@ -1538,17 +1560,17 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/auth.go b/auth.go index 44309fa..709bd64 100644 --- a/auth.go +++ b/auth.go @@ -24,6 +24,8 @@ import ( const ( RoleSuperAdmin = "superadmin" RoleReseller = "reseller" + QuotaModeSlots = "slots" + QuotaModeCredit = "credits" sessionTTL = 12 * time.Hour adminBcryptCost = 12 ) @@ -33,14 +35,19 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ // ---------- AdminUser ---------- type AdminUser struct { - ID int - Username string - PasswordHash string - Role string - MaxUsers int - ExpiresAt *time.Time - IsActive bool - CreatedAt time.Time + ID int + Username string + PasswordHash string + Role string + MaxUsers int + ParentUsername string + QuotaMode string + CreditBalance int + WhatsApp string + MonthlyPriceCents int + ExpiresAt *time.Time + IsActive bool + CreatedAt time.Time } // ---------- Session store (in-memory) ---------- @@ -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 == sql.ErrNoRows { - return nil, nil - } 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 + } 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) - } - 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 + // 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) + } } - 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. diff --git a/bot_api.go b/bot_api.go index bd4a5f2..24c4322 100644 --- a/bot_api.go +++ b/bot_api.go @@ -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) diff --git a/dnstt_integration.go b/dnstt_integration.go index bc69d9b..cff1535 100644 --- a/dnstt_integration.go +++ b/dnstt_integration.go @@ -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) diff --git a/go-checksums.txt b/go-checksums.txt new file mode 100644 index 0000000..b44f229 --- /dev/null +++ b/go-checksums.txt @@ -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 diff --git a/go.mod b/go.mod index 5908e01..7dc087e 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b277c72..ea94704 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh index dabfac9..157bd75 100644 --- a/install.sh +++ b/install.sh @@ -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 ──────────────────────────────────────────────────────────── diff --git a/main.go b/main.go index 38e0468..9d1ece3 100644 --- a/main.go +++ b/main.go @@ -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) - return + 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 + } + p.PreserveExpires = true + p.MaxConnections = jsonInt(row["max_connections"]) } - if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers { - http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) + 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. diff --git a/managed_servers.go b/managed_servers.go index 536065c..3a0c6bb 100644 --- a/managed_servers.go +++ b/managed_servers.go @@ -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,35 +709,42 @@ 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 strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil + return row, true, nil } } - return "", false, 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 } func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool { @@ -689,31 +752,40 @@ func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner 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 + // 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 resellerQuotaUsage{}, err } - if ms.EnableSSH { - 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 - } - var users []map[string]interface{} - if err := json.Unmarshal(data, &users); err != nil { - return 0, err - } - for _, user := range users { - if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner { - total++ - } + var users []map[string]interface{} + if err := json.Unmarshal(data, &users); err != nil { + return resellerQuotaUsage{}, err + } + for _, user := range users { + if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner { + 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") - if err != nil || status < 200 || status >= 300 { - if err == nil { - err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status) - } - return 0, err + 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) } - var inbounds []map[string]interface{} - if err := json.Unmarshal(data, &inbounds); err != nil { - return 0, 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++ - } + return resellerQuotaUsage{}, err + } + var inbounds []map[string]interface{} + if err := json.Unmarshal(data, &inbounds); err != nil { + 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 { + usage.Weighted += resellerProvisionCost(jsonInt(item["max_conns"])) + usage.XrayAccounts++ } } } } - return total, nil + return usage, nil +} + +func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) { + usage, err := ownedQuotaUsageAcrossManagedServers(ctx, store, owner) + return usage.Weighted, err } diff --git a/panel_log_limiter.go b/panel_log_limiter.go index b52ef7d..d038494 100644 --- a/panel_log_limiter.go +++ b/panel_log_limiter.go @@ -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") diff --git a/reseller_management.go b/reseller_management.go new file mode 100644 index 0000000..6039558 --- /dev/null +++ b/reseller_management.go @@ -0,0 +1,1399 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +var ( + errResellerForbidden = errors.New("reseller is outside your management scope") + errResellerQuota = errors.New("insufficient reseller quota") + errResellerCredits = errors.New("insufficient reseller credits") + errResellerHasUsage = errors.New("limit cannot be lower than current committed usage") + errResellerInvalidState = errors.New("reseller cannot be activated while its parent is inactive or expired") + errResellerParent = errors.New("parent reseller not found") + errResellerPlan = errors.New("sub-reseller plan must match its parent") + errResellerConnLimit = errors.New("reseller accounts require at least one allowed connection") + errCreditLimitImmutable = errors.New("the connection limit of a credit account cannot be changed") +) + +var ( + resellerQuotaLocks sync.Map + resellerLifecycleMu sync.Mutex +) + +func lockResellerQuota(username string) func() { + value, _ := resellerQuotaLocks.LoadOrStore(strings.TrimSpace(username), &sync.Mutex{}) + mu := value.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +func lockResellerQuotaSet(usernames []string) func() { + unique := make(map[string]struct{}, len(usernames)) + ordered := make([]string, 0, len(usernames)) + for _, username := range usernames { + username = strings.TrimSpace(username) + if username == "" { + continue + } + if _, exists := unique[username]; exists { + continue + } + unique[username] = struct{}{} + ordered = append(ordered, username) + } + sort.Strings(ordered) + unlocks := make([]func(), 0, len(ordered)) + for _, username := range ordered { + unlocks = append(unlocks, lockResellerQuota(username)) + } + return func() { + for i := len(unlocks) - 1; i >= 0; i-- { + unlocks[i]() + } + } +} + +func resellerSubtreeUsernames(users []*AdminUser) []string { + usernames := make([]string, 0, len(users)) + for _, user := range users { + if user != nil { + usernames = append(usernames, user.Username) + } + } + return usernames +} + +func normalizeQuotaMode(mode string) string { + if strings.EqualFold(strings.TrimSpace(mode), QuotaModeCredit) || strings.EqualFold(strings.TrimSpace(mode), "credito") { + return QuotaModeCredit + } + return QuotaModeSlots +} + +func normalizeWhatsApp(value string) string { + value = strings.TrimSpace(value) + var b strings.Builder + for i, r := range value { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } else if r == '+' && i == 0 { + b.WriteRune(r) + } + if b.Len() >= 32 { + break + } + } + return b.String() +} + +func resellerProvisionCost(maxConnections int) int { + if maxConnections < 1 { + return 1 + } + return maxConnections +} + +func adminAccountChainActive(username string) error { + username = strings.TrimSpace(username) + if username == "" { + return nil + } + 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 + parentUsername := "" + active := false + var expiresAt *time.Time + if u, ok := adminUsers.get(username); ok { + active = u.IsActive + expiresAt = u.ExpiresAt + parentUsername = u.ParentUsername + } else if state, ok := resellerRuntimeStates.get(username); ok { + active = state.IsActive + expiresAt = state.ExpiresAt + parentUsername = state.ParentUsername + } else { + return fmt.Errorf("reseller account not found") + } + if !active { + return fmt.Errorf("reseller account suspended") + } + if expiresAt != nil && now.After(*expiresAt) { + return fmt.Errorf("reseller account expired") + } + username = strings.TrimSpace(parentUsername) + } + if username != "" { + return fmt.Errorf("reseller hierarchy is too deep") + } + return nil +} + +func resellerCanManage(sess *AdminSession, target *AdminUser) bool { + if sess == nil || target == nil || target.Role != RoleReseller { + return false + } + if sess.Role == RoleSuperAdmin { + return true + } + return sess.Role == RoleReseller && target.ParentUsername == sess.Username +} + +func listResellerSubtree(all []*AdminUser, root string) []*AdminUser { + children := make(map[string][]*AdminUser) + byUsername := make(map[string]*AdminUser) + for _, u := range all { + if u == nil || u.Role != RoleReseller { + continue + } + byUsername[u.Username] = u + children[u.ParentUsername] = append(children[u.ParentUsername], u) + } + rootUser := byUsername[root] + if rootUser == nil { + return nil + } + out := make([]*AdminUser, 0, 4) + queue := []*AdminUser{rootUser} + seen := make(map[string]bool) + for len(queue) > 0 { + u := queue[0] + queue = queue[1:] + if u == nil || seen[u.Username] { + continue + } + seen[u.Username] = true + out = append(out, u) + queue = append(queue, children[u.Username]...) + } + return out +} + +func (s *Store) directChildAllocation(ctx context.Context, parentUsername, excludeUsername string) (int, error) { + var total int + err := s.db.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(max_users), 0) + FROM admin_users + WHERE role=$1 AND parent_username=$2 AND quota_mode=$3 AND username<>$4`, + RoleReseller, parentUsername, QuotaModeSlots, excludeUsername).Scan(&total) + return total, err +} + +func (s *Store) directChildCount(ctx context.Context, parentUsername string) int { + var total int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM admin_users WHERE role=$1 AND parent_username=$2`, + RoleReseller, parentUsername).Scan(&total); err != nil { + log.Printf("count child resellers for %s: %v", parentUsername, err) + return 0 + } + return total +} + +func (s *Store) appendResellerAudit(ctx context.Context, actor, target, action, details string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO reseller_audit_log (actor_username, target_username, action, details) + VALUES ($1,$2,$3,$4)`, actor, target, action, details) + return err +} + +type ResellerDTO struct { + ID int `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + ParentUsername string `json:"parent_username,omitempty"` + QuotaMode string `json:"quota_mode"` + MaxUsers int `json:"max_users"` + CreditBalance int `json:"credit_balance"` + UsedUsers int `json:"used_users"` + UsedSSH int `json:"used_ssh_users"` + UsedXray int `json:"used_xray_users"` + ChildAllocation int `json:"child_allocation"` + ChildCount int `json:"child_count"` + Available int `json:"available"` + UsageIncomplete bool `json:"usage_incomplete,omitempty"` + WhatsApp string `json:"whatsapp,omitempty"` + MonthlyPriceCents int `json:"monthly_price_cents"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + IsActive bool `json:"is_active"` + EffectiveActive bool `json:"effective_active"` + CreatedAt time.Time `json:"created_at"` +} + +type ResellerPayload struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` + ParentUsername string `json:"parent_username,omitempty"` + QuotaMode string `json:"quota_mode"` + MaxUsers int `json:"max_users"` + Credits int `json:"credits"` + ExpiresAt string `json:"expires_at"` + IsActive bool `json:"is_active"` + WhatsApp string `json:"whatsapp"` + MonthlyPriceCents int `json:"monthly_price_cents"` +} + +func handleListResellers(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + sess := sessionFromCtx(r.Context()) + users, err := store.ListAdminUsers(r.Context()) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + out := make([]ResellerDTO, 0, len(users)) + for _, u := range users { + if u.Role != RoleReseller || !resellerCanManage(sess, u) { + continue + } + usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), store, u.Username) + used := usage.Weighted + usedSSH := usage.SSHAccounts + usedXray := usage.XrayAccounts + if usageErr != nil { + used = countOwnedQuota(r.Context(), store, u.Username) + usedSSH = countOwnedUsers(u.Username) + usedXray = countOwnedXrayClients(r.Context(), store, u.Username) + } + childAllocation, _ := store.directChildAllocation(r.Context(), u.Username, "") + available := u.CreditBalance + if normalizeQuotaMode(u.QuotaMode) == QuotaModeSlots { + if u.MaxUsers == 0 { + available = -1 + } else { + available = max(0, u.MaxUsers-used-childAllocation) + } + } + out = append(out, ResellerDTO{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + ParentUsername: u.ParentUsername, + QuotaMode: normalizeQuotaMode(u.QuotaMode), + MaxUsers: u.MaxUsers, + CreditBalance: u.CreditBalance, + UsedUsers: used, + UsedSSH: usedSSH, + UsedXray: usedXray, + ChildAllocation: childAllocation, + ChildCount: store.directChildCount(r.Context(), u.Username), + Available: available, + UsageIncomplete: usageErr != nil, + WhatsApp: u.WhatsApp, + MonthlyPriceCents: u.MonthlyPriceCents, + ExpiresAt: u.ExpiresAt, + IsActive: u.IsActive, + EffectiveActive: adminAccountChainActive(u.Username) == nil, + CreatedAt: u.CreatedAt, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) + } +} + +func parseResellerExpiry(raw string) (*time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return nil, fmt.Errorf("invalid expires_at (RFC3339 required)") + } + return &t, nil +} + +func (s *Store) saveResellerRecord(ctx context.Context, actor string, desired, existing *AdminUser, parentDirectUsed int) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if desired.ParentUsername != "" { + var parentMode string + var parentMax, parentCredits int + var parentActive bool + var parentExpiry sql.NullTime + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(quota_mode, 'slots'), max_users, COALESCE(credit_balance, 0), is_active, expires_at + FROM admin_users WHERE username=$1 AND role=$2 FOR UPDATE`, + desired.ParentUsername, RoleReseller).Scan(&parentMode, &parentMax, &parentCredits, &parentActive, &parentExpiry); err != nil { + if err == sql.ErrNoRows { + return errResellerParent + } + return err + } + if !parentActive || (parentExpiry.Valid && time.Now().After(parentExpiry.Time)) { + return errResellerInvalidState + } + parentMode = normalizeQuotaMode(parentMode) + if parentMode != desired.QuotaMode { + return errResellerPlan + } + if parentMode == QuotaModeSlots { + var allocated int + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(max_users), 0) FROM admin_users + WHERE role=$1 AND parent_username=$2 AND quota_mode=$3 AND username<>$4`, + RoleReseller, desired.ParentUsername, QuotaModeSlots, desired.Username).Scan(&allocated); err != nil { + return err + } + if parentMax > 0 && parentDirectUsed+allocated+desired.MaxUsers > parentMax { + return errResellerQuota + } + } else { + oldCredits := 0 + if existing != nil { + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(credit_balance, 0) FROM admin_users + WHERE id=$1 AND role=$2 FOR UPDATE`, existing.ID, RoleReseller).Scan(&oldCredits); err != nil { + return err + } + } + diff := desired.CreditBalance - oldCredits + if diff > parentCredits { + return errResellerCredits + } + if diff != 0 { + parentCredits -= diff + if _, err := tx.ExecContext(ctx, + `UPDATE admin_users SET credit_balance=$1 WHERE username=$2`, + parentCredits, desired.ParentUsername); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO reseller_credit_ledger + (reseller_username, actor_username, delta, balance_after, action, target) + VALUES ($1,$2,$3,$4,$5,$6)`, + desired.ParentUsername, actor, -diff, parentCredits, "sub-reseller transfer", desired.Username); err != nil { + return err + } + } + } + } + + var expiresAt interface{} + if desired.ExpiresAt != nil { + expiresAt = *desired.ExpiresAt + } + if existing == nil { + if err := tx.QueryRowContext(ctx, ` + 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, created_at`, + desired.Username, desired.PasswordHash, RoleReseller, desired.MaxUsers, + desired.ParentUsername, desired.QuotaMode, desired.CreditBalance, desired.WhatsApp, + desired.MonthlyPriceCents, expiresAt, desired.IsActive).Scan(&desired.ID, &desired.CreatedAt); err != nil { + return err + } + } else { + if _, err := tx.ExecContext(ctx, ` + UPDATE admin_users SET password_hash=$2, max_users=$3, credit_balance=$4, + whatsapp=$5, monthly_price_cents=$6, expires_at=$7, is_active=$8 + WHERE id=$1 AND role=$9`, + desired.ID, desired.PasswordHash, desired.MaxUsers, desired.CreditBalance, + desired.WhatsApp, desired.MonthlyPriceCents, expiresAt, desired.IsActive, + RoleReseller); err != nil { + return err + } + } + details := fmt.Sprintf("mode=%s limit=%d credits=%d parent=%s active=%t", + desired.QuotaMode, desired.MaxUsers, desired.CreditBalance, desired.ParentUsername, desired.IsActive) + action := "created reseller" + if existing != nil { + action = "updated reseller" + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO reseller_audit_log (actor_username, target_username, action, details) + VALUES ($1,$2,$3,$4)`, actor, desired.Username, action, details); err != nil { + return err + } + return tx.Commit() +} + +func handleCreateReseller(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + var p ResellerPayload + dec := json.NewDecoder(r.Body) + 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 := validateAdminUsername(p.Username); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if p.MaxUsers < 0 || p.MaxUsers > 1000000 || p.Credits < 0 || p.Credits > 1000000000 { + http.Error(w, "limit is outside the allowed range", http.StatusBadRequest) + return + } + if p.MonthlyPriceCents < 0 || p.MonthlyPriceCents > 1000000000 { + http.Error(w, "monthly price is outside the allowed range", http.StatusBadRequest) + return + } + + ctx := r.Context() + sess := sessionFromCtx(ctx) + if sess == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + resellerLifecycleMu.Lock() + defer resellerLifecycleMu.Unlock() + existing, err := store.GetAdminUserByUsername(ctx, p.Username) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if existing != nil && !resellerCanManage(sess, existing) { + http.Error(w, errResellerForbidden.Error(), http.StatusForbidden) + return + } + + desired := &AdminUser{Username: p.Username, Role: RoleReseller, IsActive: p.IsActive} + if existing != nil { + *desired = *existing + desired.IsActive = p.IsActive + } + if existing == nil && strings.TrimSpace(p.Password) == "" { + http.Error(w, "password required for new account", http.StatusBadRequest) + return + } + if strings.TrimSpace(p.Password) != "" { + if err := validateAdminPassword(p.Password); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + hash, err := hashAdminPassword(p.Password) + if err != nil { + http.Error(w, "failed to hash password", http.StatusInternalServerError) + return + } + desired.PasswordHash = hash + } + + if existing == nil { + desired.ParentUsername = strings.TrimSpace(p.ParentUsername) + desired.QuotaMode = normalizeQuotaMode(p.QuotaMode) + if sess.Role == RoleReseller { + parent, ok := adminUsers.get(sess.Username) + if !ok { + http.Error(w, "parent reseller not found", http.StatusForbidden) + return + } + desired.ParentUsername = sess.Username + desired.QuotaMode = normalizeQuotaMode(parent.QuotaMode) + } + } else { + if strings.TrimSpace(p.ParentUsername) != "" && strings.TrimSpace(p.ParentUsername) != existing.ParentUsername { + http.Error(w, "a reseller parent cannot be changed from the edit form", http.StatusBadRequest) + return + } + if strings.TrimSpace(p.QuotaMode) != "" && normalizeQuotaMode(p.QuotaMode) != normalizeQuotaMode(existing.QuotaMode) { + http.Error(w, "plan mode cannot be changed after creation", http.StatusBadRequest) + return + } + desired.QuotaMode = normalizeQuotaMode(existing.QuotaMode) + } + + if desired.ParentUsername != "" { + parent, err := store.GetAdminUserByUsername(ctx, desired.ParentUsername) + if err != nil || parent == nil || parent.Role != RoleReseller { + http.Error(w, "parent reseller not found", http.StatusBadRequest) + return + } + if adminAccountChainActive(parent.Username) != nil { + http.Error(w, "parent reseller is inactive or expired", http.StatusForbidden) + return + } + if normalizeQuotaMode(parent.QuotaMode) != desired.QuotaMode { + http.Error(w, "sub-reseller plan must match its parent", http.StatusBadRequest) + return + } + } + + if desired.QuotaMode == QuotaModeCredit { + desired.MaxUsers = 0 + desired.CreditBalance = p.Credits + desired.ExpiresAt = nil + } else { + desired.MaxUsers = p.MaxUsers + desired.CreditBalance = 0 + desired.ExpiresAt, err = parseResellerExpiry(p.ExpiresAt) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if desired.ParentUsername != "" && desired.MaxUsers < 1 { + http.Error(w, "sub-reseller limit must be at least 1", http.StatusBadRequest) + return + } + } + desired.WhatsApp = normalizeWhatsApp(p.WhatsApp) + desired.MonthlyPriceCents = p.MonthlyPriceCents + lockNames := []string{desired.ParentUsername} + if existing != nil { + lockNames = append(lockNames, desired.Username) + } + quotaUnlock := lockResellerQuotaSet(lockNames) + defer quotaUnlock() + + if desired.IsActive && desired.ParentUsername != "" && adminAccountChainActive(desired.ParentUsername) != nil { + http.Error(w, errResellerInvalidState.Error(), http.StatusForbidden) + return + } + if desired.QuotaMode == QuotaModeSlots && existing != nil && desired.MaxUsers > 0 { + directUsed, usageErr := countOwnedQuotaAcrossManagedServers(ctx, store, desired.Username) + if usageErr != nil { + http.Error(w, "could not verify current reseller usage", http.StatusBadGateway) + return + } + children, childErr := store.directChildAllocation(ctx, desired.Username, "") + if childErr != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if directUsed+children > desired.MaxUsers { + http.Error(w, errResellerHasUsage.Error(), http.StatusConflict) + return + } + } + + parentDirectUsed := 0 + if desired.ParentUsername != "" && desired.QuotaMode == QuotaModeSlots { + parentDirectUsed, err = countOwnedQuotaAcrossManagedServers(ctx, store, desired.ParentUsername) + if err != nil { + http.Error(w, "could not verify parent quota", http.StatusBadGateway) + return + } + } + wasActive := existing != nil && adminAccountChainActive(existing.Username) == nil + if err := store.saveResellerRecord(ctx, sess.Username, desired, existing, parentDirectUsed); err != nil { + switch { + case errors.Is(err, errResellerQuota), errors.Is(err, errResellerCredits), errors.Is(err, errResellerHasUsage): + http.Error(w, err.Error(), http.StatusConflict) + case errors.Is(err, errResellerParent), errors.Is(err, errResellerPlan): + http.Error(w, err.Error(), http.StatusBadRequest) + case errors.Is(err, errResellerInvalidState): + http.Error(w, err.Error(), http.StatusConflict) + default: + writeInternalError(w, "save reseller "+desired.Username, err) + } + return + } + if err := loadAdminUsersIntoCache(ctx, store); err != nil { + log.Printf("reload reseller cache: %v", err) + } + if existing != nil && strings.TrimSpace(p.Password) != "" { + sessions.DeleteUser(desired.ID) + } + isActive := adminAccountChainActive(desired.Username) == nil + if wasActive != isActive { + if err := applyResellerSubtreeRuntime(ctx, store, desired.Username, isActive); err != nil { + log.Printf("apply reseller runtime for %s: %v", desired.Username, err) + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"username": desired.Username, "created": existing == nil}) + } +} + +type resellerRuntimePayload struct { + Owner string `json:"owner_username"` + ParentUsername string `json:"parent_username,omitempty"` + Action string `json:"action"` + EffectiveActive bool `json:"effective_active"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +func resellerRuntimePayloadFromState(state ResellerRuntimeState, action string) resellerRuntimePayload { + return resellerRuntimePayload{ + Owner: state.OwnerUsername, + ParentUsername: state.ParentUsername, + Action: action, + EffectiveActive: state.IsActive, + ExpiresAt: state.ExpiresAt, + } +} + +func sendResellerRuntimeToServer(ctx context.Context, ms *ManagedServer, payload resellerRuntimePayload) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + status, data, _, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/resellers/runtime", body, "application/json") + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("HTTP %d: %q", status, remoteErrorSnippet(data)) + } + return nil +} + +func applyOwnerRuntimeLocal(ctx context.Context, store *Store, owner, action string) error { + switch action { + case "suspend": + disconnectOwnerUsers(owner) + return suspendOwnerXrayClients(ctx, store, owner) + case "reactivate": + return restoreOwnerXrayClients(ctx, store, owner) + case "delete": + rows, err := store.db.QueryContext(ctx, `SELECT username FROM ssh_users WHERE owner_username=$1`, owner) + if err != nil { + return err + } + var usernames []string + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + rows.Close() + return err + } + usernames = append(usernames, username) + } + if err := rows.Close(); err != nil { + return err + } + for _, username := range usernames { + userMgr.DisconnectUser(username) + } + if _, err := store.db.ExecContext(ctx, `DELETE FROM ssh_users WHERE owner_username=$1`, owner); err != nil { + return err + } + removeOwnerXrayClients(ctx, store, owner) + reloadUsersFromDB(ctx, store) + return nil + default: + return fmt.Errorf("unsupported runtime action") + } +} + +func applyOwnerRuntimeRemote(ctx context.Context, store *Store, owner, action string) error { + servers, err := store.ListManagedServers(ctx) + if err != nil { + return err + } + effectiveActive := action == "reactivate" && adminAccountChainActive(owner) == nil + state, err := resellerRuntimeStateFor(owner, effectiveActive) + if err != nil { + return err + } + payload := resellerRuntimePayloadFromState(state, action) + var failures []string + for _, ms := range servers { + // Cleanup also targets disabled nodes: skipping one could leave access that + // becomes live again when the node or protocol is re-enabled. + if err := sendResellerRuntimeToServer(ctx, ms, payload); err != nil { + failures = append(failures, ms.Name+": "+err.Error()) + } + } + if len(failures) > 0 { + return fmt.Errorf("managed server runtime update failed: %s", strings.Join(failures, "; ")) + } + return nil +} + +func applyResellerSubtreeRuntime(ctx context.Context, store *Store, root string, active bool) error { + all, err := store.ListAdminUsers(ctx) + if err != nil { + return err + } + action := "suspend" + if active { + action = "reactivate" + } + var failures []string + for _, owner := range listResellerSubtree(all, root) { + if active && adminAccountChainActive(owner.Username) != nil { + continue + } + if err := applyOwnerRuntimeLocal(ctx, store, owner.Username, action); err != nil { + failures = append(failures, "local "+owner.Username+": "+err.Error()) + } + if err := applyOwnerRuntimeRemote(ctx, store, owner.Username, action); err != nil { + failures = append(failures, owner.Username+": "+err.Error()) + } + } + if len(failures) > 0 { + return errors.New(strings.Join(failures, "; ")) + } + return nil +} + +func handleResellerRuntime(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var p resellerRuntimePayload + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&p); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + p.Owner = strings.TrimSpace(p.Owner) + p.ParentUsername = strings.TrimSpace(p.ParentUsername) + if err := validateAdminUsername(p.Owner); err != nil { + http.Error(w, "invalid owner", http.StatusBadRequest) + return + } + if p.ParentUsername != "" { + if err := validateAdminUsername(p.ParentUsername); err != nil || p.ParentUsername == p.Owner { + http.Error(w, "invalid parent owner", http.StatusBadRequest) + return + } + } + p.Action = strings.ToLower(strings.TrimSpace(p.Action)) + state := ResellerRuntimeState{ + OwnerUsername: p.Owner, + ParentUsername: p.ParentUsername, + IsActive: p.EffectiveActive, + ExpiresAt: p.ExpiresAt, + } + ctx := r.Context() + switch p.Action { + case "sync": + if err := store.UpsertResellerRuntimeState(ctx, state); err != nil { + log.Printf("sync reseller runtime state for %s: %v", p.Owner, err) + http.Error(w, "runtime state update failed", http.StatusInternalServerError) + return + } + action := "suspend" + if resellerRuntimeChainActive(state.OwnerUsername) == nil { + action = "reactivate" + } + if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, action); err != nil { + if action == "reactivate" { + // State synchronization is still useful for SSH authentication even + // when an unrelated legacy Xray client cannot be restored. + log.Printf("restore Xray while syncing reseller %s: %v", p.Owner, err) + break + } + log.Printf("reconcile synced reseller runtime for %s: %v", p.Owner, err) + http.Error(w, "runtime update failed", http.StatusInternalServerError) + return + } + case "suspend": + state.IsActive = false + if err := store.UpsertResellerRuntimeState(ctx, state); err != nil { + log.Printf("suspend reseller runtime state for %s: %v", p.Owner, err) + http.Error(w, "runtime state update failed", http.StatusInternalServerError) + return + } + if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, "suspend"); err != nil { + log.Printf("suspend reseller runtime for %s: %v", p.Owner, err) + http.Error(w, "runtime update failed", http.StatusInternalServerError) + return + } + case "reactivate": + state.IsActive = true + if err := store.UpsertResellerRuntimeState(ctx, state); err != nil { + log.Printf("reactivate reseller runtime state for %s: %v", p.Owner, err) + http.Error(w, "runtime state update failed", http.StatusInternalServerError) + return + } + if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, "reactivate"); err != nil { + state.IsActive = false + _ = store.UpsertResellerRuntimeState(ctx, state) + _ = applyOwnerRuntimeLocal(ctx, store, p.Owner, "suspend") + log.Printf("reactivate reseller runtime for %s: %v", p.Owner, err) + http.Error(w, "runtime update failed", http.StatusInternalServerError) + return + } + case "delete": + state.IsActive = false + if err := store.UpsertResellerRuntimeState(ctx, state); err != nil { + log.Printf("block reseller runtime state before delete for %s: %v", p.Owner, err) + http.Error(w, "runtime state update failed", http.StatusInternalServerError) + return + } + if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, "delete"); err != nil { + log.Printf("delete reseller runtime for %s: %v", p.Owner, err) + http.Error(w, "runtime cleanup failed", http.StatusInternalServerError) + return + } + if err := store.DeleteResellerRuntimeState(ctx, p.Owner); err != nil { + log.Printf("delete reseller runtime state for %s: %v", p.Owner, err) + http.Error(w, "runtime state cleanup failed", http.StatusInternalServerError) + return + } + default: + http.Error(w, "unsupported runtime action", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +type resellerActionPayload struct { + Username string `json:"username"` + Action string `json:"action"` + Days int `json:"days,omitempty"` +} + +func handleResellerAction(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var p resellerActionPayload + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&p); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + p.Username = strings.TrimSpace(p.Username) + resellerLifecycleMu.Lock() + defer resellerLifecycleMu.Unlock() + target, err := store.GetAdminUserByUsername(r.Context(), p.Username) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + sess := sessionFromCtx(r.Context()) + if target == nil { + http.Error(w, "reseller not found", http.StatusNotFound) + return + } + if !resellerCanManage(sess, target) { + http.Error(w, errResellerForbidden.Error(), http.StatusForbidden) + return + } + all, err := store.ListAdminUsers(r.Context()) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(listResellerSubtree(all, target.Username))) + defer quotaUnlock() + // The target may have changed while another lifecycle action held the + // subtree lock, so base this action on a fresh row. + target, err = store.GetAdminUserByUsername(r.Context(), p.Username) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if target == nil { + http.Error(w, "reseller not found", http.StatusNotFound) + return + } + if !resellerCanManage(sess, target) { + http.Error(w, errResellerForbidden.Error(), http.StatusForbidden) + return + } + + action := strings.ToLower(strings.TrimSpace(p.Action)) + details := "" + active := target.IsActive + expiresAt := target.ExpiresAt + switch action { + case "suspend": + active = false + case "reactivate": + if target.ExpiresAt != nil && time.Now().After(*target.ExpiresAt) { + http.Error(w, "renew this reseller before reactivating it", http.StatusConflict) + return + } + if target.ParentUsername != "" && adminAccountChainActive(target.ParentUsername) != nil { + http.Error(w, errResellerInvalidState.Error(), http.StatusConflict) + return + } + active = true + case "renew": + if normalizeQuotaMode(target.QuotaMode) == QuotaModeCredit { + http.Error(w, "credit resellers do not expire", http.StatusBadRequest) + return + } + if p.Days == 0 { + p.Days = 30 + } + if p.Days < 1 || p.Days > 3650 { + http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest) + return + } + base := time.Now() + if target.ExpiresAt != nil && target.ExpiresAt.After(base) { + base = *target.ExpiresAt + } + next := base.AddDate(0, 0, p.Days) + expiresAt = &next + active = true + details = "days=" + strconv.Itoa(p.Days) + case "pull": + if sess.Role != RoleSuperAdmin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if target.ParentUsername == "" { + http.Error(w, "reseller is already attached to the main panel", http.StatusConflict) + return + } + details = "previous_parent=" + target.ParentUsername + default: + http.Error(w, "unsupported reseller action", http.StatusBadRequest) + return + } + + tx, err := store.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + updateQuery := `UPDATE admin_users SET is_active=$1, expires_at=$2 WHERE id=$3 AND role=$4` + updateArgs := []interface{}{active, expiresAt, target.ID, RoleReseller} + if action == "pull" { + updateQuery = `UPDATE admin_users SET parent_username='' WHERE id=$1 AND role=$2` + updateArgs = []interface{}{target.ID, RoleReseller} + } + if _, err := tx.ExecContext(r.Context(), updateQuery, updateArgs...); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if _, err := tx.ExecContext(r.Context(), ` + INSERT INTO reseller_audit_log (actor_username, target_username, action, details) + VALUES ($1,$2,$3,$4)`, sess.Username, target.Username, action+" reseller", details); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + _ = loadAdminUsersIntoCache(r.Context(), store) + if !active { + sessions.DeleteUser(target.ID) + } + runtimeActive := active + if action == "pull" { + runtimeActive = adminAccountChainActive(target.Username) == nil + } + runtimeErr := applyResellerSubtreeRuntime(r.Context(), store, target.Username, runtimeActive) + runtimeWarning := "" + if runtimeErr != nil { + log.Printf("apply reseller action runtime for %s: %v", target.Username, runtimeErr) + runtimeWarning = "Some managed servers could not be updated. Check server status and retry." + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "runtime_warning": runtimeWarning, + }) + } +} + +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() + sess := sessionFromCtx(ctx) + resellerLifecycleMu.Lock() + defer resellerLifecycleMu.Unlock() + target, err := store.GetAdminUserByUsername(ctx, username) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if target == nil { + http.Error(w, "reseller not found", http.StatusNotFound) + return + } + if !resellerCanManage(sess, target) { + http.Error(w, errResellerForbidden.Error(), http.StatusForbidden) + return + } + all, err := store.ListAdminUsers(ctx) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + subtree := listResellerSubtree(all, username) + quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(subtree)) + defer quotaUnlock() + // Reload balances after acquiring the provisioning locks. This prevents an + // in-flight credit spend or child transfer from being refunded twice. + all, err = store.ListAdminUsers(ctx) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + subtree = listResellerSubtree(all, username) + if len(subtree) == 0 { + http.Error(w, "reseller not found", http.StatusNotFound) + return + } + // Suspend first and refresh the session cache before distributed cleanup. + // A failed node cleanup can then be retried safely without allowing new + // accounts to appear underneath the reseller being deleted. + blockTx, err := store.db.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + for _, owner := range subtree { + if _, err := blockTx.ExecContext(ctx, `UPDATE admin_users SET is_active=FALSE WHERE id=$1 AND role=$2`, owner.ID, RoleReseller); err != nil { + _ = blockTx.Rollback() + http.Error(w, "database error", http.StatusInternalServerError) + return + } + } + if err := blockTx.Commit(); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + _ = loadAdminUsersIntoCache(ctx, store) + for _, owner := range subtree { + sessions.DeleteUser(owner.ID) + } + for _, owner := range subtree { + if err := applyOwnerRuntimeRemote(ctx, store, owner.Username, "delete"); err != nil { + writeBadGatewayError(w, "delete reseller resources from managed servers", err) + return + } + } + for i := len(subtree) - 1; i >= 0; i-- { + if err := applyOwnerRuntimeLocal(ctx, store, subtree[i].Username, "delete"); err != nil { + writeInternalError(w, "delete local reseller resources", err) + return + } + } + + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + if target.ParentUsername != "" && normalizeQuotaMode(target.QuotaMode) == QuotaModeCredit { + refund := 0 + for _, u := range subtree { + if normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit { + refund += u.CreditBalance + } + } + if refund > 0 { + var balance int + if err := tx.QueryRowContext(ctx, ` + UPDATE admin_users SET credit_balance=credit_balance+$1 + WHERE username=$2 AND quota_mode=$3 RETURNING credit_balance`, + refund, target.ParentUsername, QuotaModeCredit).Scan(&balance); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO reseller_credit_ledger + (reseller_username, actor_username, delta, balance_after, action, target) + VALUES ($1,$2,$3,$4,$5,$6)`, + target.ParentUsername, sess.Username, refund, balance, "deleted sub-reseller refund", target.Username); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + } + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO reseller_audit_log (actor_username, target_username, action, details) + VALUES ($1,$2,$3,$4)`, sess.Username, target.Username, "deleted reseller", + fmt.Sprintf("removed_accounts=%d", len(subtree))); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + for i := len(subtree) - 1; i >= 0; i-- { + if _, err := tx.ExecContext(ctx, `DELETE FROM admin_users WHERE id=$1 AND role=$2`, subtree[i].ID, RoleReseller); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + } + if err := tx.Commit(); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + for _, u := range subtree { + sessions.DeleteUser(u.ID) + adminUsers.delete(u.Username) + } + _ = loadAdminUsersIntoCache(ctx, store) + w.WriteHeader(http.StatusNoContent) + } +} + +type ResellerAuditDTO struct { + ID int64 `json:"id"` + ActorUsername string `json:"actor_username"` + TargetUsername string `json:"target_username"` + Action string `json:"action"` + Details string `json:"details"` + CreatedAt time.Time `json:"created_at"` +} + +func handleResellerAudit(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + sess := sessionFromCtx(r.Context()) + if sess == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + query := `SELECT id, actor_username, target_username, action, details, created_at + FROM reseller_audit_log ORDER BY created_at DESC LIMIT 200` + args := []interface{}{} + if sess.Role == RoleReseller { + query = `SELECT id, actor_username, target_username, action, details, created_at + FROM reseller_audit_log + WHERE actor_username=$1 OR target_username=$1 OR target_username IN + (SELECT username FROM admin_users WHERE parent_username=$1 AND role=$2) + ORDER BY created_at DESC LIMIT 200` + args = append(args, sess.Username, RoleReseller) + } + rows, err := store.db.QueryContext(r.Context(), query, args...) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + defer rows.Close() + out := make([]ResellerAuditDTO, 0, 64) + for rows.Next() { + var item ResellerAuditDTO + if err := rows.Scan(&item.ID, &item.ActorUsername, &item.TargetUsername, &item.Action, &item.Details, &item.CreatedAt); err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + out = append(out, item) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) + } +} + +func reserveResellerProvisionCredits(ctx context.Context, store *Store, owner string, cost int, target string) (bool, error) { + u, ok := adminUsers.get(owner) + if !ok || normalizeQuotaMode(u.QuotaMode) != QuotaModeCredit { + return false, nil + } + if cost < 1 { + cost = 1 + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + var balance int + err = tx.QueryRowContext(ctx, ` + UPDATE admin_users SET credit_balance=credit_balance-$1 + WHERE username=$2 AND role=$3 AND quota_mode=$4 AND is_active=TRUE AND credit_balance >= $1 + RETURNING credit_balance`, cost, owner, RoleReseller, QuotaModeCredit).Scan(&balance) + if err == sql.ErrNoRows { + return false, errResellerCredits + } + if err != nil { + return false, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO reseller_credit_ledger + (reseller_username, actor_username, delta, balance_after, action, target) + VALUES ($1,$2,$3,$4,$5,$6)`, owner, owner, -cost, balance, "provision account", target); err != nil { + return false, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO reseller_audit_log (actor_username, target_username, action, details) + VALUES ($1,$2,$3,$4)`, owner, owner, "spent credits", fmt.Sprintf("target=%s cost=%d", target, cost)); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + copyUser := *u + copyUser.CreditBalance = balance + adminUsers.set(©User) + return true, nil +} + +func refundResellerProvisionCredits(ctx context.Context, store *Store, owner string, cost int, target string) { + if cost < 1 { + return + } + var balance int + err := store.db.QueryRowContext(ctx, ` + UPDATE admin_users SET credit_balance=credit_balance+$1 + WHERE username=$2 AND quota_mode=$3 RETURNING credit_balance`, + cost, owner, QuotaModeCredit).Scan(&balance) + if err != nil { + log.Printf("refund reseller credits for %s: %v", owner, err) + return + } + _, _ = store.db.ExecContext(ctx, ` + INSERT INTO reseller_credit_ledger + (reseller_username, actor_username, delta, balance_after, action, target) + VALUES ($1,$2,$3,$4,$5,$6)`, owner, owner, cost, balance, "provision refund", target) + if u, ok := adminUsers.get(owner); ok { + copyUser := *u + copyUser.CreditBalance = balance + adminUsers.set(©User) + } +} + +func authorizeResellerProvision(ctx context.Context, store *Store, owner, target string, maxConnections int) (bool, int, error) { + u, ok := adminUsers.get(owner) + if !ok || u.Role != RoleReseller { + return false, 0, fmt.Errorf("reseller account not found") + } + if err := adminAccountChainActive(owner); err != nil { + return false, 0, err + } + if maxConnections < 1 { + return false, 0, errResellerConnLimit + } + cost := resellerProvisionCost(maxConnections) + if normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit { + charged, err := reserveResellerProvisionCredits(ctx, store, owner, cost, target) + return charged, cost, err + } + used, err := countOwnedQuotaAcrossManagedServers(ctx, store, owner) + if err != nil { + return false, 0, err + } + children, err := store.directChildAllocation(ctx, owner, "") + if err != nil { + return false, 0, err + } + if u.MaxUsers > 0 && used+children+cost > u.MaxUsers { + return false, 0, errResellerQuota + } + return false, 0, nil +} + +func authorizeResellerQuotaChange(ctx context.Context, store *Store, owner string, oldMaxConnections, newMaxConnections int) error { + u, ok := adminUsers.get(owner) + if !ok || u.Role != RoleReseller { + return fmt.Errorf("reseller account not found") + } + if err := adminAccountChainActive(owner); err != nil { + return err + } + if newMaxConnections < 1 { + return errResellerConnLimit + } + oldCost := resellerProvisionCost(oldMaxConnections) + newCost := resellerProvisionCost(newMaxConnections) + if normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit { + if newCost != oldCost { + return errCreditLimitImmutable + } + return nil + } + delta := newCost - oldCost + if delta <= 0 || u.MaxUsers == 0 { + return nil + } + used, err := countOwnedQuotaAcrossManagedServers(ctx, store, owner) + if err != nil { + return err + } + children, err := store.directChildAllocation(ctx, owner, "") + if err != nil { + return err + } + if used+children+delta > u.MaxUsers { + return errResellerQuota + } + return nil +} + +func writeResellerProvisionError(w http.ResponseWriter, err error) { + if errors.Is(err, errResellerConnLimit) || errors.Is(err, errCreditLimitImmutable) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.Is(err, errResellerCredits) || errors.Is(err, errResellerQuota) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + writeBadGatewayError(w, "verify reseller quota", err) +} + +func resellerProvisionExpiry(owner string) string { + u, ok := adminUsers.get(owner) + if !ok || normalizeQuotaMode(u.QuotaMode) != QuotaModeCredit { + return "" + } + return time.Now().UTC().AddDate(0, 0, 31).Format(time.RFC3339) +} + +func resellerExpiryExtended(existing, requested string) bool { + requested = strings.TrimSpace(requested) + if requested == "" || requested == strings.TrimSpace(existing) { + return false + } + requestedTime, err := time.Parse(time.RFC3339, requested) + if err != nil { + return true + } + existingTime, err := time.Parse(time.RFC3339, strings.TrimSpace(existing)) + if err != nil { + return true + } + return requestedTime.After(existingTime) +} + +func resellerTimeExtended(existing *time.Time, requested string) bool { + requested = strings.TrimSpace(requested) + if requested == "" { + return false + } + var requestedTime time.Time + var err error + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { + requestedTime, err = time.Parse(layout, requested) + if err == nil { + break + } + } + if err != nil || existing == nil { + return true + } + return requestedTime.After(*existing) +} diff --git a/reseller_management_test.go b/reseller_management_test.go new file mode 100644 index 0000000..d97e39a --- /dev/null +++ b/reseller_management_test.go @@ -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") + } +} diff --git a/reseller_runtime_state.go b/reseller_runtime_state.go new file mode 100644 index 0000000..deb0b78 --- /dev/null +++ b/reseller_runtime_state.go @@ -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 +} diff --git a/security_hardening_test.go b/security_hardening_test.go index 081b97e..cf1eaf3 100644 --- a/security_hardening_test.go +++ b/security_hardening_test.go @@ -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" diff --git a/security_http.go b/security_http.go index d683902..1ddee82 100644 --- a/security_http.go +++ b/security_http.go @@ -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 { diff --git a/server_config_api.go b/server_config_api.go index 9c044f9..4871bca 100644 --- a/server_config_api.go +++ b/server_config_api.go @@ -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 } diff --git a/tls_api.go b/tls_api.go index b663371..dc8cb37 100644 --- a/tls_api.go +++ b/tls_api.go @@ -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") diff --git a/update.sh b/update.sh index 22132fc..99d2504 100644 --- a/update.sh +++ b/update.sh @@ -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 diff --git a/xray_clients.go b/xray_clients.go index d5a436b..804b0c1 100644 --- a/xray_clients.go +++ b/xray_clients.go @@ -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,19 +424,34 @@ 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 == "" { - _ = store.DeleteXrayClientMeta(ctx, m.UUID) + if m.OwnerUsername == "" { + _ = store.DeleteXrayClientMeta(ctx, m.UUID) + } continue } - 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 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 err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil { - log.Printf("xray expiry: delete meta %s: %v", m.UUID, err) + 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) } diff --git a/xray_integration.go b/xray_integration.go index a40989c..1d24056 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -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,8 +1951,19 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to read body", http.StatusBadRequest) return } + 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 { - http.Error(w, err.Error(), http.StatusBadRequest) + writeInternalError(w, "save Xray configuration", err) return } w.WriteHeader(http.StatusOK) @@ -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() @@ -2579,34 +2647,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) { return } var req struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Email string `json:"email"` - ExpiresAt string `json:"expires_at"` - MaxConnections int `json:"max_connections"` - ServerID string `json:"server_id,omitempty"` + UUID string `json:"uuid"` + Name string `json:"name"` + Email string `json:"email"` + 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) { - http.Error(w, "forbidden", http.StatusForbidden) - return + 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 { diff --git a/xray_xhttp.go b/xray_xhttp.go index f816104..eb31809 100644 --- a/xray_xhttp.go +++ b/xray_xhttp.go @@ -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 {