Compare commits

...
6 Commits
Author SHA1 Message Date
penguinehis a345e70e5a Beta 1 2026-07-13 18:01:39 -03:00
penguinehis dab8b09f0c Fix panel 2026-07-13 02:24:33 -03:00
penguinehis 635f190630 Panel update 2026-07-13 02:21:56 -03:00
penguinehis 64b1fc5cb3 Fix admin panel 2026-07-13 02:00:27 -03:00
penguinehis 047e4be207 XHTTP Panel Fix 2026-07-13 01:34:28 -03:00
penguinehis 7d90568869 Xhttp and panel update 2026-07-13 01:20:57 -03:00
39 changed files with 5577 additions and 884 deletions
+1
View File
@@ -1,2 +1,3 @@
/shell2.exe /shell2.exe
/BOT_PLAN.md /BOT_PLAN.md
/SECURITY_REVIEW.md
+89 -13
View File
@@ -11,7 +11,12 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
- Banco de dados PostgreSQL - Banco de dados PostgreSQL
- Integração com Xray-core/V2Ray - Integração com Xray-core/V2Ray
- Configurador visual para VLESS e VMess - Configurador visual para VLESS e VMess
- Contas de revendedor (reseller) com cota de usuários e escopo próprio - Editor visual completo de inbounds (criar, editar, duplicar e remover)
- Endpoint XHTTP compartilhado no modo nativo: VLESS **ou** VMess em `/` e SSH em `/ssh`, usando o mesmo domínio/porta/TLS
- Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual
- Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel
- Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray
- 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
@@ -48,6 +53,36 @@ gRPC
Observação: Reality deve ser usado apenas em protocolos compatíveis. No configurador visual, VMess não usa Reality. Observação: Reality deve ser usado apenas em protocolos compatíveis. No configurador visual, VMess não usa Reality.
### Endpoint XHTTP compartilhado
Na aba **Xray → Configuração Xray → Visual**, o cartão **Shared XHTTP endpoint** cria dois inbounds na mesma porta:
- VLESS **ou** VMess em `/` (selecione um protocolo raiz);
- SSH sobre XHTTP em `/ssh`, autenticado pelas contas SSH normais do painel.
O listener HTTP interno escolhe o caminho mais específico, portanto `/ssh` é encaminhado ao servidor SSH antes do inbound raiz. Todos os inbounds na mesma porta devem usar a mesma opção TLS e o mesmo certificado. Esse recurso exige o modo **Internal native emulator**; o binário Xray externo não reconhece o protocolo `ssh` específico do DragonCore.
Para configurações XHTTP antigas, carregue a configuração visual e clique em **Ativar SSH /ssh** no cartão VLESS/VMess existente. O painel preserva o inbound original, seus clientes, path, host, modo, opções avançadas e TLS; cria apenas o inbound SSH correspondente na mesma porta e salva/reinicia o Xray. Não é necessário recriar clientes ou o endpoint.
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`
@@ -55,6 +90,8 @@ Observação: Reality deve ser usado apenas em protocolos compatíveis. No confi
- 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
@@ -74,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
@@ -522,7 +560,13 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
- PostgreSQL database - PostgreSQL database
- Xray-core/V2Ray integration - Xray-core/V2Ray integration
- Visual configurator for VLESS and VMess - Visual configurator for VLESS and VMess
- Reseller accounts with a user quota and self-scoped access - Full visual inbound editor (create, edit, duplicate, and remove)
- Native shared XHTTP endpoint: VLESS **or** VMess on `/` and SSH on `/ssh`, using the same domain/port/TLS
- Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher
- Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations
- Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray
- 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
@@ -559,6 +603,19 @@ gRPC
Note: Reality should only be used with compatible protocols. In the visual configurator, VMess does not use Reality. Note: Reality should only be used with compatible protocols. In the visual configurator, VMess does not use Reality.
### Shared XHTTP endpoint
In **Xray → Xray Config → Visual**, the **Shared XHTTP endpoint** card creates two inbounds on the same port:
- VLESS **or** VMess on `/` (select one root protocol);
- SSH over XHTTP on `/ssh`, authenticated by the panel's regular SSH accounts.
The internal HTTP listener selects the most specific path, so `/ssh` reaches the SSH server before the root inbound. Every inbound sharing a port must use the same TLS setting and certificate. This feature requires **Internal native emulator** mode; the external Xray binary does not recognize DragonCore's custom `ssh` protocol.
For older XHTTP configurations, load the visual configuration and click **Enable SSH /ssh** on the existing VLESS/VMess card. The panel preserves the original inbound, clients, path, host, mode, advanced options, and TLS; it only creates the matching SSH inbound on the same port and then saves/restarts Xray. There is no need to recreate clients or the endpoint.
The migration confirmation is rendered inside the panel. If saving fails, the temporary SSH inbound is removed from the draft and the old inbound remains intact, so the operation can be retried after fixing the displayed error.
### Requirements ### Requirements
- Linux server with `systemd` - Linux server with `systemd`
@@ -566,6 +623,8 @@ Note: Reality should only be used with compatible protocols. In the visual confi
- `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
@@ -585,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
@@ -1076,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.
--- ---
@@ -1144,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)
}
}
+52
View File
@@ -650,6 +650,57 @@ select:disabled {
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;} .update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;} .btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;}
/* --- Shared visual language for every workspace tab --- */
.page-hero{
--hero-accent:34,211,238;
position:relative;display:flex;align-items:flex-end;justify-content:space-between;gap:22px;
min-height:150px;margin-bottom:18px;padding:25px 27px;overflow:hidden;
border:1px solid rgba(var(--hero-accent),.22);border-radius:28px;
background:
radial-gradient(circle at 88% 8%,rgba(var(--hero-accent),.24),transparent 33%),
radial-gradient(circle at 8% 115%,rgba(139,92,246,.12),transparent 40%),
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
box-shadow:0 24px 70px rgba(0,0,0,.32),inset 0 1px 0 rgba(255,255,255,.04);
}
.page-hero::after{content:"";position:absolute;right:-65px;top:-100px;width:250px;height:250px;border:1px solid rgba(255,255,255,.055);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.017),0 0 0 68px rgba(255,255,255,.011);pointer-events:none;}
.page-hero[data-tone="green"]{--hero-accent:49,214,123}.page-hero[data-tone="purple"]{--hero-accent:139,92,246}.page-hero[data-tone="amber"]{--hero-accent:255,200,87}.page-hero[data-tone="blue"]{--hero-accent:80,145,255}
.page-hero-copy,.page-hero-pills,.page-hero-mark{position:relative;z-index:1}.page-hero-copy{max-width:700px}.page-kicker{display:block;color:rgb(var(--hero-accent));font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.page-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em}.page-hero p{max-width:650px;margin-top:9px;color:var(--muted);font-size:.82rem;line-height:1.55}.page-hero-pills{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap;max-width:42%}.page-hero-pills span{padding:7px 10px;border:1px solid rgba(var(--hero-accent),.18);border-radius:999px;background:rgba(var(--hero-accent),.075);color:var(--text-2);font-size:.69rem;font-weight:850;letter-spacing:.04em}.page-hero-mark{display:grid;place-items:center;width:64px;height:64px;border:1px solid rgba(var(--hero-accent),.25);border-radius:22px;background:rgba(var(--hero-accent),.10);color:rgb(var(--hero-accent));font-size:1.05rem;font-weight:950;box-shadow:0 18px 44px rgba(0,0,0,.24)}
/* Bot-style live workspace heroes shared by SSH, Xray, and infrastructure. */
.page-hero.status-hero{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;min-height:0}.status-hero .page-hero-copy,.workspace-hero-actions,.workspace-overview-grid,.workspace-hero-toolbar{position:relative;z-index:1}.workspace-hero-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:620px}.workspace-live-status{display:inline-flex;align-items:center;gap:7px;max-width:360px;min-height:34px;padding:7px 11px;overflow:hidden;border:1px solid rgba(160,174,192,.14);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.7rem;font-weight:850;text-overflow:ellipsis;white-space:nowrap}.workspace-live-status::before{content:"";width:7px;height:7px;flex:0 0 auto;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor}.workspace-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08)}.workspace-live-status.is-warn{color:#ffd36d;border-color:rgba(255,200,87,.25);background:rgba(255,200,87,.08)}.workspace-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08)}.workspace-live-status.is-loading{color:#71dff0;border-color:rgba(34,211,238,.23);background:rgba(34,211,238,.075)}
.workspace-overview-grid{grid-column:1/-1;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;width:100%;margin-top:23px}.workspace-overview-grid.five{grid-template-columns:repeat(5,minmax(0,1fr))}.workspace-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid rgba(160,174,192,.14);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px)}.workspace-overview-card>div{display:flex;flex:1;flex-direction:column;gap:4px;min-width:0}.workspace-overview-card small{color:var(--muted);font-size:.64rem;font-weight:800;letter-spacing:.095em;text-transform:uppercase}.workspace-overview-card strong{overflow:hidden;color:var(--text);font-size:1rem;text-overflow:ellipsis;white-space:nowrap}.workspace-card-note{overflow:hidden;color:var(--muted);font-size:.61rem;text-overflow:ellipsis;white-space:nowrap}.workspace-overview-icon{display:grid;place-items:center;flex:0 0 auto;width:34px;height:34px;border:1px solid rgba(34,211,238,.17);border-radius:12px;background:rgba(34,211,238,.12);color:#4de0ef;font-size:.68rem;font-weight:950}.workspace-overview-icon.green{color:#72e6a4;border-color:rgba(49,214,123,.18);background:rgba(49,214,123,.11)}.workspace-overview-icon.purple{color:#b19cff;border-color:rgba(139,92,246,.2);background:rgba(139,92,246,.13)}.workspace-overview-icon.amber{color:#ffd36d;border-color:rgba(255,200,87,.18);background:rgba(255,200,87,.11)}.workspace-overview-icon.blue{color:#84b3ff;border-color:rgba(80,145,255,.2);background:rgba(80,145,255,.12)}.workspace-overview-icon.red{color:#ff8f99;border-color:rgba(255,91,105,.2);background:rgba(255,91,105,.11)}.workspace-mini-meter{height:3px;margin-top:2px;overflow:hidden;border-radius:999px;background:rgba(148,163,184,.1)}.workspace-mini-meter span{display:block;width:0;height:100%;border-radius:inherit;background:linear-gradient(90deg,rgb(var(--hero-accent)),#74edb0);transition:width .25s ease}
.workspace-hero-toolbar{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:13px;padding-top:13px;border-top:1px solid rgba(148,163,184,.1)}.workspace-toolbar-status{min-width:0;color:var(--muted);font-size:.7rem;line-height:1.45}.workspace-toolbar-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap}.workspace-toolbar-actions .input-sm{max-width:220px}
/* Four infrastructure screens share one compact section switcher. */
.infra-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:20px;padding:6px;border:1px solid rgba(160,174,192,.14);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px)}.infra-section-nav{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px}.infra-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease}.infra-section-nav button span{margin-right:6px;color:#80abff}.infra-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04)}.infra-section-nav button.active{color:#fff;border-color:rgba(80,145,255,.3);background:linear-gradient(135deg,rgba(80,145,255,.2),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.infra-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(80,145,255,.3);border-radius:13px;background:#090d15;color:var(--text);font-weight:850}
/* Bot-style sub-navigation shared by SSH, Xray, resellers, and settings. */
#tab-ssh{--section-accent:49,214,123}#tab-xray{--section-accent:139,92,246}#tab-resellers{--section-accent:255,200,87}#tab-server{--section-accent:80,145,255}
.workspace-nav-shell{--section-accent:80,145,255;position:sticky;top:92px;z-index:12;margin-bottom:20px;padding:6px;border:1px solid rgba(160,174,192,.14);border-radius:19px;background:rgba(7,10,16,.9);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px)}.workspace-nav-shell[data-tone="green"]{--section-accent:49,214,123}.workspace-nav-shell[data-tone="purple"]{--section-accent:139,92,246}.workspace-nav-shell[data-tone="amber"]{--section-accent:255,200,87}.workspace-nav-shell[data-tone="blue"]{--section-accent:80,145,255}.workspace-section-nav{display:grid;grid-template-columns:repeat(var(--workspace-nav-columns,2),minmax(0,1fr));gap:5px}.workspace-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease}.workspace-section-nav button span{margin-right:6px;color:rgb(var(--section-accent));font-size:.72rem}.workspace-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04)}.workspace-section-nav button.active{color:#fff;border-color:rgba(var(--section-accent),.3);background:linear-gradient(135deg,rgba(var(--section-accent),.19),rgba(34,211,238,.07));box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.workspace-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(var(--section-accent),.3);border-radius:13px;background:#090d15;color:var(--text);font-weight:850}.workspace-section{display:none;min-width:0}.workspace-section.active{display:block}.workspace-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px}.workspace-section-heading>div>span{display:block;color:rgb(var(--section-accent,80,145,255));font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.workspace-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em}.workspace-section-heading p{color:var(--muted);font-size:.79rem;line-height:1.5}.workspace-form-card{width:min(100%,920px)}.workspace-section-status{margin-top:16px}.settings-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.settings-panel-grid>.card{margin-top:0!important}.settings-panel-grid>.settings-span-all{grid-column:1/-1}.settings-workspace>.workspace-section>.card{width:min(100%,1040px);margin-top:0!important}
.role-reseller #xraySectionNav{grid-template-columns:repeat(2,minmax(0,1fr))}
/* Shared panel-native confirmations and non-blocking notifications. */
.panel-dialog{position:fixed;inset:0;z-index:120;display:grid;place-items:center;padding:20px}.panel-dialog.hidden{display:none!important}.panel-dialog-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(8px)}.panel-dialog-card{--hero-accent:80,145,255;position:relative;width:min(100%,510px);padding:22px;border:1px solid rgba(var(--hero-accent),.28);border-radius:25px;background:radial-gradient(circle at 100% 0,rgba(var(--hero-accent),.13),transparent 34%),linear-gradient(180deg,#121925,#080c13);box-shadow:0 36px 110px rgba(0,0,0,.66);animation:fadeIn .16s ease both}.panel-dialog-card.is-danger{--hero-accent:255,91,105}.panel-dialog-card.is-success{--hero-accent:49,214,123}.panel-dialog-head{display:flex;align-items:center;gap:13px}.panel-dialog-head h3{margin-top:5px;font-size:1.15rem;letter-spacing:-.025em}.panel-dialog-icon{display:grid;place-items:center;flex:0 0 auto;width:45px;height:45px;border:1px solid rgba(var(--hero-accent),.28);border-radius:15px;background:rgba(var(--hero-accent),.11);color:rgb(var(--hero-accent));font-size:.76rem;font-weight:950}.panel-dialog-card>p{margin-top:16px;color:var(--text-2);font-size:.79rem;line-height:1.58}.panel-dialog-detail{margin-top:12px;padding:12px 13px;border:1px solid rgba(148,163,184,.11);border-radius:14px;background:rgba(255,255,255,.035);color:var(--muted);font-size:.7rem;line-height:1.5;white-space:pre-line}.panel-dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:19px;padding-top:15px;border-top:1px solid rgba(148,163,184,.1)}.panel-dialog-open{overflow:hidden}.panel-toast-stack{position:fixed;right:20px;bottom:20px;z-index:130;display:flex;flex-direction:column-reverse;gap:9px;width:min(390px,calc(100vw - 40px));pointer-events:none}.panel-toast{--toast-accent:80,145,255;display:flex;align-items:flex-start;gap:10px;padding:12px 13px;border:1px solid rgba(var(--toast-accent),.28);border-radius:17px;background:rgba(10,15,24,.96);box-shadow:0 18px 55px rgba(0,0,0,.48);backdrop-filter:blur(15px);animation:toastIn .18s ease both;pointer-events:auto}.panel-toast.success{--toast-accent:49,214,123}.panel-toast.warning{--toast-accent:255,200,87}.panel-toast.error{--toast-accent:255,91,105}.panel-toast-icon{display:grid;place-items:center;flex:0 0 auto;width:28px;height:28px;border-radius:10px;background:rgba(var(--toast-accent),.12);color:rgb(var(--toast-accent));font-size:.68rem;font-weight:950}.panel-toast-copy{min-width:0;flex:1}.panel-toast-copy strong{display:block;color:var(--text);font-size:.75rem}.panel-toast-copy p{margin-top:3px;color:var(--muted);font-size:.69rem;line-height:1.42}.panel-toast-close{padding:2px;border:0;background:transparent;color:var(--muted);font-size:.85rem;cursor:pointer}@keyframes toastIn{from{opacity:0;transform:translateY(9px) scale(.98)}to{opacity:1;transform:none}}
/* Bring legacy screens up to the Bot workspace's information density. */
.tab-pane:not(#tab-bot)>.grid2,.tab-pane:not(#tab-bot)>#serversListView>.grid2{gap:16px}.tab-pane:not(#tab-bot) .card-hdr{padding-bottom:12px;border-bottom:1px solid rgba(148,163,184,.09)}.tab-pane:not(#tab-bot) .card-title{font-size:.96rem}.tab-pane:not(#tab-bot) .statusbar{margin-top:13px;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
/* Xray visual configuration studio */
.shared-endpoint-card{--hero-accent:139,92,246;position:relative;margin-bottom:18px;padding:20px;overflow:hidden;border:1px solid rgba(139,92,246,.22);border-radius:22px;background:radial-gradient(circle at 96% 0,rgba(139,92,246,.17),transparent 32%),rgba(8,12,20,.82)}
.shared-endpoint-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.shared-endpoint-head h3,.visual-editor-heading h3{margin-top:5px;font-size:1.12rem;letter-spacing:-.02em}.shared-endpoint-head p{margin-top:5px;color:var(--muted);font-size:.75rem;line-height:1.45}.shared-endpoint-head code,.shared-route-preview code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#c8bbff}
.shared-route-preview{display:grid;grid-template-columns:1fr 48px 1fr;align-items:center;gap:8px;margin:17px 0;padding:10px;border:1px solid rgba(148,163,184,.11);border-radius:17px;background:rgba(255,255,255,.025)}.shared-route-preview span{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border:1px solid rgba(139,92,246,.16);border-radius:13px;background:rgba(139,92,246,.07)}.shared-route-preview strong{font-size:.78rem}.shared-route-preview code{font-size:.77rem;font-weight:900}.shared-route-preview i{height:1px;background:linear-gradient(90deg,rgba(139,92,246,.2),rgba(34,211,238,.7),rgba(139,92,246,.2));position:relative}.shared-route-preview i::after{content:"";position:absolute;right:0;top:-3px;width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}
.shared-endpoint-grid{grid-template-columns:repeat(3,minmax(0,1fr));}.shared-endpoint-actions{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:15px;padding-top:14px;border-top:1px solid rgba(148,163,184,.1)}.shared-endpoint-actions .hint{max-width:650px}
.legacy-xhttp-migration{display:flex;align-items:center;gap:13px;margin:-3px 0 15px;padding:13px 15px;border:1px solid rgba(49,214,123,.19);border-radius:18px;background:linear-gradient(135deg,rgba(49,214,123,.075),rgba(34,211,238,.035));color:var(--text-2)}.legacy-xhttp-icon{display:grid;place-items:center;flex:0 0 auto;width:42px;height:42px;border:1px solid rgba(49,214,123,.25);border-radius:14px;background:rgba(49,214,123,.11);color:#72e6a4;font-size:.68rem;font-weight:950;letter-spacing:.035em}.legacy-xhttp-migration strong{display:block;color:var(--text);font-size:.8rem}.legacy-xhttp-migration p{margin-top:3px;color:var(--muted);font-size:.71rem;line-height:1.5}
.visual-config-toolbar{display:grid;grid-template-columns:180px minmax(0,1fr) auto;align-items:end;gap:14px;margin-bottom:13px;padding:13px 15px;border:1px solid rgba(148,163,184,.1);border-radius:18px;background:rgba(255,255,255,.025)}.visual-config-toolbar-copy{display:flex;flex-direction:column;gap:4px;padding-bottom:4px}.visual-config-toolbar-copy strong{font-size:.84rem}.visual-config-toolbar-copy span{color:var(--muted);font-size:.71rem}
.visual-inbound-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:14px}.visual-inbound-card{position:relative;display:flex;flex-direction:column;gap:13px;min-width:0;padding:15px;border:1px solid rgba(148,163,184,.11);border-radius:18px;background:rgba(255,255,255,.027);transition:.15s ease}.visual-inbound-card:hover{border-color:rgba(139,92,246,.3);background:rgba(139,92,246,.045);transform:translateY(-1px)}.visual-inbound-card-head,.visual-inbound-meta,.visual-inbound-actions{display:flex;align-items:center;gap:8px}.visual-inbound-card-head{justify-content:space-between}.visual-inbound-name{min-width:0}.visual-inbound-name strong{display:block;overflow:hidden;text-overflow:ellipsis;color:var(--text);font-size:.84rem;white-space:nowrap}.visual-inbound-name small{display:block;margin-top:4px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.67rem}.visual-inbound-meta{flex-wrap:wrap}.visual-inbound-meta span{padding:4px 7px;border-radius:8px;background:rgba(148,163,184,.07);color:var(--muted);font-size:.67rem}.visual-inbound-actions{justify-content:flex-end;margin-top:auto;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
.legacy-ssh-btn{margin-right:auto;border-color:rgba(49,214,123,.3)!important;background:linear-gradient(135deg,rgba(49,214,123,.18),rgba(34,211,238,.09))!important;color:#8af0b5!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.legacy-ssh-btn:hover:not(:disabled){border-color:rgba(49,214,123,.52)!important;transform:translateY(-1px)}.legacy-ssh-btn.is-enabled:disabled{opacity:1;border-color:rgba(49,214,123,.16)!important;background:rgba(49,214,123,.07)!important;color:#72b98e!important;cursor:default}
.visual-inbound-editor{margin:14px 0;padding:18px;border:1px solid rgba(34,211,238,.2);border-radius:22px;background:radial-gradient(circle at 100% 0,rgba(34,211,238,.09),transparent 28%),rgba(6,10,16,.86)}.visual-editor-heading{--hero-accent:34,211,238;display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:15px;padding-bottom:13px;border-bottom:1px solid rgba(148,163,184,.1)}.visual-save-bar{position:sticky;bottom:14px;z-index:8;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:16px;padding:13px 15px;border:1px solid rgba(139,92,246,.2);border-radius:19px;background:rgba(8,12,20,.9);box-shadow:0 18px 48px rgba(0,0,0,.35);backdrop-filter:blur(16px)}
@media(max-width:1180px){.workspace-overview-grid.five{grid-template-columns:repeat(3,minmax(0,1fr))}.infra-nav-shell,.workspace-nav-shell{top:78px}}
@media(max-width:1100px){.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:repeat(2,minmax(0,1fr))}#configSectionNav{grid-template-columns:repeat(3,minmax(0,1fr))}#xraySectionNav{grid-template-columns:repeat(2,minmax(0,1fr))}.shared-endpoint-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.visual-inbound-list{grid-template-columns:1fr}}
@media(max-width:760px){.page-hero{min-height:0;padding:20px;border-radius:22px;align-items:flex-start;flex-direction:column}.page-hero.status-hero{display:grid;grid-template-columns:1fr}.page-hero h2{font-size:1.55rem}.page-hero-pills{max-width:none;justify-content:flex-start}.page-hero-mark{width:50px;height:50px;border-radius:17px}.workspace-hero-actions{justify-content:flex-start;max-width:none;margin-top:14px}.workspace-live-status{max-width:100%}.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:18px}.workspace-hero-toolbar{align-items:stretch;flex-direction:column}.workspace-toolbar-actions{justify-content:flex-start}.workspace-toolbar-actions .input-sm{width:100%;max-width:none}.infra-section-nav,.workspace-section-nav{display:none}.infra-section-select,.workspace-section-select{display:block}.infra-nav-shell,.workspace-nav-shell{top:76px}.workspace-section-heading{align-items:flex-start;flex-direction:column}.workspace-section-heading>.btn,.workspace-section-heading>.card-actions{width:100%}.workspace-section-heading>.btn{justify-content:center}.settings-panel-grid{grid-template-columns:1fr}.settings-panel-grid>.settings-span-all{grid-column:auto}.shared-endpoint-card{padding:15px}.shared-endpoint-head,.shared-endpoint-actions,.visual-save-bar{align-items:flex-start;flex-direction:column}.shared-endpoint-actions .btn,.visual-save-bar .btn{width:100%}.shared-route-preview{grid-template-columns:1fr}.shared-route-preview i{width:1px;height:22px;justify-self:center}.shared-route-preview i::after{right:-3px;top:auto;bottom:0}.shared-endpoint-grid{grid-template-columns:1fr!important}.legacy-xhttp-migration{align-items:flex-start}.visual-config-toolbar{grid-template-columns:1fr;align-items:stretch}.visual-config-toolbar .btn{width:100%}.visual-inbound-actions{align-items:stretch;flex-wrap:wrap}.legacy-ssh-btn{flex:1 0 100%;margin-right:0}.panel-dialog{padding:14px}.panel-dialog-card{padding:19px}.panel-dialog-actions .btn{flex:1}.panel-toast-stack{right:14px;bottom:14px;width:calc(100vw - 28px)}}
@media(max-width:460px){.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:1fr}.workspace-hero-actions .btn{flex:1}.workspace-toolbar-actions .btn{flex:1}.workspace-overview-card{padding:11px 12px}}
/* --- Bot sales workspace --- */ /* --- Bot sales workspace --- */
#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);} #tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);}
.bot-hero{ .bot-hero{
@@ -692,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;}
+174 -5
View File
@@ -5,8 +5,9 @@ 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 formCollapsed = true;
let tlsForwardersState = []; let tlsForwardersState = [];
let managedTlsForwardersState = []; let managedTlsForwardersState = [];
let editingXrayClientId = null; let editingXrayClientId = null;
@@ -15,6 +16,7 @@ let wzLoadedFullConfig = null;
let wzLoadedConfigText = ""; let wzLoadedConfigText = "";
let wzLoadedServerID = null; let wzLoadedServerID = null;
let wzDirty = false; let wzDirty = false;
let wzEditingIndex = -1;
let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null }; let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
let currentTab = "dashboard"; let currentTab = "dashboard";
let inboundsRefreshInFlight = false; let inboundsRefreshInFlight = false;
@@ -114,6 +116,64 @@ Object.assign(I18N_ALIASES, {
"Atualizações do painel":"Panel Updates","Verificando…":"Checking…","Abrir Git":"Open Git","Verificar agora":"Check now","Versão instalada":"Installed version","Última versão no Git":"Latest Git version","Última verificação":"Last checked", "Atualizações do painel":"Panel Updates","Verificando…":"Checking…","Abrir Git":"Open Git","Verificar agora":"Check now","Versão instalada":"Installed version","Última versão no Git":"Latest Git version","Última verificação":"Last checked",
"Comparando a versão instalada com o repositório Git.":"Comparing the installed version with the Git repository.","Para atualizar:":"To update:","Copiar comando":"Copy command","Atualizado":"Up to date","Atualização disponível":"Update available","Alterações locais":"Local changes","Desconhecido":"Unknown" "Comparando a versão instalada com o repositório Git.":"Comparing the installed version with the Git repository.","Para atualizar:":"To update:","Copiar comando":"Copy command","Atualizado":"Up to date","Atualização disponível":"Update available","Alterações locais":"Local changes","Desconhecido":"Unknown"
}); });
Object.assign(I18N_TEXT["en-US"], {
"Command center":"Command center","Overview command copy":"Accounts, connections, and infrastructure at a glance.","Access workspace":"Access workspace","SSH accounts":"SSH accounts","SSH accounts copy":"Create, limit, and monitor SSH and SlowDNS access securely.",
"Proxy studio":"Proxy studio","Xray visual":"Visual Xray","Xray visual copy":"Manage clients, inbounds, and a shared XHTTP endpoint without editing JSON.","Partner operations":"Partner operations","Partner operations copy":"Control partner quotas, expiration, and access in one place.",
"Fleet control":"Fleet control","Fleet control copy":"Add nodes, test credentials, and configure remote infrastructure.","Live fleet":"Live fleet","Server status copy":"Health, load, and active sessions for every managed node.","Observability":"Observability","Monitoring copy":"Server resources, interfaces, and capacity in real time.",
"Traffic intelligence":"Traffic intelligence","Network traffic":"Network traffic","Network traffic copy":"Daily and monthly history for understanding infrastructure usage.","Diagnostics":"Diagnostics","System logs copy":"Investigate the panel, DNSTT, and Xray in a focused view.","System studio":"System studio","Settings copy":"Network, SSH, tunnels, and TLS organized visually and applied live.",
"One domain and port":"One domain and port","Shared endpoint copy":"The selected protocol uses /; SSH uses /ssh. Available in native Xray mode.","Protocol on /":"Protocol on /","Shared port":"Shared port","Listen IP":"Listen IP","HTTP host":"HTTP host","optional":"optional","XHTTP mode":"XHTTP mode","Security":"Security","No TLS":"No TLS","Certificate file":"Certificate file","Key file":"Key file","Create / update endpoint":"Create / update endpoint",
"Configured inbounds":"Configured inbounds","Visual inbound help":"Edit any card visually or use JSON for advanced fields.","New inbound":"New inbound","Visual editor":"Visual editor","Add inbound":"Add inbound","Save changes":"Save changes","Duplicate":"Duplicate","Remove":"Remove","Save config and restart":"Save config and restart",
"Old XHTTP configuration?":"Old XHTTP configuration?","Legacy XHTTP migration copy":"Use “Enable SSH /ssh” on the existing inbound card. The panel preserves the inbound, clients, and every current option; it only adds the SSH route and restarts Xray.","Enable SSH /ssh":"Enable SSH /ssh","SSH /ssh enabled":"SSH /ssh enabled","Legacy XHTTP migration":"Legacy XHTTP migration"
});
Object.assign(I18N_TEXT["pt-BR"], {
"Command center":"Central de comando","Overview command copy":"Contas, conexões e infraestrutura em uma leitura rápida.","Access workspace":"Área de acessos","SSH accounts":"Contas SSH","SSH accounts copy":"Crie, limite e acompanhe acessos SSH e SlowDNS com segurança.",
"Proxy studio":"Estúdio de proxy","Xray visual":"Xray visual","Xray visual copy":"Gerencie clientes, inbounds e um endpoint XHTTP compartilhado sem editar JSON.","Partner operations":"Operação de parceiros","Partner operations copy":"Controle cotas, validade e acesso dos parceiros em um só lugar.",
"Fleet control":"Controle da frota","Fleet control copy":"Adicione nós, teste credenciais e configure a infraestrutura remota.","Live fleet":"Frota ao vivo","Server status copy":"Saúde, carga e sessões ativas de cada nó gerenciado.","Observability":"Observabilidade","Monitoring copy":"Recursos, interfaces e capacidade do servidor em tempo real.",
"Traffic intelligence":"Inteligência de tráfego","Network traffic":"Tráfego de rede","Network traffic copy":"Histórico diário e mensal para entender o consumo da infraestrutura.","Diagnostics":"Diagnóstico","System logs copy":"Investigue painel, DNSTT e Xray com uma visualização focada.","System studio":"Estúdio do sistema","Settings copy":"Rede, SSH, túneis e TLS organizados em blocos visuais e aplicados ao vivo.",
"One domain and port":"Um domínio e uma porta","Shared endpoint copy":"O protocolo selecionado usa /; SSH usa /ssh. Disponível no modo Xray nativo.","Protocol on /":"Protocolo em /","Shared port":"Porta compartilhada","Listen IP":"IP de listen","HTTP host":"Host HTTP","optional":"opcional","XHTTP mode":"Modo XHTTP","Security":"Segurança","No TLS":"Sem TLS","Certificate file":"Arquivo do certificado","Key file":"Arquivo da chave","Create / update endpoint":"Criar / atualizar endpoint",
"Configured inbounds":"Inbounds configurados","Visual inbound help":"Edite qualquer cartão visualmente ou use JSON para campos avançados.","New inbound":"Novo inbound","Visual editor":"Editor visual","Add inbound":"Adicionar inbound","Save changes":"Salvar alterações","Duplicate":"Duplicar","Remove":"Remover","Save config and restart":"Salvar configuração e reiniciar",
"Old XHTTP configuration?":"Configuração XHTTP antiga?","Legacy XHTTP migration copy":"Use “Ativar SSH /ssh” no cartão do inbound existente. O painel mantém o inbound, os clientes e todas as opções atuais; adiciona somente a rota SSH e reinicia o Xray.","Enable SSH /ssh":"Ativar SSH /ssh","SSH /ssh enabled":"SSH /ssh ativado","Legacy XHTTP migration":"Migração de configuração XHTTP antiga"
});
Object.assign(I18N_TEXT["en-US"], {
"Infrastructure":"Infrastructure","Infrastructure areas":"Infrastructure areas","Infrastructure area":"Infrastructure area","Waiting for data":"Waiting for data","Operation":"Operation","Accounts":"Accounts","Active accounts":"Active","Online now":"Online now","Counters":"Counters","Save mode":"Save mode",
"Nodes":"Nodes","Active nodes":"Active","SSH enabled":"SSH enabled","Xray enabled":"Xray enabled","Sessions":"Sessions","Processor":"Processor","Memory":"Memory","Network now":"Network now","Interfaces":"Interfaces","Today":"Today","This month":"This month","Recent period":"Recent period","Update":"Refresh","Clear history":"Clear history",
"Infrastructure workspace copy":"Manage nodes, monitor system health, and inspect traffic from one workspace.","Confirmation":"Confirmation","Confirm action":"Confirm action","Confirm":"Confirm","Completed":"Completed","Action failed":"Action failed","Attention":"Attention","Information":"Information","Close":"Close",
"Loading SSH status…":"Loading SSH status…","Loading":"Loading","Could not load SSH status":"Could not load SSH status","Error":"Error","Online":"Online","SSH data updated at {time}":"SSH data updated at {time}","Delete SSH account":"Delete SSH account","The active SSH sessions for this account will be disconnected.":"The active SSH sessions for this account will be disconnected.","Delete account":"Delete account",
"Loading Xray status…":"Loading Xray status…","Could not load Xray status":"Could not load Xray status","Remove Xray client":"Remove Xray client","The client will lose access immediately after the configuration reload.":"The client will lose access immediately after the configuration reload.","Remove client":"Remove client","Client removed successfully.":"Client removed successfully.","Xray client":"Xray client",
"Loading infrastructure…":"Loading infrastructure…","Infrastructure loaded with fallback data":"Infrastructure loaded with fallback data","{count} active nodes · updated {time}":"{count} active nodes · updated {time}","Delete managed server":"Delete managed server","Delete server \"{name}\"?":"Delete server \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.","Delete server":"Delete server",
"Updating live status…":"Updating live status…","Live · updated {time}":"Live · updated {time}","Error loading server status":"Error loading server status","Clean live interface totals":"Clean live interface totals","Clean the live Interface totals now?":"Clean the live Interface totals now?","VnStat daily and monthly history will be preserved.":"VnStat daily and monthly history will be preserved.","Clean totals":"Clean totals","Live interface totals were cleaned.":"Live interface totals were cleaned.","Traffic counters":"Traffic counters","Clean VnStat history":"Clean VnStat history","Clean all daily and monthly traffic history?":"Clean all daily and monthly traffic history?","Live interface totals are separate and will not be reset.":"Live interface totals are separate and will not be reset.","Clean history":"Clean history","VnStat history was cleaned.":"VnStat history was cleaned.","Traffic history":"Traffic history","Clean panel log":"Clean panel log","Clean the current panel log now?":"Clean the current panel log now?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"This only clears the panel log file. Automatic size-based cleanup remains enabled.","Clean log":"Clean log",
"Delete reseller":"Delete reseller","Delete reseller \"{name}\"?":"Delete reseller \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Their owned access will be removed and active SSH sessions will be disconnected.","Remove inbound":"Remove inbound","Remove inbound {name}?":"Remove inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clients attached only to this inbound will stop connecting after the configuration is saved.",
"This endpoint already has an SSH /ssh route.":"This endpoint already has an SSH /ssh route.","Add SSH /ssh without rebuilding this inbound.":"Add SSH /ssh without rebuilding this inbound.","SSH migration attention":"SSH migration attention","Could not enable SSH /ssh":"Could not enable SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Select a VLESS/VMess inbound using XHTTP.","Load the selected server configuration before enabling SSH.":"Load the selected server configuration before enabling SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH is already enabled on /ssh by inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"Path /ssh is already used by inbound {name}. Edit that path first.","This inbound uses TLS but has no reusable certificate and key file paths.":"This inbound uses TLS but has no reusable certificate and key file paths.","Safe XHTTP migration":"Safe XHTTP migration","Enable SSH on /ssh":"Enable SSH on /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Add SSH to the same endpoint without rebuilding {name}?","Listener":"Listener","Existing path preserved":"Existing path preserved","New SSH path":"New SSH path","Clients preserved":"Clients preserved","Enabling SSH…":"Enabling SSH…","Could not enable SSH: {error}":"Could not enable SSH: {error}","Migration was cancelled because it would alter the old inbound.":"Migration was cancelled because it would alter the old inbound.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh added to the draft without changing {name}.","Enabling SSH /ssh without changing {name}…":"Enabling SSH /ssh without changing {name}…","The SSH route could not be saved. The old inbound was not changed.":"The SSH route could not be saved. The old inbound was not changed.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh is active. The old inbound and all clients were preserved.","Configuration for this server was not loaded.":"Configuration for this server was not loaded.","Invalid visual config: {error}":"Invalid visual config: {error}","Xray could not restart.":"Xray could not restart.","Could not save configuration: {error}":"Could not save configuration: {error}","selected":"selected","this inbound":"this inbound","the old inbound":"the old inbound","untagged":"untagged"
});
Object.assign(I18N_TEXT["pt-BR"], {
"Infrastructure":"Infraestrutura","Infrastructure areas":"Áreas da infraestrutura","Infrastructure area":"Área da infraestrutura","Waiting for data":"Aguardando dados","Operation":"Operação","Accounts":"Contas","Active accounts":"Ativas","Online now":"Online agora","Counters":"Contadores","Save mode":"Salvar modo",
"Nodes":"Nós","Active nodes":"Ativos","SSH enabled":"SSH habilitado","Xray enabled":"Xray habilitado","Sessions":"Sessões","Processor":"Processador","Memory":"Memória","Network now":"Rede agora","Interfaces":"Interfaces","Today":"Hoje","This month":"Este mês","Recent period":"Período recente","Update":"Atualizar","Clear history":"Limpar histórico",
"Infrastructure workspace copy":"Gerencie nós, acompanhe a saúde do sistema e consulte tráfego em um único espaço.","Confirmation":"Confirmação","Confirm action":"Confirmar ação","Confirm":"Confirmar","Completed":"Concluído","Action failed":"Ação não concluída","Attention":"Atenção","Information":"Informação","Close":"Fechar",
"Loading SSH status…":"Carregando status SSH…","Loading":"Carregando","Could not load SSH status":"Não foi possível carregar o status SSH","Error":"Erro","Online":"Online","SSH data updated at {time}":"Dados SSH atualizados às {time}","Delete SSH account":"Excluir conta SSH","The active SSH sessions for this account will be disconnected.":"As sessões SSH ativas desta conta serão desconectadas.","Delete account":"Excluir conta",
"Loading Xray status…":"Carregando status do Xray…","Could not load Xray status":"Não foi possível carregar o status do Xray","Remove Xray client":"Remover cliente Xray","The client will lose access immediately after the configuration reload.":"O cliente perderá o acesso imediatamente após recarregar a configuração.","Remove client":"Remover cliente","Client removed successfully.":"Cliente removido com sucesso.","Xray client":"Cliente Xray",
"Loading infrastructure…":"Carregando infraestrutura…","Infrastructure loaded with fallback data":"Infraestrutura carregada com dados locais de segurança","{count} active nodes · updated {time}":"{count} nós ativos · atualizado às {time}","Delete managed server":"Excluir servidor gerenciado","Delete server \"{name}\"?":"Excluir o servidor \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"O nó remoto não será apagado, mas será removido deste painel e deixará de receber ações gerenciadas.","Delete server":"Excluir servidor",
"Updating live status…":"Atualizando status ao vivo…","Live · updated {time}":"Ao vivo · atualizado às {time}","Error loading server status":"Erro ao carregar status do servidor","Clean live interface totals":"Limpar totais ao vivo das interfaces","Clean the live Interface totals now?":"Limpar agora os totais ao vivo das interfaces?","VnStat daily and monthly history will be preserved.":"O histórico diário e mensal do VnStat será preservado.","Clean totals":"Limpar totais","Live interface totals were cleaned.":"Os totais ao vivo das interfaces foram limpos.","Traffic counters":"Contadores de tráfego","Clean VnStat history":"Limpar histórico VnStat","Clean all daily and monthly traffic history?":"Limpar todo o histórico diário e mensal de tráfego?","Live interface totals are separate and will not be reset.":"Os totais ao vivo das interfaces são separados e não serão zerados.","Clean history":"Limpar histórico","VnStat history was cleaned.":"O histórico VnStat foi limpo.","Traffic history":"Histórico de tráfego","Clean panel log":"Limpar log do painel","Clean the current panel log now?":"Limpar agora o log atual do painel?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"Isso limpa somente o arquivo de log do painel. A limpeza automática por tamanho continuará ativa.","Clean log":"Limpar log",
"Delete reseller":"Excluir revendedor","Delete reseller \"{name}\"?":"Excluir o revendedor \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Os acessos pertencentes a ele serão removidos e as sessões SSH ativas serão desconectadas.","Remove inbound":"Remover inbound","Remove inbound {name}?":"Remover o inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clientes vinculados somente a este inbound deixarão de conectar após salvar a configuração.",
"This endpoint already has an SSH /ssh route.":"Este endpoint já possui uma rota SSH /ssh.","Add SSH /ssh without rebuilding this inbound.":"Adicione SSH /ssh sem recriar este inbound.","SSH migration attention":"Atenção na migração SSH","Could not enable SSH /ssh":"Não foi possível ativar SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Selecione um inbound VLESS/VMess usando XHTTP.","Load the selected server configuration before enabling SSH.":"Carregue a configuração do servidor selecionado antes de ativar SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"O SSH compartilhado exige o modo Xray nativo. Selecione Internal native emulator e salve o modo primeiro.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"O inbound {name} usa {security}, que o SSH XHTTP nativo não suporta. Use TLS ou sem TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH já está ativado em /ssh pelo inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"O caminho /ssh já é usado pelo inbound {name}. Edite esse caminho primeiro.","This inbound uses TLS but has no reusable certificate and key file paths.":"Este inbound usa TLS, mas não possui caminhos reutilizáveis para certificado e chave.","Safe XHTTP migration":"Migração XHTTP segura","Enable SSH on /ssh":"Ativar SSH em /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Adicionar SSH ao mesmo endpoint sem recriar {name}?","Listener":"Listener","Existing path preserved":"Path existente preservado","New SSH path":"Novo path SSH","Clients preserved":"Clientes preservados","Enabling SSH…":"Ativando SSH…","Could not enable SSH: {error}":"Não foi possível ativar SSH: {error}","Migration was cancelled because it would alter the old inbound.":"A migração foi cancelada porque alteraria o inbound antigo.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh foi adicionado ao rascunho sem alterar {name}.","Enabling SSH /ssh without changing {name}…":"Ativando SSH /ssh sem alterar {name}…","The SSH route could not be saved. The old inbound was not changed.":"A rota SSH não pôde ser salva. O inbound antigo não foi alterado.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh foi salvo, mas o Xray não conseguiu reiniciar. Verifique o log e use o botão Reiniciar.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh está ativo. O inbound antigo e todos os clientes foram preservados.","Configuration for this server was not loaded.":"A configuração deste servidor não foi carregada.","Invalid visual config: {error}":"Configuração visual inválida: {error}","Xray could not restart.":"O Xray não conseguiu reiniciar.","Could not save configuration: {error}":"Não foi possível salvar a configuração: {error}","selected":"selecionado","this inbound":"este inbound","the old inbound":"o inbound antigo","untagged":"sem tag"
});
Object.assign(I18N_TEXT["en-US"], {
"Create user":"Create user","Edit user":"Edit user","Create SSH user":"Create SSH user","SSH user saved successfully.":"SSH user saved successfully.","SSH and SlowDNS areas":"SSH and SlowDNS areas","SSH and SlowDNS area":"SSH and SlowDNS area",
"01 · Management":"01 · Management","02 · Registration":"02 · Registration","SSH and SlowDNS users":"SSH and SlowDNS users","SSH users section copy":"Review connections, limits, expiration, and actions for every account.","SSH create section copy":"Set authentication, expiration, connections, and speed in a dedicated screen.",
"Configuration":"Configuration","Xray areas":"Xray areas","Xray area":"Xray area","Xray users section copy":"Review clients, connections, expiration, and traffic by inbound.","Create Xray user":"Create Xray user","Xray create section copy":"Choose the inbound and register the client without opening the users table.","New Xray client":"New Xray client","Loading inbounds…":"Loading inbounds…","Select where the client will be added.":"Select where the client will be added.","Generated automatically":"Generated automatically","Generate":"Generate","Display name":"Display name","Email / identifier":"Email / identifier","Expires on":"Expires on","Maximum connections":"Maximum connections","0 = unlimited":"0 = unlimited","Back to users":"Back to users","Fill in the new client details.":"Fill in the new client details.",
"No compatible inbound found":"No compatible inbound found","No inbound":"No inbound","The client will be added to {tag} on port {port}.":"The client will be added to {tag} on port {port}.","Create or enable a compatible inbound before adding a client.":"Create or enable a compatible inbound before adding a client.","Ready to create a new Xray client.":"Ready to create a new Xray client.","Waiting for a compatible inbound.":"Waiting for a compatible inbound.","Select a compatible inbound.":"Select a compatible inbound.","Creating Xray client…":"Creating Xray client…","Xray user created successfully.":"Xray user created successfully.","Xray user":"Xray user","Could not create the Xray user: {error}":"Could not create the Xray user: {error}",
"03 · Service":"03 · Service","04 · Diagnostics":"04 · Diagnostics","Xray configuration section copy":"Edit the endpoint, inbounds, TLS, and advanced options visually or as JSON.","Xray logs section copy":"Review the latest service messages in a focused screen.",
"Reseller areas":"Reseller areas","Reseller area":"Reseller area","Create reseller":"Create reseller","Edit reseller":"Edit reseller","Registered resellers":"Registered resellers","Reseller list section copy":"Review quotas, shared usage, expiration, and status for every partner.","Reseller create section copy":"Set login, shared limit, expiration, and access in a dedicated screen.","Reseller saved successfully.":"Reseller saved successfully.",
"Configuration areas":"Configuration areas","Configuration area":"Configuration area","Network and SSH":"Network and SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"TLS forwarders","01 · Base":"01 · Base","02 · DNS tunnel":"02 · DNS tunnel","03 · UDP":"03 · UDP","04 · Security":"04 · Security","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, default limits, idle timeout, and the connection banner.","SlowDNS section copy":"Manage domains, local DNS, capacity, queues, and controlled restarts.","UDP section copy":"Set the listener, capacity, map expiration, and service restart.","TLS section copy":"Create TLS listeners with automatic, pasted, or file-based certificates.","Xray core section copy":"Enable the core, choose the runtime, and apply safe native tuning."
});
Object.assign(I18N_TEXT["pt-BR"], {
"Create user":"Criar usuário","Edit user":"Editar usuário","Create SSH user":"Criar usuário SSH","SSH user saved successfully.":"Usuário SSH salvo com sucesso.","SSH and SlowDNS areas":"Áreas SSH e SlowDNS","SSH and SlowDNS area":"Área SSH e SlowDNS",
"01 · Management":"01 · Gestão","02 · Registration":"02 · Cadastro","SSH and SlowDNS users":"Usuários SSH e SlowDNS","SSH users section copy":"Consulte conexões, limites, validade e ações de cada conta.","SSH create section copy":"Defina autenticação, validade, conexões e velocidade em uma tela dedicada.",
"Configuration":"Configuração","Xray areas":"Áreas do Xray","Xray area":"Área do Xray","Xray users section copy":"Consulte clientes, conexões, validade e tráfego separados por inbound.","Create Xray user":"Criar usuário Xray","Xray create section copy":"Escolha o inbound e cadastre o cliente sem abrir a tabela de usuários.","New Xray client":"Novo cliente Xray","Loading inbounds…":"Carregando inbounds…","Select where the client will be added.":"Selecione onde o cliente será adicionado.","Generated automatically":"Gerado automaticamente","Generate":"Gerar","Display name":"Nome de exibição","Email / identifier":"Email / identificação","Expires on":"Expira em","Maximum connections":"Máximo de conexões","0 = unlimited":"0 = ilimitado","Back to users":"Voltar aos usuários","Fill in the new client details.":"Preencha os dados do novo cliente.",
"No compatible inbound found":"Nenhum inbound compatível encontrado","No inbound":"Sem inbound","The client will be added to {tag} on port {port}.":"O cliente será adicionado em {tag} na porta {port}.","Create or enable a compatible inbound before adding a client.":"Crie ou ative um inbound compatível antes de adicionar um cliente.","Ready to create a new Xray client.":"Pronto para criar um novo cliente Xray.","Waiting for a compatible inbound.":"Aguardando um inbound compatível.","Select a compatible inbound.":"Selecione um inbound compatível.","Creating Xray client…":"Criando cliente Xray…","Xray user created successfully.":"Usuário Xray criado com sucesso.","Xray user":"Usuário Xray","Could not create the Xray user: {error}":"Não foi possível criar o usuário Xray: {error}",
"03 · Service":"03 · Serviço","04 · Diagnostics":"04 · Diagnóstico","Xray configuration section copy":"Edite endpoint, inbounds, TLS e opções avançadas visualmente ou em JSON.","Xray logs section copy":"Acompanhe as últimas mensagens do serviço em uma tela focada.",
"Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.",
"Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros."
});
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))])); const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
let currentLang = detectInitialLanguage(); let currentLang = detectInitialLanguage();
let i18nTranslating = false; let i18nTranslating = false;
@@ -263,8 +323,6 @@ const rStatus = document.getElementById("rStatus");
// User form // User form
const userForm = document.getElementById("userForm"); const userForm = document.getElementById("userForm");
const userFormWrap = document.getElementById("userFormWrap");
const toggleFormBtn = document.getElementById("toggleFormBtn");
const cancelUserBtn = document.getElementById("cancelUserBtn"); const cancelUserBtn = document.getElementById("cancelUserBtn");
const newUserBtn = document.getElementById("newUserBtn"); const newUserBtn = document.getElementById("newUserBtn");
const saveUserBtn = document.getElementById("saveUserBtn"); const saveUserBtn = document.getElementById("saveUserBtn");
@@ -279,6 +337,11 @@ const fMaxConn = document.getElementById("fMaxConn");
const fExpires = document.getElementById("fExpires"); const fExpires = document.getElementById("fExpires");
const fUp = document.getElementById("fUp"); const fUp = document.getElementById("fUp");
const fDown = document.getElementById("fDown"); const fDown = document.getElementById("fDown");
const sshLiveStatus = document.getElementById("sshLiveStatus");
const sshMetricState = document.getElementById("sshMetricState");
const sshMetricTotal = document.getElementById("sshMetricTotal");
const sshMetricActive = document.getElementById("sshMetricActive");
const sshMetricOnline = document.getElementById("sshMetricOnline");
// Xray // Xray
const xrayChip = document.getElementById("xrayChip"); const xrayChip = document.getElementById("xrayChip");
@@ -308,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
@@ -330,11 +398,18 @@ 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");
const serversStatusCountChip = document.getElementById("serversStatusCountChip"); const serversStatusCountChip = document.getElementById("serversStatusCountChip");
const fleetLiveStatus = document.getElementById("fleetLiveStatus");
const fleetMetricNodes = document.getElementById("fleetMetricNodes");
const fleetMetricActive = document.getElementById("fleetMetricActive");
const fleetMetricSSH = document.getElementById("fleetMetricSSH");
const fleetMetricXray = document.getElementById("fleetMetricXray");
const fleetStatusOnline = document.getElementById("fleetStatusOnline");
const fleetStatusOffline = document.getElementById("fleetStatusOffline");
const fleetStatusSessions = document.getElementById("fleetStatusSessions");
// Stats // Stats
const cpuVal = document.getElementById("cpuVal"); const cpuVal = document.getElementById("cpuVal");
@@ -345,6 +420,8 @@ const memDetail = document.getElementById("memDetail");
const ifaceBody = document.getElementById("ifaceBody"); const ifaceBody = document.getElementById("ifaceBody");
const ifaceSummary = document.getElementById("ifaceSummary"); const ifaceSummary = document.getElementById("ifaceSummary");
const statsUpdated = document.getElementById("statsUpdated"); const statsUpdated = document.getElementById("statsUpdated");
const statsNetVal = document.getElementById("statsNetVal");
const statsIfaceVal = document.getElementById("statsIfaceVal");
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn"); const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard"); const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated"); const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
@@ -362,9 +439,99 @@ const vnstatStatus = document.getElementById("vnstatStatus");
const vnTodayTotal = document.getElementById("vnTodayTotal"); const vnTodayTotal = document.getElementById("vnTodayTotal");
const vnMonthTotal = document.getElementById("vnMonthTotal"); const vnMonthTotal = document.getElementById("vnMonthTotal");
const vnIfaceCount = document.getElementById("vnIfaceCount"); const vnIfaceCount = document.getElementById("vnIfaceCount");
const vnLatestPeriod = document.getElementById("vnLatestPeriod");
const reloadVnstatBtn = document.getElementById("reloadVnstatBtn"); const reloadVnstatBtn = document.getElementById("reloadVnstatBtn");
const resetVnstatBtn = document.getElementById("resetVnstatBtn"); const resetVnstatBtn = document.getElementById("resetVnstatBtn");
// Shared panel-native dialog and toast surfaces. Destructive and migration
// actions use these instead of browser confirm/alert prompts.
const panelConfirmDialog = document.getElementById("panelConfirmDialog");
const panelDialogCard = panelConfirmDialog?.querySelector(".panel-dialog-card");
const panelDialogIcon = document.getElementById("panelDialogIcon");
const panelDialogEyebrow = document.getElementById("panelDialogEyebrow");
const panelDialogTitle = document.getElementById("panelDialogTitle");
const panelDialogMessage = document.getElementById("panelDialogMessage");
const panelDialogDetail = document.getElementById("panelDialogDetail");
const panelDialogCancelBtn = document.getElementById("panelDialogCancelBtn");
const panelDialogConfirmBtn = document.getElementById("panelDialogConfirmBtn");
const panelToastStack = document.getElementById("panelToastStack");
let panelDialogResolver = null;
let panelDialogLastFocus = null;
function closePanelConfirm(accepted = false) {
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
panelConfirmDialog.classList.add("hidden");
panelConfirmDialog.setAttribute("aria-hidden", "true");
document.body.classList.remove("panel-dialog-open");
const resolver = panelDialogResolver;
panelDialogResolver = null;
resolver?.(!!accepted);
panelDialogLastFocus?.focus?.();
panelDialogLastFocus = null;
}
function panelConfirm(options = {}) {
const opts = typeof options === "string" ? { message: options } : options;
if (!panelConfirmDialog || !panelDialogConfirmBtn) return Promise.resolve(false);
if (panelDialogResolver) closePanelConfirm(false);
panelDialogLastFocus = document.activeElement;
const tone = opts.tone || (opts.danger ? "danger" : "default");
panelDialogCard?.classList.toggle("is-danger", tone === "danger");
panelDialogCard?.classList.toggle("is-success", tone === "success");
if (panelDialogIcon) panelDialogIcon.textContent = opts.icon || (tone === "danger" ? "!" : tone === "success" ? "✓" : "?");
if (panelDialogEyebrow) panelDialogEyebrow.textContent = opts.eyebrow || t("Confirmation");
if (panelDialogTitle) panelDialogTitle.textContent = opts.title || t("Confirm action");
if (panelDialogMessage) panelDialogMessage.textContent = opts.message || "";
if (panelDialogDetail) {
panelDialogDetail.textContent = opts.detail || "";
panelDialogDetail.classList.toggle("hidden", !opts.detail);
}
if (panelDialogCancelBtn) panelDialogCancelBtn.textContent = opts.cancelLabel || t("Cancel");
panelDialogConfirmBtn.textContent = opts.confirmLabel || t("Confirm");
panelDialogConfirmBtn.className = tone === "danger" ? "btn btn-danger" : "btn";
panelConfirmDialog.classList.remove("hidden");
panelConfirmDialog.setAttribute("aria-hidden", "false");
document.body.classList.add("panel-dialog-open");
setTimeout(() => panelDialogConfirmBtn.focus(), 0);
return new Promise(resolve => { panelDialogResolver = resolve; });
}
function showPanelToast(message, tone = "info", title = "", duration = 4800) {
if (!panelToastStack || !message) return null;
const toast = document.createElement("article");
toast.className = `panel-toast ${tone}`;
const icon = document.createElement("span");
icon.className = "panel-toast-icon";
icon.textContent = tone === "success" ? "✓" : tone === "error" ? "!" : tone === "warning" ? "i" : "•";
const copy = document.createElement("div");
copy.className = "panel-toast-copy";
const heading = document.createElement("strong");
heading.textContent = title || (tone === "success" ? t("Completed") : tone === "error" ? t("Action failed") : tone === "warning" ? t("Attention") : t("Information"));
const body = document.createElement("p");
body.textContent = message;
copy.append(heading, body);
const close = document.createElement("button");
close.className = "panel-toast-close";
close.type = "button";
close.setAttribute("aria-label", t("Close"));
close.textContent = "×";
const remove = () => toast.remove();
close.addEventListener("click", remove);
toast.append(icon, copy, close);
panelToastStack.appendChild(toast);
if (duration > 0) setTimeout(remove, duration);
return toast;
}
panelDialogCancelBtn?.addEventListener("click", () => closePanelConfirm(false));
panelDialogConfirmBtn?.addEventListener("click", () => closePanelConfirm(true));
panelConfirmDialog?.querySelector("[data-panel-dialog-close]")?.addEventListener("click", () => closePanelConfirm(false));
document.addEventListener("keydown", event => {
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
if (event.key === "Escape") closePanelConfirm(false);
if (event.key === "Enter" && document.activeElement !== panelDialogCancelBtn) closePanelConfirm(true);
});
// ─── API helper ─────────────────────────────────────────────────────────────── // ─── API helper ───────────────────────────────────────────────────────────────
async function api(path, opts = {}) { async function api(path, opts = {}) {
const o = Object.assign({ headers: {} }, opts); const o = Object.assign({ headers: {} }, opts);
@@ -373,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) {
+182 -14
View File
@@ -4,14 +4,126 @@ const tabTitles = {
ssh: ["Accounts", "SSH / SlowDNS"], ssh: ["Accounts", "SSH / SlowDNS"],
xray: ["Accounts", "Xray Users"], xray: ["Accounts", "Xray Users"],
resellers: ["Administration", "Resellers"], resellers: ["Administration", "Resellers"],
servers: ["Administration", "Servers"], servers: ["Infrastructure", "Servers"],
"servers-status": ["Administration", "Servers Status"], "servers-status": ["Infrastructure", "Servers Status"],
stats: ["Server", "Monitoring"], stats: ["Infrastructure", "Monitoring"],
vnstat: ["Traffic", "VnStat"], vnstat: ["Infrastructure", "Traffic"],
logs: ["System", "Logs"], logs: ["System", "Logs"],
bot: ["Vendas", "Bot / Telegram"], bot: ["Vendas", "Bot / Telegram"],
server: ["System", "Settings"], server: ["System", "Settings"],
}; };
const infrastructureTabs = ["servers", "servers-status", "stats", "vnstat"];
const infrastructureNavItems = [
{ tab:"servers", icon:"▣", label:"Servers" },
{ tab:"servers-status", icon:"●", label:"Status" },
{ tab:"stats", icon:"◴", label:"Server" },
{ tab:"vnstat", icon:"⇅", label:"Traffic" },
];
function syncInfrastructureNavigation(tab = currentTab) {
if (!infrastructureTabs.includes(tab)) return;
document.querySelectorAll("[data-infra-tab]").forEach(button => button.classList.toggle("active", button.dataset.infraTab === tab));
document.querySelectorAll(".infra-section-select").forEach(select => { select.value = tab; });
}
function mountInfrastructureNavigation() {
document.querySelectorAll(".infra-nav-mount").forEach(mount => {
const shell = document.createElement("div");
shell.className = "infra-nav-shell";
const nav = document.createElement("nav");
nav.className = "infra-section-nav";
nav.setAttribute("aria-label", t("Infrastructure areas"));
const select = document.createElement("select");
select.className = "infra-section-select";
select.setAttribute("aria-label", t("Infrastructure area"));
infrastructureNavItems.forEach(item => {
const button = document.createElement("button");
button.type = "button";
button.dataset.infraTab = item.tab;
const icon = document.createElement("span");
icon.textContent = item.icon;
button.append(icon, document.createTextNode(" " + t(item.label)));
button.addEventListener("click", () => selectTab(item.tab));
nav.appendChild(button);
const option = document.createElement("option");
option.value = item.tab;
option.textContent = t(item.label);
select.appendChild(option);
});
select.addEventListener("change", () => selectTab(select.value));
shell.append(nav, select);
mount.replaceChildren(shell);
});
syncInfrastructureNavigation();
}
const workspaceSectionDefaults = {
ssh: "users",
xray: "users",
resellers: "users",
config: "general",
};
function workspaceSectionRoot(workspace) {
const tab = workspace === "config" ? "server" : workspace;
return document.getElementById(`tab-${tab}`);
}
function activeWorkspaceSection(workspace) {
const root = workspaceSectionRoot(workspace);
return root?.querySelector(`[data-workspace-panel="${workspace}"].active`)?.dataset.workspaceSectionPanel
|| workspaceSectionDefaults[workspace]
|| "";
}
function setWorkspaceSection(workspace, section, options = {}) {
const root = workspaceSectionRoot(workspace);
if (!root) return false;
const panels = Array.from(root.querySelectorAll(`[data-workspace-panel="${workspace}"]`));
const targets = panels.filter(panel => panel.dataset.workspaceSectionPanel === section && !panel.classList.contains("hidden"));
if (!targets.length) {
section = workspaceSectionDefaults[workspace] || panels.find(panel => !panel.classList.contains("hidden"))?.dataset.workspaceSectionPanel || "";
}
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.workspaceSectionPanel === section));
root.querySelectorAll(`[data-workspace="${workspace}"][data-workspace-section]`).forEach(button => {
const active = button.dataset.workspaceSection === section;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
});
const select = root.querySelector(`[data-workspace-select="${workspace}"]`);
if (select) select.value = section;
if (!options.silent) {
if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig();
if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs();
}
return true;
}
function prepareWorkspaceSection(workspace, section) {
if (section !== "create") return;
if (workspace === "ssh" && typeof prepareNewSSHUser === "function") prepareNewSSHUser();
if (workspace === "xray" && typeof prepareXrayClientCreator === "function") prepareXrayClientCreator();
if (workspace === "resellers" && typeof prepareNewReseller === "function") prepareNewReseller();
}
function navigateWorkspaceSection(workspace, section) {
prepareWorkspaceSection(workspace, section);
return setWorkspaceSection(workspace, section);
}
function mountWorkspaceSectionNavigation() {
document.querySelectorAll("[data-workspace][data-workspace-section]").forEach(button => {
button.setAttribute("role", "tab");
button.addEventListener("click", () => navigateWorkspaceSection(button.dataset.workspace, button.dataset.workspaceSection));
});
document.querySelectorAll("[data-workspace-select]").forEach(select => {
select.addEventListener("change", () => navigateWorkspaceSection(select.dataset.workspaceSelect, select.value));
});
Object.entries(workspaceSectionDefaults).forEach(([workspace, section]) => setWorkspaceSection(workspace, section, { silent:true }));
}
function updatePageHeading() { function updatePageHeading() {
const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab]; const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab];
if (pageEyebrow) pageEyebrow.textContent = t(eyebrow); if (pageEyebrow) pageEyebrow.textContent = t(eyebrow);
@@ -21,12 +133,14 @@ function updatePageHeading() {
function selectTab(tab) { function selectTab(tab) {
currentTab = tab; currentTab = tab;
const pane = document.getElementById("tab-" + tab); const pane = document.getElementById("tab-" + tab);
const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`); const navTab = infrastructureTabs.includes(tab) ? "servers" : tab;
if (!pane || !btn) return; const btn = document.querySelector(`.tab-btn[data-tab="${navTab}"]`);
if (!pane) return;
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
btn.classList.add("active"); btn?.classList.add("active");
pane.classList.add("active"); pane.classList.add("active");
syncInfrastructureNavigation(tab);
updatePageHeading(); updatePageHeading();
document.body.classList.remove("sidebar-open"); document.body.classList.remove("sidebar-open");
@@ -34,15 +148,18 @@ function selectTab(tab) {
if (tab === "xray") { if (tab === "xray") {
loadXrayStatus(); loadXrayStatus();
loadInbounds({ silent: true }); loadInbounds({ silent: true });
if (currentRole === "superadmin") loadWizardFromConfig(); if (currentRole === "superadmin" && activeWorkspaceSection("xray") === "config") loadWizardFromConfig();
} }
if (tab === "stats" && currentRole === "superadmin") loadStats(); if (tab === "stats" && currentRole === "superadmin") loadStats();
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();
} }
mountInfrastructureNavigation();
mountWorkspaceSectionNavigation();
document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab))); document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open")); menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
@@ -101,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");
@@ -117,6 +241,13 @@ function initAfterLogin() {
document.querySelectorAll(".xray-admin-only").forEach(el => { document.querySelectorAll(".xray-admin-only").forEach(el => {
el.classList.toggle("hidden", currentRole !== "superadmin"); el.classList.toggle("hidden", currentRole !== "superadmin");
}); });
document.querySelectorAll("option.xray-admin-only").forEach(option => {
option.hidden = currentRole !== "superadmin";
option.disabled = currentRole !== "superadmin";
});
if (currentRole !== "superadmin" && ["config", "logs"].includes(activeWorkspaceSection("xray"))) {
setWorkspaceSection("xray", "users", { silent:true });
}
resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller"); resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller");
dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller"); dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
@@ -152,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 {}
} }
@@ -209,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)));
} }
@@ -267,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);
}
} }
} }
+90 -16
View File
@@ -1,14 +1,27 @@
// ─── SSH Users ──────────────────────────────────────────────────────────────── // ─── SSH Users ────────────────────────────────────────────────────────────────
let editingSSHUser = "";
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers); document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => { document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
setFormCollapsed(false); newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
cancelUserBtn.addEventListener("click", () => {
prepareNewSSHUser();
setWorkspaceSection("ssh", "users");
});
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 title = document.getElementById("userFormTitle");
if (heading) heading.textContent = t("Create user");
if (title) title.textContent = t("Create SSH user");
userStatus.textContent = t("New user."); userStatus.textContent = t("New user.");
fUsername.focus(); requestAnimationFrame(() => fUsername.focus());
}); }
cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
document.getElementById("genTotpBtn").addEventListener("click", () => { document.getElementById("genTotpBtn").addEventListener("click", () => {
fTotpSecret.value = genBase32(); fTotpSecret.value = genBase32();
if (!fTotpPeriod.value) fTotpPeriod.value = 60; if (!fTotpPeriod.value) fTotpPeriod.value = 60;
@@ -18,14 +31,13 @@ document.getElementById("genTotpBtn").addEventListener("click", () => {
}); });
document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; }); document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
function setFormCollapsed(v) {
formCollapsed = v;
userFormWrap.classList.toggle("collapsed", v);
toggleFormBtn.textContent = v ? t("Show form") : t("Hide form");
}
async function loadUsers() { async function loadUsers() {
userStatus.textContent = t("Loading…"); userStatus.textContent = t("Loading…");
if (sshLiveStatus) {
sshLiveStatus.textContent = t("Loading SSH status…");
sshLiveStatus.className = "workspace-live-status is-loading";
}
if (sshMetricState) sshMetricState.textContent = t("Loading");
try { try {
const res = await api(withServerParam("/api/users", selectedSSHServer())); const res = await api(withServerParam("/api/users", selectedSSHServer()));
const data = await res.json(); const data = await res.json();
@@ -33,6 +45,11 @@ async function loadUsers() {
userStatus.textContent = t("Loaded."); userStatus.textContent = t("Loaded.");
lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()}); lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()});
} catch (e) { } catch (e) {
if (sshLiveStatus) {
sshLiveStatus.textContent = t("Could not load SSH status");
sshLiveStatus.className = "workspace-live-status is-error";
}
if (sshMetricState) sshMetricState.textContent = t("Error");
if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); } if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); }
} }
} }
@@ -76,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),
@@ -85,16 +106,26 @@ 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);
}); });
const activeCount = Math.max(0, users.length - expiredCount); const activeCount = Math.max(0, users.length - expiredCount);
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online}); userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
if (sshMetricTotal) sshMetricTotal.textContent = String(users.length);
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
if (sshMetricState) sshMetricState.textContent = t("Online");
if (sshLiveStatus) {
sshLiveStatus.textContent = t("SSH data updated at {time}", {time:new Date().toLocaleTimeString()});
sshLiveStatus.className = "workspace-live-status is-ok";
}
} }
function fillUserForm(u) { function fillUserForm(u) {
setFormCollapsed(false); editingSSHUser = u.username || "";
setWorkspaceSection("ssh", "create");
fUsername.value = u.username || ""; fUsername.value = u.username || "";
fPassword.value = ""; fPassword.value = "";
fTotpSecret.value = u.totp_secret || ""; fTotpSecret.value = u.totp_secret || "";
@@ -103,9 +134,17 @@ 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) : "";
const heading = document.getElementById("userFormHeading");
const title = document.getElementById("userFormTitle");
if (heading) heading.textContent = t("Edit user");
if (title) title.textContent = t("Editing {name}", {name:u.username});
userStatus.textContent = t("Editing {name}", {name: u.username}); userStatus.textContent = t("Editing {name}", {name: u.username});
} }
@@ -122,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(),
@@ -134,6 +175,8 @@ userForm.addEventListener("submit", async e => {
fPassword.value = ""; fPassword.value = "";
loadUsers(); loadUsers();
if (currentRole === "reseller") loadMe(); if (currentRole === "reseller") loadMe();
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
setWorkspaceSection("ssh", "users");
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
else userStatus.textContent = t("Error: {error}", {error: e.message}); else userStatus.textContent = t("Error: {error}", {error: e.message});
@@ -143,7 +186,13 @@ userForm.addEventListener("submit", async e => {
}); });
async function deleteUser(username) { async function deleteUser(username) {
if (!confirm(t("Delete user \"{name}\"?", {name: username}))) return; const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete SSH account"),
message:t("Delete user \"{name}\"?", {name: username}),
detail:t("The active SSH sessions for this account will be disconnected."),
confirmLabel:t("Delete account"),
});
if (!accepted) return;
userStatus.textContent = t("Deleting {name}…", {name: username}); userStatus.textContent = t("Deleting {name}…", {name: username});
try { try {
const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" }); const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" });
@@ -157,3 +206,28 @@ async function deleteUser(username) {
} }
} }
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");
}
}
+189 -65
View File
@@ -9,15 +9,28 @@ document.getElementById("xLoadInboundsBtn").addEventListener("click", () => load
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg); document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg); document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs); document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
document.getElementById("xrayOpenCreateBtn")?.addEventListener("click", () => navigateWorkspaceSection("xray", "create"));
document.getElementById("xCreateCancelBtn")?.addEventListener("click", () => setWorkspaceSection("xray", "users"));
document.getElementById("xCreateUUIDBtn")?.addEventListener("click", () => {
const field = document.getElementById("xCreateUUID");
if (field) field.value = genUUID();
});
document.getElementById("xCreateInbound")?.addEventListener("change", updateXrayCreatorInboundLabel);
document.getElementById("xCreateClientForm")?.addEventListener("submit", submitXrayClientCreator);
async function loadXrayStatus() { async function loadXrayStatus() {
if (xrayChip) {
xrayChip.textContent = t("Loading Xray status…");
xrayChip.className = "workspace-live-status is-loading";
}
try { try {
const res = await api(withServerParam("/api/xray/status", selectedXrayServer())); const res = await api(withServerParam("/api/xray/status", selectedXrayServer()));
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
const s = await res.json(); const s = await res.json();
const run = !!s.running; const run = !!s.running;
xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled")); xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled"));
xrayChip.className = "chip " + (run ? "green" : "red"); xrayChip.className = "workspace-live-status " + (run ? "is-ok" : (s.enabled ? "is-warn" : "is-error"));
xRunning.textContent = run ? t("Running") : t("Stopped"); xRunning.textContent = run ? t("Running") : t("Stopped");
xRunning.style.color = run ? "var(--success)" : "var(--danger)"; xRunning.style.color = run ? "var(--success)" : "var(--danger)";
xPID.textContent = s.pid || (s.native ? "internal" : "--"); xPID.textContent = s.pid || (s.native ? "internal" : "--");
@@ -43,7 +56,15 @@ async function loadXrayStatus() {
if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled"))); if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled")));
renderDashboardCounters(); renderDashboardCounters();
if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error}); if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error});
} catch (e) { if (e.message==="auth") doAuthError(); } } catch (e) {
if (xrayChip) {
xrayChip.textContent = t("Could not load Xray status");
xrayChip.className = "workspace-live-status is-error";
}
if (xRunning) { xRunning.textContent = t("Error"); xRunning.style.color = "var(--danger)"; }
if (xStatus && e.message !== "auth") xStatus.textContent = t("Error: {error}", {error:e.message});
if (e.message==="auth") doAuthError();
}
} }
async function saveXrayCoreMode() { async function saveXrayCoreMode() {
@@ -98,9 +119,11 @@ async function xrayCtrl(action) {
xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK."; xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK.";
setTimeout(loadXrayStatus, 700); setTimeout(loadXrayStatus, 700);
setTimeout(() => loadInbounds({ force: true }), 1200); setTimeout(() => loadInbounds({ force: true }), 1200);
return true;
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
else xStatus.textContent = t("Error: {error}", {error: e.message}); else xStatus.textContent = t("Error: {error}", {error: e.message});
return false;
} }
} }
@@ -145,6 +168,7 @@ async function copyText(text) {
function renderInbounds(inbounds, options = {}) { function renderInbounds(inbounds, options = {}) {
const { silent = false, force = false } = options || {}; const { silent = false, force = false } = options || {};
updateDashboardXray(inbounds); updateDashboardXray(inbounds);
syncXrayCreatorInbounds(inbounds);
const nextStructure = inboundStructure(inbounds); const nextStructure = inboundStructure(inbounds);
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return; if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return;
@@ -161,8 +185,7 @@ function renderInbounds(inbounds, options = {}) {
} }
inboundsContainer.innerHTML = ""; inboundsContainer.innerHTML = "";
lastInboundsStructure = nextStructure; lastInboundsStructure = nextStructure;
inbounds.forEach((ib, inboundIndex) => { inbounds.forEach(ib => {
const formKey = String(inboundIndex);
const section = document.createElement("div"); const section = document.createElement("div");
section.dataset.inboundTag = String(ib.tag || ""); section.dataset.inboundTag = String(ib.tag || "");
section.dataset.inboundProtocol = String(ib.protocol || ""); section.dataset.inboundProtocol = String(ib.protocol || "");
@@ -184,39 +207,11 @@ function renderInbounds(inbounds, options = {}) {
const openButton = document.createElement("button"); const openButton = document.createElement("button");
openButton.className = "btn btn-sm"; openButton.className = "btn btn-sm";
openButton.type = "button"; openButton.type = "button";
openButton.textContent = t("+ Add Client"); openButton.textContent = t("Create user");
openButton.addEventListener("click", () => openAddClient(ib.tag, formKey)); openButton.addEventListener("click", () => openAddClient(ib.tag));
hdr.appendChild(openButton); hdr.appendChild(openButton);
section.appendChild(hdr); section.appendChild(hdr);
// Add client mini-form (hidden by default)
const addForm = document.createElement("div");
addForm.id = `add-form-${formKey}`;
addForm.className = "hidden";
addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;";
addForm.innerHTML = `
<div class="form-grid" style="grid-template-columns:1fr 1fr;">
<div class="field">
<label>UUID</label>
<div class="field-row">
<input id="newUUID-${formKey}" placeholder="auto-generate" style="border-radius:6px;"/>
<button class="btn btn-ghost btn-sm" id="genUUID-${formKey}" type="button">Gen</button>
</div>
</div>
<div class="field"><label>${t("Email / label")}</label><input id="newEmail-${formKey}" placeholder="user@example" style="border-radius:6px;"/></div>
<div class="field"><label>${t("Display Name")}</label><input id="newName-${formKey}" placeholder="e.g. Maykinho01" style="border-radius:6px;"/></div>
<div class="field"><label>${t("Expiry Date")}</label><input type="datetime-local" id="newExpiry-${formKey}" style="border-radius:6px;color-scheme:dark;"/></div>
<div class="field"><label>${t("Max Connections")} <span class="hint">${t("(0 = unlimited)")}</span></label><input type="number" min="0" id="newMaxConns-${formKey}" placeholder="0" style="border-radius:6px;"/></div>
</div>
<div class="form-actions" style="margin-top:6px;">
<button class="btn btn-sm" id="addClient-${formKey}" type="button">${t("Add")}</button>
<button class="btn btn-ghost btn-sm" id="cancelAddClient-${formKey}" type="button">${t("Cancel")}</button>
</div>`;
addForm.querySelector(`#genUUID-${formKey}`).addEventListener("click", () => { document.getElementById(`newUUID-${formKey}`).value = genUUID(); });
addForm.querySelector(`#addClient-${formKey}`).addEventListener("click", () => addClient(ib.tag, formKey));
addForm.querySelector(`#cancelAddClient-${formKey}`).addEventListener("click", () => addForm.classList.add("hidden"));
section.appendChild(addForm);
// Clients table // Clients table
const tblWrap = document.createElement("div"); const tblWrap = document.createElement("div");
tblWrap.className = "tbl-wrap"; tblWrap.className = "tbl-wrap";
@@ -249,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);
}); });
@@ -271,32 +271,141 @@ function renderInbounds(inbounds, options = {}) {
}); });
} }
function openAddClient(tag, formKey = tag) { let xrayCreatorInbounds = [];
const form = document.getElementById(`add-form-${formKey}`); let xrayCreatorInboundSignature = "";
if (form) { form.classList.remove("hidden"); }
const uuidField = document.getElementById(`newUUID-${formKey}`); function syncXrayCreatorInbounds(inbounds = []) {
if (uuidField && !uuidField.value) uuidField.value = genUUID(); const select = document.getElementById("xCreateInbound");
if (!select) return;
const previous = select.value;
const nextInbounds = (inbounds || []).filter(ib => ib?.tag).map(ib => ({
tag: String(ib.tag),
protocol: String(ib.protocol || "xray").toUpperCase(),
port: ib.port ?? "?",
}));
const nextSignature = JSON.stringify(nextInbounds);
xrayCreatorInbounds = nextInbounds;
if (nextSignature === xrayCreatorInboundSignature) {
updateXrayCreatorInboundLabel();
return;
}
xrayCreatorInboundSignature = nextSignature;
select.replaceChildren();
if (!xrayCreatorInbounds.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = t("No compatible inbound found");
select.appendChild(option);
select.disabled = true;
} else {
xrayCreatorInbounds.forEach(inbound => {
const option = document.createElement("option");
option.value = inbound.tag;
option.textContent = `${inbound.protocol} · ${inbound.tag} · :${inbound.port}`;
select.appendChild(option);
});
select.disabled = false;
select.value = xrayCreatorInbounds.some(inbound => inbound.tag === previous) ? previous : xrayCreatorInbounds[0].tag;
}
updateXrayCreatorInboundLabel();
} }
async function addClient(tag, formKey = tag) { function updateXrayCreatorInboundLabel() {
const uuidEl = document.getElementById(`newUUID-${formKey}`); const selected = document.getElementById("xCreateInbound")?.value || "";
const emailEl = document.getElementById(`newEmail-${formKey}`); const inbound = xrayCreatorInbounds.find(item => item.tag === selected);
const nameEl = document.getElementById(`newName-${formKey}`); const chip = document.getElementById("xCreateProtocolChip");
const expiryEl = document.getElementById(`newExpiry-${formKey}`); const hint = document.getElementById("xCreateInboundHint");
const maxConnsEl = document.getElementById(`newMaxConns-${formKey}`); if (chip) chip.textContent = inbound ? inbound.protocol : t("No inbound");
const uuid = (uuidEl?.value || "").trim(); if (hint) hint.textContent = inbound
const email = (emailEl?.value || "").trim(); ? t("The client will be added to {tag} on port {port}.", {tag:inbound.tag, port:inbound.port})
const name = (nameEl?.value || "").trim(); : t("Create or enable a compatible inbound before adding a client.");
const expiresAt = isoFromLocal(expiryEl?.value || ""); }
const maxConns = parseInt(maxConnsEl?.value || "0", 10) || 0;
if (!uuid) { xStatus.textContent = t("UUID required."); return; } function prepareXrayClientCreator(preferredTag = "") {
const form = document.getElementById("xCreateClientForm");
form?.reset();
const inbound = document.getElementById("xCreateInbound");
if (inbound && preferredTag && xrayCreatorInbounds.some(item => item.tag === preferredTag)) inbound.value = preferredTag;
const uuid = document.getElementById("xCreateUUID");
if (uuid) uuid.value = genUUID();
const maxConns = document.getElementById("xCreateMaxConns");
if (maxConns) {
maxConns.min = currentRole === "reseller" ? "1" : "0";
maxConns.value = currentRole === "reseller" ? "1" : "0";
}
const status = document.getElementById("xCreateClientStatus");
if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
updateXrayCreatorInboundLabel();
requestAnimationFrame(() => document.getElementById("xCreateName")?.focus());
}
function openAddClient(tag) {
setWorkspaceSection("xray", "create");
prepareXrayClientCreator(tag);
}
async function submitXrayClientCreator(event) {
event?.preventDefault?.();
const tag = document.getElementById("xCreateInbound")?.value || "";
const uuid = (document.getElementById("xCreateUUID")?.value || "").trim();
const status = document.getElementById("xCreateClientStatus");
const button = document.getElementById("xCreateClientBtn");
if (!tag) { if (status) status.textContent = t("Select a compatible inbound."); return false; }
if (!uuid) { if (status) status.textContent = t("UUID required."); return false; }
const payload = {
inbound_tag: tag,
uuid,
email: (document.getElementById("xCreateEmail")?.value || "").trim(),
name: (document.getElementById("xCreateName")?.value || "").trim(),
expires_at: isoFromLocal(document.getElementById("xCreateExpiry")?.value || ""),
max_connections: parseInt(document.getElementById("xCreateMaxConns")?.value || "0", 10) || 0,
server_id: selectedXrayServer(),
};
if (button) button.disabled = true;
if (status) status.textContent = t("Creating Xray client…");
try { try {
const res = await api("/api/xray/clients/add", { const res = await api("/api/xray/clients/add", { method:"POST", body:JSON.stringify(payload) });
method: "POST",
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns, server_id: selectedXrayServer() }),
});
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
xStatus.textContent = t("Client {id}… added. Native mode hot-reloads without restart.", {id: uuid.slice(0,8)}); const success = t("Client {id}… added. Native mode hot-reloads without restart.", {id:uuid.slice(0,8)});
if (status) status.textContent = success;
xStatus.textContent = success;
showPanelToast(t("Xray user created successfully."), "success", t("Xray user"));
setTimeout(() => { loadInbounds({ force:true }); if (currentRole === "reseller") loadMe(); }, 700);
const name = document.getElementById("xCreateName");
const email = document.getElementById("xCreateEmail");
const expiry = document.getElementById("xCreateExpiry");
if (name) name.value = "";
if (email) email.value = "";
if (expiry) expiry.value = "";
const nextUUID = document.getElementById("xCreateUUID");
if (nextUUID) nextUUID.value = genUUID();
return true;
} catch (e) {
if (e.message === "auth") doAuthError();
else {
if (status) status.textContent = t("Error: {error}", {error:e.message});
xStatus.textContent = t("Error: {error}", {error:e.message});
showPanelToast(t("Could not create the Xray user: {error}", {error:e.message}), "error", t("Xray user"));
}
return false;
} finally {
if (button) button.disabled = false;
}
}
async function removeClient(tag, uuid) {
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Remove Xray client"),
message:t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}),
detail:t("The client will lose access immediately after the configuration reload."),
confirmLabel:t("Remove client"),
});
if (!accepted) return;
try {
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error(await res.text());
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart.");
showPanelToast(t("Client removed successfully."), "success", t("Xray client"));
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
@@ -304,16 +413,31 @@ async function addClient(tag, formKey = tag) {
} }
} }
async function removeClient(tag, uuid) { async function renewXrayClient(client) {
if (!confirm(t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}))) return; 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 { try {
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" }); const res = await api("/api/xray/clients/renew", {
if (!res.ok && res.status !== 204) throw new Error(await res.text()); method:"POST",
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart."); body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }),
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); });
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) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message === "auth") doAuthError();
else xStatus.textContent = t("Error: {error}", {error: e.message}); else showPanelToast(e.message, "error", "Renovar Xray");
} }
} }
+258 -73
View File
@@ -1,112 +1,297 @@
// ─── Resellers ──────────────────────────────────────────────────────────────── // ─── Hierarchical resellers ───────────────────────────────────────────────────
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers); let resellersCache = [];
document.getElementById("newResellerBtn").addEventListener("click", () => { let editingReseller = "";
resellerFormTitle.textContent = "Create Reseller";
resellerForm.reset();
rActive.checked = true;
resellerStatus.textContent = "New reseller.";
});
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
resellerForm.reset();
rActive.checked = true;
resellerFormTitle.textContent = "Create Reseller";
});
document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers);
document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers);
document.getElementById("newResellerBtn")?.addEventListener("click", () => {
prepareNewReseller();
navigateWorkspaceSection("resellers", "create");
});
document.getElementById("cancelResellerBtn")?.addEventListener("click", () => {
prepareNewReseller();
setWorkspaceSection("resellers", "users");
});
document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit);
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers); document.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() {
editingReseller = "";
resellerFormTitle.textContent = t("Create Reseller");
const heading = document.getElementById("resellerFormHeading");
if (heading) heading.textContent = t("Create reseller");
resellerForm.reset();
rUsername.disabled = false;
rParent.disabled = false;
rQuotaMode.disabled = currentRole === "reseller";
rQuotaMode.value = currentRole === "reseller" ? currentQuotaMode : "slots";
rMaxUsers.min = currentRole === "reseller" ? "1" : "0";
rMaxUsers.value = currentRole === "reseller" ? "1" : "30";
rCredits.value = "1";
rActive.checked = true;
populateResellerParents();
toggleResellerPlanFields();
resellerStatus.textContent = t("New reseller.");
requestAnimationFrame(() => rUsername.focus());
}
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) {
resellerFormTitle.textContent = `Edit: ${r.username}`; editingReseller = r.username;
rUsername.value = r.username; setWorkspaceSection("resellers", "create");
rPassword.value = ""; resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
rMaxUsers.value = r.max_users || 0; const heading = document.getElementById("resellerFormHeading");
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : ""; if (heading) heading.textContent = t("Edit reseller");
rActive.checked = r.is_active; rUsername.value = r.username;
resellerStatus.textContent = `Editing ${r.username}.`; rUsername.disabled = true;
rPassword.value = "";
populateResellerParents();
rParent.value = r.parent_username || "";
rParent.disabled = true;
rQuotaMode.value = r.quota_mode || "slots";
rQuotaMode.disabled = true;
rMaxUsers.value = r.max_users || 0;
rCredits.value = r.credit_balance || 0;
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
rWhatsApp.value = r.whatsapp || "";
rMonthlyPrice.value = ((r.monthly_price_cents || 0) / 100).toFixed(2);
rActive.checked = !!r.is_active;
toggleResellerPlanFields();
resellerStatus.textContent = t("Editing {name}.", {name: r.username});
} }
resellerForm.addEventListener("submit", async e => { 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."; showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
resellerForm.reset(); rActive.checked = true; prepareNewReseller();
resellerFormTitle.textContent = "Create Reseller"; await loadResellers();
loadResellers(); 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) {
if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return; const labels = { renew: "Renovar por 30 dias", suspend: "Suspender revendedor", reactivate: "Reativar revendedor", pull: "Puxar para o painel principal" };
resellerStatus.textContent = `Deleting ${username}`; const descriptions = {
renew: "A validade será estendida a partir da data atual ou da validade existente.",
suspend: "A conta, seus descendentes e os acessos SSH/Xray ficarão bloqueados sem apagar os cadastros.",
reactivate: "Os acessos preservados serão restaurados nos servidores disponíveis.",
pull: "O revendedor deixará a revenda atual e passará a ser administrado diretamente pelo superadmin. Os créditos já transferidos não serão duplicados.",
};
const accepted = await panelConfirm({
tone: action === "suspend" ? "danger" : "default",
icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
title: labels[action],
message: `${labels[action]}${reseller.username}”?`,
detail: descriptions[action],
confirmLabel: labels[action],
});
if (!accepted) return;
resellerStatus.textContent = `${labels[action]}`;
try { 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>`;
} }
} }
+42 -5
View File
@@ -19,8 +19,6 @@ document.getElementById("reloadServersBtn")?.addEventListener("click", loadServe
document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers); document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers);
document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers); document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers);
document.getElementById("refreshServersStatusBtn")?.addEventListener("click", () => loadServersStatus()); document.getElementById("refreshServersStatusBtn")?.addEventListener("click", () => loadServersStatus());
document.querySelector("[data-tab='servers']")?.addEventListener("click", loadServers);
document.querySelector("[data-tab='servers-status']")?.addEventListener("click", () => loadServersStatus());
document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm); document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm);
document.getElementById("testServerBtn")?.addEventListener("click", testServerForm); document.getElementById("testServerBtn")?.addEventListener("click", testServerForm);
document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView()); document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView());
@@ -34,17 +32,39 @@ serverForm?.addEventListener("submit", async e => {
}); });
async function loadServers() { async function loadServers() {
let loadError = null;
if (fleetLiveStatus) {
fleetLiveStatus.textContent = t("Loading infrastructure…");
fleetLiveStatus.className = "workspace-live-status is-loading";
}
try { try {
const res = await api("/api/servers"); const res = await api("/api/servers");
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
serversCache = await res.json() || []; serversCache = await res.json() || [];
} catch (e) { } catch (e) {
loadError = e;
serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }]; serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }];
if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message; if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message;
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
} }
renderServerSelectors(); renderServerSelectors();
renderServersTable(); renderServersTable();
updateFleetOverview(loadError);
}
function updateFleetOverview(error = null) {
const rows = Array.isArray(serversCache) ? serversCache.filter(Boolean) : [];
const active = rows.filter(server => server.is_active !== false);
if (fleetMetricNodes) fleetMetricNodes.textContent = String(rows.length);
if (fleetMetricActive) fleetMetricActive.textContent = String(active.length);
if (fleetMetricSSH) fleetMetricSSH.textContent = String(active.filter(server => server.enable_ssh || server.is_local).length);
if (fleetMetricXray) fleetMetricXray.textContent = String(active.filter(server => server.enable_xray || server.is_local).length);
if (fleetLiveStatus) {
fleetLiveStatus.textContent = error
? t("Infrastructure loaded with fallback data")
: t("{count} active nodes · updated {time}", {count:active.length, time:new Date().toLocaleTimeString()});
fleetLiveStatus.className = `workspace-live-status ${error ? "is-warn" : "is-ok"}`;
}
} }
@@ -57,6 +77,7 @@ async function loadServersStatus(options = {}) {
if (serversStatusCountChip) serversStatusCountChip.textContent = String(nodes.length); if (serversStatusCountChip) serversStatusCountChip.textContent = String(nodes.length);
if (!silent) { if (!silent) {
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers..."); serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers...");
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-loading";
serversStatusGrid.innerHTML = `<div class="hint">Loading servers...</div>`; serversStatusGrid.innerHTML = `<div class="hint">Loading servers...</div>`;
} }
const rows = await Promise.all(nodes.map(loadSingleServerStatus)); const rows = await Promise.all(nodes.map(loadSingleServerStatus));
@@ -64,11 +85,23 @@ async function loadServersStatus(options = {}) {
if (serversStatusPageStatus) { if (serversStatusPageStatus) {
const online = rows.filter(r => r.ok).length; const online = rows.filter(r => r.ok).length;
serversStatusPageStatus.textContent = `${online}/${rows.length} servers online - Updated ${new Date().toLocaleTimeString()}`; serversStatusPageStatus.textContent = `${online}/${rows.length} servers online - Updated ${new Date().toLocaleTimeString()}`;
serversStatusPageStatus.className = `workspace-live-status ${online === rows.length ? "is-ok" : online > 0 ? "is-warn" : "is-error"}`;
} }
const online = rows.filter(row => row.ok).length;
const sessions = rows.reduce((total, row) => {
const ssh = (Array.isArray(row.users) ? row.users : []).reduce((sum, user) => sum + Number(user.active_conns || 0), 0);
let xray = 0;
(Array.isArray(row.inbounds) ? row.inbounds : []).forEach(inbound => { xray += (inbound.clients || []).filter(client => !!client.online).length; });
return total + ssh + xray;
}, 0);
if (fleetStatusOnline) fleetStatusOnline.textContent = String(online);
if (fleetStatusOffline) fleetStatusOffline.textContent = String(Math.max(0, rows.length - online));
if (fleetStatusSessions) fleetStatusSessions.textContent = String(sessions);
} catch (e) { } catch (e) {
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
else { else {
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Error loading server status: " + e.message); serversStatusPageStatus && (serversStatusPageStatus.textContent = "Error loading server status: " + e.message);
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-error";
if (!silent) serversStatusGrid.innerHTML = `<div class="hint">Error loading server status.</div>`; if (!silent) serversStatusGrid.innerHTML = `<div class="hint">Error loading server status.</div>`;
} }
} }
@@ -321,7 +354,13 @@ async function testServerForm() {
} }
async function deleteServer(s) { async function deleteServer(s) {
if (!confirm(`Delete server "${s.name || s.base_url}"?`)) return; const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete managed server"),
message:t("Delete server \"{name}\"?", {name:s.name || s.base_url}),
detail:t("The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions."),
confirmLabel:t("Delete server"),
});
if (!accepted) return;
try { try {
const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" }); const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error(await res.text()); if (!res.ok && res.status !== 204) throw new Error(await res.text());
@@ -665,5 +704,3 @@ async function loadManagedDnsttPubkey() {
else if (st) st.textContent = "Error: " + e.message; else if (st) st.textContent = "Error: " + e.message;
} }
} }
+46 -8
View File
@@ -1,5 +1,5 @@
// ─── Stats ──────────────────────────────────────────────────────────────────── // ─── Stats ────────────────────────────────────────────────────────────────────
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats); document.getElementById("refreshStatsBtn")?.addEventListener("click", loadStats);
async function loadDashboardStats() { async function loadDashboardStats() {
try { try {
@@ -115,6 +115,10 @@ async function loadDnsttHealth() {
async function loadStats() { async function loadStats() {
if (statsUpdated) {
statsUpdated.textContent = t("Updating live status…");
statsUpdated.className = "workspace-live-status is-loading";
}
try { try {
const res = await api("/api/stats"); const res = await api("/api/stats");
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -139,24 +143,40 @@ async function loadStats() {
ifaceBody.appendChild(tr); ifaceBody.appendChild(tr);
}); });
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); const currentNetwork = ifaces.reduce((sum, item) => sum + Number(item.rx_mbps || 0) + Number(item.tx_mbps || 0), 0);
if (statsNetVal) statsNetVal.textContent = `${fmtMbps(currentNetwork)} Mb/s`;
if (statsIfaceVal) statsIfaceVal.textContent = String(ifaces.length);
if (statsUpdated) {
statsUpdated.textContent = t("Live · updated {time}", {time:new Date().toLocaleTimeString()});
statsUpdated.className = "workspace-live-status is-ok";
}
await loadDnsttHealth(); await loadDnsttHealth();
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats."; else if (statsUpdated) {
statsUpdated.textContent = t("Error loading server status");
statsUpdated.className = "workspace-live-status is-error";
}
} }
} }
resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats); resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats);
async function resetInterfaceStats() { async function resetInterfaceStats() {
if (!confirm("Clean the live Interface totals now? This does not delete VnStat daily/monthly history.")) return; const accepted = await panelConfirm({
tone:"danger", icon:"⇅", title:t("Clean live interface totals"),
message:t("Clean the live Interface totals now?"),
detail:t("VnStat daily and monthly history will be preserved."),
confirmLabel:t("Clean totals"),
});
if (!accepted) return;
resetIfaceStatsBtn.disabled = true; resetIfaceStatsBtn.disabled = true;
ifaceSummary.textContent = "Cleaning interface totals…"; ifaceSummary.textContent = "Cleaning interface totals…";
try { try {
const res = await api("/api/stats/interfaces/reset", { method:"POST" }); const res = await api("/api/stats/interfaces/reset", { method:"POST" });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days."; ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days.";
showPanelToast(t("Live interface totals were cleaned."), "success", t("Traffic counters"));
loadStats(); loadStats();
} catch (e) { } catch (e) {
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
@@ -167,7 +187,6 @@ async function resetInterfaceStats() {
} }
// ─── VnStat ─────────────────────────────────────────────────────────────────── // ─── VnStat ───────────────────────────────────────────────────────────────────
document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat);
reloadVnstatBtn?.addEventListener("click", loadVnstat); reloadVnstatBtn?.addEventListener("click", loadVnstat);
resetVnstatBtn?.addEventListener("click", resetVnstatHistory); resetVnstatBtn?.addEventListener("click", resetVnstatHistory);
@@ -188,6 +207,7 @@ function renderVnstatRows(body, rows, emptyLabel) {
async function loadVnstat() { async function loadVnstat() {
vnstatStatus.textContent = "Loading VnStat usage…"; vnstatStatus.textContent = "Loading VnStat usage…";
vnstatStatus.className = "workspace-live-status is-loading";
try { try {
const res = await api("/api/vnstat?days=31&months=12"); const res = await api("/api/vnstat?days=31&months=12");
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -208,21 +228,33 @@ async function loadVnstat() {
vnTodayTotal.textContent = fmtBytes(todayTotal); vnTodayTotal.textContent = fmtBytes(todayTotal);
vnMonthTotal.textContent = fmtBytes(monthTotal); vnMonthTotal.textContent = fmtBytes(monthTotal);
vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0); vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0);
if (vnLatestPeriod) vnLatestPeriod.textContent = daily[0]?.period || monthly[0]?.period || "--";
vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned."; vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned.";
vnstatStatus.className = "workspace-live-status is-ok";
} catch (e) { } catch (e) {
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
else vnstatStatus.textContent = "Error loading VnStat usage: " + e.message; else {
vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
vnstatStatus.className = "workspace-live-status is-error";
}
} }
} }
async function resetVnstatHistory() { async function resetVnstatHistory() {
if (!confirm("Clean all VnStat daily/monthly usage history? This does not reset the live Interface totals.")) return; const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Clean VnStat history"),
message:t("Clean all daily and monthly traffic history?"),
detail:t("Live interface totals are separate and will not be reset."),
confirmLabel:t("Clean history"),
});
if (!accepted) return;
resetVnstatBtn.disabled = true; resetVnstatBtn.disabled = true;
vnstatStatus.textContent = "Cleaning VnStat history…"; vnstatStatus.textContent = "Cleaning VnStat history…";
try { try {
const res = await api("/api/vnstat/reset", { method:"POST" }); const res = await api("/api/vnstat/reset", { method:"POST" });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
vnstatStatus.textContent = "VnStat history cleaned."; vnstatStatus.textContent = "VnStat history cleaned.";
showPanelToast(t("VnStat history was cleaned."), "success", t("Traffic history"));
loadVnstat(); loadVnstat();
} catch (e) { } catch (e) {
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
@@ -260,7 +292,13 @@ async function loadSystemLogs() {
async function clearPanelLog() { async function clearPanelLog() {
const st = document.getElementById("systemLogStatus"); const st = document.getElementById("systemLogStatus");
if (!confirm("Clean the panel log now? Logs are already auto-cleaned after 1 MiB.")) return; const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Clean panel log"),
message:t("Clean the current panel log now?"),
detail:t("This only clears the panel log file. Automatic size-based cleanup remains enabled."),
confirmLabel:t("Clean log"),
});
if (!accepted) return;
st.textContent = "Cleaning panel log…"; st.textContent = "Cleaning panel log…";
try { try {
const res = await api("/api/system/logs/reset", { method:"POST" }); const res = await api("/api/system/logs/reset", { method:"POST" });
+618 -53
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(),
}; };
@@ -82,7 +88,9 @@ function loadWizardFromConfig() {
wzLoadedFullConfig = cloneJsonSafe(cfg); wzLoadedFullConfig = cloneJsonSafe(cfg);
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning"; document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || []; wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
wzEditingIndex = -1;
renderWzInbounds(); renderWzInbounds();
loadSharedEndpointForm();
wzDirty = false; wzDirty = false;
if (st) st.textContent = `Config loaded from ${target}.`; if (st) st.textContent = `Config loaded from ${target}.`;
}).catch(e => { }).catch(e => {
@@ -90,7 +98,9 @@ function loadWizardFromConfig() {
wzLoadedConfigText = ""; wzLoadedConfigText = "";
wzLoadedFullConfig = null; wzLoadedFullConfig = null;
wzInbounds = []; wzInbounds = [];
wzEditingIndex = -1;
renderWzInbounds(); renderWzInbounds();
loadSharedEndpointForm();
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
else if (st) st.textContent = "Error: " + e.message; else if (st) st.textContent = "Error: " + e.message;
}); });
@@ -99,49 +109,562 @@ function loadWizardFromConfig() {
function renderWzInbounds() { function renderWzInbounds() {
const list = document.getElementById("wzInboundsList"); const list = document.getElementById("wzInboundsList");
if (!list) return; if (!list) return;
list.replaceChildren();
if (!wzInbounds.length) { if (!wzInbounds.length) {
list.innerHTML = '<div class="hint" style="padding:4px 0;">No inbounds. Click + Add to create one.</div>'; const empty = document.createElement("div");
empty.className = "hint visual-empty-state";
empty.textContent = "Nenhum inbound configurado. Crie um endpoint compartilhado ou adicione um inbound.";
list.appendChild(empty);
return; return;
} }
list.innerHTML = "";
wzInbounds.forEach((ib, i) => { wzInbounds.forEach((ib, i) => {
const row = document.createElement("div"); const row = document.createElement("article");
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;"; row.className = "visual-inbound-card";
const portStr = ib.port !== undefined ? `:${ib.port}` : ""; const portStr = ib.port !== undefined ? `:${ib.port}` : "";
const ss = ib.streamSettings || {}; const ss = ib.streamSettings || {};
const net = ss.network || ""; const net = ss.network || "";
const sec = ss.security || ""; const sec = ss.security || "";
const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : ""; const transportSettings = ss.xhttpSettings || ss.splithttpSettings || ss.wsSettings || ss.httpupgradeSettings || ss.httpSettings || ss.grpcSettings || {};
const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : ""; const head = document.createElement("div");
row.innerHTML = `<span class="chip">${escapeHTML(ib.protocol || "")}</span> head.className = "visual-inbound-card-head";
<span style="font-family:monospace;">${escapeHTML((ib.tag||"untagged")+portStr)}</span> const name = document.createElement("div");
<span class="hint" style="flex:1;">${escapeHTML((ib.listen||"0.0.0.0")+(net?" · "+net:"")+modeLabel+secLabel)}</span>`; name.className = "visual-inbound-name";
const title = document.createElement("strong");
title.textContent = ib.tag || "untagged";
const address = document.createElement("small");
address.textContent = `${ib.listen || "0.0.0.0"}${portStr}`;
name.append(title, address);
const protocol = document.createElement("span");
protocol.className = "chip";
protocol.textContent = String(ib.protocol || "unknown").toUpperCase();
head.append(name, protocol);
row.appendChild(head);
const meta = document.createElement("div");
meta.className = "visual-inbound-meta";
[net || "default", transportSettings.path || transportSettings.serviceName || "no path", sec || "no TLS"].forEach(value => {
const item = document.createElement("span");
item.textContent = value;
meta.appendChild(item);
});
row.appendChild(meta);
const clients = ib.settings?.clients; const clients = ib.settings?.clients;
if (Array.isArray(clients) && clients.length) { if (Array.isArray(clients) && clients.length) {
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "chip green"; badge.className = "chip green";
badge.textContent = clients.length + " client" + (clients.length!==1?"s":""); badge.textContent = clients.length + " client" + (clients.length!==1?"s":"");
row.appendChild(badge); meta.appendChild(badge);
} }
const actions = document.createElement("div");
actions.className = "visual-inbound-actions";
const xhttp = visualXHTTPSettings(ib);
const proxyProtocol = String(ib.protocol || "").toLowerCase();
if (xhttp && ["vless", "vmess"].includes(proxyProtocol)) {
const sshRoute = findSSHRouteForInbound(ib);
const sshBtn = document.createElement("button");
sshBtn.className = sshRoute ? "btn btn-soft btn-sm legacy-ssh-btn is-enabled" : "btn btn-sm legacy-ssh-btn";
sshBtn.type = "button";
sshBtn.textContent = t(sshRoute ? "SSH /ssh enabled" : "Enable SSH /ssh");
sshBtn.disabled = !!sshRoute;
sshBtn.title = sshRoute ? t("This endpoint already has an SSH /ssh route.") : t("Add SSH /ssh without rebuilding this inbound.");
sshBtn.onclick = () => enableSSHForWzInbound(i, true, sshBtn);
actions.appendChild(sshBtn);
}
const duplicateBtn = document.createElement("button");
duplicateBtn.className = "btn btn-ghost btn-sm";
duplicateBtn.type = "button";
duplicateBtn.textContent = "Duplicar";
duplicateBtn.onclick = () => duplicateWzInbound(i);
const editBtn = document.createElement("button");
editBtn.className = "btn btn-ghost btn-sm";
editBtn.type = "button";
editBtn.textContent = "Editar";
editBtn.onclick = () => editWzInbound(i);
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.textContent = "Remove"; delBtn.type = "button";
delBtn.onclick = () => { wzInbounds.splice(i,1); wzDirty = true; renderWzInbounds(); }; delBtn.textContent = "Remover";
row.appendChild(delBtn); delBtn.onclick = async () => {
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Remove inbound"),
message:t("Remove inbound {name}?", {name:ib.tag || "untagged"}),
detail:t("Clients attached only to this inbound will stop connecting after the configuration is saved."),
confirmLabel:t("Remove inbound"),
});
if (!accepted) return;
wzInbounds.splice(i,1);
if (wzEditingIndex === i) wzCancelInbound();
else if (wzEditingIndex > i) wzEditingIndex--;
wzDirty = true;
renderWzInbounds();
loadSharedEndpointForm();
};
actions.append(duplicateBtn, editBtn, delBtn);
row.appendChild(actions);
list.appendChild(row); list.appendChild(row);
}); });
} }
function wzToggleAddInbound() { function wzToggleAddInbound() {
const form = document.getElementById("wzAddInboundForm"); const form = document.getElementById("wzAddInboundForm");
form.classList.toggle("hidden"); if (!form.classList.contains("hidden") && wzEditingIndex < 0) return wzCancelInbound();
if (!form.classList.contains("hidden")) { resetWzInboundForm();
onWzProtoChange(document.getElementById("wzProtocol").value); form.classList.remove("hidden");
onWzNetworkChange(document.getElementById("wzNetwork").value); form.scrollIntoView({ behavior:"smooth", block:"nearest" });
onWzTLSChange(document.getElementById("wzTLS").value); }
function setWzValue(id, value) {
const el = document.getElementById(id);
if (el) el.value = value ?? "";
}
function resetWzInboundForm() {
wzEditingIndex = -1;
setWzValue("wzProtocol", "vless");
setWzValue("wzPort", "10086");
setWzValue("wzListenIP", "0.0.0.0");
setWzValue("wzTag", "vless-in");
setWzValue("wzNetwork", "tcp");
setWzValue("wzWSPath", "/ws");
setWzValue("wzXHTTPPath", "/xhttp");
setWzValue("wzXHTTPHost", "");
setWzValue("wzXHTTPMode", "auto");
setWzValue("wzHUPath", "/upgrade");
setWzValue("wzHUHost", "");
setWzValue("wzH2Path", "/h2");
setWzValue("wzH2Host", "");
setWzValue("wzGRPCService", "grpc-service");
document.getElementById("wzGRPCMulti").checked = false;
setWzValue("wzTLS", "none");
["wzTLSCert", "wzTLSKey", "wzTLSCertPath", "wzTLSKeyPath", "wzRealityDest", "wzRealitySNI", "wzRealityPriv", "wzRealityShortID", "wzTrojanPass", "wzSSPass"].forEach(id => setWzValue(id, ""));
setWzValue("wzSSMethod", "chacha20-ietf-poly1305");
document.getElementById("wzInboundFormTitle").textContent = "Novo inbound";
document.getElementById("wzSaveInboundBtn").textContent = "Adicionar inbound";
document.getElementById("wzEditingBadge").classList.add("hidden");
onWzProtoChange("vless");
onWzNetworkChange("tcp");
onWzTLSChange("none");
}
function wzCancelInbound() {
wzEditingIndex = -1;
document.getElementById("wzAddInboundForm")?.classList.add("hidden");
document.getElementById("wzEditingBadge")?.classList.add("hidden");
}
function editWzInbound(index) {
const ib = wzInbounds[index];
if (!ib) return;
resetWzInboundForm();
wzEditingIndex = index;
const proto = String(ib.protocol || "vless").toLowerCase();
setWzValue("wzProtocol", proto);
onWzProtoChange(proto);
setWzValue("wzPort", ib.port ?? "");
setWzValue("wzListenIP", ib.listen || (proto === "socks" ? "127.0.0.1" : "0.0.0.0"));
setWzValue("wzTag", ib.tag || `${proto}-in`);
const ss = ib.streamSettings || {};
const network = proto === "ssh" ? "xhttp" : (ss.network || "tcp");
setWzValue("wzNetwork", network);
onWzNetworkChange(network);
const xh = ss.xhttpSettings || ss.splithttpSettings || {};
setWzValue("wzWSPath", ss.wsSettings?.path || "/ws");
setWzValue("wzXHTTPPath", xh.path || "/xhttp");
setWzValue("wzXHTTPHost", xh.host || "");
setWzValue("wzXHTTPMode", xh.mode || "auto");
setWzValue("wzHUPath", ss.httpupgradeSettings?.path || "/upgrade");
setWzValue("wzHUHost", ss.httpupgradeSettings?.host || "");
setWzValue("wzH2Path", ss.httpSettings?.path || "/h2");
setWzValue("wzH2Host", Array.isArray(ss.httpSettings?.host) ? (ss.httpSettings.host[0] || "") : (ss.httpSettings?.host || ""));
setWzValue("wzGRPCService", ss.grpcSettings?.serviceName || "grpc-service");
document.getElementById("wzGRPCMulti").checked = !!ss.grpcSettings?.multiMode;
const security = ss.security === "tls" ? "tls" : (ss.security === "reality" ? "reality" : "none");
setWzValue("wzTLS", security);
onWzTLSChange(security);
const cert = ss.tlsSettings?.certificates?.[0] || {};
setWzValue("wzTLSCert", cert.certificateFile || "");
setWzValue("wzTLSKey", cert.keyFile || "");
setWzValue("wzTLSCertPath", cert.certificateFile || "");
setWzValue("wzTLSKeyPath", cert.keyFile || "");
setWzValue("wzRealityDest", ss.realitySettings?.dest || "");
setWzValue("wzRealitySNI", ss.realitySettings?.serverNames?.[0] || "");
setWzValue("wzRealityPriv", ss.realitySettings?.privateKey || "");
setWzValue("wzRealityShortID", ss.realitySettings?.shortIds?.[0] || "");
setWzValue("wzTrojanPass", ib.settings?.clients?.[0]?.password || "");
setWzValue("wzSSPass", ib.settings?.password || "");
setWzValue("wzSSMethod", ib.settings?.method || "chacha20-ietf-poly1305");
document.getElementById("wzInboundFormTitle").textContent = `Editar ${ib.tag || "inbound"}`;
document.getElementById("wzSaveInboundBtn").textContent = "Salvar alterações";
document.getElementById("wzEditingBadge").classList.remove("hidden");
const form = document.getElementById("wzAddInboundForm");
form.classList.remove("hidden");
form.scrollIntoView({ behavior:"smooth", block:"start" });
}
function duplicateWzInbound(index) {
const source = wzInbounds[index];
if (!source) return;
const copy = cloneJsonSafe(source);
const tags = new Set(wzInbounds.map(ib => ib?.tag));
let n = 2;
let tag = `${source.tag || source.protocol || "inbound"}-copy`;
while (tags.has(tag)) tag = `${source.tag || source.protocol || "inbound"}-copy-${n++}`;
copy.tag = tag;
const port = Number(copy.port || 0);
if (port > 0 && port < 65535) copy.port = port + 1;
wzInbounds.push(copy);
wzDirty = true;
renderWzInbounds();
editWzInbound(wzInbounds.length - 1);
}
function normalizeVisualPath(value) {
let path = String(value || "/").trim().split("?", 1)[0];
if (!path.startsWith("/")) path = `/${path}`;
path = path.replace(/\/+$/, "") || "/";
return path;
}
function visualXHTTPSettings(ib) {
const ss = ib?.streamSettings || {};
if (!["xhttp", "splithttp"].includes(String(ss.network || "").toLowerCase())) return null;
return ss.xhttpSettings || ss.splithttpSettings || {};
}
function sameVisualEndpoint(a, b) {
return String(a?.listen || "0.0.0.0") === String(b?.listen || "0.0.0.0") && String(a?.port) === String(b?.port);
}
function findSSHRouteForInbound(source) {
return wzInbounds.find(candidate => {
const xh = visualXHTTPSettings(candidate);
return sameVisualEndpoint(source, candidate) && !!xh && String(candidate?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh";
}) || null;
}
function uniqueLegacySSHTag(source) {
const tags = new Set(wzInbounds.map(ib => String(ib?.tag || "")));
const sourceTag = String(source?.tag || source?.port || "xhttp").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "xhttp";
let tag = `ssh-${sourceTag}`;
let suffix = 2;
while (tags.has(tag)) tag = `ssh-${sourceTag}-${suffix++}`;
return tag;
}
// Adds SSH to a legacy VLESS/VMess XHTTP listener without rebuilding or
// modifying the original inbound. The companion inherits its listen address,
// port, host, XHTTP mode, advanced transport options, TLS certificate, and key;
// only its protocol, empty SSH settings, tag, and /ssh path differ.
function reportSSHMigration(message, tone = "error") {
const st = document.getElementById("wzStatus");
if (st) st.textContent = message;
if (typeof showPanelToast === "function") {
showPanelToast(message, tone, tone === "success" ? t("SSH /ssh enabled") : tone === "warning" ? t("SSH migration attention") : t("Could not enable SSH /ssh"));
} }
} }
async function enableSSHForWzInbound(index, applyNow = true, trigger = null) {
const st = document.getElementById("wzStatus");
const source = wzInbounds[index];
const selectedID = selectedXrayServer() || "local";
if (!source || !visualXHTTPSettings(source) || !["vless", "vmess"].includes(String(source.protocol || "").toLowerCase())) {
reportSSHMigration(t("Select a VLESS/VMess inbound using XHTTP."));
return false;
}
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
reportSSHMigration(t("Load the selected server configuration before enabling SSH."));
return false;
}
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
reportSSHMigration(t("Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first."));
return false;
}
const security = String(source.streamSettings?.security || "").toLowerCase();
if (security && security !== "none" && security !== "tls") {
reportSSHMigration(t("Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.", {name:source.tag || t("selected"), security}));
return false;
}
const existingSSH = findSSHRouteForInbound(source);
if (existingSSH) {
reportSSHMigration(t("SSH is already enabled on /ssh by inbound {name}.", {name:existingSSH.tag}), "success");
return true;
}
const pathConflict = wzInbounds.find(candidate => {
const xh = visualXHTTPSettings(candidate);
return sameVisualEndpoint(source, candidate) && !!xh && normalizeVisualPath(xh.path) === "/ssh";
});
if (pathConflict) {
reportSSHMigration(t("Path /ssh is already used by inbound {name}. Edit that path first.", {name:pathConflict.tag || t("untagged")}));
return false;
}
if (security === "tls") {
const certificate = source.streamSettings?.tlsSettings?.certificates?.[0] || {};
if (!certificate.certificateFile || !certificate.keyFile) {
reportSSHMigration(t("This inbound uses TLS but has no reusable certificate and key file paths."));
return false;
}
}
if (applyNow) {
const xhttp = visualXHTTPSettings(source) || {};
const clients = Array.isArray(source.settings?.clients) ? source.settings.clients.length : 0;
const accepted = await panelConfirm({
tone:"success", icon:"SSH", eyebrow:t("Safe XHTTP migration"), title:t("Enable SSH on /ssh"),
message:t("Add SSH to the same endpoint without rebuilding {name}?", {name:source.tag || t("this inbound")}),
detail:[
`${t("Listener")}: ${source.listen || "0.0.0.0"}:${source.port}`,
`${t("Existing path preserved")}: ${normalizeVisualPath(xhttp.path)}`,
`${t("New SSH path")}: /ssh`,
`${t("Clients preserved")}: ${clients}`,
`${t("Security")}: ${security === "tls" ? "TLS" : t("No TLS")}`,
].join("\n"),
confirmLabel:t("Enable SSH /ssh"),
});
if (!accepted) return false;
}
if (trigger) {
trigger.disabled = true;
trigger.textContent = t("Enabling SSH…");
}
const streamSettings = cloneJsonSafe(source.streamSettings || {});
const settingsKey = streamSettings.xhttpSettings ? "xhttpSettings" : (streamSettings.splithttpSettings ? "splithttpSettings" : "xhttpSettings");
streamSettings[settingsKey] = Object.assign({}, streamSettings[settingsKey] || {}, { path:"/ssh" });
const sshInbound = {
tag: uniqueLegacySSHTag(source),
listen: source.listen || "0.0.0.0",
port: cloneJsonSafe(source.port),
protocol: "ssh",
settings: {},
streamSettings,
};
const previousDirty = wzDirty;
const before = cloneJsonSafe(source);
wzInbounds.push(sshInbound);
try {
validateVisualInbounds(wzInbounds);
} catch (error) {
wzInbounds.pop();
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
reportSSHMigration(t("Could not enable SSH: {error}", {error:error.message}));
return false;
}
if (JSON.stringify(source) !== JSON.stringify(before)) {
wzInbounds.pop();
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
reportSSHMigration(t("Migration was cancelled because it would alter the old inbound."));
return false;
}
wzDirty = true;
renderWzInbounds();
if (!applyNow) {
if (st) st.textContent = t("SSH /ssh added to the draft without changing {name}.", {name:source.tag || t("the old inbound")});
return true;
}
if (st) st.textContent = t("Enabling SSH /ssh without changing {name}…", {name:source.tag || t("the old inbound")});
const result = await applyWizardConfig();
if (!result?.saved) {
const addedIndex = wzInbounds.indexOf(sshInbound);
if (addedIndex >= 0) wzInbounds.splice(addedIndex, 1);
wzDirty = previousDirty;
renderWzInbounds();
reportSSHMigration(result?.error || t("The SSH route could not be saved. The old inbound was not changed."));
return false;
}
if (!result.restarted) {
reportSSHMigration(t("SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log."), "warning");
return true;
}
reportSSHMigration(t("SSH /ssh is active. The old inbound and all clients were preserved."), "success");
return true;
}
function validateVisualInbounds(inbounds) {
const tags = new Set();
const binds = new Map();
const nativeMode = (document.getElementById("xCoreMode")?.value || "native") === "native";
for (const ib of inbounds || []) {
const tag = String(ib?.tag || "").trim();
if (!tag) throw new Error("every inbound needs a tag");
if (tags.has(tag)) throw new Error(`duplicate inbound tag: ${tag}`);
tags.add(tag);
const port = Number(ib?.port || 0);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port on ${tag}`);
const bind = `${String(ib?.listen || "0.0.0.0").trim()}:${port}`;
const xh = visualXHTTPSettings(ib);
if (!binds.has(bind)) binds.set(bind, []);
binds.get(bind).push({ ib, xh });
if (String(ib?.protocol || "").toLowerCase() === "ssh") {
if (!xh) throw new Error(`SSH inbound ${tag} requires XHTTP`);
if (!nativeMode) throw new Error(`SSH inbound ${tag} requires native Xray mode`);
}
}
for (const [bind, rows] of binds) {
if (rows.length < 2) continue;
if (!nativeMode || rows.some(row => !row.xh)) throw new Error(`multiple inbounds cannot share ${bind} unless all use native XHTTP`);
const paths = new Set();
const firstSecurity = String(rows[0].ib.streamSettings?.security || "none");
const firstCert = rows[0].ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
for (const row of rows) {
const path = normalizeVisualPath(row.xh.path);
if (paths.has(path)) throw new Error(`duplicate XHTTP path ${path} on ${bind}`);
paths.add(path);
const security = String(row.ib.streamSettings?.security || "none");
const cert = row.ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
if (security !== firstSecurity) throw new Error(`shared XHTTP inbounds on ${bind} must use the same TLS setting`);
if (security === "tls" && (cert.certificateFile !== firstCert.certificateFile || cert.keyFile !== firstCert.keyFile)) {
throw new Error(`shared XHTTP inbounds on ${bind} must use the same certificate`);
}
}
}
}
function findSharedEndpointPair() {
const roots = wzInbounds.filter(ib => {
const xh = visualXHTTPSettings(ib);
return !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
});
for (const proxy of roots) {
const ssh = wzInbounds.find(ib => String(ib?.protocol || "").toLowerCase() === "ssh" &&
String(ib.listen || "0.0.0.0") === String(proxy.listen || "0.0.0.0") && String(ib.port) === String(proxy.port) &&
normalizeVisualPath(visualXHTTPSettings(ib)?.path) === "/ssh");
if (ssh) return { proxy, ssh };
}
const proxy = wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || null;
const ssh = wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || null;
return proxy && ssh ? { proxy, ssh } : null;
}
function loadSharedEndpointForm() {
const status = document.getElementById("sharedXHTTPStatus");
if (!status) return;
const pair = findSharedEndpointPair();
if (!pair) {
status.textContent = wzLoadedConfigText ? "Nenhum endpoint compartilhado detectado. Preencha os campos para criar um." : "Carregue a configuração para detectar um endpoint existente.";
return;
}
const xh = visualXHTTPSettings(pair.proxy) || {};
const ss = pair.proxy.streamSettings || {};
const cert = ss.tlsSettings?.certificates?.[0] || {};
setWzValue("sharedXHTTPProtocol", pair.proxy.protocol || "vless");
setWzValue("sharedXHTTPPort", pair.proxy.port || 443);
setWzValue("sharedXHTTPListen", pair.proxy.listen || "0.0.0.0");
setWzValue("sharedXHTTPHost", xh.host || "");
setWzValue("sharedXHTTPMode", xh.mode || "auto");
setWzValue("sharedXHTTPSecurity", ss.security === "tls" ? "tls" : "none");
setWzValue("sharedXHTTPCert", cert.certificateFile || "");
setWzValue("sharedXHTTPKey", cert.keyFile || "");
updateSharedEndpointControls();
status.textContent = `Endpoint detectado em ${pair.proxy.listen || "0.0.0.0"}:${pair.proxy.port}${String(pair.proxy.protocol).toUpperCase()} / e SSH /ssh.`;
}
function updateSharedEndpointControls() {
const protocol = document.getElementById("sharedXHTTPProtocol")?.value || "vless";
const security = document.getElementById("sharedXHTTPSecurity")?.value || "none";
document.getElementById("sharedProxyRouteLabel").textContent = protocol.toUpperCase();
document.querySelectorAll(".shared-tls-field").forEach(el => el.classList.toggle("hidden", security !== "tls"));
}
function applySharedXHTTPEndpoint() {
const status = document.getElementById("sharedXHTTPStatus");
const selectedID = selectedXrayServer() || "local";
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
status.textContent = "Carregue a configuração do servidor selecionado antes de editar.";
return;
}
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
status.textContent = "O endpoint compartilhado requer o modo Xray nativo.";
return;
}
const protocol = document.getElementById("sharedXHTTPProtocol").value;
const port = Number(document.getElementById("sharedXHTTPPort").value || 0);
const listen = document.getElementById("sharedXHTTPListen").value.trim() || "0.0.0.0";
const host = document.getElementById("sharedXHTTPHost").value.trim();
const mode = document.getElementById("sharedXHTTPMode").value || "auto";
const security = document.getElementById("sharedXHTTPSecurity").value;
const cert = document.getElementById("sharedXHTTPCert").value.trim();
const key = document.getElementById("sharedXHTTPKey").value.trim();
if (!["vless", "vmess"].includes(protocol) || !Number.isInteger(port) || port < 1 || port > 65535) {
status.textContent = "Escolha VLESS/VMess e uma porta válida.";
return;
}
if (/[\u0000-\u001f\u007f]/.test(`${listen}${host}${cert}${key}`)) {
status.textContent = "Os campos contêm caracteres de controle inválidos.";
return;
}
if (security === "tls" && (!cert || !key)) {
status.textContent = "Informe os arquivos do certificado e da chave para usar TLS.";
return;
}
const pair = findSharedEndpointPair();
const sameEndpoint = ib => String(ib?.listen || "0.0.0.0") === listen && String(ib?.port) === String(port);
const existingProxy = pair?.proxy || wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || wzInbounds.find(ib => {
const xh = visualXHTTPSettings(ib);
return sameEndpoint(ib) && !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
}) || null;
const existingSSH = pair?.ssh || wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || wzInbounds.find(ib => {
const xh = visualXHTTPSettings(ib);
return sameEndpoint(ib) && !!xh && String(ib?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh";
}) || null;
const removeSet = new Set([existingProxy, existingSSH].filter(Boolean));
const others = wzInbounds.filter(ib => !removeSet.has(ib));
const blocking = others.find(ib => String(ib.listen || "0.0.0.0") === listen && String(ib.port) === String(port) && !visualXHTTPSettings(ib));
if (blocking) {
status.textContent = `A porta já é usada pelo inbound não-XHTTP ${blocking.tag || "sem tag"}. Escolha outra porta.`;
return;
}
const buildSharedStream = (existing, path) => {
const stream = cloneJsonSafe(existing?.streamSettings || {});
stream.network = "xhttp";
stream.xhttpSettings = Object.assign({}, stream.xhttpSettings || stream.splithttpSettings || {}, { path, mode });
delete stream.splithttpSettings;
if (host) stream.xhttpSettings.host = host;
else delete stream.xhttpSettings.host;
if (security === "tls") {
stream.security = "tls";
stream.tlsSettings = Object.assign({}, stream.tlsSettings || {}, { certificates:[{ certificateFile:cert, keyFile:key }] });
} else {
delete stream.security;
delete stream.tlsSettings;
}
delete stream.realitySettings;
return stream;
};
const previousClients = Array.isArray(existingProxy?.settings?.clients) ? cloneJsonSafe(existingProxy.settings.clients) : [];
const proxyInbound = cloneJsonSafe(existingProxy || {});
proxyInbound.tag = existingProxy?.tag || "shared-proxy-xhttp";
proxyInbound.listen = listen;
proxyInbound.port = port;
proxyInbound.protocol = protocol;
proxyInbound.settings = existingProxy?.protocol === protocol ? cloneJsonSafe(existingProxy.settings || {}) : {};
proxyInbound.settings.clients = previousClients;
if (protocol === "vless") proxyInbound.settings.decryption = "none";
else delete proxyInbound.settings.decryption;
proxyInbound.streamSettings = buildSharedStream(existingProxy, "/");
const sshInbound = cloneJsonSafe(existingSSH || {});
sshInbound.tag = existingSSH?.tag || "shared-ssh-xhttp";
sshInbound.listen = listen;
sshInbound.port = port;
sshInbound.protocol = "ssh";
sshInbound.settings = {};
sshInbound.streamSettings = buildSharedStream(existingSSH, "/ssh");
wzInbounds = [...others, proxyInbound, sshInbound];
wzDirty = true;
renderWzInbounds();
loadSharedEndpointForm();
status.textContent = "Endpoint atualizado no rascunho. Clique em Salvar configuração e reiniciar para aplicar.";
}
document.getElementById("sharedXHTTPProtocol")?.addEventListener("change", updateSharedEndpointControls);
document.getElementById("sharedXHTTPSecurity")?.addEventListener("change", updateSharedEndpointControls);
document.getElementById("sharedXHTTPApplyBtn")?.addEventListener("click", applySharedXHTTPEndpoint);
updateSharedEndpointControls();
function onWzProtoChange(val) { function onWzProtoChange(val) {
const isSSH = val === "ssh"; const isSSH = val === "ssh";
// SSH tunnels reuse the VLESS/VMess transport block to expose the XHTTP // SSH tunnels reuse the VLESS/VMess transport block to expose the XHTTP
@@ -227,78 +750,115 @@ function wzSaveInbound() {
const port = parseInt(document.getElementById("wzPort").value || "0", 10); const port = parseInt(document.getElementById("wzPort").value || "0", 10);
const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0"; const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
const tag = document.getElementById("wzTag").value.trim() || proto+"-in"; const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
if (!port) { alert("Port required."); return; } const st = document.getElementById("wzStatus");
const ib = { tag, port, listen, protocol: proto, settings: {} }; if (!Number.isInteger(port) || port < 1 || port > 65535) { st.textContent = "Informe uma porta válida."; return; }
if (/[\u0000-\u001f\u007f]/.test(`${listen}${tag}`)) { st.textContent = "Listen ou tag contém caracteres inválidos."; return; }
if (wzInbounds.some((item, index) => index !== wzEditingIndex && item?.tag === tag)) { st.textContent = `A tag ${tag} já está em uso.`; return; }
const original = wzEditingIndex >= 0 ? cloneJsonSafe(wzInbounds[wzEditingIndex]) : null;
const ib = original || {};
const previousClients = Array.isArray(original?.settings?.clients) ? cloneJsonSafe(original.settings.clients) : [];
ib.tag = tag;
ib.port = port;
ib.listen = listen;
ib.protocol = proto;
ib.settings = {};
if (proto === "vless" || proto === "vmess") { if (proto === "vless" || proto === "vmess") {
ib.settings = proto === "vless" ? { clients: [], decryption: "none" } : { clients: [] }; ib.settings = original?.protocol === proto && original.settings ? cloneJsonSafe(original.settings) : {};
ib.settings.clients = previousClients;
if (proto === "vless") ib.settings.decryption = "none";
else delete ib.settings.decryption;
const net = document.getElementById("wzNetwork").value; const net = document.getElementById("wzNetwork").value;
const tlsVal = document.getElementById("wzTLS").value; const tlsVal = document.getElementById("wzTLS").value;
ib.streamSettings = { network: net }; const previousStream = original?.protocol === proto && original?.streamSettings?.network === net ? cloneJsonSafe(original.streamSettings) : {};
ib.streamSettings = previousStream || {};
ib.streamSettings.network = net;
["wsSettings", "xhttpSettings", "splithttpSettings", "httpupgradeSettings", "httpSettings", "grpcSettings"].forEach(key => {
if (!((net === "ws" && key === "wsSettings") || (net === "xhttp" && (key === "xhttpSettings" || key === "splithttpSettings")) || (net === "httpupgrade" && key === "httpupgradeSettings") || (net === "h2" && key === "httpSettings") || (net === "grpc" && key === "grpcSettings"))) delete ib.streamSettings[key];
});
// Transport-specific settings // Transport-specific settings
switch (net) { switch (net) {
case "ws": case "ws":
ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" }; ib.streamSettings.wsSettings = Object.assign({}, ib.streamSettings.wsSettings || {}, { path: document.getElementById("wzWSPath").value.trim() || "/" });
break; break;
case "xhttp": case "xhttp":
ib.streamSettings.xhttpSettings = { ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp", path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
host: document.getElementById("wzXHTTPHost").value.trim() || undefined, host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
mode: document.getElementById("wzXHTTPMode").value, mode: document.getElementById("wzXHTTPMode").value,
}; });
delete ib.streamSettings.splithttpSettings;
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host; if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
break; break;
case "httpupgrade": case "httpupgrade":
ib.streamSettings.httpupgradeSettings = { ib.streamSettings.httpupgradeSettings = Object.assign({}, ib.streamSettings.httpupgradeSettings || {}, {
path: document.getElementById("wzHUPath").value.trim() || "/", path: document.getElementById("wzHUPath").value.trim() || "/",
host: document.getElementById("wzHUHost").value.trim() || undefined, host: document.getElementById("wzHUHost").value.trim() || undefined,
}; });
if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host; if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
break; break;
case "h2": case "h2":
ib.streamSettings.httpSettings = { ib.streamSettings.httpSettings = Object.assign({}, ib.streamSettings.httpSettings || {}, {
path: document.getElementById("wzH2Path").value.trim() || "/", path: document.getElementById("wzH2Path").value.trim() || "/",
host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean), host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
}; });
break; break;
case "grpc": case "grpc":
ib.streamSettings.grpcSettings = { ib.streamSettings.grpcSettings = Object.assign({}, ib.streamSettings.grpcSettings || {}, {
serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc", serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
multiMode: document.getElementById("wzGRPCMulti").checked, multiMode: document.getElementById("wzGRPCMulti").checked,
}; });
break; break;
} }
// TLS / Reality // TLS / Reality
if (tlsVal === "tls") { if (tlsVal === "tls") {
const certificateFile = document.getElementById("wzTLSCert").value.trim();
const keyFile = document.getElementById("wzTLSKey").value.trim();
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
ib.streamSettings.security = "tls"; ib.streamSettings.security = "tls";
ib.streamSettings.tlsSettings = { ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }], certificates: [{ certificateFile, keyFile }],
}; });
delete ib.streamSettings.realitySettings;
} else if (tlsVal === "reality" && proto === "vless") { } else if (tlsVal === "reality" && proto === "vless") {
ib.streamSettings.security = "reality"; ib.streamSettings.security = "reality";
ib.streamSettings.realitySettings = { ib.streamSettings.realitySettings = Object.assign({}, ib.streamSettings.realitySettings || {}, {
dest: document.getElementById("wzRealityDest").value.trim(), dest: document.getElementById("wzRealityDest").value.trim(),
serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean), serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
privateKey: document.getElementById("wzRealityPriv").value.trim(), privateKey: document.getElementById("wzRealityPriv").value.trim(),
shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean), shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean),
}; });
delete ib.streamSettings.tlsSettings;
} else {
delete ib.streamSettings.security;
delete ib.streamSettings.tlsSettings;
delete ib.streamSettings.realitySettings;
} }
} else if (proto === "ssh") { } else if (proto === "ssh") {
// SSH tunnel over XHTTP: no proxy clients — the decoded stream is handed to // SSH tunnel over XHTTP: no proxy clients — the decoded stream is handed to
// the SSH server, so authentication is an ordinary SSH account. // the SSH server, so authentication is an ordinary SSH account.
ib.settings = {}; ib.settings = {};
ib.streamSettings = { network: "xhttp" }; ib.streamSettings = original?.protocol === "ssh" ? (cloneJsonSafe(original.streamSettings) || {}) : {};
ib.streamSettings.xhttpSettings = { ib.streamSettings.network = "xhttp";
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp", path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
host: document.getElementById("wzXHTTPHost").value.trim() || undefined, host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
mode: document.getElementById("wzXHTTPMode").value, mode: document.getElementById("wzXHTTPMode").value,
}; });
delete ib.streamSettings.splithttpSettings;
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host; if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
const tlsVal = document.getElementById("wzTLS").value; const tlsVal = document.getElementById("wzTLS").value;
if (tlsVal === "tls") { if (tlsVal === "tls") {
const certificateFile = document.getElementById("wzTLSCert").value.trim();
const keyFile = document.getElementById("wzTLSKey").value.trim();
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
ib.streamSettings.security = "tls"; ib.streamSettings.security = "tls";
ib.streamSettings.tlsSettings = { ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }], certificates: [{ certificateFile, keyFile }],
}; });
} else {
delete ib.streamSettings.security;
delete ib.streamSettings.tlsSettings;
} }
} else if (proto === "trojan") { } else if (proto === "trojan") {
ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] }; ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
@@ -309,13 +869,13 @@ function wzSaveInbound() {
ib.settings = { auth: "noauth", udp: true }; ib.settings = { auth: "noauth", udp: true };
ib.streamSettings = { network: "tcp" }; ib.streamSettings = { network: "tcp" };
} }
wzInbounds.push(ib); if (wzEditingIndex >= 0) wzInbounds[wzEditingIndex] = ib;
else wzInbounds.push(ib);
wzDirty = true; wzDirty = true;
renderWzInbounds(); renderWzInbounds();
document.getElementById("wzAddInboundForm").classList.add("hidden"); loadSharedEndpointForm();
document.getElementById("wzPort").value = ""; st.textContent = original ? `Inbound ${tag} atualizado no rascunho.` : `Inbound ${tag} adicionado ao rascunho.`;
document.getElementById("wzTag").value = ""; wzCancelInbound();
document.getElementById("wzListenIP").value = "";
} }
@@ -344,6 +904,7 @@ function buildConfigFromVisualEditor() {
const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : []; const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : [];
const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api"); const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api");
const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || []; const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
validateVisualInbounds(visualInbounds);
cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds]; cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds];
return cfg; return cfg;
@@ -363,7 +924,7 @@ async function applyWizardConfig() {
if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) { if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) {
if (st) st.textContent = `Reloading config from ${target} before saving...`; if (st) st.textContent = `Reloading config from ${target} before saving...`;
loadWizardFromConfig(); loadWizardFromConfig();
return; return { saved:false, restarted:false, error:t("Configuration for this server was not loaded.") };
} }
let cfg; let cfg;
@@ -371,7 +932,7 @@ async function applyWizardConfig() {
cfg = buildConfigFromVisualEditor(); cfg = buildConfigFromVisualEditor();
} catch(e) { } catch(e) {
if (st) st.textContent = `Invalid visual config: ${e.message}`; if (st) st.textContent = `Invalid visual config: ${e.message}`;
return; return { saved:false, restarted:false, error:t("Invalid visual config: {error}", {error:e.message}) };
} }
if (st) st.textContent = `Saving config to ${target}...`; if (st) st.textContent = `Saving config to ${target}...`;
@@ -384,11 +945,15 @@ async function applyWizardConfig() {
wzLoadedServerID = selectedID; wzLoadedServerID = selectedID;
wzDirty = false; wzDirty = false;
if (st) st.textContent = `Saved on ${target}. Restarting Xray...`; if (st) st.textContent = `Saved on ${target}. Restarting Xray...`;
await xrayCtrl("restart"); const restarted = await xrayCtrl("restart");
if (st) st.textContent = `Config saved on ${target} and Xray restarted.`; if (st) st.textContent = restarted
? `Config saved on ${target} and Xray restarted.`
: `Config saved on ${target}, but Xray could not restart. Check Xray logs before editing again.`;
setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700); setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700);
return { saved:true, restarted:!!restarted, error:restarted ? "" : t("Xray could not restart.") };
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
else if (st) st.textContent = "Error: " + e.message; else if (st) st.textContent = "Error: " + e.message;
return { saved:false, restarted:false, error:t("Could not save configuration: {error}", {error:e.message}) };
} }
} }
+18 -4
View File
@@ -320,7 +320,8 @@ async function botSavePlan(event) {
} }
async function botDeletePlan(id) { async function botDeletePlan(id) {
if (!confirm("Excluir este plano? Esta ação não pode ser desfeita.")) return; const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir plano", message:"Excluir este plano?", detail:"Esta ação não pode ser desfeita.", confirmLabel:"Excluir plano" });
if (!accepted) return;
try { try {
await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" }); await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" });
await loadBotPlans(); await loadBotPlans();
@@ -396,7 +397,8 @@ async function botSavePkg(event) {
} }
async function botDeletePkg(id) { async function botDeletePkg(id) {
if (!confirm("Excluir este pacote de créditos?")) return; const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir pacote", message:"Excluir este pacote de créditos?", confirmLabel:"Excluir pacote" });
if (!accepted) return;
try { try {
await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" }); await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" });
await loadBotPkgs(); await loadBotPkgs();
@@ -491,7 +493,13 @@ async function botSaveUserAction(event) {
async function botToggleBlock(user) { async function botToggleBlock(user) {
const isBlocked = user.Role === "blocked"; const isBlocked = user.Role === "blocked";
if (!confirm(isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?")) return; const accepted = await panelConfirm({
tone:isBlocked ? "default" : "danger", icon:isBlocked ? "✓" : "!",
title:isBlocked ? "Desbloquear cliente" : "Bloquear cliente",
message:isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?",
confirmLabel:isBlocked ? "Desbloquear" : "Bloquear",
});
if (!accepted) return;
try { try {
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) }); await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) });
await loadBotUsers(); await loadBotUsers();
@@ -551,7 +559,13 @@ async function botReprocess(id) {
} }
async function botRefund(id) { async function botRefund(id) {
if (!confirm(`Isso apenas marca o pagamento #${id} como estornado no painel. Não envia um estorno financeiro ao Mercado Pago. Continuar?`)) return; const accepted = await panelConfirm({
tone:"danger", icon:"!", title:"Marcar como estornado",
message:`Marcar o pagamento #${id} como estornado no painel?`,
detail:"Esta ação não envia um estorno financeiro ao Mercado Pago; ela altera somente o status interno.",
confirmLabel:"Marcar estornado",
});
if (!accepted) return;
try { try {
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) }); await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) });
await loadBotTxns(); await loadBotTxns();
+291 -127
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=20260713secure1"/> <link rel="stylesheet" href="assets/app.css?v=20260713reseller10"/>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -51,11 +51,8 @@
<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>Servidores</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-status"><span class="nav-icon"></span><span>Status Servidores</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="stats"><span class="nav-icon">📊</span><span>Servidor</span></button>
<button class="tab-btn superadmin-only hidden" data-tab="vnstat"><span class="nav-icon"></span><span>Tráfego</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>
<button class="tab-btn superadmin-only hidden" data-tab="server"><span class="nav-icon"></span><span>Configurações</span></button> <button class="tab-btn superadmin-only hidden" data-tab="server"><span class="nav-icon"></span><span>Configurações</span></button>
@@ -209,6 +206,26 @@
<!-- ═══════════ SSH Users Tab ═══════════ --> <!-- ═══════════ SSH Users Tab ═══════════ -->
<div class="tab-pane" id="tab-ssh"> <div class="tab-pane" id="tab-ssh">
<section class="page-hero status-hero" data-tone="green">
<div class="page-hero-copy"><span class="page-kicker">Access workspace</span><h2>Contas SSH</h2><p>Crie, limite e acompanhe acessos SSH e SlowDNS com segurança.</p></div>
<div class="workspace-hero-actions"><span class="workspace-live-status" id="sshLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="sshHeroRefreshBtn" type="button">Atualizar</button></div>
<div class="workspace-overview-grid">
<article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Operação</small><strong id="sshMetricState">Carregando</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon purple"></span><div><small>Contas</small><strong id="sshMetricTotal">0</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan"></span><div><small>Ativas</small><strong id="sshMetricActive">0</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon amber"></span><div><small>Online agora</small><strong id="sshMetricOnline">0</strong></div></article>
</div>
</section>
<div class="workspace-nav-shell" data-tone="green">
<nav class="workspace-section-nav" id="sshSectionNav" aria-label="Áreas SSH e SlowDNS" style="--workspace-nav-columns:2">
<button class="active" type="button" data-workspace="ssh" data-workspace-section="users"><span></span> Usuários</button>
<button type="button" data-workspace="ssh" data-workspace-section="create"><span></span> Criar usuário</button>
</nav>
<select id="sshSection" class="workspace-section-select" data-workspace-select="ssh" aria-label="Área SSH e SlowDNS">
<option value="users">Usuários</option><option value="create">Criar usuário</option>
</select>
</div>
<!-- Reseller info card (visible to resellers only) --> <!-- Reseller info card (visible to resellers only) -->
<div id="resellerInfoCard" class="card hidden" style="margin-bottom:12px;"> <div id="resellerInfoCard" class="card hidden" style="margin-bottom:12px;">
@@ -241,7 +258,8 @@
<div class="hint" id="sshServerHint">Servers with SSH enabled are available here.</div> <div class="hint" id="sshServerHint">Servers with SSH enabled are available here.</div>
</div> </div>
<div class="grid2"> <section class="workspace-section active" data-workspace-panel="ssh" data-workspace-section-panel="users">
<div class="workspace-section-heading"><div><span>01 · Gestão</span><h3>Usuários SSH e SlowDNS</h3><p>Consulte conexões, limites, validade e ações de cada conta.</p></div></div>
<!-- Users list --> <!-- Users list -->
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
@@ -262,16 +280,17 @@
<tbody id="usersBody"></tbody> <tbody id="usersBody"></tbody>
</table> </table>
</div> </div>
<div class="statusbar"><span id="userStatus">Ready.</span><span id="lastReload"></span></div>
</div> </div>
</section>
<section class="workspace-section" data-workspace-panel="ssh" data-workspace-section-panel="create">
<div class="workspace-section-heading"><div><span>02 · Cadastro</span><h3 id="userFormHeading">Criar usuário</h3><p>Defina autenticação, validade, conexões e velocidade em uma tela dedicada.</p></div></div>
<!-- Create / edit user form --> <!-- Create / edit user form -->
<div class="card"> <div class="card workspace-form-card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">Create / update user</div> <div class="card-title" id="userFormTitle">Criar usuário SSH</div>
<button class="btn btn-ghost btn-sm" id="toggleFormBtn">Show form</button>
</div> </div>
<div id="userFormWrap" class="collapsible collapsed"> <div id="userFormWrap">
<form id="userForm"> <form id="userForm">
<div class="form-grid"> <div class="form-grid">
<div class="field"><label>Username</label><input id="fUsername" required autocomplete="off"/></div> <div class="field"><label>Username</label><input id="fUsername" required autocomplete="off"/></div>
@@ -299,11 +318,49 @@
</form> </form>
</div> </div>
</div> </div>
</div> </section>
<div class="statusbar workspace-section-status"><span id="userStatus">Ready.</span><span id="lastReload"></span></div>
</div><!-- /tab-ssh --> </div><!-- /tab-ssh -->
<!-- ═══════════ Xray Tab (superadmin only) ═══════════ --> <!-- ═══════════ Xray Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-xray"> <div class="tab-pane" id="tab-xray">
<section class="page-hero status-hero" data-tone="purple">
<div class="page-hero-copy"><span class="page-kicker">Proxy studio</span><h2>Xray visual</h2><p>Gerencie clientes, inbounds e um endpoint XHTTP compartilhado sem editar JSON.</p></div>
<div class="workspace-hero-actions"><span class="workspace-live-status" id="xrayChip">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="xRefreshBtn" type="button">Atualizar</button></div>
<div class="workspace-overview-grid five">
<article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Status</small><strong id="xRunning">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan"></span><div><small>Online</small><strong id="xOnlineUsers">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">#</span><div><small>PID</small><strong id="xPID">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon amber"></span><div><small>Uptime</small><strong id="xUptime">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">API</span><div><small>Contadores</small><strong id="xStatsConfig">--</strong></div></article>
</div>
<div class="workspace-hero-toolbar">
<span id="xStatus" class="workspace-toolbar-status">Pronto.</span>
<div class="workspace-toolbar-actions xray-admin-only">
<select id="xCoreMode" class="input-sm" title="Xray runtime mode"><option value="native">Internal native emulator</option><option value="external">External xray binary</option></select>
<button class="btn btn-ghost btn-sm" id="xSaveModeBtn">Salvar modo</button>
<button class="btn btn-ghost btn-sm" id="xStartBtn">Iniciar</button>
<button class="btn btn-danger btn-sm" id="xStopBtn">Parar</button>
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Reiniciar</button>
<button class="btn btn-ghost btn-sm" id="xRepairStatsBtn">Reparar contadores</button>
</div>
</div>
</section>
<div class="workspace-nav-shell" data-tone="purple">
<nav class="workspace-section-nav" id="xraySectionNav" aria-label="Áreas do Xray" style="--workspace-nav-columns:4">
<button class="active" type="button" data-workspace="xray" data-workspace-section="users"><span></span> Usuários</button>
<button type="button" data-workspace="xray" data-workspace-section="create"><span></span> Criar usuário</button>
<button class="xray-admin-only" type="button" data-workspace="xray" data-workspace-section="config"><span></span> Configuração</button>
<button class="xray-admin-only" type="button" data-workspace="xray" data-workspace-section="logs"><span></span> Logs</button>
</nav>
<select id="xraySection" class="workspace-section-select" data-workspace-select="xray" aria-label="Área do Xray">
<option value="users">Usuários</option><option value="create">Criar usuário</option>
<option class="xray-admin-only" value="config">Configuração</option><option class="xray-admin-only" value="logs">Logs</option>
</select>
</div>
<section class="workspace-section active" data-workspace-panel="xray" data-workspace-section-panel="users">
<!-- Edit Client Panel (hidden by default) --> <!-- Edit Client Panel (hidden by default) -->
<div id="editXrayClientPanel" class="card hidden" style="margin-bottom:12px;border-color:rgba(245,158,11,.4);"> <div id="editXrayClientPanel" class="card hidden" style="margin-bottom:12px;border-color:rgba(245,158,11,.4);">
<div class="card-hdr"> <div class="card-hdr">
@@ -322,6 +379,7 @@
</div> </div>
<div id="editXrayClientStatus" class="hint" style="margin-top:4px;"></div> <div id="editXrayClientStatus" class="hint" style="margin-top:4px;"></div>
</div> </div>
</section>
<div class="card reseller-only hidden reseller-helper-card"> <div class="card reseller-only hidden reseller-helper-card">
<div class="card-hdr"><div class="card-title">Área do revendedor</div><span class="chip green">cota única</span></div> <div class="card-hdr"><div class="card-title">Área do revendedor</div><span class="chip green">cota única</span></div>
<p class="hint">Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.</p> <p class="hint">Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.</p>
@@ -344,35 +402,10 @@
<div class="hint hidden" id="xrayServerHint"></div> <div class="hint hidden" id="xrayServerHint"></div>
</div> </div>
<!-- Status --> <section class="workspace-section active" data-workspace-panel="xray" data-workspace-section-panel="users">
<div class="card"> <div class="workspace-section-heading"><div><span>01 · Gestão</span><h3>Usuários Xray</h3><p>Consulte clientes, conexões, validade e tráfego separados por inbound.</p></div><button class="btn btn-sm" id="xrayOpenCreateBtn" type="button">Criar usuário</button></div>
<div class="card-hdr">
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
<div class="card-actions xray-admin-only">
<select id="xCoreMode" class="input-sm" title="Xray runtime mode">
<option value="native">Internal native emulator</option>
<option value="external">External xray binary</option>
</select>
<button class="btn btn-ghost btn-sm" id="xSaveModeBtn">Save mode</button>
<button class="btn btn-ghost btn-sm" id="xStartBtn">Start</button>
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
<button class="btn btn-ghost btn-sm" id="xRepairStatsBtn">Repair counters</button>
<button class="btn btn-ghost btn-sm" id="xRefreshBtn">Refresh</button>
</div>
</div>
<div class="metrics">
<div class="metric"><div class="m-label">Status</div><div class="m-val" id="xRunning">--</div></div>
<div class="metric"><div class="m-label">Online</div><div class="m-val" id="xOnlineUsers">--</div></div>
<div class="metric"><div class="m-label">PID</div><div class="m-val" id="xPID">--</div></div>
<div class="metric"><div class="m-label">Uptime</div><div class="m-val" id="xUptime">--</div></div>
<div class="metric"><div class="m-label">Counters API</div><div class="m-val" id="xStatsConfig">--</div></div>
</div>
<div class="statusbar"><span id="xStatus">Ready.</span></div>
</div>
<!-- Inbounds & clients --> <!-- Inbounds & clients -->
<div class="card" style="margin-top:12px;"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">Inbounds &amp; Clients</div> <div class="card-title">Inbounds &amp; Clients</div>
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div> <div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div>
@@ -381,9 +414,31 @@
<div class="hint" style="padding:8px 0;">Loading inbounds…</div> <div class="hint" style="padding:8px 0;">Loading inbounds…</div>
</div> </div>
</div> </div>
</section>
<section class="workspace-section" data-workspace-panel="xray" data-workspace-section-panel="create">
<div class="workspace-section-heading"><div><span>02 · Cadastro</span><h3>Criar usuário Xray</h3><p>Escolha o inbound e cadastre o cliente sem abrir a tabela de usuários.</p></div></div>
<div class="card workspace-form-card">
<div class="card-hdr"><div class="card-title">Novo cliente Xray</div><span class="chip" id="xCreateProtocolChip">inbound</span></div>
<form id="xCreateClientForm">
<div class="form-grid">
<div class="field" style="grid-column:1/-1"><label>Inbound</label><select id="xCreateInbound" required><option value="">Carregando inbounds…</option></select><span class="hint" id="xCreateInboundHint">Selecione onde o cliente será adicionado.</span></div>
<div class="field"><label>UUID</label><div class="field-row"><input id="xCreateUUID" required autocomplete="off" placeholder="Gerado automaticamente"/><button class="btn btn-ghost btn-sm" id="xCreateUUIDBtn" type="button">Gerar</button></div></div>
<div class="field"><label>Nome de exibição</label><input id="xCreateName" autocomplete="off" placeholder="Ex.: Cliente 01"/></div>
<div class="field"><label>Email / identificação</label><input id="xCreateEmail" autocomplete="off" placeholder="cliente@example"/></div>
<div class="field"><label>Expira em</label><input id="xCreateExpiry" type="datetime-local"/></div>
<div class="field"><label>Máximo de conexões <span class="hint">0 = ilimitado</span></label><input id="xCreateMaxConns" type="number" min="0" value="0"/></div>
</div>
<div class="form-actions"><button class="btn" id="xCreateClientBtn" type="submit">Criar usuário</button><button class="btn btn-ghost" id="xCreateCancelBtn" type="button">Voltar aos usuários</button></div>
<div class="statusbar"><span id="xCreateClientStatus">Preencha os dados do novo cliente.</span></div>
</form>
</div>
</section>
<section class="workspace-section xray-admin-only" data-workspace-panel="xray" data-workspace-section-panel="config">
<div class="workspace-section-heading"><div><span>03 · Serviço</span><h3>Configuração Xray</h3><p>Edite endpoint, inbounds, TLS e opções avançadas visualmente ou em JSON.</p></div></div>
<!-- Config editor --> <!-- Config editor -->
<div class="card xray-admin-only" style="margin-top:12px;"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">Xray Config</div> <div class="card-title">Xray Config</div>
<div class="card-actions"> <div class="card-actions">
@@ -393,7 +448,38 @@
</div> </div>
<!-- Wizard pane --> <!-- Wizard pane -->
<div id="xrayWizardPane"> <div id="xrayWizardPane">
<div class="form-grid" style="margin-bottom:8px;"> <section class="shared-endpoint-card">
<div class="shared-endpoint-head">
<div><span class="page-kicker">Shared XHTTP endpoint</span><h3>Um domínio e uma porta</h3><p>O protocolo selecionado usa <code>/</code>; SSH usa <code>/ssh</code>. Disponível no modo Xray nativo.</p></div>
<span class="chip green">path routing</span>
</div>
<div class="shared-route-preview" aria-label="Shared endpoint route preview">
<span><strong id="sharedProxyRouteLabel">VLESS</strong><code>/</code></span>
<i></i>
<span><strong>SSH</strong><code>/ssh</code></span>
</div>
<div class="form-grid shared-endpoint-grid">
<div class="field"><label>Protocolo em /</label><select id="sharedXHTTPProtocol"><option value="vless">VLESS</option><option value="vmess">VMess</option></select></div>
<div class="field"><label>Porta compartilhada</label><input id="sharedXHTTPPort" type="number" min="1" max="65535" value="443"/></div>
<div class="field"><label>IP de listen</label><input id="sharedXHTTPListen" value="0.0.0.0" placeholder="0.0.0.0"/></div>
<div class="field"><label>Host HTTP <span class="hint">opcional</span></label><input id="sharedXHTTPHost" placeholder="vpn.seudominio.com"/></div>
<div class="field"><label>Modo XHTTP</label><select id="sharedXHTTPMode"><option value="auto">auto</option><option value="packet-up">packet-up</option><option value="stream-up">stream-up</option><option value="stream-down">stream-down</option><option value="stream-one">stream-one</option></select></div>
<div class="field"><label>Segurança</label><select id="sharedXHTTPSecurity"><option value="none">Sem TLS</option><option value="tls">TLS</option></select></div>
<div class="field shared-tls-field hidden"><label>Arquivo do certificado</label><input id="sharedXHTTPCert" placeholder="/opt/sshpanel/certs/domain/cert.pem"/></div>
<div class="field shared-tls-field hidden"><label>Arquivo da chave</label><input id="sharedXHTTPKey" placeholder="/opt/sshpanel/certs/domain/key.pem"/></div>
</div>
<div class="shared-endpoint-actions"><span id="sharedXHTTPStatus" class="hint">Carregue a configuração para detectar um endpoint existente.</span><button class="btn" id="sharedXHTTPApplyBtn" type="button">Criar / atualizar endpoint</button></div>
</section>
<aside class="legacy-xhttp-migration" aria-label="Migração de configuração XHTTP antiga">
<span class="legacy-xhttp-icon" aria-hidden="true">SSH+</span>
<div>
<strong>Configuração XHTTP antiga?</strong>
<p>Use “Ativar SSH /ssh” no cartão do inbound existente. O painel mantém o inbound, os clientes e todas as opções atuais; adiciona somente a rota SSH e reinicia o Xray.</p>
</div>
</aside>
<div class="visual-config-toolbar">
<div class="field"> <div class="field">
<label>Log Level</label> <label>Log Level</label>
<select id="wzLogLevel"> <select id="wzLogLevel">
@@ -404,14 +490,13 @@
<option value="debug">debug</option> <option value="debug">debug</option>
</select> </select>
</div> </div>
<div class="visual-config-toolbar-copy"><strong>Inbounds configurados</strong><span>Edite qualquer cartão visualmente ou use JSON para campos avançados.</span></div>
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Novo inbound</button>
</div> </div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;"> <div id="wzInboundsList" class="visual-inbound-list"></div>
<span style="font-size:.8rem;font-weight:600;">Inbounds</span>
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Add</button>
</div>
<div id="wzInboundsList" style="margin-bottom:8px;"></div>
<!-- Add inbound form --> <!-- Add inbound form -->
<div id="wzAddInboundForm" class="hidden" style="border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;"> <div id="wzAddInboundForm" class="visual-inbound-editor hidden">
<div class="visual-editor-heading"><div><span class="page-kicker">Visual editor</span><h3 id="wzInboundFormTitle">Novo inbound</h3></div><span id="wzEditingBadge" class="chip hidden">editing</span></div>
<div class="form-grid"> <div class="form-grid">
<div class="field"> <div class="field">
<label>Protocol</label> <label>Protocol</label>
@@ -537,13 +622,13 @@
</div> </div>
</div> </div>
<div class="form-actions" style="margin-top:8px;"> <div class="form-actions" style="margin-top:8px;">
<button class="btn btn-sm" type="button" onclick="wzSaveInbound()">Add Inbound</button> <button class="btn btn-sm" id="wzSaveInboundBtn" type="button" onclick="wzSaveInbound()">Adicionar inbound</button>
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('wzAddInboundForm').classList.add('hidden')">Cancel</button> <button class="btn btn-ghost btn-sm" id="wzCancelInboundBtn" type="button" onclick="wzCancelInbound()">Cancelar</button>
</div> </div>
</div> </div>
<div class="form-actions" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;"> <div class="visual-save-bar">
<button class="btn btn-sm" type="button" onclick="applyWizardConfig()">Save Config</button> <span class="hint" id="wzStatus"></span>
<span class="hint" id="wzStatus" style="margin-left:8px;"></span> <button class="btn" type="button" onclick="applyWizardConfig()">Salvar configuração e reiniciar</button>
</div> </div>
</div> </div>
<!-- JSON pane (hidden by default) --> <!-- JSON pane (hidden by default) -->
@@ -556,20 +641,45 @@
</div> </div>
</div> </div>
</div> </div>
</section>
<section class="workspace-section xray-admin-only" data-workspace-panel="xray" data-workspace-section-panel="logs">
<div class="workspace-section-heading"><div><span>04 · Diagnóstico</span><h3>Logs do Xray</h3><p>Acompanhe as últimas mensagens do serviço em uma tela focada.</p></div></div>
<!-- Logs --> <!-- Logs -->
<div class="card xray-admin-only" style="margin-top:12px;"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">Logs <span class="chip">last 200 lines</span></div> <div class="card-title">Logs <span class="chip">last 200 lines</span></div>
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadLogsBtn">Refresh</button></div> <div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadLogsBtn">Refresh</button></div>
</div> </div>
<pre class="log-box" id="xLogsBox"></pre> <pre class="log-box" id="xLogsBox"></pre>
</div> </div>
</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">
<div class="grid2"> <section class="page-hero status-hero" data-tone="amber">
<div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle hierarquia, créditos, cotas, validade e acesso dos parceiros em um só lugar.</p></div>
<div class="workspace-hero-actions"><span class="workspace-live-status is-loading" id="resellerLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="resellerHeroReloadBtn" type="button">Atualizar</button></div>
<div class="workspace-overview-grid">
<article class="workspace-overview-card"><span class="workspace-overview-icon amber"></span><div><small>Revendedores</small><strong id="resellerMetricTotal">--</strong><span class="workspace-card-note">parceiros diretos</span></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Ativos</small><strong id="resellerMetricActive">--</strong><span class="workspace-card-note">acesso liberado</span></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">#</span><div><small>Alocação</small><strong id="resellerMetricAllocated">--</strong><span class="workspace-card-note">slots reservados</span></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">Cr</span><div><small>Créditos</small><strong id="resellerMetricCredits">--</strong><span class="workspace-card-note">saldo nos parceiros</span></div></article>
</div>
</section>
<div class="workspace-nav-shell" data-tone="amber">
<nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:3">
<button class="active" type="button" data-workspace="resellers" data-workspace-section="users"><span></span> Revendedores</button>
<button type="button" data-workspace="resellers" data-workspace-section="create"><span></span> Criar revendedor</button>
<button type="button" data-workspace="resellers" data-workspace-section="audit"><span></span> Atividade</button>
</nav>
<select id="resellerSection" class="workspace-section-select" data-workspace-select="resellers" aria-label="Área de revendedores">
<option value="users">Revendedores</option><option value="create">Criar revendedor</option><option value="audit">Atividade</option>
</select>
</div>
<section class="workspace-section active" data-workspace-panel="resellers" data-workspace-section-panel="users">
<div class="workspace-section-heading"><div><span>01 · Gestão</span><h3>Revendedores cadastrados</h3><p>Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.</p></div></div>
<!-- Resellers list --> <!-- Resellers list -->
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
@@ -582,16 +692,18 @@
<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>
</div> </div>
<div class="statusbar"><span id="resellerStatus">Ready.</span></div>
</div> </div>
</section>
<section class="workspace-section" data-workspace-panel="resellers" data-workspace-section-panel="create">
<div class="workspace-section-heading"><div><span>02 · Cadastro</span><h3 id="resellerFormHeading">Criar revendedor</h3><p>Defina login, limite compartilhado, validade e acesso em uma tela dedicada.</p></div></div>
<!-- Create / edit reseller form --> <!-- Create / edit reseller form -->
<div class="card"> <div class="card workspace-form-card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title" id="resellerFormTitle">Create Reseller</div> <div class="card-title" id="resellerFormTitle">Create Reseller</div>
<button class="btn btn-ghost btn-sm" id="cancelResellerBtn">Cancel</button> <button class="btn btn-ghost btn-sm" id="cancelResellerBtn">Cancel</button>
@@ -600,26 +712,49 @@
<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>
</div> </div>
</form> </form>
</div> </div>
</div> </section>
<section class="workspace-section" data-workspace-panel="resellers" data-workspace-section-panel="audit">
<div class="workspace-section-heading"><div><span>03 · Auditoria</span><h3>Atividade das revendas</h3><p>Veja quem criou, alterou, renovou, suspendeu ou removeu cada conta.</p></div><button class="btn btn-ghost btn-sm" id="reloadResellerAuditBtn" type="button">Atualizar</button></div>
<div class="card">
<div class="tbl-wrap"><table class="reseller-audit-table"><thead><tr><th>Quando</th><th>Responsável</th><th>Revendedor</th><th>Ação</th><th>Detalhes</th></tr></thead><tbody id="resellerAuditBody"></tbody></table></div>
</div>
</section>
<div class="statusbar workspace-section-status"><span id="resellerStatus">Ready.</span></div>
</div><!-- /tab-resellers --> </div><!-- /tab-resellers -->
<!-- ═══════════ Servers Tab (superadmin only) ═══════════ --> <!-- ═══════════ Servers Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-servers"> <div class="tab-pane" id="tab-servers">
<section class="page-hero status-hero" data-tone="blue">
<div class="page-hero-copy"><span class="page-kicker">Fleet control</span><h2>Infraestrutura</h2><p>Gerencie nós, acompanhe a saúde do sistema e consulte tráfego em um único espaço.</p></div>
<div class="workspace-hero-actions"><span class="workspace-live-status" id="fleetLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="refreshServersBtn" type="button">Atualizar</button></div>
<div class="workspace-overview-grid">
<article class="workspace-overview-card"><span class="workspace-overview-icon blue"></span><div><small>Nós</small><strong id="fleetMetricNodes">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Ativos</small><strong id="fleetMetricActive">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan">SSH</span><div><small>SSH habilitado</small><strong id="fleetMetricSSH">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon purple"></span><div><small>Xray habilitado</small><strong id="fleetMetricXray">--</strong></div></article>
</div>
</section>
<div class="infra-nav-mount"></div>
<div id="serversListView"> <div id="serversListView">
<div class="grid2 servers-grid"> <div class="grid2 servers-grid">
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">Managed servers <span class="chip" id="serversCountChip">0</span></div> <div class="card-title">Managed servers <span class="chip" id="serversCountChip">0</span></div>
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="refreshServersBtn" type="button">Reload</button></div>
</div> </div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
@@ -841,38 +976,33 @@
<!-- ═══════════ Servers Status Tab (superadmin only) ═══════════ --> <!-- ═══════════ Servers Status Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-servers-status"> <div class="tab-pane" id="tab-servers-status">
<div class="card servers-status-toolbar"> <section class="page-hero status-hero" data-tone="green">
<div class="card-hdr"> <div class="page-hero-copy"><span class="page-kicker">Live fleet</span><h2>Status dos servidores</h2><p>Saúde, carga e sessões ativas de cada nó gerenciado.</p></div>
<div class="card-title">Servers Status <span class="chip" id="serversStatusCountChip">0</span></div> <div class="workspace-hero-actions"><span class="workspace-live-status" id="serversStatusPageStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="refreshServersStatusBtn" type="button">Atualizar</button></div>
<div class="card-actions"> <div class="workspace-overview-grid">
<button class="btn btn-ghost btn-sm" id="refreshServersStatusBtn" type="button">Refresh</button> <article class="workspace-overview-card"><span class="workspace-overview-icon blue"></span><div><small>Total</small><strong id="serversStatusCountChip">0</strong></div></article>
</div> <article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Online</small><strong id="fleetStatusOnline">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon red"></span><div><small>Offline</small><strong id="fleetStatusOffline">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon amber"></span><div><small>Sessões</small><strong id="fleetStatusSessions">--</strong></div></article>
</div> </div>
<div class="statusbar"> </section>
<span id="serversStatusPageStatus">Ready.</span> <div class="infra-nav-mount"></div>
</div>
</div>
<div class="servers-status-grid" id="serversStatusGrid"></div> <div class="servers-status-grid" id="serversStatusGrid"></div>
</div><!-- /tab-servers-status --> </div><!-- /tab-servers-status -->
<!-- ═══════════ Stats Tab (superadmin only) ═══════════ --> <!-- ═══════════ Stats Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-stats"> <div class="tab-pane" id="tab-stats">
<div class="grid2"> <section class="page-hero status-hero" data-tone="cyan">
<div class="card"> <div class="page-hero-copy"><span class="page-kicker">Observability</span><h2>Monitoramento</h2><p>Recursos, interfaces e capacidade do servidor em tempo real.</p></div>
<div class="card-hdr"><div class="card-title">Server Load</div><span class="hint" id="statsUpdated">--</span></div> <div class="workspace-hero-actions"><span class="workspace-live-status is-ok" id="statsUpdated">Atualização ao vivo</span><button class="btn btn-ghost btn-sm" id="refreshStatsBtn" type="button">Atualizar</button></div>
<div class="metrics"> <div class="workspace-overview-grid">
<div class="metric"> <article class="workspace-overview-card has-meter"><span class="workspace-overview-icon cyan">CPU</span><div><small>Processador</small><strong id="cpuVal">--%</strong><div class="workspace-mini-meter"><span id="cpuBar"></span></div></div></article>
<div class="m-label">CPU</div> <article class="workspace-overview-card has-meter"><span class="workspace-overview-icon purple">RAM</span><div><small>Memória</small><strong id="memVal">--%</strong><span class="workspace-card-note" id="memDetail">--</span><div class="workspace-mini-meter"><span id="memBar"></span></div></div></article>
<div class="m-val"><span id="cpuVal">--%</span></div> <article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Rede agora</small><strong id="statsNetVal">--</strong></div></article>
<div class="bar"><div class="bar-inner" id="cpuBar" style="width:0%"></div></div> <article class="workspace-overview-card"><span class="workspace-overview-icon amber">#</span><div><small>Interfaces</small><strong id="statsIfaceVal">--</strong></div></article>
</div>
<div class="metric">
<div class="m-label">RAM</div>
<div class="m-val"><span id="memVal">--%</span> <span class="hint" id="memDetail"></span></div>
<div class="bar"><div class="bar-inner" id="memBar" style="width:0%"></div></div>
</div>
</div>
</div> </div>
</section>
<div class="infra-nav-mount"></div>
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">Interfaces <span class="chip">rx/tx Mbps</span><span class="chip warn">30-day rolling</span></div> <div class="card-title">Interfaces <span class="chip">rx/tx Mbps</span><span class="chip warn">30-day rolling</span></div>
@@ -886,26 +1016,21 @@
</div> </div>
<div class="statusbar"><span id="ifaceSummary"></span><span class="hint">Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.</span></div> <div class="statusbar"><span id="ifaceSummary"></span><span class="hint">Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.</span></div>
</div> </div>
</div>
</div><!-- /tab-stats --> </div><!-- /tab-stats -->
<!-- ═══════════ VnStat Tab (superadmin only) ═══════════ --> <!-- ═══════════ VnStat Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-vnstat"> <div class="tab-pane" id="tab-vnstat">
<div class="card"> <section class="page-hero status-hero" data-tone="purple">
<div class="card-hdr"> <div class="page-hero-copy"><span class="page-kicker">Traffic intelligence</span><h2>Tráfego de rede</h2><p>Histórico diário e mensal para entender o consumo da infraestrutura.</p></div>
<div class="card-title">VnStat Usage <span class="chip">daily / monthly</span></div> <div class="workspace-hero-actions"><span class="workspace-live-status" id="vnstatStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="reloadVnstatBtn" type="button">Atualizar</button><button class="btn btn-danger btn-sm" id="resetVnstatBtn" type="button">Limpar histórico</button></div>
<div class="form-actions" style="margin-top:0"> <div class="workspace-overview-grid">
<button class="btn btn-ghost btn-sm" id="reloadVnstatBtn" type="button">Refresh</button> <article class="workspace-overview-card"><span class="workspace-overview-icon cyan">D</span><div><small>Hoje</small><strong id="vnTodayTotal">--</strong></div></article>
<button class="btn btn-danger btn-sm" id="resetVnstatBtn" type="button">Clean VnStat history</button> <article class="workspace-overview-card"><span class="workspace-overview-icon purple">M</span><div><small>Este mês</small><strong id="vnMonthTotal">--</strong></div></article>
</div> <article class="workspace-overview-card"><span class="workspace-overview-icon green"></span><div><small>Interfaces</small><strong id="vnIfaceCount">--</strong></div></article>
<article class="workspace-overview-card"><span class="workspace-overview-icon amber"></span><div><small>Período recente</small><strong id="vnLatestPeriod">--</strong></div></article>
</div> </div>
<div class="metrics"> </section>
<div class="metric"><div class="m-label">Today total</div><div class="m-val" id="vnTodayTotal">--</div></div> <div class="infra-nav-mount"></div>
<div class="metric"><div class="m-label">This month total</div><div class="m-val" id="vnMonthTotal">--</div></div>
<div class="metric"><div class="m-label">Interfaces tracked</div><div class="m-val" id="vnIfaceCount">--</div></div>
</div>
<div class="statusbar"><span id="vnstatStatus">VnStat history does not auto-clean. Use the button when you want to reset it.</span></div>
</div>
<div class="grid2" style="margin-top:12px;"> <div class="grid2" style="margin-top:12px;">
<div class="card"> <div class="card">
@@ -931,6 +1056,7 @@
<!-- ═══════════ Logs Tab (superadmin only) ═══════════ --> <!-- ═══════════ Logs Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-logs"> <div class="tab-pane" id="tab-logs">
<section class="page-hero" data-tone="amber"><div class="page-hero-copy"><span class="page-kicker">Diagnostics</span><h2>Logs do sistema</h2><p>Investigue painel, DNSTT e Xray com uma visualização focada.</p></div><div class="page-hero-pills"><span>Panel</span><span>DNSTT</span><span>Xray</span></div></section>
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">System Logs</div> <div class="card-title">System Logs</div>
@@ -1085,10 +1211,25 @@
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ --> <!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-server"> <div class="tab-pane" id="tab-server">
<section class="page-hero" data-tone="blue"><div class="page-hero-copy"><span class="page-kicker">System studio</span><h2>Configurações</h2><p>Rede, SSH, túneis e TLS organizados em blocos visuais e aplicados ao vivo.</p></div></section>
<div class="grid2"> <div class="workspace-nav-shell" data-tone="blue">
<!-- ── Left column ── --> <nav class="workspace-section-nav" id="configSectionNav" aria-label="Áreas de configuração" style="--workspace-nav-columns:5">
<div> <button class="active" type="button" data-workspace="config" data-workspace-section="general"><span></span> Rede e SSH</button>
<button type="button" data-workspace="config" data-workspace-section="slowdns"><span>DNS</span> SlowDNS</button>
<button type="button" data-workspace="config" data-workspace-section="udp"><span></span> UDP Gateway</button>
<button type="button" data-workspace="config" data-workspace-section="tls"><span></span> TLS</button>
<button type="button" data-workspace="config" data-workspace-section="xray"><span></span> Xray</button>
</nav>
<select id="configSection" class="workspace-section-select" data-workspace-select="config" aria-label="Área de configuração">
<option value="general">Rede e SSH</option><option value="slowdns">SlowDNS</option><option value="udp">UDP Gateway</option><option value="tls">TLS</option><option value="xray">Xray</option>
</select>
</div>
<div class="settings-workspace">
<section class="workspace-section active" data-workspace-panel="config" data-workspace-section-panel="general">
<div class="workspace-section-heading"><div><span>01 · Base</span><h3>Rede e SSH</h3><p>Configure listeners, limites padrão, tempo ocioso e o banner de conexão.</p></div></div>
<div class="settings-panel-grid">
<!-- Network --> <!-- Network -->
<div class="card"> <div class="card">
@@ -1153,7 +1294,7 @@
</div> </div>
<!-- Banner --> <!-- Banner -->
<div class="card" style="margin-top:12px"> <div class="card settings-span-all" style="margin-top:12px">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">SSH Banner</div> <div class="card-title">SSH Banner</div>
<span class="chip green">live</span> <span class="chip green">live</span>
@@ -1165,11 +1306,11 @@
<div class="hint" style="margin-top:6px;">Banner file: /opt/sshpanel/banner.txt</div> <div class="hint" style="margin-top:6px;">Banner file: /opt/sshpanel/banner.txt</div>
</div> </div>
</div><!-- /left --> </div>
</section>
<!-- ── Right column ── -->
<div>
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="slowdns">
<div class="workspace-section-heading"><div><span>02 · Túnel DNS</span><h3>SlowDNS / DNSTT</h3><p>Gerencie domínios, DNS local, capacidade, filas e reinício controlado.</p></div></div>
<!-- DNSTT --> <!-- DNSTT -->
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
@@ -1251,6 +1392,10 @@
</div> </div>
</div> </div>
</section>
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="udp">
<div class="workspace-section-heading"><div><span>03 · UDP</span><h3>UDP Gateway</h3><p>Defina listener, capacidade, expiração de mapa e reinício do serviço.</p></div></div>
<!-- UDPGW --> <!-- UDPGW -->
<div class="card" style="margin-top:12px"> <div class="card" style="margin-top:12px">
<div class="card-hdr"> <div class="card-hdr">
@@ -1296,6 +1441,10 @@
</div> </div>
</div> </div>
</section>
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="tls">
<div class="workspace-section-heading"><div><span>04 · Segurança</span><h3>Encaminhadores TLS</h3><p>Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.</p></div></div>
<!-- TLS Forwarders --> <!-- TLS Forwarders -->
<div class="card" style="margin-top:12px"> <div class="card" style="margin-top:12px">
<div class="card-hdr"> <div class="card-hdr">
@@ -1346,6 +1495,10 @@
</div> </div>
</div> </div>
</section>
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="xray">
<div class="workspace-section-heading"><div><span>05 · Core</span><h3>Xray</h3><p>Ative o core, escolha o runtime e aplique os ajustes nativos seguros.</p></div></div>
<!-- Xray --> <!-- Xray -->
<div class="card" style="margin-top:12px"> <div class="card" style="margin-top:12px">
<div class="card-hdr"> <div class="card-hdr">
@@ -1372,8 +1525,8 @@
</details> </details>
</div> </div>
</div><!-- /right --> </section>
</div><!-- /grid2 --> </div><!-- /settings-workspace -->
<!-- Save bar --> <!-- Save bar -->
<div class="save-bar"> <div class="save-bar">
@@ -1393,20 +1546,31 @@
</div><!-- /shell --> </div><!-- /shell -->
</div><!-- /app --> </div><!-- /app -->
<div class="panel-dialog hidden" id="panelConfirmDialog" aria-hidden="true">
<div class="panel-dialog-backdrop" data-panel-dialog-close></div>
<section class="panel-dialog-card" role="dialog" aria-modal="true" aria-labelledby="panelDialogTitle" aria-describedby="panelDialogMessage">
<div class="panel-dialog-head"><span class="panel-dialog-icon" id="panelDialogIcon" aria-hidden="true">?</span><div><span class="page-kicker" id="panelDialogEyebrow">Confirmação</span><h3 id="panelDialogTitle">Confirmar ação</h3></div></div>
<p id="panelDialogMessage"></p>
<div class="panel-dialog-detail hidden" id="panelDialogDetail"></div>
<div class="panel-dialog-actions"><button class="btn btn-ghost" id="panelDialogCancelBtn" type="button">Cancelar</button><button class="btn" id="panelDialogConfirmBtn" type="button">Confirmar</button></div>
</section>
</div>
<div class="panel-toast-stack" id="panelToastStack" aria-live="polite" aria-atomic="false"></div>
<!-- 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=20260713secure1"></script> <script defer src="assets/js/01-core.js?v=20260713reseller10"></script>
<script defer src="assets/js/02-shell.js?v=20260713secure1"></script> <script defer src="assets/js/02-shell.js?v=20260713reseller10"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260713secure1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260713reseller10"></script>
<script defer src="assets/js/04-xray.js?v=20260713secure1"></script> <script defer src="assets/js/04-xray.js?v=20260713reseller10"></script>
<script defer src="assets/js/05-resellers.js?v=20260713secure1"></script> <script defer src="assets/js/05-resellers.js?v=20260713reseller10"></script>
<script defer src="assets/js/06-servers.js?v=20260713secure1"></script> <script defer src="assets/js/06-servers.js?v=20260713reseller10"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260713secure1"></script> <script defer src="assets/js/07-stats-logs.js?v=20260713reseller10"></script>
<script defer src="assets/js/08-server-config.js?v=20260713secure1"></script> <script defer src="assets/js/08-server-config.js?v=20260713reseller10"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260713secure1"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260713reseller10"></script>
<script defer src="assets/js/11-update-status.js?v=20260713secure1"></script> <script defer src="assets/js/11-update-status.js?v=20260713reseller10"></script>
<script defer src="assets/js/12-bot.js?v=20260713secure1"></script> <script defer src="assets/js/12-bot.js?v=20260713reseller10"></script>
<script defer src="assets/js/10-boot.js?v=20260713secure1"></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)
} }
+197 -62
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"
@@ -1103,7 +1104,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = os.WriteFile(configFile, pretty, 0o600) _ = writeFileAtomic(configFile, pretty, 0o600)
return pretty, nil return pretty, nil
} }
} }
@@ -1115,7 +1116,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
return nil, err return nil, err
} else if changed { } else if changed {
data = forced data = forced
_ = os.WriteFile(configFile, forced, 0o600) _ = writeFileAtomic(configFile, forced, 0o600)
xrayLogf("xray: forced debug log output in file config") xrayLogf("xray: forced debug log output in file config")
} }
return data, nil return data, nil
@@ -1141,7 +1142,7 @@ func (m *XrayManager) writeConfigLocked(data []byte) error {
return fmt.Errorf("save Xray config to database: %w", err) return fmt.Errorf("save Xray config to database: %w", err)
} }
} }
if err := os.WriteFile(configFile, pretty, 0o600); err != nil { if err := writeFileAtomic(configFile, pretty, 0o600); err != nil {
return err return err
} }
m.importConfigClientsLocked(pretty, "saved xray config") m.importConfigClientsLocked(pretty, "saved xray config")
@@ -1226,7 +1227,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
return err return err
} }
m.importConfigClientsLocked(pretty, "database xray config") m.importConfigClientsLocked(pretty, "database xray config")
return os.WriteFile(configFile, pretty, 0o600) return writeFileAtomic(configFile, pretty, 0o600)
} }
data, err := os.ReadFile(configFile) data, err := os.ReadFile(configFile)
@@ -1252,7 +1253,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
if err := statsStore.UpsertXrayConfig(ctx, key, pretty); err != nil { if err := statsStore.UpsertXrayConfig(ctx, key, pretty); err != nil {
return err return err
} }
return os.WriteFile(configFile, pretty, 0o600) return writeFileAtomic(configFile, pretty, 0o600)
} }
} }
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -1319,7 +1320,7 @@ func (m *XrayManager) syncConfigFileFromStoreLocked() error {
return err return err
} }
m.importConfigClientsLocked(pretty, "database xray config") m.importConfigClientsLocked(pretty, "database xray config")
return os.WriteFile(configFile, pretty, 0o600) return writeFileAtomic(configFile, pretty, 0o600)
} }
// GetConfig reads the current Xray JSON config. With PostgreSQL enabled, the // GetConfig reads the current Xray JSON config. With PostgreSQL enabled, the
@@ -1341,6 +1342,11 @@ func (m *XrayManager) SetConfig(data []byte) error {
if changed { if changed {
xrayLogf("xray: added/repaired Stats API support and forced debug logs while saving config") xrayLogf("xray: added/repaired Stats API support and forced debug logs while saving config")
} }
if m.cfg != nil && m.cfg.UseNative() {
if err := validateNativeInboundBindings(patched); err != nil {
return err
}
}
return m.writeConfigLocked(patched) return m.writeConfigLocked(patched)
} }
@@ -1878,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)
@@ -1893,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)
@@ -1908,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)
@@ -1933,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")
@@ -1945,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)
@@ -1967,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
@@ -2288,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
} }
@@ -2441,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 {
@@ -2467,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
} }
@@ -2499,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)
} }
@@ -2528,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 {
@@ -2559,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()
@@ -2574,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)
@@ -2612,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,
@@ -2631,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 {
@@ -2640,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 != "" {
@@ -2659,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) {
@@ -2675,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)
@@ -2699,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 {
+125 -14
View File
@@ -110,6 +110,18 @@ type nativeXrayServer struct {
var nativeXray = &nativeXrayServer{} var nativeXray = &nativeXrayServer{}
// nativeXHTTPListener groups all XHTTP inbounds that bind the same address.
// XHTTP is ordinary HTTP at the transport layer, so routing by the configured
// path lets VLESS/VMess and the DragonCore SSH tunnel safely share one TLS port.
// The most specific path wins: for example /ssh/ is checked before /.
type nativeXHTTPListener struct {
addr string
inbounds []*nativeInbound
tlsConfig *tls.Config
security string
headerSize int
}
// nativeRunning reports whether the in-process Xray listeners are up. // nativeRunning reports whether the in-process Xray listeners are up.
func (s *nativeXrayServer) nativeRunning() bool { func (s *nativeXrayServer) nativeRunning() bool {
s.mu.Lock() s.mu.Lock()
@@ -140,7 +152,18 @@ func (s *nativeXrayServer) start(configFile string) error {
var opened []net.Listener var opened []net.Listener
active := make(map[string]*nativeInbound, len(inbounds)) active := make(map[string]*nativeInbound, len(inbounds))
xhttpGroups := make(map[string][]*nativeInbound)
var xhttpOrder []string
for _, ib := range inbounds { for _, ib := range inbounds {
if ib.isXHTTP() {
addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port))
if _, exists := xhttpGroups[addr]; !exists {
xhttpOrder = append(xhttpOrder, addr)
}
xhttpGroups[addr] = append(xhttpGroups[addr], ib)
active[ib.tag] = ib
continue
}
addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port)) addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port))
ln, err := net.Listen("tcp", addr) ln, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
@@ -151,25 +174,40 @@ func (s *nativeXrayServer) start(configFile string) error {
return fmt.Errorf("native xray: listen %s (inbound %q): %w", addr, ib.tag, err) return fmt.Errorf("native xray: listen %s (inbound %q): %w", addr, ib.tag, err)
} }
serveLn := ln opened = append(opened, ln)
if ib.isXHTTP() { xrayGo(fmt.Sprintf("native xray accept loop %s", addr), func() { ib.acceptLoop(ln) })
// HTTP/XHTTP needs a real http.Server because one logical XHTTP
// session can span several HTTP requests/connections. TLS is therefore
// wrapped at listener level instead of inside serve().
if ib.security == "tls" {
serveLn = tls.NewListener(ln, ib.tlsConfig)
}
opened = append(opened, serveLn)
xrayGo(fmt.Sprintf("native xray xhttp listener %s", addr), func() { ib.serveXHTTPListener(serveLn) })
} else {
opened = append(opened, serveLn)
xrayGo(fmt.Sprintf("native xray accept loop %s", addr), func() { ib.acceptLoop(serveLn) })
}
active[ib.tag] = ib active[ib.tag] = ib
xrayLogf("native xray: serving %s/%s on %s (inbound %q, security=%s, %d clients)", xrayLogf("native xray: serving %s/%s on %s (inbound %q, security=%s, %d clients)",
ib.protocol, ib.transport, addr, ib.tag, orNone(ib.security), ib.clientCount()) ib.protocol, ib.transport, addr, ib.tag, orNone(ib.security), ib.clientCount())
} }
for _, addr := range xhttpOrder {
group, err := newNativeXHTTPListener(addr, xhttpGroups[addr])
if err != nil {
for _, l := range opened {
_ = l.Close()
}
return err
}
ln, err := net.Listen("tcp", addr)
if err != nil {
for _, l := range opened {
_ = l.Close()
}
return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err)
}
serveLn := net.Listener(ln)
if group.security == "tls" {
serveLn = tls.NewListener(ln, group.tlsConfig)
}
opened = append(opened, serveLn)
xrayGo(fmt.Sprintf("native xray shared xhttp listener %s", addr), func() { group.serve(serveLn) })
for _, ib := range group.inbounds {
xrayLogf("native xray: serving %s/%s on %s%s (inbound %q, security=%s, %d clients)",
ib.protocol, ib.transport, addr, ib.path, ib.tag, orNone(ib.security), ib.clientCount())
}
}
s.listeners = opened s.listeners = opened
s.inboundsByTag = active s.inboundsByTag = active
s.running = true s.running = true
@@ -941,6 +979,79 @@ type nativeInboundJSON struct {
} `json:"streamSettings"` } `json:"streamSettings"`
} }
// validateNativeInboundBindings checks the listener topology before a visual or
// raw-JSON update replaces the running native config. It intentionally mirrors
// the startup rules so an invalid shared-port edit is rejected before the last
// working file is overwritten.
func validateNativeInboundBindings(data []byte) error {
var cf nativeXrayConfigFile
if err := json.Unmarshal(data, &cf); err != nil {
return fmt.Errorf("native xray: parse config: %w", err)
}
type binding struct {
tag string
xhttp bool
path string
security string
certFile string
keyFile string
}
groups := make(map[string][]binding)
for _, in := range cf.Inbounds {
proto := strings.ToLower(strings.TrimSpace(in.Protocol))
if proto != "ssh" && !xrayClientProtos[proto] {
continue
}
port, ok := parseSinglePort(in.Port)
if !ok {
return fmt.Errorf("native xray: inbound %q has an unsupported port", in.Tag)
}
network := strings.ToLower(firstNonEmpty(in.StreamSettings.Network, "tcp"))
isXHTTP := network == "xhttp" || network == "splithttp"
if proto == "ssh" && !isXHTTP {
return fmt.Errorf("native xray: inbound %q protocol ssh requires XHTTP", in.Tag)
}
item := binding{tag: in.Tag, xhttp: isXHTTP, security: strings.ToLower(strings.TrimSpace(in.StreamSettings.Security))}
if item.security == "none" {
item.security = ""
}
if isXHTTP {
xh := mergeNativeXHTTPSettings(in.StreamSettings.XHTTPSettings, in.StreamSettings.SplitHTTPSettings)
item.path = normalizeXHTTPPath(firstNonEmpty(xh.Path, "/xhttp"))
}
if len(in.StreamSettings.TLSSettings.Certificates) > 0 {
item.certFile = strings.TrimSpace(in.StreamSettings.TLSSettings.Certificates[0].CertificateFile)
item.keyFile = strings.TrimSpace(in.StreamSettings.TLSSettings.Certificates[0].KeyFile)
}
host := normalizeNativeListenHost(firstNonEmpty(in.Listen, "0.0.0.0"))
addr := net.JoinHostPort(host, strconv.Itoa(port))
groups[addr] = append(groups[addr], item)
}
for addr, items := range groups {
if len(items) < 2 {
continue
}
paths := make(map[string]string, len(items))
first := items[0]
for _, item := range items {
if !item.xhttp {
return fmt.Errorf("native xray: multiple inbounds on %s require XHTTP path routing", addr)
}
if previous, exists := paths[item.path]; exists {
return fmt.Errorf("native xray: XHTTP inbounds %q and %q use the same path %s on %s", previous, item.tag, item.path, addr)
}
paths[item.path] = item.tag
if item.security != first.security {
return fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS setting", addr)
}
if item.security == "tls" && (item.certFile != first.certFile || item.keyFile != first.keyFile) {
return fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS certificate", addr)
}
}
}
return nil
}
// parseNativeInbounds reads the Xray config file and returns one nativeInbound // parseNativeInbounds reads the Xray config file and returns one nativeInbound
// per servable client-bearing inbound. Unsupported inbounds (api dokodemo-door, // per servable client-bearing inbound. Unsupported inbounds (api dokodemo-door,
// freedom, etc.) are silently skipped. // freedom, etc.) are silently skipped.
+50
View File
@@ -10,12 +10,62 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
func TestSharedXHTTPListenerRoutesMostSpecificPath(t *testing.T) {
root := &nativeInbound{tag: "shared-proxy-xhttp", protocol: "vless", transport: "xhttp", path: "/", xhttpHost: "proxy.example", xhttpSessions: make(map[string]*nativeXHTTPSession)}
ssh := &nativeInbound{tag: "shared-ssh-xhttp", protocol: "ssh", transport: "xhttp", path: "/ssh", xhttpHost: "ssh.example", xhttpSessions: make(map[string]*nativeXHTTPSession)}
group, err := newNativeXHTTPListener("127.0.0.1:443", []*nativeInbound{root, ssh})
if err != nil {
t.Fatalf("new shared XHTTP listener: %v", err)
}
sshReq := httptest.NewRequest(http.MethodOptions, "http://ssh.example/ssh/session", nil)
sshRec := httptest.NewRecorder()
group.ServeHTTP(sshRec, sshReq)
if sshRec.Code != http.StatusOK {
t.Fatalf("/ssh routed to root instead of SSH inbound: status=%d", sshRec.Code)
}
rootReq := httptest.NewRequest(http.MethodOptions, "http://proxy.example/session", nil)
rootRec := httptest.NewRecorder()
group.ServeHTTP(rootRec, rootReq)
if rootRec.Code != http.StatusOK {
t.Fatalf("/ routed incorrectly: status=%d", rootRec.Code)
}
}
func TestSharedXHTTPListenerRejectsDuplicatePath(t *testing.T) {
a := &nativeInbound{tag: "a", transport: "xhttp", path: "/same"}
b := &nativeInbound{tag: "b", transport: "xhttp", path: "/same/"}
if _, err := newNativeXHTTPListener("127.0.0.1:443", []*nativeInbound{a, b}); err == nil {
t.Fatal("expected duplicate normalized XHTTP path to be rejected")
}
}
func TestValidateNativeSharedXHTTPConfig(t *testing.T) {
valid := []byte(`{"inbounds":[
{"tag":"legacy-proxy","listen":"0.0.0.0","port":443,"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/legacy"}}},
{"tag":"ssh","listen":"0.0.0.0","port":443,"protocol":"ssh","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/ssh"}}}
]}`)
if err := validateNativeInboundBindings(valid); err != nil {
t.Fatalf("valid shared endpoint rejected: %v", err)
}
duplicate := []byte(`{"inbounds":[
{"tag":"a","listen":"0.0.0.0","port":443,"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/same"}}},
{"tag":"b","listen":"0.0.0.0","port":443,"protocol":"ssh","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/same/"}}}
]}`)
if err := validateNativeInboundBindings(duplicate); err == nil {
t.Fatal("duplicate normalized XHTTP path was accepted")
}
}
// startEchoServer starts a TCP server that echoes everything back and returns // startEchoServer starts a TCP server that echoes everything back and returns
// its port and a cleanup func. // its port and a cleanup func.
func startEchoServer(t *testing.T) (int, func()) { func startEchoServer(t *testing.T) (int, func()) {
+92 -13
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"container/heap" "container/heap"
"context" "context"
"crypto/tls"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
@@ -97,34 +98,112 @@ func mergeNativeXHTTPSettings(primary, fallback nativeXHTTPSettingsJSON) nativeX
return out return out
} }
func newNativeXHTTPListener(addr string, inbounds []*nativeInbound) (*nativeXHTTPListener, error) {
if len(inbounds) == 0 {
return nil, fmt.Errorf("native xray: shared XHTTP listener %s has no inbounds", addr)
}
group := &nativeXHTTPListener{addr: addr, inbounds: append([]*nativeInbound(nil), inbounds...)}
paths := make(map[string]string, len(inbounds))
for i, ib := range group.inbounds {
if ib == nil || !ib.isXHTTP() {
return nil, fmt.Errorf("native xray: shared XHTTP listener %s contains a non-XHTTP inbound", addr)
}
ib.path = normalizeXHTTPPath(ib.path)
if previous, exists := paths[ib.path]; exists {
return nil, fmt.Errorf("native xray: XHTTP inbounds %q and %q use the same path %s on %s", previous, ib.tag, ib.path, addr)
}
paths[ib.path] = ib.tag
if size := ib.xhttpServerMaxHeaderBytes(); size > group.headerSize {
group.headerSize = size
}
if i == 0 {
group.security = ib.security
group.tlsConfig = ib.tlsConfig
continue
}
if ib.security != group.security {
return nil, fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS setting", addr)
}
if group.security == "tls" && !sameNativeTLSCertificate(group.tlsConfig, ib.tlsConfig) {
return nil, fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS certificate", addr)
}
}
if group.security == "tls" && group.tlsConfig == nil {
return nil, fmt.Errorf("native xray: shared XHTTP listener %s has no TLS configuration", addr)
}
return group, nil
}
func sameNativeTLSCertificate(a, b *tls.Config) bool {
if a == nil || b == nil || len(a.Certificates) == 0 || len(b.Certificates) == 0 {
return a == b
}
ac := a.Certificates[0].Certificate
bc := b.Certificates[0].Certificate
if len(ac) == 0 || len(bc) == 0 || len(ac[0]) != len(bc[0]) {
return false
}
return string(ac[0]) == string(bc[0])
}
func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) { func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
defer xrayRecover(fmt.Sprintf("native xray XHTTP listener inbound=%q addr=%s", ib.tag, ln.Addr())) group, err := newNativeXHTTPListener(ln.Addr().String(), []*nativeInbound{ib})
if err != nil {
xrayLogf("native xray: XHTTP listener %q rejected: %v", ib.tag, err)
return
}
group.serve(ln)
}
func (g *nativeXHTTPListener) serve(ln net.Listener) {
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
h2s := &http2.Server{} h2s := &http2.Server{}
handler := http.Handler(ib) handler := http.Handler(g)
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP // Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
// listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without // listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without
// h2c, some clients/CDNs can reach the port but the request never reaches the // h2c, some clients/CDNs can reach the port but the request never reaches the
// XHTTP handler, which makes the proxy look dead with no useful target logs. // XHTTP handler, which makes the proxy look dead with no useful target logs.
if ib.security != "tls" { if g.security != "tls" {
handler = h2c.NewHandler(ib, h2s) handler = h2c.NewHandler(g, h2s)
} }
srv := &http.Server{ srv := &http.Server{
Handler: handler, Handler: handler,
ReadHeaderTimeout: 4 * time.Second, ReadHeaderTimeout: 4 * time.Second,
MaxHeaderBytes: ib.xhttpServerMaxHeaderBytes(), MaxHeaderBytes: g.headerSize,
} }
if ib.security == "tls" && ib.tlsConfig != nil { if g.security == "tls" && g.tlsConfig != nil {
srv.TLSConfig = ib.tlsConfig srv.TLSConfig = g.tlsConfig
_ = http2.ConfigureServer(srv, h2s) _ = http2.ConfigureServer(srv, h2s)
} }
// Backstop reaper for connected sessions whose client vanished without the // Each path has its own session namespace and idle sweeper.
// request context ever firing. Lives for the lifetime of this listener.
stopSweep := make(chan struct{}) stopSweep := make(chan struct{})
defer close(stopSweep) defer close(stopSweep)
xrayGo(fmt.Sprintf("native xray XHTTP idle sweeper inbound=%q", ib.tag), func() { ib.sweepXHTTPSessions(stopSweep) }) for _, ib := range g.inbounds {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) && !isListenerClosed(err) { ib := ib
xrayLogf("native xray: XHTTP server for inbound %q stopped: %v", ib.tag, err) xrayGo(fmt.Sprintf("native xray XHTTP idle sweeper inbound=%q", ib.tag), func() { ib.sweepXHTTPSessions(stopSweep) })
} }
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) && !isListenerClosed(err) {
xrayLogf("native xray: shared XHTTP server on %s stopped: %v", g.addr, err)
}
}
// ServeHTTP picks the longest configured path. This makes a root XHTTP inbound
// coexist with more specific services such as /ssh without allowing the root
// handler to steal the SSH session path.
func (g *nativeXHTTPListener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var selected *nativeInbound
selectedLen := -1
for _, ib := range g.inbounds {
if _, ok := ib.matchXHTTPPath(r.URL.Path); ok && len(ib.path) > selectedLen {
selected = ib
selectedLen = len(ib.path)
}
}
if selected == nil {
w.WriteHeader(http.StatusNotFound)
return
}
selected.ServeHTTP(w, r)
} }
// sweepXHTTPSessions periodically evicts connected XHTTP sessions that have seen // sweepXHTTPSessions periodically evicts connected XHTTP sessions that have seen
@@ -537,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 {