1 Commits
Author SHA1 Message Date
penguinehis a345e70e5a Beta 1 2026-07-13 18:01:39 -03:00
33 changed files with 3741 additions and 589 deletions
+53 -13
View File
@@ -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 - Á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 - 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 - 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 - Gerenciamento multi-servidor (master/slave) direto pelo painel
- API HTTP completa para bots/automações (ver **HTTP API Reference**) - API HTTP completa para bots/automações (ver **HTTP API Reference**)
- API pública `/check` para consultar usuário ou UUID - 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. 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 ### Requisitos
- Servidor Linux com `systemd` - 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` - Gerenciador de pacotes `apt`, `yum` ou `dnf`
- Portas liberadas no firewall/security group conforme a configuração usada - 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: Distribuições alvo:
- Ubuntu / Debian / Linux Mint - Ubuntu / Debian / Linux Mint
@@ -92,6 +111,7 @@ sudo bash install.sh
Durante a instalação, o script instala/configura: Durante a instalação, o script instala/configura:
- Go - Go
- verificação SHA-256 dos arquivos oficiais de Go e Xray antes da extração
- PostgreSQL - PostgreSQL
- Xray-core - Xray-core
- Binário do DragonCoreSSH V40 - 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 - 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 - 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 - 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 - Multi-server (master/slave) management directly from the panel
- Full HTTP API for bots/automations (see **HTTP API Reference**) - Full HTTP API for bots/automations (see **HTTP API Reference**)
- Public `/check` API for checking username or UUID - 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 - `apt`, `yum`, or `dnf` package manager
- Required ports opened in the firewall/security group - 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: Target distributions:
- Ubuntu / Debian / Linux Mint - Ubuntu / Debian / Linux Mint
@@ -621,6 +644,7 @@ sudo bash install.sh
During installation, the script installs/configures: During installation, the script installs/configures:
- Go - Go
- SHA-256 verification of the official Go and Xray archives before extraction
- PostgreSQL - PostgreSQL
- Xray-core - Xray-core
- DragonCoreSSH V40 binary - 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). - No body. Deletes the session for the supplied `X-Session-Token`. Returns `200` (empty).
#### `GET /api/auth/me` — session #### `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 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.
- `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 }`.
#### `POST /api/resellers/create` — superadmin #### `GET /api/resellers`
Creates or updates a reseller (upsert by username). - `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" }`.
- 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). - `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.
- `201 Created` (empty). Errors: `400 username required`, `400 password required for new account`, `400 invalid expires_at (RFC3339 required)`; `500 db error`.
#### `DELETE /api/resellers/delete` — superadmin #### `POST /api/resellers/create`
- Query: `username` (string, required). Also disconnects/removes the reseller's owned SSH users and Xray clients. Creates or edits a reseller.
- `204 No Content`. Errors: `400 username required`; `500 db error`. - 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.
--- ---
+340
View File
@@ -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)
}
+110
View File
@@ -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)
}
+57
View File
@@ -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)
}
}
+1
View File
@@ -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-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);} .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-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;} .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;}
+10 -2
View File
@@ -5,6 +5,8 @@ if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
localStorage.removeItem("SESSION_TOKEN"); localStorage.removeItem("SESSION_TOKEN");
let currentRole = ""; let currentRole = "";
let currentUser = ""; let currentUser = "";
let currentQuotaMode = "slots";
let currentCreditBalance = 0;
let statsTimer = null, usersTimer = null, xrayTimer = null; let statsTimer = null, usersTimer = null, xrayTimer = null;
let tlsForwardersState = []; let tlsForwardersState = [];
let managedTlsForwardersState = []; let managedTlsForwardersState = [];
@@ -369,8 +371,13 @@ const resellerFormTitle = document.getElementById("resellerFormTitle");
const resellerForm = document.getElementById("resellerForm"); const resellerForm = document.getElementById("resellerForm");
const rUsername = document.getElementById("rUsername"); const rUsername = document.getElementById("rUsername");
const rPassword = document.getElementById("rPassword"); const rPassword = document.getElementById("rPassword");
const rParent = document.getElementById("rParent");
const rQuotaMode = document.getElementById("rQuotaMode");
const rMaxUsers = document.getElementById("rMaxUsers"); const rMaxUsers = document.getElementById("rMaxUsers");
const rCredits = document.getElementById("rCredits");
const rExpires = document.getElementById("rExpires"); const rExpires = document.getElementById("rExpires");
const rWhatsApp = document.getElementById("rWhatsApp");
const rMonthlyPrice = document.getElementById("rMonthlyPrice");
const rActive = document.getElementById("rActive"); const rActive = document.getElementById("rActive");
// Managed servers // Managed servers
@@ -391,7 +398,6 @@ const serverFormStatus = document.getElementById("serverFormStatus");
const serversListView = document.getElementById("serversListView"); const serversListView = document.getElementById("serversListView");
const serverConfigSubpage = document.getElementById("serverConfigSubpage"); const serverConfigSubpage = document.getElementById("serverConfigSubpage");
const cfgServerName = document.getElementById("cfgServerName"); const cfgServerName = document.getElementById("cfgServerName");
const managedConfigEditor = document.getElementById("managedConfigEditor");
const managedConfigStatus = document.getElementById("managedConfigStatus"); const managedConfigStatus = document.getElementById("managedConfigStatus");
const serversStatusGrid = document.getElementById("serversStatusGrid"); const serversStatusGrid = document.getElementById("serversStatusGrid");
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus"); const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
@@ -534,7 +540,9 @@ async function api(path, opts = {}) {
"X-Session-Token": sessionToken, "X-Session-Token": sessionToken,
}); });
const res = await fetch(path, o); 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; return res;
} }
function withServerParam(path, serverID) { function withServerParam(path, serverID) {
+50 -6
View File
@@ -153,7 +153,7 @@ function selectTab(tab) {
if (tab === "stats" && currentRole === "superadmin") loadStats(); if (tab === "stats" && currentRole === "superadmin") loadStats();
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat(); if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus(); 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 === "servers" && currentRole === "superadmin") loadServers();
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab(); if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
} }
@@ -218,6 +218,13 @@ function clearTimers() {
} }
function initAfterLogin() { 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; meUsername.textContent = currentUser;
mainApp.classList.remove("role-superadmin", "role-reseller"); mainApp.classList.remove("role-superadmin", "role-reseller");
mainApp.classList.add(currentRole === "superadmin" ? "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 res = await api("/api/auth/me");
const d = await res.json(); const d = await res.json();
dashboardCache.me = d; 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 used = d.used_users ?? 0;
const max = d.max_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"); rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration");
rStatus.textContent = d.is_active ? t("Active") : t("Suspended"); const effectiveActive = d.effective_active ?? d.is_active;
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)"; rStatus.textContent = effectiveActive ? t("Active") : t("Suspended");
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0); 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(); renderDashboardCounters();
} catch {} } 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}); 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 = []) { function flattenXrayClients(inbounds = []) {
return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c))); 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; const me = dashboardCache.me;
if (currentRole === "reseller" && 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);
}
} }
} }
+46 -2
View File
@@ -1,4 +1,5 @@
// ─── SSH Users ──────────────────────────────────────────────────────────────── // ─── SSH Users ────────────────────────────────────────────────────────────────
let editingSSHUser = "";
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers); document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers); document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create")); newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
@@ -7,8 +8,13 @@ cancelUserBtn.addEventListener("click", () => {
setWorkspaceSection("ssh", "users"); setWorkspaceSection("ssh", "users");
}); });
function prepareNewSSHUser() { function prepareNewSSHUser() {
editingSSHUser = "";
userForm.reset(); userForm.reset();
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6; 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 heading = document.getElementById("userFormHeading");
const title = document.getElementById("userFormTitle"); const title = document.getElementById("userFormTitle");
if (heading) heading.textContent = t("Create user"); if (heading) heading.textContent = t("Create user");
@@ -87,6 +93,10 @@ function renderUsers(users) {
tr.appendChild(td); tr.appendChild(td);
}); });
const tdA = document.createElement("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"), { const editBtn = Object.assign(document.createElement("button"), {
className:"btn btn-ghost btn-sm", textContent:t("Edit"), className:"btn btn-ghost btn-sm", textContent:t("Edit"),
onclick: () => fillUserForm(u), onclick: () => fillUserForm(u),
@@ -96,7 +106,8 @@ function renderUsers(users) {
style: "margin-left:4px;", style: "margin-left:4px;",
onclick: () => deleteUser(u.username), onclick: () => deleteUser(u.username),
}); });
tdA.append(editBtn, delBtn); tdA.className = "bot-row-actions";
tdA.append(renewBtn, editBtn, delBtn);
tr.appendChild(tdA); tr.appendChild(tdA);
usersBody.appendChild(tr); usersBody.appendChild(tr);
}); });
@@ -113,6 +124,7 @@ function renderUsers(users) {
} }
function fillUserForm(u) { function fillUserForm(u) {
editingSSHUser = u.username || "";
setWorkspaceSection("ssh", "create"); setWorkspaceSection("ssh", "create");
fUsername.value = u.username || ""; fUsername.value = u.username || "";
fPassword.value = ""; fPassword.value = "";
@@ -122,6 +134,10 @@ function fillUserForm(u) {
fTotpDigits.value = u.totp_digits || 6; fTotpDigits.value = u.totp_digits || 6;
fAllowStatic.checked = !!u.allow_static_password; fAllowStatic.checked = !!u.allow_static_password;
fMaxConn.value = u.max_connections || ""; 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 || ""; fUp.value = u.limit_mbps_up || "";
fDown.value = u.limit_mbps_down || ""; fDown.value = u.limit_mbps_down || "";
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : ""; 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), totp_digits: parseInt(fTotpDigits.value||"6",10),
allow_static_password: !!fAllowStatic.checked, allow_static_password: !!fAllowStatic.checked,
max_connections: parseInt(fMaxConn.value||"0",10), 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_up: parseInt(fUp.value||"0",10),
limit_mbps_down: parseInt(fDown.value||"0",10), limit_mbps_down: parseInt(fDown.value||"0",10),
server_id: selectedSSHServer(), server_id: selectedSSHServer(),
@@ -187,3 +205,29 @@ async function deleteUser(username) {
else userStatus.textContent = t("Error deleting."); 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");
}
}
+38 -2
View File
@@ -244,12 +244,17 @@ function renderInbounds(inbounds, options = {}) {
editBtn.style.marginLeft = "4px"; editBtn.style.marginLeft = "4px";
editBtn.textContent = t("Edit"); editBtn.textContent = t("Edit");
editBtn.onclick = () => openEditXrayClient(ib.tag, c); 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"); const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger btn-sm"; delBtn.className = "btn btn-danger btn-sm";
delBtn.style.marginLeft = "4px"; delBtn.style.marginLeft = "4px";
delBtn.textContent = t("Del"); delBtn.textContent = t("Del");
delBtn.onclick = () => removeClient(ib.tag, c.id); delBtn.onclick = () => removeClient(ib.tag, c.id);
actTd.append(copyBtn, editBtn, delBtn); actTd.append(copyBtn, renewBtn, editBtn, delBtn);
tr.appendChild(actTd); tr.appendChild(actTd);
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -324,7 +329,10 @@ function prepareXrayClientCreator(preferredTag = "") {
const uuid = document.getElementById("xCreateUUID"); const uuid = document.getElementById("xCreateUUID");
if (uuid) uuid.value = genUUID(); if (uuid) uuid.value = genUUID();
const maxConns = document.getElementById("xCreateMaxConns"); 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"); 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."); if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
updateXrayCreatorInboundLabel(); 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() { async function loadXrayCfg() {
if (!xCfgEditor) return; if (!xCfgEditor) return;
const target = selectedXrayServerLabel(); const target = selectedXrayServerLabel();
+241 -71
View File
@@ -1,127 +1,297 @@
// ─── Resellers ──────────────────────────────────────────────────────────────── // ─── Hierarchical resellers ───────────────────────────────────────────────────
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers); let resellersCache = [];
document.getElementById("newResellerBtn").addEventListener("click", () => navigateWorkspaceSection("resellers", "create")); let editingReseller = "";
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
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(); prepareNewReseller();
setWorkspaceSection("resellers", "users"); 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() { function prepareNewReseller() {
resellerFormTitle.textContent = "Create Reseller"; editingReseller = "";
resellerFormTitle.textContent = t("Create Reseller");
const heading = document.getElementById("resellerFormHeading"); const heading = document.getElementById("resellerFormHeading");
if (heading) heading.textContent = t("Create reseller"); if (heading) heading.textContent = t("Create reseller");
resellerForm.reset(); 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; rActive.checked = true;
resellerStatus.textContent = "New reseller."; populateResellerParents();
toggleResellerPlanFields();
resellerStatus.textContent = t("New reseller.");
requestAnimationFrame(() => rUsername.focus()); requestAnimationFrame(() => rUsername.focus());
} }
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
async function loadResellers() { async function loadResellers() {
resellerStatus.textContent = "Loading…"; resellerStatus.textContent = t("Loading…");
setResellerLiveStatus("Carregando revendedores…", "is-loading");
try { try {
const res = await api("/api/resellers"); const res = await api("/api/resellers");
const data = await res.json(); if (!res.ok) throw new Error(await res.text());
renderResellers(data || []); resellersCache = await res.json() || [];
resellerStatus.textContent = "Loaded."; renderResellers(resellersCache);
populateResellerParents();
resellerStatus.textContent = t("Loaded.");
setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok");
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message === "auth") doAuthError();
else resellerStatus.textContent = "Error loading."; else {
resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim();
setResellerLiveStatus("Falha ao carregar revendedores", "is-error");
}
} }
} }
function setResellerLiveStatus(message, tone) {
const el = document.getElementById("resellerLiveStatus");
if (!el) return;
el.textContent = message;
el.className = `workspace-live-status ${tone || ""}`.trim();
}
function renderResellerMetrics(list) {
const active = list.filter(r => r.effective_active).length;
const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0);
const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0);
document.getElementById("resellerMetricTotal").textContent = String(list.length);
document.getElementById("resellerMetricActive").textContent = String(active);
document.getElementById("resellerMetricAllocated").textContent = String(allocated);
document.getElementById("resellerMetricCredits").textContent = String(credits);
}
function renderResellers(list) { function renderResellers(list) {
resellerCountChip.textContent = list.length; resellerCountChip.textContent = list.length;
renderResellerMetrics(list);
resellersBody.innerHTML = ""; resellersBody.innerHTML = "";
if (!list.length) {
resellersBody.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhum revendedor direto cadastrado.</td></tr>`;
return;
}
list.forEach(r => { list.forEach(r => {
const expired = r.expires_at && new Date(r.expires_at) < new Date(); const expired = !!r.expires_at && new Date(r.expires_at) < new Date();
const max = r.max_users || 0; const effective = !!r.effective_active && !expired;
const used = r.used_users || 0; const maxUsers = Number(r.max_users || 0);
const remaining = max ? Math.max(0, max - used) : "∞"; const directUsed = Number(r.used_users || 0);
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 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"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td>${escapeHTML(r.username)}</td>
<td> <td>
<strong>${used} / ${max || "∞"}</strong> <div class="bot-primary-cell"><strong>${escapeHTML(r.username)}</strong>
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div> <small>${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""}</small>
<div class="table-meter"><span style="width:${pct}%"></span></div> ${r.whatsapp ? `<small>${escapeHTML(r.whatsapp)}</small>` : ""}
</div>
</td> </td>
<td>${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td> <td>
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td> <strong>${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`}</strong>
<div class="hint">${isCredit ? "31 dias por renovação" : `Disponível ${remaining} · capacidade usada ${directUsed} · reservado ${childAllocation}`} · SSH ${r.used_ssh_users || 0} contas · Xray ${r.used_xray_users || 0} contas</div>
${isCredit ? "" : `<div class="table-meter"><span style="width:${pct}%"></span></div>`}
</td>
<td>${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
<td><span class="${effective ? "badge-on" : "badge-off"}">${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"}</span></td>
<td></td>`; <td></td>`;
const tdA = tr.lastElementChild; const actions = document.createElement("div");
const editBtn = Object.assign(document.createElement("button"),{ actions.className = "bot-row-actions reseller-row-actions";
className:"btn btn-ghost btn-sm", textContent:t("Edit"), actions.appendChild(resellerActionButton(t("Edit"), "btn btn-ghost btn-sm", () => fillResellerForm(r)));
onclick: () => 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")));
const delBtn = Object.assign(document.createElement("button"),{ 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")));
className:"btn btn-danger btn-sm", textContent:t("Del"), actions.appendChild(resellerActionButton(t("Del"), "btn btn-danger btn-sm", () => deleteReseller(r)));
style: "margin-left:4px;", tr.lastElementChild.appendChild(actions);
onclick: () => deleteReseller(r.username),
});
tdA.append(editBtn, delBtn);
resellersBody.appendChild(tr); resellersBody.appendChild(tr);
}); });
} }
function resellerActionButton(label, className, onclick) {
return Object.assign(document.createElement("button"), { type: "button", className, textContent: label, onclick });
}
function populateResellerParents() {
if (!rParent) return;
const selected = rParent.value;
rParent.innerHTML = `<option value="">Principal / sem pai</option>`;
resellersCache
.filter(r => r.username !== editingReseller && r.effective_active)
.forEach(r => {
const option = document.createElement("option");
option.value = r.username;
option.textContent = `${r.username} · ${r.quota_mode === "credits" ? `${r.credit_balance || 0} Cr` : `${r.available < 0 ? "∞" : r.available} slots`}`;
rParent.appendChild(option);
});
if ([...rParent.options].some(o => o.value === selected)) rParent.value = selected;
}
function fillResellerForm(r) { function fillResellerForm(r) {
editingReseller = r.username;
setWorkspaceSection("resellers", "create"); setWorkspaceSection("resellers", "create");
resellerFormTitle.textContent = `Edit: ${r.username}`; resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
const heading = document.getElementById("resellerFormHeading"); const heading = document.getElementById("resellerFormHeading");
if (heading) heading.textContent = t("Edit reseller"); if (heading) heading.textContent = t("Edit reseller");
rUsername.value = r.username; rUsername.value = r.username;
rPassword.value = ""; rUsername.disabled = true;
rMaxUsers.value = r.max_users || 0; rPassword.value = "";
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : ""; populateResellerParents();
rActive.checked = r.is_active; rParent.value = r.parent_username || "";
resellerStatus.textContent = `Editing ${r.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 => { resellerForm.addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById("saveResellerBtn"); const btn = document.getElementById("saveResellerBtn");
btn.disabled = true; btn.disabled = true;
resellerStatus.textContent = "Saving…"; resellerStatus.textContent = t("Saving…");
const mode = rQuotaMode.value || "slots";
const payload = { const payload = {
username: rUsername.value.trim(), username: rUsername.value.trim(),
password: rPassword.value || undefined, password: rPassword.value || undefined,
max_users: parseInt(rMaxUsers.value||"0",10), parent_username: currentRole === "superadmin" ? rParent.value : undefined,
expires_at: isoFromLocal(rExpires.value), quota_mode: mode,
is_active: rActive.checked, 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 { try {
const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) }); const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error((await res.text()).trim());
resellerStatus.textContent = "Saved.";
resellerForm.reset(); rActive.checked = true;
resellerFormTitle.textContent = "Create Reseller";
loadResellers();
showPanelToast(t("Reseller saved successfully."), "success", t("Resellers")); showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
prepareNewReseller();
await loadResellers();
setWorkspaceSection("resellers", "users"); setWorkspaceSection("resellers", "users");
if (currentRole === "reseller") loadMe();
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message === "auth") doAuthError();
else resellerStatus.textContent = "Error: "+e.message; else {
} finally { btn.disabled = false; } 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({ const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete reseller"), tone: action === "suspend" ? "danger" : "default",
message:t("Delete reseller \"{name}\"?", {name:username}), icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
detail:t("Their owned access will be removed and active SSH sessions will be disconnected."), title: labels[action],
confirmLabel:t("Delete reseller"), message: `${labels[action]}${reseller.username}”?`,
detail: descriptions[action],
confirmLabel: labels[action],
}); });
if (!accepted) return; if (!accepted) return;
resellerStatus.textContent = `Deleting ${username}`; resellerStatus.textContent = `${labels[action]}`;
try { try {
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" }); const res = await api("/api/resellers/action", {
if (!res.ok && res.status !== 204) throw new Error("failed"); method: "POST",
resellerStatus.textContent = "Deleted."; body: JSON.stringify({ username: reseller.username, action, days: action === "renew" ? 30 : undefined }),
loadResellers(); });
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) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message === "auth") doAuthError();
else resellerStatus.textContent = "Error deleting."; 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 = `<tr class="bot-empty-row"><td colspan="5">Carregando atividade…</td></tr>`;
try {
const res = await api("/api/resellers/audit");
if (!res.ok) throw new Error(await res.text());
const rows = await res.json() || [];
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhuma atividade registrada.</td></tr>`;
return;
}
rows.forEach(item => {
const tr = document.createElement("tr");
[fmtDate(item.created_at), item.actor_username, item.target_username, item.action, item.details || "—"].forEach(value => {
const td = document.createElement("td");
td.textContent = value;
tr.appendChild(td);
});
body.appendChild(tr);
});
} catch (e) {
if (e.message === "auth") doAuthError();
else body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Falha ao carregar a atividade.</td></tr>`;
} }
} }
+8 -2
View File
@@ -5,7 +5,11 @@ function openEditXrayClient(tag, client) {
document.getElementById("editXrayName").value = client.name || ""; document.getElementById("editXrayName").value = client.name || "";
document.getElementById("editXrayEmail").value = client.email || ""; document.getElementById("editXrayEmail").value = client.email || "";
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : ""; 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("editXrayClientStatus").textContent = "";
document.getElementById("editXrayClientPanel").classList.remove("hidden"); document.getElementById("editXrayClientPanel").classList.remove("hidden");
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" }); document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
@@ -24,7 +28,9 @@ async function saveEditXrayClient() {
uuid: editingXrayClientId, uuid: editingXrayClientId,
name: document.getElementById("editXrayName").value.trim(), name: document.getElementById("editXrayName").value.trim(),
email: document.getElementById("editXrayEmail").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), max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
server_id: selectedXrayServer(), server_id: selectedXrayServer(),
}; };
+44 -22
View File
@@ -16,7 +16,7 @@
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})(); })();
</script> </script>
<link rel="stylesheet" href="assets/app.css?v=20260713sections5"/> <link rel="stylesheet" href="assets/app.css?v=20260713reseller10"/>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -51,7 +51,7 @@
<button class="tab-btn" data-tab="ssh"><span class="nav-icon">👥</span><span>SSH / SlowDNS</span></button> <button class="tab-btn" data-tab="ssh"><span class="nav-icon">👥</span><span>SSH / SlowDNS</span></button>
<button class="tab-btn" data-tab="xray"><span class="nav-icon"></span><span>Xray Users</span></button> <button class="tab-btn" data-tab="xray"><span class="nav-icon"></span><span>Xray Users</span></button>
<div class="nav-group-label superadmin-only hidden">Administração</div> <div class="nav-group-label superadmin-only hidden">Administração</div>
<button class="tab-btn superadmin-only hidden" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button> <button class="tab-btn" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon"></span><span>Infraestrutura</span></button> <button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon"></span><span>Infraestrutura</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon"></span><span>Logs</span></button> <button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon"></span><span>Logs</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="bot"><span class="nav-icon">🤖</span><span>Bot / Vendas</span></button> <button class="tab-btn superadmin-only hidden" data-tab="bot"><span class="nav-icon">🤖</span><span>Bot / Vendas</span></button>
@@ -656,16 +656,26 @@
</section> </section>
</div><!-- /tab-xray --> </div><!-- /tab-xray -->
<!-- ═══════════ Resellers Tab (superadmin only) ═══════════ --> <!-- ═══════════ Hierarchical reseller management ═══════════ -->
<div class="tab-pane" id="tab-resellers"> <div class="tab-pane" id="tab-resellers">
<section class="page-hero" data-tone="amber"><div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle cotas, validade e acesso dos parceiros em um só lugar.</p></div></section> <section class="page-hero status-hero" data-tone="amber">
<div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle hierarquia, créditos, cotas, validade e acesso dos parceiros em um só lugar.</p></div>
<div class="workspace-hero-actions"><span class="workspace-live-status is-loading" id="resellerLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="resellerHeroReloadBtn" type="button">Atualizar</button></div>
<div class="workspace-overview-grid">
<article class="workspace-overview-card"><span class="workspace-overview-icon amber"></span><div><small>Revendedores</small><strong id="resellerMetricTotal">--</strong><span class="workspace-card-note">parceiros diretos</span></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Ativos</small><strong id="resellerMetricActive">--</strong><span class="workspace-card-note">acesso liberado</span></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">#</span><div><small>Alocação</small><strong id="resellerMetricAllocated">--</strong><span class="workspace-card-note">slots reservados</span></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">Cr</span><div><small>Créditos</small><strong id="resellerMetricCredits">--</strong><span class="workspace-card-note">saldo nos parceiros</span></div></article>
</div>
</section>
<div class="workspace-nav-shell" data-tone="amber"> <div class="workspace-nav-shell" data-tone="amber">
<nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:2"> <nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:3">
<button class="active" type="button" data-workspace="resellers" data-workspace-section="users"><span></span> Revendedores</button> <button class="active" type="button" data-workspace="resellers" data-workspace-section="users"><span></span> Revendedores</button>
<button type="button" data-workspace="resellers" data-workspace-section="create"><span></span> Criar revendedor</button> <button type="button" data-workspace="resellers" data-workspace-section="create"><span></span> Criar revendedor</button>
<button type="button" data-workspace="resellers" data-workspace-section="audit"><span></span> Atividade</button>
</nav> </nav>
<select id="resellerSection" class="workspace-section-select" data-workspace-select="resellers" aria-label="Área de revendedores"> <select id="resellerSection" class="workspace-section-select" data-workspace-select="resellers" aria-label="Área de revendedores">
<option value="users">Revendedores</option><option value="create">Criar revendedor</option> <option value="users">Revendedores</option><option value="create">Criar revendedor</option><option value="audit">Atividade</option>
</select> </select>
</div> </div>
<section class="workspace-section active" data-workspace-panel="resellers" data-workspace-section-panel="users"> <section class="workspace-section active" data-workspace-panel="resellers" data-workspace-section-panel="users">
@@ -682,7 +692,7 @@
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr> <thead><tr>
<th>Username</th><th>Users (used/max)</th><th>Expires</th><th>Status</th><th>Actions</th> <th>Conta</th><th>Plano e uso</th><th>Validade</th><th>Status</th><th>Ações</th>
</tr></thead> </tr></thead>
<tbody id="resellersBody"></tbody> <tbody id="resellersBody"></tbody>
</table> </table>
@@ -702,9 +712,14 @@
<div class="form-grid"> <div class="form-grid">
<div class="field"><label>Username</label><input id="rUsername" required autocomplete="off"/></div> <div class="field"><label>Username</label><input id="rUsername" required autocomplete="off"/></div>
<div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="rPassword" type="password" autocomplete="new-password"/></div> <div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="rPassword" type="password" autocomplete="new-password"/></div>
<div class="field"><label>Max SSH users (0 = unlimited)</label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div> <div class="field superadmin-only" id="rParentField"><label>Revendedor pai <span class="hint">(vazio = principal)</span></label><select id="rParent"><option value="">Principal / sem pai</option></select></div>
<div class="field"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div> <div class="field"><label>Modo do plano</label><select id="rQuotaMode"><option value="slots">Validade / slots</option><option value="credits">Créditos</option></select><span class="hint" id="rQuotaModeHint">Sub-revendedores herdam o modo da conta pai.</span></div>
<div class="field"><label>Active</label><input id="rActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div> <div class="field" id="rSlotsField"><label>Limite compartilhado <span class="hint">(0 = ilimitado só para principal)</span></label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
<div class="field hidden" id="rCreditsField"><label>Saldo de créditos</label><input id="rCredits" type="number" min="0" placeholder="30"/></div>
<div class="field" id="rExpiresField"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
<div class="field"><label>WhatsApp</label><input id="rWhatsApp" autocomplete="tel" placeholder="+5511999999999"/></div>
<div class="field"><label>Valor mensal</label><input id="rMonthlyPrice" type="number" min="0" step="0.01" placeholder="0,00"/></div>
<label class="bot-check-field"><input id="rActive" type="checkbox" checked/> Acesso ativo</label>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn" type="submit" id="saveResellerBtn">Save reseller</button> <button class="btn" type="submit" id="saveResellerBtn">Save reseller</button>
@@ -712,6 +727,13 @@
</form> </form>
</div> </div>
</section> </section>
<section class="workspace-section" data-workspace-panel="resellers" data-workspace-section-panel="audit">
<div class="workspace-section-heading"><div><span>03 · Auditoria</span><h3>Atividade das revendas</h3><p>Veja quem criou, alterou, renovou, suspendeu ou removeu cada conta.</p></div><button class="btn btn-ghost btn-sm" id="reloadResellerAuditBtn" type="button">Atualizar</button></div>
<div class="card">
<div class="tbl-wrap"><table class="reseller-audit-table"><thead><tr><th>Quando</th><th>Responsável</th><th>Revendedor</th><th>Ação</th><th>Detalhes</th></tr></thead><tbody id="resellerAuditBody"></tbody></table></div>
</div>
</section>
<div class="statusbar workspace-section-status"><span id="resellerStatus">Ready.</span></div> <div class="statusbar workspace-section-status"><span id="resellerStatus">Ready.</span></div>
</div><!-- /tab-resellers --> </div><!-- /tab-resellers -->
@@ -1538,17 +1560,17 @@
<!-- app.js was split into ordered modules for maintainability. They are plain <!-- app.js was split into ordered modules for maintainability. They are plain
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. --> so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260713sections5"></script> <script defer src="assets/js/01-core.js?v=20260713reseller10"></script>
<script defer src="assets/js/02-shell.js?v=20260713sections5"></script> <script defer src="assets/js/02-shell.js?v=20260713reseller10"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260713sections5"></script> <script defer src="assets/js/03-ssh-users.js?v=20260713reseller10"></script>
<script defer src="assets/js/04-xray.js?v=20260713sections5"></script> <script defer src="assets/js/04-xray.js?v=20260713reseller10"></script>
<script defer src="assets/js/05-resellers.js?v=20260713sections5"></script> <script defer src="assets/js/05-resellers.js?v=20260713reseller10"></script>
<script defer src="assets/js/06-servers.js?v=20260713sections5"></script> <script defer src="assets/js/06-servers.js?v=20260713reseller10"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260713sections5"></script> <script defer src="assets/js/07-stats-logs.js?v=20260713reseller10"></script>
<script defer src="assets/js/08-server-config.js?v=20260713sections5"></script> <script defer src="assets/js/08-server-config.js?v=20260713reseller10"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260713sections5"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260713reseller10"></script>
<script defer src="assets/js/11-update-status.js?v=20260713sections5"></script> <script defer src="assets/js/11-update-status.js?v=20260713reseller10"></script>
<script defer src="assets/js/12-bot.js?v=20260713sections5"></script> <script defer src="assets/js/12-bot.js?v=20260713reseller10"></script>
<script defer src="assets/js/10-boot.js?v=20260713sections5"></script> <script defer src="assets/js/10-boot.js?v=20260713reseller10"></script>
</body> </body>
</html> </html>
+151 -248
View File
@@ -24,6 +24,8 @@ import (
const ( const (
RoleSuperAdmin = "superadmin" RoleSuperAdmin = "superadmin"
RoleReseller = "reseller" RoleReseller = "reseller"
QuotaModeSlots = "slots"
QuotaModeCredit = "credits"
sessionTTL = 12 * time.Hour sessionTTL = 12 * time.Hour
adminBcryptCost = 12 adminBcryptCost = 12
) )
@@ -33,14 +35,19 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$
// ---------- AdminUser ---------- // ---------- AdminUser ----------
type AdminUser struct { type AdminUser struct {
ID int ID int
Username string Username string
PasswordHash string PasswordHash string
Role string Role string
MaxUsers int MaxUsers int
ExpiresAt *time.Time ParentUsername string
IsActive bool QuotaMode string
CreatedAt time.Time CreditBalance int
WhatsApp string
MonthlyPriceCents int
ExpiresAt *time.Time
IsActive bool
CreatedAt time.Time
} }
// ---------- Session store (in-memory) ---------- // ---------- 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 // Re-check the account on every request. This immediately revokes sessions
// after an account is suspended, expired, deleted, or has its role changed. // after an account is suspended, expired, deleted, or has its role changed.
u, ok := adminUsers.get(s.Username) u, ok := adminUsers.get(s.Username)
if !ok || u.ID != s.UserID || !u.IsActive || u.Role != s.Role || if !ok || u.ID != s.UserID || u.Role != s.Role || adminAccountChainActive(s.Username) != nil {
(u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
sessions.Delete(token) sessions.Delete(token)
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
@@ -345,7 +351,47 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 ''`, `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 { for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil { if _, err := s.db.ExecContext(ctx, stmt); err != nil {
@@ -355,45 +401,51 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
return nil 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{} u := &AdminUser{}
var expiresAt sql.NullTime var expiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at &u.ParentUsername, &u.QuotaMode, &u.CreditBalance, &u.WhatsApp, &u.MonthlyPriceCents,
FROM admin_users WHERE username = $1`, username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
&expiresAt, &u.IsActive, &u.CreatedAt) &expiresAt, &u.IsActive, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
if expiresAt.Valid { if expiresAt.Valid {
u.ExpiresAt = &expiresAt.Time 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 return u, nil
} }
func (s *Store) ListAdminUsers(ctx context.Context) ([]*AdminUser, error) { func (s *Store) ListAdminUsers(ctx context.Context) ([]*AdminUser, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at `SELECT `+adminUserSelectColumns+` FROM admin_users ORDER BY role, username`)
FROM admin_users ORDER BY role, username`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []*AdminUser var out []*AdminUser
for rows.Next() { for rows.Next() {
u := &AdminUser{} u, err := scanAdminUser(rows)
var expiresAt sql.NullTime if err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
return nil, err return nil, err
} }
if expiresAt.Valid {
u.ExpiresAt = &expiresAt.Time
}
out = append(out, u) out = append(out, u)
} }
return out, rows.Err() return out, rows.Err()
@@ -406,15 +458,21 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
} }
if u.ID == 0 { if u.ID == 0 {
return s.db.QueryRowContext(ctx, return s.db.QueryRowContext(ctx,
`INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active) `INSERT INTO admin_users (username, password_hash, role, max_users, parent_username,
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, quota_mode, credit_balance, whatsapp, monthly_price_cents, expires_at, is_active)
u.Username, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive, 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) ).Scan(&u.ID)
} }
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4, `UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4, parent_username=$5,
expires_at=$5, is_active=$6 WHERE id=$1`, quota_mode=$6, credit_balance=$7, whatsapp=$8, monthly_price_cents=$9,
u.ID, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive) 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 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) { func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at `SELECT `+adminUserSelectColumns+` FROM admin_users
FROM admin_users
WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`, WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`,
RoleReseller) RoleReseller)
if err != nil { 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) { func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUser, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at `SELECT `+adminUserSelectColumns+` FROM admin_users
FROM admin_users
WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`, WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`,
RoleReseller) RoleReseller)
if err != nil { if err != nil {
@@ -462,15 +518,10 @@ func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUs
func scanAdminUsers(rows *sql.Rows) ([]*AdminUser, error) { func scanAdminUsers(rows *sql.Rows) ([]*AdminUser, error) {
var out []*AdminUser var out []*AdminUser
for rows.Next() { for rows.Next() {
u := &AdminUser{} u, err := scanAdminUser(rows)
var expiresAt sql.NullTime if err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
return nil, err return nil, err
} }
if expiresAt.Valid {
u.ExpiresAt = &expiresAt.Time
}
out = append(out, u) out = append(out, u)
} }
return out, rows.Err() return out, rows.Err()
@@ -516,7 +567,12 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
if err != nil { if err != nil {
return err return err
} }
states, err := store.ListResellerRuntimeStates(ctx)
if err != nil {
return err
}
adminUsers.replaceAll(users) adminUsers.replaceAll(users)
resellerRuntimeStates.replaceAll(states)
return nil 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. // ownerIsActive returns nil if an SSH user's reseller owner is active, or an error if suspended/expired.
func ownerIsActive(ownerUsername string) error { func ownerIsActive(ownerUsername string) error {
if ownerUsername == "" { if _, replicated := resellerRuntimeStates.get(ownerUsername); replicated {
return nil return resellerRuntimeChainActive(ownerUsername)
} }
u, ok := adminUsers.get(ownerUsername) return adminAccountChainActive(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
} }
// disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner. // disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner.
@@ -579,29 +625,38 @@ func startResellerExpiryChecker(store *Store) {
} }
for _, u := range expired { for _, u := range expired {
log.Printf("reseller %s expired — suspending", u.Username) 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 { if err := store.SetAdminUserActive(ctx, u.Username, false); err != nil {
quotaUnlock()
resellerLifecycleMu.Unlock()
log.Printf("reseller expiry: %v", err) log.Printf("reseller expiry: %v", err)
continue continue
} }
u.IsActive = false u.IsActive = false
adminUsers.set(u) adminUsers.set(u)
disconnectOwnerUsers(u.Username) sessions.DeleteUser(u.ID)
removeOwnerXrayClients(ctx, store, u.Username) 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) // Replicated owner records on managed nodes also enforce expiration and
renewed, err := store.ListInactiveButRenewedResellers(ctx) // inherited parent suspension without contacting the master on each login.
if err != nil { for _, state := range resellerRuntimeStates.list() {
log.Printf("reseller renewal check: %v", err) if resellerRuntimeChainActive(state.OwnerUsername) != nil {
} if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, "suspend"); err != nil {
for _, u := range renewed { log.Printf("replicated reseller expiry runtime for %s: %v", state.OwnerUsername, err)
log.Printf("reseller %s renewed — reactivating", u.Username) }
if err := store.SetAdminUserActive(ctx, u.Username, true); err != nil {
log.Printf("reseller renewal: %v", err)
continue
} }
u.IsActive = true
adminUsers.set(u)
} }
sessions.cleanup() sessions.cleanup()
@@ -664,12 +719,8 @@ func handleLogin(store *Store) http.HandlerFunc {
http.Error(w, "invalid credentials", http.StatusUnauthorized) http.Error(w, "invalid credentials", http.StatusUnauthorized)
return return
} }
if !u.IsActive { if adminAccountChainActive(u.Username) != nil {
http.Error(w, "account suspended", http.StatusForbidden) http.Error(w, "account suspended or expired", http.StatusForbidden)
return
}
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
http.Error(w, "account expired", http.StatusForbidden)
return return
} }
@@ -720,183 +771,35 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
} }
if s.Role == RoleReseller { if s.Role == RoleReseller {
if u, ok := adminUsers.get(s.Username); ok { 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["max_users"] = u.MaxUsers
resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username) usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), statsStore, s.Username)
resp["used_ssh_users"] = countOwnedUsers(s.Username) if usageErr != nil {
resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username) 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["expires_at"] = u.ExpiresAt
resp["is_active"] = u.IsActive resp["is_active"] = u.IsActive
resp["effective_active"] = adminAccountChainActive(u.Username) == nil
} }
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
} }
// ---------- Reseller management (superadmin only) ---------- // Reseller management handlers live in reseller_management.go.
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)
}
}
+3 -3
View File
@@ -119,7 +119,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
case http.MethodGet: case http.MethodGet:
cfg, err := LoadBotConfig(ctx, store) cfg, err := LoadBotConfig(ctx, store)
if err != nil { if err != nil {
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "load bot configuration", err)
return return
} }
botWriteJSON(w, botConfigDTO{ botWriteJSON(w, botConfigDTO{
@@ -224,7 +224,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost), XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
} }
if err := SaveBotConfig(ctx, store, cfg); err != nil { if err := SaveBotConfig(ctx, store, cfg); err != nil {
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "save bot configuration", err)
return return
} }
reloadBotService(store) reloadBotService(store)
@@ -265,7 +265,7 @@ func handleBotPlans(store *Store) http.HandlerFunc {
return return
} }
if err := store.UpsertPlan(ctx, &p); err != nil { if err := store.UpsertPlan(ctx, &p); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "save bot plan", err)
return return
} }
botWriteJSON(w, p) botWriteJSON(w, p)
+8 -10
View File
@@ -1843,20 +1843,18 @@ func handleDnsttGenKey(w http.ResponseWriter, r *http.Request) {
} }
privkey, err := noise.GeneratePrivkey() privkey, err := noise.GeneratePrivkey()
if err != nil { if err != nil {
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "generate DNSTT key", err)
return return
} }
f, err := os.OpenFile(dnsttKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) var encoded bytes.Buffer
if err != nil { if err := noise.WriteKey(&encoded, privkey); err != nil {
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "encode DNSTT key", err)
return return
} }
if err := noise.WriteKey(f, privkey); err != nil { if err := writeFileAtomic(dnsttKeyFile, encoded.Bytes(), 0o600); err != nil {
f.Close() writeInternalError(w, "write DNSTT key", err)
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
return return
} }
f.Close()
pubkey := noise.PubkeyFromPrivkey(privkey) pubkey := noise.PubkeyFromPrivkey(privkey)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
@@ -1879,13 +1877,13 @@ func handleDnsttGetPubKey(w http.ResponseWriter, r *http.Request) {
} }
f, err := os.Open(keyPath) f, err := os.Open(keyPath)
if err != nil { if err != nil {
http.Error(w, "open key: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "open DNSTT key", err)
return return
} }
defer f.Close() defer f.Close()
privkey, err := noise.ReadKey(f) privkey, err := noise.ReadKey(f)
if err != nil { if err != nil {
http.Error(w, "read key: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "read DNSTT key", err)
return return
} }
pubkey := noise.PubkeyFromPrivkey(privkey) pubkey := noise.PubkeyFromPrivkey(privkey)
+5
View File
@@ -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
+6 -6
View File
@@ -1,14 +1,14 @@
module shell2 module shell2
go 1.25.4 go 1.25.12
require ( require (
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/xtaci/kcp-go/v5 v5.6.61 github.com/xtaci/kcp-go/v5 v5.6.61
github.com/xtaci/smux v1.5.50 github.com/xtaci/smux v1.5.50
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.54.0
golang.org/x/net v0.47.0 golang.org/x/net v0.57.0
golang.org/x/time v0.14.0 golang.org/x/time v0.15.0
www.bamsoftware.com/git/dnstt.git v1.20241021.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/klauspost/reedsolomon v1.12.0 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.40.0 // indirect
) )
+12 -12
View File
@@ -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-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-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 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/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-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 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-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-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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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/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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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-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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+70 -6
View File
@@ -15,6 +15,7 @@ LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}" PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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_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}" REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)" MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir" [[ -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)" TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)" 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() { require_systemd() {
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)" SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
if [[ -z "$SYSTEMCTL_BIN" ]]; then if [[ -z "$SYSTEMCTL_BIN" ]]; then
@@ -235,16 +268,21 @@ if command -v go &>/dev/null; then
fi fi
if $NEED_GO; then if $NEED_GO; then
GO_EXPECTED_SHA256=""
MACHINE=$(uname -m) MACHINE=$(uname -m)
case "$MACHINE" in case "$MACHINE" in
x86_64) GOARCH="amd64" ;; x86_64) GOARCH="amd64" ;;
aarch64) GOARCH="arm64" ;; aarch64) GOARCH="arm64" ;;
armv7l) GOARCH="armv6l" ;; armv7l) GOARCH="armv6l" ;;
*) GOARCH="amd64" ;; *) error "Unsupported CPU architecture: $MACHINE" ;;
esac 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" GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
info " Downloading $GO_URL" info " Downloading $GO_URL"
wget -q --show-progress -O /tmp/go.tar.gz "$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 rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz tar -C /usr/local -xzf /tmp/go.tar.gz
rm -f /tmp/go.tar.gz rm -f /tmp/go.tar.gz
@@ -299,25 +337,51 @@ fi
# ── 6. Xray binary ────────────────────────────────────────────────────────── # ── 6. Xray binary ──────────────────────────────────────────────────────────
info "[6/10] Downloading Xray-core…" 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) MACHINE=$(uname -m)
case "$MACHINE" in case "$MACHINE" in
x86_64) XRAY_ARCH="64" ;; x86_64) XRAY_ARCH="64" ;;
aarch64) XRAY_ARCH="arm64-v8a" ;; aarch64) XRAY_ARCH="arm64-v8a" ;;
armv7l) XRAY_ARCH="arm32-v7a" ;; armv7l) XRAY_ARCH="arm32-v7a" ;;
*) XRAY_ARCH="64" ;; *) error "Unsupported CPU architecture: $MACHINE" ;;
esac 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})" info " Xray ${XRAY_VER} (${XRAY_ARCH})"
wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL" 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 || { unzip -o /tmp/xray.zip xray -d "$INSTALL_DIR" > /dev/null 2>&1 || {
mkdir -p /tmp/xray_extract mkdir -p /tmp/xray_extract
unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1 unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1
mv /tmp/xray_extract/xray "$INSTALL_DIR/xray" mv /tmp/xray_extract/xray "$INSTALL_DIR/xray"
} }
chmod +x "$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 "$INSTALL_DIR/xray" version
# ── 7. PostgreSQL ──────────────────────────────────────────────────────────── # ── 7. PostgreSQL ────────────────────────────────────────────────────────────
+130 -38
View File
@@ -1612,6 +1612,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
// SSH user management (session required; role-filtered inside handlers) // SSH user management (session required; role-filtered inside handlers)
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers))) mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store)))) 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)))) mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
// Server stats: visible to authenticated sessions; reset remains superadmin-only. // 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", saSession(http.HandlerFunc(handleDnsttStats)))
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs))) mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
// Superadmin-only: reseller management // Hierarchical reseller management. Scope checks inside each handler limit a
mux.Handle("/api/resellers", saSession(http.HandlerFunc(handleListResellers(store)))) // reseller to its direct children; superadmins retain global management.
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store)))) mux.Handle("/api/resellers", sessionMiddleware(http.HandlerFunc(handleListResellers(store))))
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(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 // Master/slave server management. Superadmins can add slave nodes; all authenticated
// users can read the enabled server list to pick where accounts are created. // 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/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd))) 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/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))) mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation // Superadmin-only: TLS certificate generation
@@ -1783,6 +1790,7 @@ type UserPayload struct {
AllowStaticPassword bool `json:"allow_static_password"` AllowStaticPassword bool `json:"allow_static_password"`
OwnerUsername string `json:"owner_username,omitempty"` OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"` ServerID string `json:"server_id,omitempty"`
PreserveExpires bool `json:"preserve_expires,omitempty"`
} }
func handleCreateUser(store *Store) http.HandlerFunc { func handleCreateUser(store *Store) http.HandlerFunc {
@@ -1796,64 +1804,113 @@ func handleCreateUser(store *Store) http.HandlerFunc {
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
var p UserPayload 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) http.Error(w, "invalid json", http.StatusBadRequest)
return return
} }
if p.Username == "" { if err := validateSSHUserPayload(&p); err != nil {
http.Error(w, "username required", http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
ctx := r.Context() ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil { if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSelectionError(w, err)
return return
} else if remote { } else if remote {
if !ms.EnableSSH { if !ms.EnableSSH {
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden) http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
return return
} }
chargedCredits, creditCost, creditOwner := false, 0, ""
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller { 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 { if ownerErr != nil {
http.Error(w, "could not verify remote ownership", http.StatusBadGateway) http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
return return
} }
currentOwner := ""
if exists {
currentOwner = strings.TrimSpace(fmt.Sprint(row["owner_username"]))
}
if exists && currentOwner != sess.Username { if exists && currentOwner != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
if !exists { if exists {
owner, ok := adminUsers.get(sess.Username) if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username) if strings.TrimSpace(p.ExpiresAt) != "" {
if quotaErr != nil { http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
http.Error(w, "could not verify reseller quota", http.StatusBadGateway) return
return }
p.PreserveExpires = true
p.MaxConnections = jsonInt(row["max_connections"])
} }
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers { if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, jsonInt(row["max_connections"]), p.MaxConnections); quotaErr != nil {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) writeResellerProvisionError(w, quotaErr)
return 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 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 = "" p.ServerID = ""
body, _ := json.Marshal(p) body, _ := json.Marshal(p)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json") status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
if err != nil { 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 return
} }
if status < 200 || status >= 300 {
if chargedCredits {
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
}
}
writeProxyResponse(w, status, data, ct) writeProxyResponse(w, status, data, ct)
return return
} }
sess := sessionFromCtx(ctx) sess := sessionFromCtx(ctx)
var existingLocalExpiry string
var existingLocalUser bool
var existingLocalMax int
if sess != nil && sess.Role == RoleReseller { if sess != nil && sess.Role == RoleReseller {
quotaUnlock := lockResellerQuota(sess.Username)
defer quotaUnlock()
var existingOwner string 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 { if err != nil && err != sql.ErrNoRows {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
@@ -1862,6 +1919,31 @@ func handleCreateUser(store *Store) http.HandlerFunc {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return 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: // Decide what password to use:
@@ -1892,29 +1974,32 @@ func handleCreateUser(store *Store) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} else { } 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 := "" ownerUsername := ""
chargedCredits, creditCost := false, 0
isNewUser := false
if sess != nil && sess.Role == RoleReseller { if sess != nil && sess.Role == RoleReseller {
ownerUsername = sess.Username ownerUsername = sess.Username
// Enforce user limit — only count on new user creation if !existingLocalUser {
var existsInDB bool isNewUser = true
_ = store.db.QueryRowContext(ctx, var quotaErr error
`SELECT TRUE FROM ssh_users WHERE username=$1`, p.Username, chargedCredits, creditCost, quotaErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
).Scan(&existsInDB)
if !existsInDB {
owner, ok := adminUsers.get(sess.Username)
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
if quotaErr != nil { if quotaErr != nil {
http.Error(w, "could not verify reseller quota", http.StatusBadGateway) writeResellerProvisionError(w, quotaErr)
return return
} }
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers { if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) p.ExpiresAt = expiry
return
} }
} }
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" { } 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 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) log.Printf("failed to upsert user: %v", err)
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
@@ -1961,15 +2049,15 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
return return
} }
username := r.URL.Query().Get("username") username := strings.TrimSpace(r.URL.Query().Get("username"))
if username == "" { if err := validateAccountUsername(username); err != nil {
http.Error(w, "username required", http.StatusBadRequest) http.Error(w, "invalid username", http.StatusBadRequest)
return return
} }
ctx := r.Context() ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil { if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSelectionError(w, err)
return return
} else if remote { } else if remote {
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, username, sess.Username) { 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) remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json") status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
if err != nil { if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) writeBadGatewayError(w, "delete SSH account from managed server", err)
return return
} }
writeProxyResponse(w, status, data, ct) writeProxyResponse(w, status, data, ct)
@@ -3041,6 +3129,7 @@ func main() {
} else { } else {
log.Printf("iface totals persistence disabled: %v", err) log.Printf("iface totals persistence disabled: %v", err)
} }
startManagedResellerStateSync(store)
} }
// start background collector for CPU + interface stats // start background collector for CPU + interface stats
@@ -3061,6 +3150,9 @@ func main() {
// Start the integrated Xray-core subprocess if configured. // Start the integrated Xray-core subprocess if configured.
initXrayManager(cfg.Xray) initXrayManager(cfg.Xray)
if store != nil {
reconcileLocalResellerRuntimeStates(store)
}
// Global banner text (from config or file) — stored in a global so the // Global banner text (from config or file) — stored in a global so the
// admin API can update it on the fly without a restart. // admin API can update it on the fly without a restart.
+150 -64
View File
@@ -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 { type ManagedServer struct {
ID int ID int
Name string Name string
@@ -344,6 +363,36 @@ func managedServerFromID(ctx context.Context, store *Store, id string) (*Managed
return ms, true, nil 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) { func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey}) 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)) 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() defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 { 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 { var out struct {
Token string `json:"token"` 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) { 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 != "" { if contentType != "" {
w.Header().Set("Content-Type", 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 { 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)) ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSelectionError(w, err)
return true return true
} }
if !remote { 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")) status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
if err != nil { if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) writeBadGatewayError(w, "proxy managed server request", err)
return true return true
} }
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") { 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) ms, err := store.UpsertManagedServer(r.Context(), p)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSaveError(w, err)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -604,17 +660,17 @@ func handleServerTest(store *Store) http.HandlerFunc {
} }
token, err := remoteLoginToken(r.Context(), ms) token, err := remoteLoginToken(r.Context(), ms)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) writeBadGatewayError(w, "test managed server login", err)
return return
} }
_ = token _ = token
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json") status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) writeBadGatewayError(w, "test managed server session", err)
return return
} }
if status < 200 || status >= 300 { 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 return
} }
w.Header().Set("Content-Type", "application/json") 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) ms, remote, err := managedServerFromID(r.Context(), store, id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSelectionError(w, err)
return return
} }
if !remote { 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") status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
if err != nil { if err != nil {
log.Printf("managed server config proxy %s: %v", ms.BaseURL, err) writeBadGatewayError(w, "proxy managed server configuration", err)
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return return
} }
writeProxyResponse(w, status, data, ct) 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 == "" { if username == "" {
return "", false, nil return nil, false, nil
} }
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json") status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
if err != nil || status < 200 || status >= 300 { if err != nil || status < 200 || status >= 300 {
if err == nil { if err == nil {
err = fmt.Errorf("remote users returned HTTP %d", status) err = fmt.Errorf("remote users returned HTTP %d", status)
} }
return "", false, err return nil, false, err
} }
var rows []map[string]interface{} var rows []map[string]interface{}
if err := json.Unmarshal(data, &rows); err != nil { if err := json.Unmarshal(data, &rows); err != nil {
return "", false, err return nil, false, err
} }
for _, row := range rows { for _, row := range rows {
if fmt.Sprint(row["username"]) == username { 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 { 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 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 == "" { if uuid == "" {
return "", false, nil return nil, false, nil
} }
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json") status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
if err != nil || status < 200 || status >= 300 { if err != nil || status < 200 || status >= 300 {
if err == nil { if err == nil {
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status) err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
} }
return "", false, err return nil, false, err
} }
var inbounds []map[string]interface{} var inbounds []map[string]interface{}
if err := json.Unmarshal(data, &inbounds); err != nil { if err := json.Unmarshal(data, &inbounds); err != nil {
return "", false, err return nil, false, err
} }
for _, ib := range inbounds { for _, ib := range inbounds {
clients, _ := ib["clients"].([]interface{}) clients, _ := ib["clients"].([]interface{})
for _, c := range clients { for _, c := range clients {
m, _ := c.(map[string]interface{}) m, _ := c.(map[string]interface{})
if fmt.Sprint(m["id"]) == uuid { 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 { 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 return err == nil && exists && actualOwner == owner
} }
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) { type resellerQuotaUsage struct {
if store == nil || owner == "" { Weighted int
return 0, nil 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) servers, err := store.ListManagedServers(ctx)
if err != nil { if err != nil {
return 0, err return resellerQuotaUsage{}, err
} }
for _, ms := range servers { for _, ms := range servers {
if !ms.IsActive { // Count every configured node and both account types. Temporarily disabling
continue // 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 { var users []map[string]interface{}
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json") if err := json.Unmarshal(data, &users); err != nil {
if err != nil || status < 200 || status >= 300 { return resellerQuotaUsage{}, err
if err == nil { }
err = fmt.Errorf("remote users returned HTTP %d", status) for _, user := range users {
} if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
return 0, err usage.Weighted += resellerProvisionCost(jsonInt(user["max_connections"]))
} usage.SSHAccounts++
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++
}
} }
} }
if ms.EnableXray { status, data, _, err = proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json") if err != nil || status < 200 || status >= 300 {
if err != nil || status < 200 || status >= 300 { if err == nil {
if err == nil { err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
}
return 0, err
} }
var inbounds []map[string]interface{} return resellerQuotaUsage{}, err
if err := json.Unmarshal(data, &inbounds); err != nil { }
return 0, err var inbounds []map[string]interface{}
} if err := json.Unmarshal(data, &inbounds); err != nil {
for _, inbound := range inbounds { return resellerQuotaUsage{}, err
clients, _ := inbound["clients"].([]interface{}) }
for _, client := range clients { for _, inbound := range inbounds {
item, _ := client.(map[string]interface{}) clients, _ := inbound["clients"].([]interface{})
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner { for _, client := range clients {
total++ 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
} }
+1 -1
View File
@@ -97,7 +97,7 @@ func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) {
path := panelLogFilePath() path := panelLogFilePath()
maxBytes := panelLogMaxBytes() maxBytes := panelLogMaxBytes()
if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil { 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 return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
File diff suppressed because it is too large Load Diff
+107
View File
@@ -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")
}
}
+265
View File
@@ -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
}
+17
View File
@@ -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) { func TestMPSignatureRequiresSecretAndValidHMAC(t *testing.T) {
const ( const (
secret = "test-secret-with-enough-entropy" secret = "test-secret-with-enough-entropy"
+15
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -10,6 +11,20 @@ import (
const maxAdminRequestBody = 8 << 20 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 // securePanelHandler applies baseline browser protections and a global request
// body ceiling. Endpoint-specific handlers may impose a smaller limit. // body ceiling. Endpoint-specific handlers may impose a smaller limit.
func securePanelHandler(next http.Handler) http.Handler { func securePanelHandler(next http.Handler) http.Handler {
+2 -2
View File
@@ -66,7 +66,7 @@ func serverConfigGet(w http.ResponseWriter, _ *http.Request) {
} }
data, err := os.ReadFile(globalCfgPath) data, err := os.ReadFile(globalCfgPath)
if err != nil { if err != nil {
http.Error(w, "failed to read config: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "read server configuration", err)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -115,7 +115,7 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil { 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 return
} }
+10 -10
View File
@@ -78,7 +78,7 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
certDir := filepath.Join(tlsCertsDir, dirName) certDir := filepath.Join(tlsCertsDir, dirName)
if err := os.MkdirAll(certDir, 0o700); err != nil { if err := os.MkdirAll(certDir, 0o700); err != nil {
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "create TLS certificate directory", err)
return return
} }
certFile := filepath.Join(certDir, "cert.pem") 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) priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "generate TLS private key", err)
return return
} }
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) 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) der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil { if err != nil {
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "generate TLS certificate", err)
return return
} }
privDER, err := x509.MarshalECPrivateKey(priv) privDER, err := x509.MarshalECPrivateKey(priv)
if err != nil { if err != nil {
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "encode TLS private key", err)
return return
} }
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil { if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil {
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "write TLS certificate", err)
return return
} }
if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil { 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 return
} }
@@ -168,7 +168,7 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
"--agree-tos", "-m", email, "-d", domain) "--agree-tos", "-m", email, "-d", domain)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { 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 return
} }
@@ -220,17 +220,17 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
} }
certDir := filepath.Join(tlsCertsDir, name) certDir := filepath.Join(tlsCertsDir, name)
if err := os.MkdirAll(certDir, 0o700); err != nil { 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 return
} }
certFile := filepath.Join(certDir, "cert.pem") certFile := filepath.Join(certDir, "cert.pem")
keyFile := filepath.Join(certDir, "key.pem") keyFile := filepath.Join(certDir, "key.pem")
if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil { 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 return
} }
if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil { 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 return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+38 -2
View File
@@ -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)" TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)" 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() { require_systemd() {
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)" SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
if [[ -z "$SYSTEMCTL_BIN" ]]; then if [[ -z "$SYSTEMCTL_BIN" ]]; then
@@ -249,7 +281,7 @@ prepare_source_from_git() {
} }
install_go_if_needed() { 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")" go_version="$(awk '$1 == "go" {print $2; exit}' "$SOURCE_DIR/go.mod" 2>/dev/null || echo "1.22.5")"
need_go=true need_go=true
@@ -270,12 +302,16 @@ install_go_if_needed() {
x86_64) goarch="amd64" ;; x86_64) goarch="amd64" ;;
aarch64) goarch="arm64" ;; aarch64) goarch="arm64" ;;
armv7l) goarch="armv6l" ;; armv7l) goarch="armv6l" ;;
*) goarch="amd64" ;; *) error "Unsupported CPU architecture: $machine" ;;
esac 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" go_url="https://go.dev/dl/go${go_version}.linux-${goarch}.tar.gz"
info " Downloading Go ${go_version} (${goarch})..." info " Downloading Go ${go_version} (${goarch})..."
need_cmd wget need_cmd wget
wget -q --show-progress -O /tmp/go.tar.gz "$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 rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz tar -C /usr/local -xzf /tmp/go.tar.gz
rm -f /tmp/go.tar.gz rm -f /tmp/go.tar.gz
+167 -10
View File
@@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"log" "log"
"strings"
"time" "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 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 last_active TIMESTAMPTZ`,
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS active_connections INT NOT NULL DEFAULT 0`, `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 { for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil { if _, err := s.db.ExecContext(ctx, stmt); err != nil {
@@ -231,8 +237,41 @@ func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername stri
return n 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 { 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) { 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 // suspendOwnerXrayClients removes an owner's clients from the live Xray config
// Xray clients from both the config file and the database every 5 minutes. // 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) { func startXrayClientExpiryChecker(store *Store) {
if store == nil { if store == nil {
return return
@@ -282,19 +424,34 @@ func startXrayClientExpiryChecker(store *Store) {
continue continue
} }
needRestart := false 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 { for _, m := range expired {
tag := m.InboundTag tag := m.InboundTag
if tag == "" { if tag == "" {
_ = store.DeleteXrayClientMeta(ctx, m.UUID) if m.OwnerUsername == "" {
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
}
continue continue
} }
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil { if present[tag][m.UUID] {
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err) if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
} else { log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
needRestart = true } else {
needRestart = true
}
} }
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil { if m.OwnerUsername == "" {
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err) 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) log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
} }
+186 -56
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -1883,7 +1884,7 @@ func handleXrayStart(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := xrayMgr.Start(); err != nil { if err := xrayMgr.Start(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeInternalError(w, "start Xray", err)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -1898,7 +1899,7 @@ func handleXrayStop(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := xrayMgr.Stop(); err != nil { if err := xrayMgr.Stop(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeInternalError(w, "stop Xray", err)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -1913,7 +1914,7 @@ func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := xrayMgr.Restart(); err != nil { if err := xrayMgr.Restart(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeInternalError(w, "restart Xray", err)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -1938,7 +1939,7 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
case http.MethodGet: case http.MethodGet:
data, err := xrayMgr.GetConfig() data, err := xrayMgr.GetConfig()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeInternalError(w, "read Xray configuration", err)
return return
} }
w.Header().Set("Content-Type", "application/json") 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) http.Error(w, "failed to read body", http.StatusBadRequest)
return 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 { if err := xrayMgr.SetConfig(body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeInternalError(w, "save Xray configuration", err)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -1972,13 +1984,13 @@ func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) {
wasRunning := xrayMgr.isRunningSnapshot() wasRunning := xrayMgr.isRunningSnapshot()
changed, err := xrayMgr.EnsureStatsAPIConfig() changed, err := xrayMgr.EnsureStatsAPIConfig()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeInternalError(w, "repair Xray statistics configuration", err)
return return
} }
restarted := false restarted := false
if wasRunning { if wasRunning {
if err := xrayMgr.Restart(); err != nil { 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 return
} }
restarted = true restarted = true
@@ -2293,7 +2305,7 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
} }
inbounds, err := xrayMgr.ListInbounds() inbounds, err := xrayMgr.ListInbounds()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeInternalError(w, "list Xray inbounds", err)
return return
} }
@@ -2446,22 +2458,40 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
OwnerUsername string `json:"owner_username,omitempty"` OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,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) http.Error(w, "invalid json", http.StatusBadRequest)
return return
} }
if req.InboundTag == "" || req.UUID == "" { req.InboundTag = strings.TrimSpace(req.InboundTag)
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) 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 return
} }
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil { if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSelectionError(w, err)
return return
} else if remote { } else if remote {
if !ms.EnableXray { if !ms.EnableXray {
http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden) http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden)
return return
} }
chargedCredits, creditCost, creditOwner := false, 0, ""
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller { if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
_, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID) _, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID)
if ownerErr != nil { if ownerErr != nil {
@@ -2472,25 +2502,44 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "UUID already exists", http.StatusConflict) http.Error(w, "UUID already exists", http.StatusConflict)
return return
} }
owner, ok := adminUsers.get(sess.Username) quotaUnlock := lockResellerQuota(sess.Username)
used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username) defer quotaUnlock()
if quotaErr != nil { chargedCredits, creditCost, ownerErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
http.Error(w, "could not verify reseller quota", http.StatusBadGateway) if ownerErr != nil {
writeResellerProvisionError(w, ownerErr)
return return
} }
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers { creditOwner = sess.Username
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
return req.ExpiresAt = expiry
} }
req.OwnerUsername = sess.Username 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 = "" req.ServerID = ""
body, _ := json.Marshal(req) body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json") status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json")
if err != nil { 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 return
} }
if status < 200 || status >= 300 {
if chargedCredits {
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
}
}
writeProxyResponse(w, status, data, ct) writeProxyResponse(w, status, data, ct)
return return
} }
@@ -2504,26 +2553,17 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context()) sess := sessionFromCtx(r.Context())
ownerUsername := "" ownerUsername := ""
chargedCredits, creditCost := false, 0
if sess != nil && sess.Role == RoleReseller { if sess != nil && sess.Role == RoleReseller {
ownerUsername = sess.Username ownerUsername = sess.Username
if statsStore == nil { if statsStore == nil {
http.Error(w, "storage not available", http.StatusInternalServerError) http.Error(w, "storage not available", http.StatusInternalServerError)
return return
} }
owner, ok := adminUsers.get(sess.Username) if err := adminAccountChainActive(sess.Username); err != nil {
if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) {
http.Error(w, "reseller account suspended or expired", http.StatusForbidden) http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
return 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) != "" { } else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(req.OwnerUsername) != "" {
ownerUsername = 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) http.Error(w, "UUID already exists in database", http.StatusBadRequest)
return return
} else if err != sql.ErrNoRows { } else if err != sql.ErrNoRows {
http.Error(w, "database error: "+err.Error(), http.StatusInternalServerError) writeInternalError(w, "check Xray client metadata", err)
return 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 { 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 return
} }
if statsStore != nil { if statsStore != nil {
@@ -2564,7 +2627,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil { 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() xrayMgr.restartIfExternalRunning()
@@ -2579,34 +2647,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
MaxConnections int `json:"max_connections"` MaxConnections int `json:"max_connections"`
ServerID string `json:"server_id,omitempty"` 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) http.Error(w, "invalid json", http.StatusBadRequest)
return return
} }
if req.UUID == "" { req.UUID = strings.TrimSpace(req.UUID)
http.Error(w, "uuid required", http.StatusBadRequest) 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 return
} }
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil { if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSelectionError(w, err)
return return
} else if remote { } else if remote {
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, req.UUID, sess.Username) { if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
http.Error(w, "forbidden", http.StatusForbidden) quotaUnlock := lockResellerQuota(sess.Username)
return 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 = "" req.ServerID = ""
body, _ := json.Marshal(req) body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", body, "application/json") status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", body, "application/json")
if err != nil { if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) writeBadGatewayError(w, "update Xray account on managed server", err)
return return
} }
writeProxyResponse(w, status, data, ct) writeProxyResponse(w, status, data, ct)
@@ -2617,16 +2721,34 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
return 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) existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID)
if err != nil { if err != nil {
http.Error(w, "client metadata not found", http.StatusNotFound) http.Error(w, "client metadata not found", http.StatusNotFound)
return return
} }
sess := sessionFromCtx(r.Context())
if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username { if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return 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{ meta := XrayClientMeta{
UUID: req.UUID, UUID: req.UUID,
@@ -2636,6 +2758,9 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
OwnerUsername: existing.OwnerUsername, OwnerUsername: existing.OwnerUsername,
MaxConns: req.MaxConnections, MaxConns: req.MaxConnections,
} }
if req.PreserveExpires {
meta.ExpiresAt = existing.ExpiresAt
}
if req.ExpiresAt != "" { if req.ExpiresAt != "" {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
if t, err := time.Parse(layout, req.ExpiresAt); err == nil { 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 { 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 return
} }
if req.Email != "" { if req.Email != "" {
@@ -2664,13 +2789,13 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
return return
} }
inboundTag := r.URL.Query().Get("inbound_tag") inboundTag := r.URL.Query().Get("inbound_tag")
uuid := r.URL.Query().Get("uuid") uuid := strings.TrimSpace(r.URL.Query().Get("uuid"))
if inboundTag == "" || uuid == "" { if err := validateXrayClientFields(uuid, inboundTag, "", "", "", 0, true); err != nil {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil { if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeManagedServerSelectionError(w, err)
return return
} else if remote { } else if remote {
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, uuid, sess.Username) { 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) 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") status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodDelete, remotePath, nil, "application/json")
if err != nil { if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) writeBadGatewayError(w, "delete Xray account from managed server", err)
return return
} }
writeProxyResponse(w, status, data, ct) 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 { 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 return
} }
if statsStore != nil { if statsStore != nil {
+1 -1
View File
@@ -616,7 +616,7 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
return return
} }
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err) 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 return
} }
if len(payload) == 0 { if len(payload) == 0 {