Compare commits
8
Commits
ba5b581aaf
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a345e70e5a | ||
|
|
dab8b09f0c | ||
|
|
635f190630 | ||
|
|
64b1fc5cb3 | ||
|
|
047e4be207 | ||
|
|
7d90568869 | ||
|
|
92c5c2ace6 | ||
|
|
9001b47204 |
@@ -1,2 +1,3 @@
|
||||
/shell2.exe
|
||||
/BOT_PLAN.md
|
||||
/SECURITY_REVIEW.md
|
||||
|
||||
@@ -11,7 +11,12 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
|
||||
- Banco de dados PostgreSQL
|
||||
- Integração com Xray-core/V2Ray
|
||||
- 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
|
||||
- API HTTP completa para bots/automações (ver **HTTP API Reference**)
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
- 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`
|
||||
- Portas liberadas no firewall/security group conforme a configuração usada
|
||||
|
||||
O instalador usa Go 1.25.12 e baixa as dependências fixadas no `go.mod`. As bibliotecas oficiais `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text` e `x/time` estão fixadas nas versões de segurança revisadas em 13/07/2026.
|
||||
|
||||
Distribuições alvo:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -74,6 +111,7 @@ sudo bash install.sh
|
||||
Durante a instalação, o script instala/configura:
|
||||
|
||||
- Go
|
||||
- verificação SHA-256 dos arquivos oficiais de Go e Xray antes da extração
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- Binário do DragonCoreSSH V40
|
||||
@@ -268,7 +306,7 @@ Ou gerar uma senha nova automaticamente:
|
||||
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
|
||||
```
|
||||
|
||||
O script atualiza o usuário `admin` no PostgreSQL, ativa ele como `superadmin`, salva `ADMIN_PASSWORD` em `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
|
||||
O script atualiza o usuário `admin` no PostgreSQL com hash bcrypt, ativa ele como `superadmin`, remove qualquer `ADMIN_PASSWORD` legado em texto puro de `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
|
||||
|
||||
### Atualização automática pelo Git
|
||||
|
||||
@@ -522,7 +560,13 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
|
||||
- PostgreSQL database
|
||||
- Xray-core/V2Ray integration
|
||||
- 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
|
||||
- Full HTTP API for bots/automations (see **HTTP API Reference**)
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
- Required ports opened in the firewall/security group
|
||||
|
||||
The installer uses Go 1.25.12 and downloads the versions pinned in `go.mod`. The official `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text`, and `x/time` modules are pinned to the security-reviewed versions current on 2026-07-13.
|
||||
|
||||
Target distributions:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -585,6 +644,7 @@ sudo bash install.sh
|
||||
During installation, the script installs/configures:
|
||||
|
||||
- Go
|
||||
- SHA-256 verification of the official Go and Xray archives before extraction
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- DragonCoreSSH V40 binary
|
||||
@@ -779,7 +839,7 @@ Or generate a new password automatically:
|
||||
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
|
||||
```
|
||||
|
||||
The script updates the `admin` user in PostgreSQL, enables it as `superadmin`, saves `ADMIN_PASSWORD` in `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
|
||||
The script updates the `admin` user in PostgreSQL with a bcrypt hash, enables it as `superadmin`, removes any legacy plaintext `ADMIN_PASSWORD` from `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
|
||||
|
||||
### Automatic Git update
|
||||
|
||||
@@ -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).
|
||||
|
||||
#### `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
|
||||
- `200`: array of `{ "id": int, "username": string, "role": string, "max_users": int, "used_users": int, "used_ssh_users": int, "used_xray_users": int, "expires_at": string/null, "is_active": bool, "created_at": string }`.
|
||||
Superadmins manage every reseller. A reseller sees and manages only its direct children; it cannot skip a hierarchy level. Child plans inherit the parent's `slots` or `credits` mode.
|
||||
|
||||
#### `POST /api/resellers/create` — superadmin
|
||||
Creates or updates a reseller (upsert by username).
|
||||
- Body: `username` (string, required); `password` (string, optional — required only when creating; if given on an existing account it is changed); `max_users` (int); `expires_at` (string, optional RFC3339; empty clears expiry); `is_active` (bool).
|
||||
- `201 Created` (empty). Errors: `400 username required`, `400 password required for new account`, `400 invalid expires_at (RFC3339 required)`; `500 db error`.
|
||||
#### `GET /api/resellers`
|
||||
- `200`: direct-child array with hierarchy, plan, weighted quota, account-count breakdown, contact, price, expiry, and effective status fields: `{ "id", "username", "parent_username", "quota_mode", "max_users", "credit_balance", "used_users", "used_ssh_users", "used_xray_users", "child_allocation", "child_count", "available", "usage_incomplete", "whatsapp", "monthly_price_cents", "expires_at", "is_active", "effective_active", "created_at" }`.
|
||||
- `used_users` is weighted by each SSH/Xray account's connection limit and includes every configured managed node. If a node cannot be verified, `usage_incomplete` is true and provisioning remains fail-closed.
|
||||
|
||||
#### `DELETE /api/resellers/delete` — superadmin
|
||||
- Query: `username` (string, required). Also disconnects/removes the reseller's owned SSH users and Xray clients.
|
||||
- `204 No Content`. Errors: `400 username required`; `500 db error`.
|
||||
#### `POST /api/resellers/create`
|
||||
Creates or edits a reseller.
|
||||
- Body: `username` (required); `password` (required only on create); `parent_username`; `quota_mode` (`slots` or `credits`); `max_users`; `credits`; `expires_at` (RFC3339 for slot plans); `is_active`; `whatsapp`; `monthly_price_cents`.
|
||||
- For reseller callers, `parent_username` and `quota_mode` are forced to the signed-in parent. Parent and plan mode are immutable after creation.
|
||||
- Credit transfers are atomic and audited. Slot limits cannot be reduced below direct account use plus reserved child allocation.
|
||||
- `201`: `{ "username": string, "created": bool }`.
|
||||
|
||||
#### `POST /api/resellers/action`
|
||||
- Body: `username`, `action` (`renew`, `suspend`, `reactivate`, or superadmin-only `pull`), and optional `days`.
|
||||
- `renew` extends a validity reseller from the later of now/current expiry. `suspend` and `reactivate` apply to the full descendant tree and owned SSH/Xray access on managed nodes. `pull` safely attaches a nested reseller directly to the main panel without duplicating transferred credits.
|
||||
- `200`: `{ "ok": true, "runtime_warning": string }`.
|
||||
|
||||
#### `DELETE /api/resellers/delete`
|
||||
- Query: `username` (required). Suspends the subtree first, then removes all descendant reseller records and their owned SSH/Xray accounts locally and from every configured managed node. Unused descendant credit balances are returned once to the direct credit parent.
|
||||
- `204 No Content`.
|
||||
|
||||
#### `GET /api/resellers/audit`
|
||||
- Returns the latest 200 lifecycle/credit events. Resellers receive only their own and direct-child activity.
|
||||
|
||||
#### `POST /api/resellers/runtime` — superadmin/internal node synchronization
|
||||
- Password-free master-to-node hierarchy/status synchronization used for managed-server suspension, expiry, reactivation, and cleanup. Login passwords and password hashes are never replicated.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type accountRenewPayload struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Days int `json:"days,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
func renewalDays(owner string, requested int) int {
|
||||
if u, ok := adminUsers.get(owner); ok && normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
||||
return 31
|
||||
}
|
||||
if requested == 0 {
|
||||
return 30
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func renewalExpiry(existing *time.Time, days int) time.Time {
|
||||
base := time.Now()
|
||||
if existing != nil && existing.After(base) {
|
||||
base = *existing
|
||||
}
|
||||
return base.AddDate(0, 0, days)
|
||||
}
|
||||
|
||||
func jsonInt(value interface{}) int {
|
||||
switch value := value.(type) {
|
||||
case int:
|
||||
return value
|
||||
case int64:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
case json.Number:
|
||||
result, _ := strconv.Atoi(value.String())
|
||||
return result
|
||||
default:
|
||||
result, _ := strconv.ParseFloat(fmt.Sprint(value), 64)
|
||||
return int(result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewSSHUser(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_connections"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew SSH account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := userMgr.Get(p.Username)
|
||||
if !ok {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
cfg := state.Cfg
|
||||
existingExpiry := state.ExpiresAt
|
||||
state.mu.Unlock()
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if cfg.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(cfg.MaxConnections)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-ssh:"+p.Username)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(existingExpiry, p.Days)
|
||||
cfg.ExpiresAt = next.UTC().Format(time.RFC3339)
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, cfg.OwnerUsername, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
userMgr.DisconnectUser(p.Username)
|
||||
reloadUsersFromDB(ctx, store)
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, cfg.OwnerUsername, "renewed SSH account",
|
||||
fmt.Sprintf("account=%s days=%d", p.Username, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewXrayClient(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.UUID = strings.TrimSpace(p.UUID)
|
||||
if _, err := parseUUID(p.UUID); err != nil {
|
||||
http.Error(w, "invalid UUID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteXrayClientInfo(ctx, ms, p.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_conns"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew Xray account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := store.GetXrayClientMeta(ctx, p.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if meta.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(meta.MaxConns)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-xray:"+p.UUID)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(meta.ExpiresAt, p.Days)
|
||||
meta.ExpiresAt = &next
|
||||
if err := store.UpsertXrayClientMeta(ctx, *meta); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, meta.OwnerUsername, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runtimeWarning := ""
|
||||
if meta.OwnerUsername != "" {
|
||||
if runtimeErr := restoreOwnerXrayClients(ctx, store, meta.OwnerUsername); runtimeErr != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, runtimeErr)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
} else if err := ensureXrayClientPresent(*meta); err != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, err)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, meta.OwnerUsername, "renewed Xray account",
|
||||
fmt.Sprintf("uuid=%s days=%d", p.UUID, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next, "runtime_warning": runtimeWarning})
|
||||
}
|
||||
}
|
||||
|
||||
func ensureXrayClientPresent(meta XrayClientMeta) error {
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Tag != meta.InboundTag {
|
||||
continue
|
||||
}
|
||||
for _, client := range inbound.Clients {
|
||||
if client.UUID == meta.UUID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
email := strings.TrimSpace(meta.Email)
|
||||
if email == "" {
|
||||
email = meta.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(meta.InboundTag, meta.UUID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inbound %s no longer exists", meta.InboundTag)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var accountUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$`)
|
||||
|
||||
func validateAccountUsername(username string) error {
|
||||
if !accountUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, @, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAccountControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0
|
||||
}
|
||||
|
||||
func validateOptionalAccountExpiry(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, raw); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid expiration date")
|
||||
}
|
||||
|
||||
func validateSSHUserPayload(p *UserPayload) error {
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
p.OwnerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
p.TOTPSecret = normalizeBase32Secret(p.TOTPSecret)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Password != nil && len(*p.Password) > 4096 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
if p.MaxConnections < 0 || p.MaxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
if p.LimitUpMbps < 0 || p.LimitUpMbps > 100000 || p.LimitDownMbps < 0 || p.LimitDownMbps > 100000 {
|
||||
return fmt.Errorf("speed limits must be between 0 and 100000 Mbps")
|
||||
}
|
||||
if err := validateOptionalAccountExpiry(p.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(p.OwnerUsername); err != nil {
|
||||
return fmt.Errorf("invalid owner username")
|
||||
}
|
||||
}
|
||||
if len(p.ServerID) > 32 || hasAccountControlCharacters(p.ServerID) {
|
||||
return fmt.Errorf("invalid server id")
|
||||
}
|
||||
if p.TOTPSecret != "" {
|
||||
if len(p.TOTPSecret) > 256 {
|
||||
return fmt.Errorf("TOTP secret is too long")
|
||||
}
|
||||
if _, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(p.TOTPSecret); err != nil {
|
||||
return fmt.Errorf("invalid TOTP secret")
|
||||
}
|
||||
}
|
||||
if p.TOTPPeriod != 0 && (p.TOTPPeriod < 15 || p.TOTPPeriod > 300) {
|
||||
return fmt.Errorf("TOTP period must be between 15 and 300 seconds")
|
||||
}
|
||||
if p.TOTPWindow < 0 || p.TOTPWindow > 10 {
|
||||
return fmt.Errorf("TOTP window must be between 0 and 10")
|
||||
}
|
||||
if p.TOTPDigits != 0 && (p.TOTPDigits < 6 || p.TOTPDigits > 8) {
|
||||
return fmt.Errorf("TOTP digits must be between 6 and 8")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateXrayClientFields(uuid, inboundTag, email, name, expiresAt string, maxConnections int, requireInbound bool) error {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
if _, err := parseUUID(uuid); err != nil {
|
||||
return fmt.Errorf("invalid UUID")
|
||||
}
|
||||
if requireInbound && strings.TrimSpace(inboundTag) == "" {
|
||||
return fmt.Errorf("inbound_tag required")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"inbound_tag": inboundTag,
|
||||
"email": email,
|
||||
"name": name,
|
||||
} {
|
||||
limit := 256
|
||||
if field == "inbound_tag" {
|
||||
limit = 128
|
||||
}
|
||||
if len(value) > limit || hasAccountControlCharacters(value) {
|
||||
return fmt.Errorf("invalid %s", field)
|
||||
}
|
||||
}
|
||||
if maxConnections < 0 || maxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
return validateOptionalAccountExpiry(expiresAt)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateSSHUserPayloadBounds(t *testing.T) {
|
||||
valid := &UserPayload{
|
||||
Username: "client-01",
|
||||
MaxConnections: 2,
|
||||
TOTPPeriod: 60,
|
||||
TOTPWindow: 1,
|
||||
TOTPDigits: 6,
|
||||
}
|
||||
if err := validateSSHUserPayload(valid); err != nil {
|
||||
t.Fatalf("valid SSH payload rejected: %v", err)
|
||||
}
|
||||
|
||||
invalid := *valid
|
||||
invalid.MaxConnections = -1
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("negative max_connections was accepted")
|
||||
}
|
||||
|
||||
invalid = *valid
|
||||
invalid.TOTPSecret = "not base32!"
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("invalid TOTP secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateXrayClientFields(t *testing.T) {
|
||||
const id = "d9428888-122b-11e1-b85c-61cd3cbb3210"
|
||||
if err := validateXrayClientFields(id, "vless-in", "client@example.test", "Client", "", 2, true); err != nil {
|
||||
t.Fatalf("valid Xray client rejected: %v", err)
|
||||
}
|
||||
if err := validateXrayClientFields("not-a-uuid", "vless-in", "", "", "", 1, true); err == nil {
|
||||
t.Fatal("invalid Xray UUID was accepted")
|
||||
}
|
||||
if err := validateXrayClientFields(id, "vless-in", "", "", "", 1001, true); err == nil {
|
||||
t.Fatal("excessive Xray connection limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditAccountConnectionLimitIsImmutable(t *testing.T) {
|
||||
username := "credit-limit-test"
|
||||
adminUsers.set(&AdminUser{Username: username, Role: RoleReseller, QuotaMode: QuotaModeCredit, IsActive: true})
|
||||
defer adminUsers.delete(username)
|
||||
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 3); err != errCreditLimitImmutable {
|
||||
t.Fatalf("credit limit change error = %v, want %v", err, errCreditLimitImmutable)
|
||||
}
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 2); err != nil {
|
||||
t.Fatalf("unchanged credit limit rejected: %v", err)
|
||||
}
|
||||
if _, _, err := authorizeResellerProvision(nil, nil, username, "ssh:test", 0); err != errResellerConnLimit {
|
||||
t.Fatalf("zero-connection credit account error = %v, want %v", err, errResellerConnLimit)
|
||||
}
|
||||
}
|
||||
@@ -649,3 +649,105 @@ select:disabled {
|
||||
.update-check-time{font-size:.9rem!important;letter-spacing:0!important;}
|
||||
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.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 --- */
|
||||
#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);}
|
||||
.bot-hero{
|
||||
position:relative;overflow:hidden;margin-bottom:18px;padding:26px;border:1px solid rgba(139,92,246,.22);border-radius:28px;
|
||||
background:
|
||||
radial-gradient(circle at 86% 4%,rgba(124,92,255,.30),transparent 34%),
|
||||
radial-gradient(circle at 12% 100%,rgba(34,211,238,.12),transparent 38%),
|
||||
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.04);
|
||||
}
|
||||
.bot-hero::after{content:"";position:absolute;right:-70px;top:-90px;width:260px;height:260px;border:1px solid rgba(255,255,255,.06);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.018),0 0 0 68px rgba(255,255,255,.012);pointer-events:none;}
|
||||
.bot-hero-copy,.bot-hero-actions,.bot-overview-grid{position:relative;z-index:1;}
|
||||
.bot-hero-copy{max-width:620px;}
|
||||
.bot-eyebrow,.bot-section-heading>div>span{display:block;color:#a997ff;font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase;}
|
||||
.bot-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em;}
|
||||
.bot-hero p,.bot-section-heading p,.bot-card-heading p{color:var(--muted);font-size:.79rem;line-height:1.5;}
|
||||
.bot-hero-copy p{margin-top:8px;}
|
||||
.bot-hero-actions{position:absolute;right:26px;top:26px;display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:48%;}
|
||||
.bot-live-status{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:7px 11px;border:1px solid var(--bot-line);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.72rem;font-weight:850;}
|
||||
.bot-live-status::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;}
|
||||
.bot-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08);}
|
||||
.bot-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08);}
|
||||
.bot-overview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-top:24px;}
|
||||
.bot-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid var(--bot-line);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px);}
|
||||
.bot-overview-card>div{display:flex;flex-direction:column;gap:4px;min-width:0;}.bot-overview-card small{color:var(--muted);font-size:.66rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;}.bot-overview-card strong{font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.bot-overview-icon{width:34px;height:34px;display:grid;place-items:center;flex:0 0 auto;border-radius:12px;color:#4de0ef;background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.17);font-size:.78rem;font-weight:950;}.bot-overview-icon.bot-purple{color:#b19cff;background:rgba(139,92,246,.13);border-color:rgba(139,92,246,.2);}.bot-overview-icon.bot-green{color:#72e6a4;background:rgba(49,214,123,.11);border-color:rgba(49,214,123,.18);}.bot-overview-icon.bot-amber{color:#ffd36d;background:rgba(255,200,87,.11);border-color:rgba(255,200,87,.18);}
|
||||
|
||||
.bot-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:22px;padding:6px;border:1px solid var(--bot-line);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px);}
|
||||
.bot-section-nav{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:5px;}
|
||||
.bot-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;}.bot-section-nav button span{margin-right:5px;color:#9b88ff;}.bot-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04);}.bot-section-nav button.active{color:#fff;border-color:rgba(139,92,246,.28);background:linear-gradient(135deg,rgba(139,92,246,.20),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04);}
|
||||
.bot-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(139,92,246,.28);border-radius:13px;background:#090d15;color:var(--text);font-weight:850;}
|
||||
.bot-section{display:none;animation:fadeIn .18s ease both;}.bot-section.active{display:block;}
|
||||
.bot-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px;}.bot-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em;}.bot-section-heading>.card-actions{justify-content:flex-end;}
|
||||
|
||||
.bot-config-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-config-grid>.card,.bot-master-detail>.card{margin-top:0!important;}
|
||||
.bot-integration-card{min-height:255px;padding:20px;}.bot-card-heading{display:flex;align-items:center;gap:12px;margin-bottom:18px;}.bot-card-heading>div:nth-child(2){min-width:0;flex:1;}.bot-card-heading h4{font-size:1rem;margin-bottom:3px;}.bot-service-icon{width:44px;height:44px;display:grid;place-items:center;flex:0 0 auto;border-radius:15px;font-weight:950;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.05);}.bot-service-icon.telegram{color:#5ed8ff;background:rgba(41,182,246,.11);}.bot-service-icon.mercado{color:#77b8ff;background:rgba(52,131,250,.11);font-size:.72rem;}.bot-service-icon.trial{color:#ffd36d;background:rgba(255,200,87,.1);}.bot-service-icon.host{color:#9ff4bf;background:rgba(49,214,123,.1);}
|
||||
.bot-switch{position:relative;display:inline-flex;cursor:pointer;}.bot-switch input{position:absolute;opacity:0;pointer-events:none;}.bot-switch span{width:44px;height:24px;border-radius:999px;background:#222b38;border:1px solid rgba(148,163,184,.18);transition:.16s ease;}.bot-switch span::after{content:"";display:block;width:18px;height:18px;margin:2px;border-radius:50%;background:#8793a4;transition:.16s ease;}.bot-switch input:checked+span{background:rgba(49,214,123,.19);border-color:rgba(49,214,123,.38);}.bot-switch input:checked+span::after{transform:translateX(20px);background:#70e7a3;box-shadow:0 0 14px rgba(49,214,123,.45);}
|
||||
.bot-secret-state{display:inline-flex;margin-left:5px;color:var(--muted);font-size:.67rem;font-weight:750;}.bot-secret-state.is-set{color:#72e6a4;}.bot-secret-state.is-missing{color:#ffb3ba;}
|
||||
.bot-webhook-box{margin-top:13px;padding:13px;border:1px solid rgba(139,92,246,.18);border-radius:16px;background:rgba(139,92,246,.055);}.bot-webhook-box.hidden{display:none!important;}.bot-copy-row{display:flex;align-items:center;gap:8px;margin-top:10px;}.bot-copy-row code{min-width:0;flex:1;padding:9px 10px;overflow:hidden;text-overflow:ellipsis;border:1px solid var(--bot-line);border-radius:11px;background:#06090f;color:#b8c4d4;font-size:.7rem;white-space:nowrap;}
|
||||
.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;}
|
||||
|
||||
.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);}
|
||||
.reseller-row-actions{min-width:265px;flex-wrap:wrap}.reseller-audit-table{min-width:820px}.reseller-audit-table td:nth-child(1){white-space:nowrap}.reseller-audit-table td:nth-child(4){color:#ffd36d;font-weight:800}.reseller-audit-table td:last-child{max-width:360px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.68rem;white-space:normal}
|
||||
.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);}
|
||||
|
||||
.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;}
|
||||
|
||||
@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}}
|
||||
@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}}
|
||||
@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}}
|
||||
|
||||
+177
-7
@@ -1,10 +1,13 @@
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
|
||||
let sessionToken = sessionStorage.getItem("SESSION_TOKEN") || localStorage.getItem("SESSION_TOKEN") || "";
|
||||
if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
let currentRole = "";
|
||||
let currentUser = "";
|
||||
let currentQuotaMode = "slots";
|
||||
let currentCreditBalance = 0;
|
||||
let statsTimer = null, usersTimer = null, xrayTimer = null;
|
||||
let formCollapsed = true;
|
||||
let tlsForwardersState = [];
|
||||
let managedTlsForwardersState = [];
|
||||
let editingXrayClientId = null;
|
||||
@@ -13,6 +16,7 @@ let wzLoadedFullConfig = null;
|
||||
let wzLoadedConfigText = "";
|
||||
let wzLoadedServerID = null;
|
||||
let wzDirty = false;
|
||||
let wzEditingIndex = -1;
|
||||
let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
|
||||
let currentTab = "dashboard";
|
||||
let inboundsRefreshInFlight = false;
|
||||
@@ -112,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",
|
||||
"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]))]));
|
||||
let currentLang = detectInitialLanguage();
|
||||
let i18nTranslating = false;
|
||||
@@ -261,8 +323,6 @@ const rStatus = document.getElementById("rStatus");
|
||||
|
||||
// User form
|
||||
const userForm = document.getElementById("userForm");
|
||||
const userFormWrap = document.getElementById("userFormWrap");
|
||||
const toggleFormBtn = document.getElementById("toggleFormBtn");
|
||||
const cancelUserBtn = document.getElementById("cancelUserBtn");
|
||||
const newUserBtn = document.getElementById("newUserBtn");
|
||||
const saveUserBtn = document.getElementById("saveUserBtn");
|
||||
@@ -277,6 +337,11 @@ const fMaxConn = document.getElementById("fMaxConn");
|
||||
const fExpires = document.getElementById("fExpires");
|
||||
const fUp = document.getElementById("fUp");
|
||||
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
|
||||
const xrayChip = document.getElementById("xrayChip");
|
||||
@@ -306,8 +371,13 @@ const resellerFormTitle = document.getElementById("resellerFormTitle");
|
||||
const resellerForm = document.getElementById("resellerForm");
|
||||
const rUsername = document.getElementById("rUsername");
|
||||
const rPassword = document.getElementById("rPassword");
|
||||
const rParent = document.getElementById("rParent");
|
||||
const rQuotaMode = document.getElementById("rQuotaMode");
|
||||
const rMaxUsers = document.getElementById("rMaxUsers");
|
||||
const rCredits = document.getElementById("rCredits");
|
||||
const rExpires = document.getElementById("rExpires");
|
||||
const rWhatsApp = document.getElementById("rWhatsApp");
|
||||
const rMonthlyPrice = document.getElementById("rMonthlyPrice");
|
||||
const rActive = document.getElementById("rActive");
|
||||
|
||||
// Managed servers
|
||||
@@ -328,11 +398,18 @@ const serverFormStatus = document.getElementById("serverFormStatus");
|
||||
const serversListView = document.getElementById("serversListView");
|
||||
const serverConfigSubpage = document.getElementById("serverConfigSubpage");
|
||||
const cfgServerName = document.getElementById("cfgServerName");
|
||||
const managedConfigEditor = document.getElementById("managedConfigEditor");
|
||||
const managedConfigStatus = document.getElementById("managedConfigStatus");
|
||||
const serversStatusGrid = document.getElementById("serversStatusGrid");
|
||||
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
|
||||
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
|
||||
const cpuVal = document.getElementById("cpuVal");
|
||||
@@ -343,6 +420,8 @@ const memDetail = document.getElementById("memDetail");
|
||||
const ifaceBody = document.getElementById("ifaceBody");
|
||||
const ifaceSummary = document.getElementById("ifaceSummary");
|
||||
const statsUpdated = document.getElementById("statsUpdated");
|
||||
const statsNetVal = document.getElementById("statsNetVal");
|
||||
const statsIfaceVal = document.getElementById("statsIfaceVal");
|
||||
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
|
||||
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
|
||||
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
|
||||
@@ -360,9 +439,99 @@ const vnstatStatus = document.getElementById("vnstatStatus");
|
||||
const vnTodayTotal = document.getElementById("vnTodayTotal");
|
||||
const vnMonthTotal = document.getElementById("vnMonthTotal");
|
||||
const vnIfaceCount = document.getElementById("vnIfaceCount");
|
||||
const vnLatestPeriod = document.getElementById("vnLatestPeriod");
|
||||
const reloadVnstatBtn = document.getElementById("reloadVnstatBtn");
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
async function api(path, opts = {}) {
|
||||
const o = Object.assign({ headers: {} }, opts);
|
||||
@@ -371,7 +540,9 @@ async function api(path, opts = {}) {
|
||||
"X-Session-Token": sessionToken,
|
||||
});
|
||||
const res = await fetch(path, o);
|
||||
if (res.status === 401 || res.status === 403) throw new Error("auth");
|
||||
// A 403 is an in-session permission or quota error; only 401 means the
|
||||
// session is no longer valid and should return to the login screen.
|
||||
if (res.status === 401) throw new Error("auth");
|
||||
return res;
|
||||
}
|
||||
function withServerParam(path, serverID) {
|
||||
@@ -564,4 +735,3 @@ function patchRenderedInbounds(inbounds) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+184
-17
@@ -4,14 +4,126 @@ const tabTitles = {
|
||||
ssh: ["Accounts", "SSH / SlowDNS"],
|
||||
xray: ["Accounts", "Xray Users"],
|
||||
resellers: ["Administration", "Resellers"],
|
||||
servers: ["Administration", "Servers"],
|
||||
"servers-status": ["Administration", "Servers Status"],
|
||||
stats: ["Server", "Monitoring"],
|
||||
vnstat: ["Traffic", "VnStat"],
|
||||
servers: ["Infrastructure", "Servers"],
|
||||
"servers-status": ["Infrastructure", "Servers Status"],
|
||||
stats: ["Infrastructure", "Monitoring"],
|
||||
vnstat: ["Infrastructure", "Traffic"],
|
||||
logs: ["System", "Logs"],
|
||||
bot: ["Vendas", "Bot / Telegram"],
|
||||
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() {
|
||||
const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab];
|
||||
if (pageEyebrow) pageEyebrow.textContent = t(eyebrow);
|
||||
@@ -21,12 +133,14 @@ function updatePageHeading() {
|
||||
function selectTab(tab) {
|
||||
currentTab = tab;
|
||||
const pane = document.getElementById("tab-" + tab);
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`);
|
||||
if (!pane || !btn) return;
|
||||
const navTab = infrastructureTabs.includes(tab) ? "servers" : tab;
|
||||
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-pane").forEach(p => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
btn?.classList.add("active");
|
||||
pane.classList.add("active");
|
||||
syncInfrastructureNavigation(tab);
|
||||
updatePageHeading();
|
||||
document.body.classList.remove("sidebar-open");
|
||||
|
||||
@@ -34,15 +148,18 @@ function selectTab(tab) {
|
||||
if (tab === "xray") {
|
||||
loadXrayStatus();
|
||||
loadInbounds({ silent: true });
|
||||
if (currentRole === "superadmin") loadWizardFromConfig();
|
||||
if (currentRole === "superadmin" && activeWorkspaceSection("xray") === "config") loadWizardFromConfig();
|
||||
}
|
||||
if (tab === "stats" && currentRole === "superadmin") loadStats();
|
||||
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
|
||||
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
|
||||
if (tab === "resellers" && currentRole === "superadmin") loadResellers();
|
||||
if (tab === "resellers") loadResellers();
|
||||
if (tab === "servers" && currentRole === "superadmin") loadServers();
|
||||
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
|
||||
}
|
||||
|
||||
mountInfrastructureNavigation();
|
||||
mountWorkspaceSectionNavigation();
|
||||
document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
|
||||
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
|
||||
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
|
||||
@@ -56,7 +173,7 @@ loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try { await api("/api/auth/logout", { method: "POST" }); } catch {}
|
||||
sessionToken = "";
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
@@ -83,7 +200,7 @@ async function doLogin() {
|
||||
sessionToken = data.token;
|
||||
currentRole = data.role;
|
||||
currentUser = data.username;
|
||||
localStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
loginOverlay.classList.add("hidden");
|
||||
mainApp.classList.remove("hidden");
|
||||
initAfterLogin();
|
||||
@@ -101,6 +218,13 @@ function clearTimers() {
|
||||
}
|
||||
|
||||
function initAfterLogin() {
|
||||
if (currentRole === "superadmin") {
|
||||
currentQuotaMode = "slots";
|
||||
currentCreditBalance = 0;
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (input) { input.disabled = false; input.title = ""; }
|
||||
});
|
||||
}
|
||||
meUsername.textContent = currentUser;
|
||||
mainApp.classList.remove("role-superadmin", "role-reseller");
|
||||
mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller");
|
||||
@@ -117,6 +241,13 @@ function initAfterLogin() {
|
||||
document.querySelectorAll(".xray-admin-only").forEach(el => {
|
||||
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");
|
||||
dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
|
||||
@@ -152,13 +283,28 @@ async function loadMe() {
|
||||
const res = await api("/api/auth/me");
|
||||
const d = await res.json();
|
||||
dashboardCache.me = d;
|
||||
currentQuotaMode = d.quota_mode || "slots";
|
||||
currentCreditBalance = d.credit_balance || 0;
|
||||
const creditPlan = currentQuotaMode === "credits";
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (!input) return;
|
||||
input.disabled = creditPlan;
|
||||
input.title = creditPlan ? "Planos por crédito usam 31 dias e são renovados pelo botão +30d." : "";
|
||||
});
|
||||
const used = d.used_users ?? 0;
|
||||
const max = d.max_users || 0;
|
||||
rUsedMax.textContent = used + " / " + (max || "∞");
|
||||
rUsedMax.textContent = currentQuotaMode === "credits"
|
||||
? `${currentCreditBalance} créditos`
|
||||
: `${used + (d.child_allocation || 0)} / ${max || "∞"}`;
|
||||
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration");
|
||||
rStatus.textContent = d.is_active ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
|
||||
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
const effectiveActive = d.effective_active ?? d.is_active;
|
||||
rStatus.textContent = effectiveActive ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = effectiveActive ? "var(--success)" : "var(--danger)";
|
||||
if (currentQuotaMode === "credits") {
|
||||
updateCreditQuotaCard(currentCreditBalance, d.used_ssh_users || 0, d.used_xray_users || 0, d.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard(used + (d.child_allocation || 0), max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
}
|
||||
renderDashboardCounters();
|
||||
} catch {}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
function updateCreditQuotaCard(balance, sshUsed = 0, xrayUsed = 0, childCount = 0) {
|
||||
if (!dashQuotaText) return;
|
||||
dashQuotaChip.textContent = `${balance} Cr`;
|
||||
dashQuotaChip.className = `chip ${balance <= 0 ? "red" : balance <= 5 ? "warn" : "green"}`;
|
||||
dashQuotaText.textContent = `${balance} créditos disponíveis`;
|
||||
dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed} · ${childCount} sub-revendas`;
|
||||
dashQuotaBar.style.width = "100%";
|
||||
if (dashQuotaRemaining) {
|
||||
dashQuotaRemaining.textContent = String(balance);
|
||||
setQuotaTone(dashQuotaRemaining, balance <= 0 ? "quota-danger" : balance <= 5 ? "quota-warn" : "quota-good");
|
||||
}
|
||||
if (dashQuotaSummaryText) dashQuotaSummaryText.textContent = `${balance} créditos no saldo`;
|
||||
if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = balance > 0 ? "100%" : "0%";
|
||||
if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${balance} Cr`;
|
||||
if (xrayResellerQuotaRemaining) xrayResellerQuotaRemaining.textContent = String(balance);
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
|
||||
}
|
||||
|
||||
function flattenXrayClients(inbounds = []) {
|
||||
return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c)));
|
||||
}
|
||||
@@ -267,7 +431,11 @@ function renderDashboardCounters() {
|
||||
|
||||
const me = dashboardCache.me;
|
||||
if (currentRole === "reseller" && me) {
|
||||
updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
if ((me.quota_mode || "slots") === "credits") {
|
||||
updateCreditQuotaCard(me.credit_balance || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length, me.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard((me.used_users ?? total) + (me.child_allocation || 0), me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,4 +456,3 @@ function refreshDashboard() {
|
||||
if (currentRole === "superadmin") loadStats();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
// ─── SSH Users ────────────────────────────────────────────────────────────────
|
||||
let editingSSHUser = "";
|
||||
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
|
||||
newUserBtn.addEventListener("click", () => {
|
||||
setFormCollapsed(false);
|
||||
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
|
||||
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
|
||||
cancelUserBtn.addEventListener("click", () => {
|
||||
prepareNewSSHUser();
|
||||
setWorkspaceSection("ssh", "users");
|
||||
});
|
||||
function prepareNewSSHUser() {
|
||||
editingSSHUser = "";
|
||||
userForm.reset();
|
||||
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
|
||||
fMaxConn.disabled = false;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.value = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Create user");
|
||||
if (title) title.textContent = t("Create SSH user");
|
||||
userStatus.textContent = t("New user.");
|
||||
fUsername.focus();
|
||||
});
|
||||
cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
|
||||
toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
|
||||
requestAnimationFrame(() => fUsername.focus());
|
||||
}
|
||||
document.getElementById("genTotpBtn").addEventListener("click", () => {
|
||||
fTotpSecret.value = genBase32();
|
||||
if (!fTotpPeriod.value) fTotpPeriod.value = 60;
|
||||
@@ -18,14 +31,13 @@ document.getElementById("genTotpBtn").addEventListener("click", () => {
|
||||
});
|
||||
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() {
|
||||
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 {
|
||||
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
||||
const data = await res.json();
|
||||
@@ -33,6 +45,11 @@ async function loadUsers() {
|
||||
userStatus.textContent = t("Loaded.");
|
||||
lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()});
|
||||
} 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."); }
|
||||
}
|
||||
}
|
||||
@@ -76,6 +93,10 @@ function renderUsers(users) {
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const tdA = document.createElement("td");
|
||||
const renewBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:"+30d",
|
||||
onclick: () => renewSSHUser(u),
|
||||
});
|
||||
const editBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillUserForm(u),
|
||||
@@ -85,16 +106,26 @@ function renderUsers(users) {
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteUser(u.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
tdA.className = "bot-row-actions";
|
||||
tdA.append(renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(tdA);
|
||||
usersBody.appendChild(tr);
|
||||
});
|
||||
const activeCount = Math.max(0, users.length - expiredCount);
|
||||
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) {
|
||||
setFormCollapsed(false);
|
||||
editingSSHUser = u.username || "";
|
||||
setWorkspaceSection("ssh", "create");
|
||||
fUsername.value = u.username || "";
|
||||
fPassword.value = "";
|
||||
fTotpSecret.value = u.totp_secret || "";
|
||||
@@ -103,9 +134,17 @@ function fillUserForm(u) {
|
||||
fTotpDigits.value = u.totp_digits || 6;
|
||||
fAllowStatic.checked = !!u.allow_static_password;
|
||||
fMaxConn.value = u.max_connections || "";
|
||||
const creditLocked = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
fMaxConn.disabled = creditLocked;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = creditLocked ? "Em planos por crédito, altere o limite criando uma nova conta." : "";
|
||||
fUp.value = u.limit_mbps_up || "";
|
||||
fDown.value = u.limit_mbps_down || "";
|
||||
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -122,7 +161,9 @@ userForm.addEventListener("submit", async e => {
|
||||
totp_digits: parseInt(fTotpDigits.value||"6",10),
|
||||
allow_static_password: !!fAllowStatic.checked,
|
||||
max_connections: parseInt(fMaxConn.value||"0",10),
|
||||
expires_at: isoFromLocal(fExpires.value),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits" && editingSSHUser
|
||||
? ""
|
||||
: isoFromLocal(fExpires.value),
|
||||
limit_mbps_up: parseInt(fUp.value||"0",10),
|
||||
limit_mbps_down: parseInt(fDown.value||"0",10),
|
||||
server_id: selectedSSHServer(),
|
||||
@@ -134,6 +175,8 @@ userForm.addEventListener("submit", async e => {
|
||||
fPassword.value = "";
|
||||
loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
|
||||
setWorkspaceSection("ssh", "users");
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else userStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
@@ -143,7 +186,13 @@ userForm.addEventListener("submit", async e => {
|
||||
});
|
||||
|
||||
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});
|
||||
try {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+193
-61
@@ -9,15 +9,28 @@ document.getElementById("xLoadInboundsBtn").addEventListener("click", () => load
|
||||
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
|
||||
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
|
||||
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() {
|
||||
if (xrayChip) {
|
||||
xrayChip.textContent = t("Loading Xray status…");
|
||||
xrayChip.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
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 run = !!s.running;
|
||||
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.style.color = run ? "var(--success)" : "var(--danger)";
|
||||
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")));
|
||||
renderDashboardCounters();
|
||||
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() {
|
||||
@@ -98,9 +119,11 @@ async function xrayCtrl(action) {
|
||||
xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK.";
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
setTimeout(() => loadInbounds({ force: true }), 1200);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +168,7 @@ async function copyText(text) {
|
||||
function renderInbounds(inbounds, options = {}) {
|
||||
const { silent = false, force = false } = options || {};
|
||||
updateDashboardXray(inbounds);
|
||||
syncXrayCreatorInbounds(inbounds);
|
||||
const nextStructure = inboundStructure(inbounds);
|
||||
|
||||
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return;
|
||||
@@ -179,35 +203,15 @@ function renderInbounds(inbounds, options = {}) {
|
||||
${escapeHTML(ib.tag || "untagged")}
|
||||
<span class="hint">:${escapeHTML(ib.port ?? "?")}</span>
|
||||
<span class="chip ${onlineCount ? "green" : ""}" data-role="inbound-online-chip">${t("{count} online", {count: onlineCount})}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm" onclick="openAddClient('${ib.tag}')">${t("+ Add Client")}</button>`;
|
||||
</div>`;
|
||||
const openButton = document.createElement("button");
|
||||
openButton.className = "btn btn-sm";
|
||||
openButton.type = "button";
|
||||
openButton.textContent = t("Create user");
|
||||
openButton.addEventListener("click", () => openAddClient(ib.tag));
|
||||
hdr.appendChild(openButton);
|
||||
section.appendChild(hdr);
|
||||
|
||||
// Add client mini-form (hidden by default)
|
||||
const addForm = document.createElement("div");
|
||||
addForm.id = `add-form-${ib.tag}`;
|
||||
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-${ib.tag}" placeholder="auto-generate" style="border-radius:6px;"/>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('newUUID-${ib.tag}').value=genUUID()">Gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>${t("Email / label")}</label><input id="newEmail-${ib.tag}" placeholder="user@example" style="border-radius:6px;"/></div>
|
||||
<div class="field"><label>${t("Display Name")}</label><input id="newName-${ib.tag}" placeholder="e.g. Maykinho01" style="border-radius:6px;"/></div>
|
||||
<div class="field"><label>${t("Expiry Date")}</label><input type="datetime-local" id="newExpiry-${ib.tag}" 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-${ib.tag}" placeholder="0" style="border-radius:6px;"/></div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:6px;">
|
||||
<button class="btn btn-sm" onclick="addClient('${ib.tag}')">${t("Add")}</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('add-form-${ib.tag}').classList.add('hidden')">${t("Cancel")}</button>
|
||||
</div>`;
|
||||
section.appendChild(addForm);
|
||||
|
||||
// Clients table
|
||||
const tblWrap = document.createElement("div");
|
||||
tblWrap.className = "tbl-wrap";
|
||||
@@ -240,12 +244,17 @@ function renderInbounds(inbounds, options = {}) {
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = t("Edit");
|
||||
editBtn.onclick = () => openEditXrayClient(ib.tag, c);
|
||||
const renewBtn = document.createElement("button");
|
||||
renewBtn.className = "btn btn-ghost btn-sm";
|
||||
renewBtn.style.marginLeft = "4px";
|
||||
renewBtn.textContent = "+30d";
|
||||
renewBtn.onclick = () => renewXrayClient(c);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = t("Del");
|
||||
delBtn.onclick = () => removeClient(ib.tag, c.id);
|
||||
actTd.append(copyBtn, editBtn, delBtn);
|
||||
actTd.append(copyBtn, renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(actTd);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -262,32 +271,141 @@ function renderInbounds(inbounds, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function openAddClient(tag) {
|
||||
const form = document.getElementById(`add-form-${tag}`);
|
||||
if (form) { form.classList.remove("hidden"); }
|
||||
const uuidField = document.getElementById(`newUUID-${tag}`);
|
||||
if (uuidField && !uuidField.value) uuidField.value = genUUID();
|
||||
let xrayCreatorInbounds = [];
|
||||
let xrayCreatorInboundSignature = "";
|
||||
|
||||
function syncXrayCreatorInbounds(inbounds = []) {
|
||||
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) {
|
||||
const uuidEl = document.getElementById(`newUUID-${tag}`);
|
||||
const emailEl = document.getElementById(`newEmail-${tag}`);
|
||||
const nameEl = document.getElementById(`newName-${tag}`);
|
||||
const expiryEl = document.getElementById(`newExpiry-${tag}`);
|
||||
const maxConnsEl = document.getElementById(`newMaxConns-${tag}`);
|
||||
const uuid = (uuidEl?.value || "").trim();
|
||||
const email = (emailEl?.value || "").trim();
|
||||
const name = (nameEl?.value || "").trim();
|
||||
const expiresAt = isoFromLocal(expiryEl?.value || "");
|
||||
const maxConns = parseInt(maxConnsEl?.value || "0", 10) || 0;
|
||||
if (!uuid) { xStatus.textContent = t("UUID required."); return; }
|
||||
function updateXrayCreatorInboundLabel() {
|
||||
const selected = document.getElementById("xCreateInbound")?.value || "";
|
||||
const inbound = xrayCreatorInbounds.find(item => item.tag === selected);
|
||||
const chip = document.getElementById("xCreateProtocolChip");
|
||||
const hint = document.getElementById("xCreateInboundHint");
|
||||
if (chip) chip.textContent = inbound ? inbound.protocol : t("No inbound");
|
||||
if (hint) hint.textContent = inbound
|
||||
? t("The client will be added to {tag} on port {port}.", {tag:inbound.tag, port:inbound.port})
|
||||
: t("Create or enable a compatible inbound before adding a client.");
|
||||
}
|
||||
|
||||
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 {
|
||||
const res = await api("/api/xray/clients/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns, server_id: selectedXrayServer() }),
|
||||
});
|
||||
const res = await api("/api/xray/clients/add", { method:"POST", body:JSON.stringify(payload) });
|
||||
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);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
@@ -295,16 +413,31 @@ async function addClient(tag) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClient(tag, uuid) {
|
||||
if (!confirm(t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}))) return;
|
||||
async function renewXrayClient(client) {
|
||||
const creditCost = Math.max(1, Number(client.max_conns || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar Xray", message:`Renovar “${client.name || client.email || client.id.slice(0, 8)}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
xStatus.textContent = "Renovando cliente Xray…";
|
||||
try {
|
||||
const res = await api(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.");
|
||||
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
|
||||
const res = await api("/api/xray/clients/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", "Renovar Xray");
|
||||
else showPanelToast("Cliente Xray renovado.", "success", "Xray");
|
||||
await loadInbounds({ force:true });
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar Xray");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,4 +482,3 @@ async function loadXrayLogs() {
|
||||
xLogsBox.scrollTop = xLogsBox.scrollHeight;
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
}
|
||||
|
||||
|
||||
+257
-73
@@ -1,113 +1,297 @@
|
||||
// ─── Resellers ────────────────────────────────────────────────────────────────
|
||||
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn").addEventListener("click", () => {
|
||||
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";
|
||||
});
|
||||
// ─── Hierarchical resellers ───────────────────────────────────────────────────
|
||||
let resellersCache = [];
|
||||
let editingReseller = "";
|
||||
|
||||
document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
navigateWorkspaceSection("resellers", "create");
|
||||
});
|
||||
document.getElementById("cancelResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
});
|
||||
document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit);
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
document.querySelectorAll("[data-workspace='resellers'][data-workspace-section='audit']").forEach(el => {
|
||||
el.addEventListener("click", loadResellerAudit);
|
||||
});
|
||||
document.querySelector("[data-workspace-select='resellers']")?.addEventListener("change", e => {
|
||||
if (e.target.value === "audit") loadResellerAudit();
|
||||
});
|
||||
rQuotaMode?.addEventListener("change", toggleResellerPlanFields);
|
||||
|
||||
function toggleResellerPlanFields() {
|
||||
const credit = rQuotaMode.value === "credits";
|
||||
document.getElementById("rSlotsField")?.classList.toggle("hidden", credit);
|
||||
document.getElementById("rCreditsField")?.classList.toggle("hidden", !credit);
|
||||
document.getElementById("rExpiresField")?.classList.toggle("hidden", credit);
|
||||
if (credit) rExpires.value = "";
|
||||
}
|
||||
|
||||
function prepareNewReseller() {
|
||||
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() {
|
||||
resellerStatus.textContent = "Loading…";
|
||||
resellerStatus.textContent = t("Loading…");
|
||||
setResellerLiveStatus("Carregando revendedores…", "is-loading");
|
||||
try {
|
||||
const res = await api("/api/resellers");
|
||||
const data = await res.json();
|
||||
renderResellers(data || []);
|
||||
resellerStatus.textContent = "Loaded.";
|
||||
const res = await api("/api/resellers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellersCache = await res.json() || [];
|
||||
renderResellers(resellersCache);
|
||||
populateResellerParents();
|
||||
resellerStatus.textContent = t("Loaded.");
|
||||
setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok");
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error loading.";
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim();
|
||||
setResellerLiveStatus("Falha ao carregar revendedores", "is-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setResellerLiveStatus(message, tone) {
|
||||
const el = document.getElementById("resellerLiveStatus");
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.className = `workspace-live-status ${tone || ""}`.trim();
|
||||
}
|
||||
|
||||
function renderResellerMetrics(list) {
|
||||
const active = list.filter(r => r.effective_active).length;
|
||||
const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0);
|
||||
const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0);
|
||||
document.getElementById("resellerMetricTotal").textContent = String(list.length);
|
||||
document.getElementById("resellerMetricActive").textContent = String(active);
|
||||
document.getElementById("resellerMetricAllocated").textContent = String(allocated);
|
||||
document.getElementById("resellerMetricCredits").textContent = String(credits);
|
||||
}
|
||||
|
||||
function renderResellers(list) {
|
||||
resellerCountChip.textContent = list.length;
|
||||
renderResellerMetrics(list);
|
||||
resellersBody.innerHTML = "";
|
||||
if (!list.length) {
|
||||
resellersBody.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhum revendedor direto cadastrado.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
list.forEach(r => {
|
||||
const expired = r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const max = r.max_users || 0;
|
||||
const used = r.used_users || 0;
|
||||
const remaining = max ? Math.max(0, max - used) : "∞";
|
||||
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
|
||||
const expired = !!r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const effective = !!r.effective_active && !expired;
|
||||
const maxUsers = Number(r.max_users || 0);
|
||||
const directUsed = Number(r.used_users || 0);
|
||||
const childAllocation = Number(r.child_allocation || 0);
|
||||
const committed = directUsed + childAllocation;
|
||||
const remaining = maxUsers ? Math.max(0, maxUsers - committed) : "∞";
|
||||
const pct = maxUsers ? Math.min(100, Math.round((committed / maxUsers) * 100)) : 0;
|
||||
const isCredit = r.quota_mode === "credits";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${r.username}</td>
|
||||
<td>
|
||||
<strong>${used} / ${max || "∞"}</strong>
|
||||
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div>
|
||||
<div class="table-meter"><span style="width:${pct}%"></span></div>
|
||||
<div class="bot-primary-cell"><strong>${escapeHTML(r.username)}</strong>
|
||||
<small>${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""}</small>
|
||||
${r.whatsapp ? `<small>${escapeHTML(r.whatsapp)}</small>` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td>
|
||||
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
|
||||
<td>
|
||||
<strong>${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`}</strong>
|
||||
<div class="hint">${isCredit ? "31 dias por renovação" : `Disponível ${remaining} · capacidade usada ${directUsed} · reservado ${childAllocation}`} · SSH ${r.used_ssh_users || 0} contas · Xray ${r.used_xray_users || 0} contas</div>
|
||||
${isCredit ? "" : `<div class="table-meter"><span style="width:${pct}%"></span></div>`}
|
||||
</td>
|
||||
<td>${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
|
||||
<td><span class="${effective ? "badge-on" : "badge-off"}">${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"}</span></td>
|
||||
<td></td>`;
|
||||
const tdA = tr.lastElementChild;
|
||||
const editBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillResellerForm(r),
|
||||
});
|
||||
const delBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteReseller(r.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "bot-row-actions reseller-row-actions";
|
||||
actions.appendChild(resellerActionButton(t("Edit"), "btn btn-ghost btn-sm", () => fillResellerForm(r)));
|
||||
if (!isCredit) actions.appendChild(resellerActionButton("+30d", "btn btn-ghost btn-sm", () => runResellerAction(r, "renew")));
|
||||
if (currentRole === "superadmin" && r.parent_username) actions.appendChild(resellerActionButton("Puxar", "btn btn-ghost btn-sm", () => runResellerAction(r, "pull")));
|
||||
actions.appendChild(resellerActionButton(r.is_active ? "Suspender" : "Reativar", r.is_active ? "btn btn-warn btn-sm" : "btn btn-ghost btn-sm", () => runResellerAction(r, r.is_active ? "suspend" : "reactivate")));
|
||||
actions.appendChild(resellerActionButton(t("Del"), "btn btn-danger btn-sm", () => deleteReseller(r)));
|
||||
tr.lastElementChild.appendChild(actions);
|
||||
resellersBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function resellerActionButton(label, className, onclick) {
|
||||
return Object.assign(document.createElement("button"), { type: "button", className, textContent: label, onclick });
|
||||
}
|
||||
|
||||
function populateResellerParents() {
|
||||
if (!rParent) return;
|
||||
const selected = rParent.value;
|
||||
rParent.innerHTML = `<option value="">Principal / sem pai</option>`;
|
||||
resellersCache
|
||||
.filter(r => r.username !== editingReseller && r.effective_active)
|
||||
.forEach(r => {
|
||||
const option = document.createElement("option");
|
||||
option.value = r.username;
|
||||
option.textContent = `${r.username} · ${r.quota_mode === "credits" ? `${r.credit_balance || 0} Cr` : `${r.available < 0 ? "∞" : r.available} slots`}`;
|
||||
rParent.appendChild(option);
|
||||
});
|
||||
if ([...rParent.options].some(o => o.value === selected)) rParent.value = selected;
|
||||
}
|
||||
|
||||
function fillResellerForm(r) {
|
||||
resellerFormTitle.textContent = `Edit: ${r.username}`;
|
||||
rUsername.value = r.username;
|
||||
rPassword.value = "";
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rActive.checked = r.is_active;
|
||||
resellerStatus.textContent = `Editing ${r.username}.`;
|
||||
editingReseller = r.username;
|
||||
setWorkspaceSection("resellers", "create");
|
||||
resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Edit reseller");
|
||||
rUsername.value = r.username;
|
||||
rUsername.disabled = true;
|
||||
rPassword.value = "";
|
||||
populateResellerParents();
|
||||
rParent.value = r.parent_username || "";
|
||||
rParent.disabled = true;
|
||||
rQuotaMode.value = r.quota_mode || "slots";
|
||||
rQuotaMode.disabled = true;
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rCredits.value = r.credit_balance || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rWhatsApp.value = r.whatsapp || "";
|
||||
rMonthlyPrice.value = ((r.monthly_price_cents || 0) / 100).toFixed(2);
|
||||
rActive.checked = !!r.is_active;
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("Editing {name}.", {name: r.username});
|
||||
}
|
||||
|
||||
resellerForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("saveResellerBtn");
|
||||
btn.disabled = true;
|
||||
resellerStatus.textContent = "Saving…";
|
||||
resellerStatus.textContent = t("Saving…");
|
||||
const mode = rQuotaMode.value || "slots";
|
||||
const payload = {
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
max_users: parseInt(rMaxUsers.value||"0",10),
|
||||
expires_at: isoFromLocal(rExpires.value),
|
||||
is_active: rActive.checked,
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
parent_username: currentRole === "superadmin" ? rParent.value : undefined,
|
||||
quota_mode: mode,
|
||||
max_users: parseInt(rMaxUsers.value || "0", 10),
|
||||
credits: parseInt(rCredits.value || "0", 10),
|
||||
expires_at: mode === "slots" ? isoFromLocal(rExpires.value) : "",
|
||||
whatsapp: rWhatsApp.value.trim(),
|
||||
monthly_price_cents: Math.round(Math.max(0, parseFloat(rMonthlyPrice.value || "0")) * 100),
|
||||
is_active: rActive.checked,
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellerStatus.textContent = "Saved.";
|
||||
resellerForm.reset(); rActive.checked = true;
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
loadResellers();
|
||||
const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
|
||||
prepareNewReseller();
|
||||
await loadResellers();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error: "+e.message;
|
||||
} finally { btn.disabled = false; }
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error")}: ${e.message}`;
|
||||
showPanelToast(e.message, "error", t("Resellers"));
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteReseller(username) {
|
||||
if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return;
|
||||
resellerStatus.textContent = `Deleting ${username}…`;
|
||||
async function runResellerAction(reseller, action) {
|
||||
const labels = { renew: "Renovar por 30 dias", suspend: "Suspender revendedor", reactivate: "Reativar revendedor", pull: "Puxar para o painel principal" };
|
||||
const descriptions = {
|
||||
renew: "A validade será estendida a partir da data atual ou da validade existente.",
|
||||
suspend: "A conta, seus descendentes e os acessos SSH/Xray ficarão bloqueados sem apagar os cadastros.",
|
||||
reactivate: "Os acessos preservados serão restaurados nos servidores disponíveis.",
|
||||
pull: "O revendedor deixará a revenda atual e passará a ser administrado diretamente pelo superadmin. Os créditos já transferidos não serão duplicados.",
|
||||
};
|
||||
const accepted = await panelConfirm({
|
||||
tone: action === "suspend" ? "danger" : "default",
|
||||
icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
|
||||
title: labels[action],
|
||||
message: `${labels[action]} “${reseller.username}”?`,
|
||||
detail: descriptions[action],
|
||||
confirmLabel: labels[action],
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = `${labels[action]}…`;
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error("failed");
|
||||
resellerStatus.textContent = "Deleted.";
|
||||
loadResellers();
|
||||
const res = await api("/api/resellers/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: reseller.username, action, days: action === "renew" ? 30 : undefined }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", labels[action]);
|
||||
else showPanelToast(`${reseller.username}: operação concluída.`, "success", labels[action]);
|
||||
await loadResellers();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error deleting.";
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", labels[action]);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReseller(reseller) {
|
||||
const accepted = await panelConfirm({
|
||||
tone: "danger", icon: "×", title: t("Delete reseller"),
|
||||
message: t("Delete reseller \"{name}\"?", {name: reseller.username}),
|
||||
detail: `Serão removidos ${reseller.child_count || 0} sub-revendedores e todos os acessos SSH/Xray pertencentes à árvore. Esta ação não pode ser desfeita.`,
|
||||
confirmLabel: t("Delete reseller"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = t("Deleting {name}…", {name: reseller.username});
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(reseller.username)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${reseller.username} removido.`, "success", t("Resellers"));
|
||||
await loadResellers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message || "Falha ao excluir.", "error", t("Delete reseller"));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResellerAudit() {
|
||||
const body = document.getElementById("resellerAuditBody");
|
||||
if (!body) return;
|
||||
body.innerHTML = `<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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ document.getElementById("reloadServersBtn")?.addEventListener("click", loadServe
|
||||
document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers);
|
||||
document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers);
|
||||
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("testServerBtn")?.addEventListener("click", testServerForm);
|
||||
document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView());
|
||||
@@ -34,17 +32,39 @@ serverForm?.addEventListener("submit", async e => {
|
||||
});
|
||||
|
||||
async function loadServers() {
|
||||
let loadError = null;
|
||||
if (fleetLiveStatus) {
|
||||
fleetLiveStatus.textContent = t("Loading infrastructure…");
|
||||
fleetLiveStatus.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/servers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serversCache = await res.json() || [];
|
||||
} 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 }];
|
||||
if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message;
|
||||
if (e.message === "auth") doAuthError();
|
||||
}
|
||||
renderServerSelectors();
|
||||
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 (!silent) {
|
||||
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers...");
|
||||
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-loading";
|
||||
serversStatusGrid.innerHTML = `<div class="hint">Loading servers...</div>`;
|
||||
}
|
||||
const rows = await Promise.all(nodes.map(loadSingleServerStatus));
|
||||
@@ -64,11 +85,23 @@ async function loadServersStatus(options = {}) {
|
||||
if (serversStatusPageStatus) {
|
||||
const online = rows.filter(r => r.ok).length;
|
||||
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) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
@@ -321,7 +354,13 @@ async function testServerForm() {
|
||||
}
|
||||
|
||||
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 {
|
||||
const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
|
||||
document.getElementById("refreshStatsBtn")?.addEventListener("click", loadStats);
|
||||
|
||||
async function loadDashboardStats() {
|
||||
try {
|
||||
@@ -91,7 +91,7 @@ async function loadDnsttHealth() {
|
||||
const a = rows[i];
|
||||
const b = rows[i + 1] || ["", ""];
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${a[0]}</td><td>${fmtInt(a[1])}</td><td>${b[0]}</td><td>${b[0] ? fmtInt(b[1]) : ""}</td>`;
|
||||
tr.innerHTML = `<td>${escapeHTML(a[0])}</td><td>${escapeHTML(fmtInt(a[1]))}</td><td>${escapeHTML(b[0])}</td><td>${b[0] ? escapeHTML(fmtInt(b[1])) : ""}</td>`;
|
||||
dnsttHealthBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,10 @@ async function loadDnsttHealth() {
|
||||
|
||||
|
||||
async function loadStats() {
|
||||
if (statsUpdated) {
|
||||
statsUpdated.textContent = t("Updating live status…");
|
||||
statsUpdated.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/stats");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -135,28 +139,44 @@ async function loadStats() {
|
||||
totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0);
|
||||
if (!ifaceBody) return;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${it.name}</td><td>${fmtMbps(it.rx_mbps)}</td><td>${fmtMbps(it.tx_mbps)}</td><td>${fmtBytes(it.rx_bytes)}</td><td>${fmtBytes(it.tx_bytes)}</td>`;
|
||||
tr.innerHTML = `<td>${escapeHTML(it.name)}</td><td>${escapeHTML(fmtMbps(it.rx_mbps))}</td><td>${escapeHTML(fmtMbps(it.tx_mbps))}</td><td>${escapeHTML(fmtBytes(it.rx_bytes))}</td><td>${escapeHTML(fmtBytes(it.tx_bytes))}</td>`;
|
||||
ifaceBody.appendChild(tr);
|
||||
});
|
||||
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();
|
||||
} catch (e) {
|
||||
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);
|
||||
|
||||
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;
|
||||
ifaceSummary.textContent = "Cleaning interface totals…";
|
||||
try {
|
||||
const res = await api("/api/stats/interfaces/reset", { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days.";
|
||||
showPanelToast(t("Live interface totals were cleaned."), "success", t("Traffic counters"));
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
@@ -167,7 +187,6 @@ async function resetInterfaceStats() {
|
||||
}
|
||||
|
||||
// ─── VnStat ───────────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat);
|
||||
reloadVnstatBtn?.addEventListener("click", loadVnstat);
|
||||
resetVnstatBtn?.addEventListener("click", resetVnstatHistory);
|
||||
|
||||
@@ -175,19 +194,20 @@ function renderVnstatRows(body, rows, emptyLabel) {
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td colspan="5" class="hint">${emptyLabel}</td>`;
|
||||
tr.innerHTML = `<td colspan="5" class="hint">${escapeHTML(emptyLabel)}</td>`;
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${r.period || "--"}</td><td>${r.iface || "--"}</td><td>${fmtBytes(r.rx_bytes||0)}</td><td>${fmtBytes(r.tx_bytes||0)}</td><td>${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))}</td>`;
|
||||
tr.innerHTML = `<td>${escapeHTML(r.period || "--")}</td><td>${escapeHTML(r.iface || "--")}</td><td>${escapeHTML(fmtBytes(r.rx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.tx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0))))}</td>`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVnstat() {
|
||||
vnstatStatus.textContent = "Loading VnStat usage…";
|
||||
vnstatStatus.className = "workspace-live-status is-loading";
|
||||
try {
|
||||
const res = await api("/api/vnstat?days=31&months=12");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -208,21 +228,33 @@ async function loadVnstat() {
|
||||
vnTodayTotal.textContent = fmtBytes(todayTotal);
|
||||
vnMonthTotal.textContent = fmtBytes(monthTotal);
|
||||
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.className = "workspace-live-status is-ok";
|
||||
} catch (e) {
|
||||
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() {
|
||||
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;
|
||||
vnstatStatus.textContent = "Cleaning VnStat history…";
|
||||
try {
|
||||
const res = await api("/api/vnstat/reset", { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
vnstatStatus.textContent = "VnStat history cleaned.";
|
||||
showPanelToast(t("VnStat history was cleaned."), "success", t("Traffic history"));
|
||||
loadVnstat();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
@@ -260,7 +292,13 @@ async function loadSystemLogs() {
|
||||
|
||||
async function clearPanelLog() {
|
||||
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…";
|
||||
try {
|
||||
const res = await api("/api/system/logs/reset", { method:"POST" });
|
||||
@@ -273,4 +311,3 @@ async function clearPanelLog() {
|
||||
else st.textContent = "Error cleaning panel log: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -263,8 +263,8 @@ function renderTLSForwarders() {
|
||||
tlsForwardersState.forEach((fw, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${fw.listen}</span>
|
||||
<span class="hint">${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}</span>`;
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${escapeHTML(fw.listen || "")}</span>
|
||||
<span class="hint">${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}</span>`;
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
@@ -446,4 +446,3 @@ async function loadDnsttPubkey() {
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ function openEditXrayClient(tag, client) {
|
||||
document.getElementById("editXrayName").value = client.name || "";
|
||||
document.getElementById("editXrayEmail").value = client.email || "";
|
||||
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
||||
document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
|
||||
const maxInput = document.getElementById("editXrayMaxConns");
|
||||
maxInput.value = client.max_conns || 0;
|
||||
maxInput.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxInput.disabled = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
maxInput.title = maxInput.disabled ? "Em planos por crédito, o limite de conexões fica fixo." : "";
|
||||
document.getElementById("editXrayClientStatus").textContent = "";
|
||||
document.getElementById("editXrayClientPanel").classList.remove("hidden");
|
||||
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
@@ -24,7 +28,9 @@ async function saveEditXrayClient() {
|
||||
uuid: editingXrayClientId,
|
||||
name: document.getElementById("editXrayName").value.trim(),
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? ""
|
||||
: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
@@ -82,7 +88,9 @@ function loadWizardFromConfig() {
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
|
||||
wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Config loaded from ${target}.`;
|
||||
}).catch(e => {
|
||||
@@ -90,7 +98,9 @@ function loadWizardFromConfig() {
|
||||
wzLoadedConfigText = "";
|
||||
wzLoadedFullConfig = null;
|
||||
wzInbounds = [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
});
|
||||
@@ -99,49 +109,562 @@ function loadWizardFromConfig() {
|
||||
function renderWzInbounds() {
|
||||
const list = document.getElementById("wzInboundsList");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
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;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
wzInbounds.forEach((ib, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
const row = document.createElement("article");
|
||||
row.className = "visual-inbound-card";
|
||||
const portStr = ib.port !== undefined ? `:${ib.port}` : "";
|
||||
const ss = ib.streamSettings || {};
|
||||
const net = ss.network || "";
|
||||
const sec = ss.security || "";
|
||||
const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
|
||||
const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
|
||||
row.innerHTML = `<span class="chip">${ib.protocol}</span>
|
||||
<span style="font-family:monospace;">${ib.tag||"untagged"}${portStr}</span>
|
||||
<span class="hint" style="flex:1;">${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}</span>`;
|
||||
const transportSettings = ss.xhttpSettings || ss.splithttpSettings || ss.wsSettings || ss.httpupgradeSettings || ss.httpSettings || ss.grpcSettings || {};
|
||||
const head = document.createElement("div");
|
||||
head.className = "visual-inbound-card-head";
|
||||
const name = document.createElement("div");
|
||||
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;
|
||||
if (Array.isArray(clients) && clients.length) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "chip green";
|
||||
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");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
delBtn.onclick = () => { wzInbounds.splice(i,1); wzDirty = true; renderWzInbounds(); };
|
||||
row.appendChild(delBtn);
|
||||
delBtn.type = "button";
|
||||
delBtn.textContent = "Remover";
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function wzToggleAddInbound() {
|
||||
const form = document.getElementById("wzAddInboundForm");
|
||||
form.classList.toggle("hidden");
|
||||
if (!form.classList.contains("hidden")) {
|
||||
onWzProtoChange(document.getElementById("wzProtocol").value);
|
||||
onWzNetworkChange(document.getElementById("wzNetwork").value);
|
||||
onWzTLSChange(document.getElementById("wzTLS").value);
|
||||
if (!form.classList.contains("hidden") && wzEditingIndex < 0) return wzCancelInbound();
|
||||
resetWzInboundForm();
|
||||
form.classList.remove("hidden");
|
||||
form.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
}
|
||||
|
||||
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) {
|
||||
const isSSH = val === "ssh";
|
||||
// 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 listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
|
||||
const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
|
||||
if (!port) { alert("Port required."); return; }
|
||||
const ib = { tag, port, listen, protocol: proto, settings: {} };
|
||||
const st = document.getElementById("wzStatus");
|
||||
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") {
|
||||
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 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
|
||||
switch (net) {
|
||||
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;
|
||||
case "xhttp":
|
||||
ib.streamSettings.xhttpSettings = {
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
};
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
break;
|
||||
case "httpupgrade":
|
||||
ib.streamSettings.httpupgradeSettings = {
|
||||
ib.streamSettings.httpupgradeSettings = Object.assign({}, ib.streamSettings.httpupgradeSettings || {}, {
|
||||
path: document.getElementById("wzHUPath").value.trim() || "/",
|
||||
host: document.getElementById("wzHUHost").value.trim() || undefined,
|
||||
};
|
||||
});
|
||||
if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
|
||||
break;
|
||||
case "h2":
|
||||
ib.streamSettings.httpSettings = {
|
||||
ib.streamSettings.httpSettings = Object.assign({}, ib.streamSettings.httpSettings || {}, {
|
||||
path: document.getElementById("wzH2Path").value.trim() || "/",
|
||||
host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
|
||||
};
|
||||
});
|
||||
break;
|
||||
case "grpc":
|
||||
ib.streamSettings.grpcSettings = {
|
||||
ib.streamSettings.grpcSettings = Object.assign({}, ib.streamSettings.grpcSettings || {}, {
|
||||
serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
|
||||
multiMode: document.getElementById("wzGRPCMulti").checked,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
// TLS / Reality
|
||||
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.tlsSettings = {
|
||||
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
|
||||
};
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
delete ib.streamSettings.realitySettings;
|
||||
} else if (tlsVal === "reality" && proto === "vless") {
|
||||
ib.streamSettings.security = "reality";
|
||||
ib.streamSettings.realitySettings = {
|
||||
ib.streamSettings.realitySettings = Object.assign({}, ib.streamSettings.realitySettings || {}, {
|
||||
dest: document.getElementById("wzRealityDest").value.trim(),
|
||||
serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
|
||||
privateKey: document.getElementById("wzRealityPriv").value.trim(),
|
||||
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") {
|
||||
// SSH tunnel over XHTTP: no proxy clients — the decoded stream is handed to
|
||||
// the SSH server, so authentication is an ordinary SSH account.
|
||||
ib.settings = {};
|
||||
ib.streamSettings = { network: "xhttp" };
|
||||
ib.streamSettings.xhttpSettings = {
|
||||
ib.streamSettings = original?.protocol === "ssh" ? (cloneJsonSafe(original.streamSettings) || {}) : {};
|
||||
ib.streamSettings.network = "xhttp";
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
};
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
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.tlsSettings = {
|
||||
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
|
||||
};
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
} else {
|
||||
delete ib.streamSettings.security;
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
}
|
||||
} else if (proto === "trojan") {
|
||||
ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
|
||||
@@ -309,13 +869,13 @@ function wzSaveInbound() {
|
||||
ib.settings = { auth: "noauth", udp: true };
|
||||
ib.streamSettings = { network: "tcp" };
|
||||
}
|
||||
wzInbounds.push(ib);
|
||||
if (wzEditingIndex >= 0) wzInbounds[wzEditingIndex] = ib;
|
||||
else wzInbounds.push(ib);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
document.getElementById("wzAddInboundForm").classList.add("hidden");
|
||||
document.getElementById("wzPort").value = "";
|
||||
document.getElementById("wzTag").value = "";
|
||||
document.getElementById("wzListenIP").value = "";
|
||||
loadSharedEndpointForm();
|
||||
st.textContent = original ? `Inbound ${tag} atualizado no rascunho.` : `Inbound ${tag} adicionado ao rascunho.`;
|
||||
wzCancelInbound();
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +904,7 @@ function buildConfigFromVisualEditor() {
|
||||
const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : [];
|
||||
const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api");
|
||||
const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
validateVisualInbounds(visualInbounds);
|
||||
cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds];
|
||||
|
||||
return cfg;
|
||||
@@ -363,7 +924,7 @@ async function applyWizardConfig() {
|
||||
if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) {
|
||||
if (st) st.textContent = `Reloading config from ${target} before saving...`;
|
||||
loadWizardFromConfig();
|
||||
return;
|
||||
return { saved:false, restarted:false, error:t("Configuration for this server was not loaded.") };
|
||||
}
|
||||
|
||||
let cfg;
|
||||
@@ -371,7 +932,7 @@ async function applyWizardConfig() {
|
||||
cfg = buildConfigFromVisualEditor();
|
||||
} catch(e) {
|
||||
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}...`;
|
||||
@@ -384,12 +945,15 @@ async function applyWizardConfig() {
|
||||
wzLoadedServerID = selectedID;
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Saved on ${target}. Restarting Xray...`;
|
||||
await xrayCtrl("restart");
|
||||
if (st) st.textContent = `Config saved on ${target} and Xray restarted.`;
|
||||
const restarted = await xrayCtrl("restart");
|
||||
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);
|
||||
return { saved:true, restarted:!!restarted, error:restarted ? "" : t("Xray could not restart.") };
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
return { saved:false, restarted:false, error:t("Could not save configuration: {error}", {error:e.message}) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ─── Auth error ───────────────────────────────────────────────────────────────
|
||||
function doAuthError() {
|
||||
sessionToken = "";
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
|
||||
+561
-272
@@ -1,340 +1,629 @@
|
||||
// ─── Bot / Vendas (Telegram + Mercado Pago) ─────────────────────────────────
|
||||
// Superadmin-only tab. Uses the shared api() helper from 01-core.js.
|
||||
// Bot / Vendas — safe DOM rendering and sectioned management workspace.
|
||||
|
||||
function botEsc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
function botStatus(id, msg, ok) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = msg; el.style.color = ok === false ? "var(--danger,#e5484d)" : ""; }
|
||||
}
|
||||
function brl(cents) {
|
||||
return "R$ " + (Number(cents || 0) / 100).toFixed(2).replace(".", ",");
|
||||
}
|
||||
const botState = {
|
||||
config: null,
|
||||
plans: [],
|
||||
packages: [],
|
||||
users: [],
|
||||
transactions: [],
|
||||
section: sessionStorage.getItem("BOT_SECTION") || "config",
|
||||
};
|
||||
|
||||
async function loadBotTab() {
|
||||
loadBotInbounds();
|
||||
const sel = document.getElementById("botSection");
|
||||
botShowSection(sel ? sel.value : "config");
|
||||
}
|
||||
|
||||
// Show one section at a time and lazy-load its data.
|
||||
function botShowSection(name) {
|
||||
document.querySelectorAll("#tab-bot .bot-section").forEach(s => { s.style.display = "none"; });
|
||||
const el = document.getElementById("botSec-" + name);
|
||||
if (el) el.style.display = "";
|
||||
switch (name) {
|
||||
case "config": loadBotConfig(); break;
|
||||
case "plans": loadBotPlans(); break;
|
||||
case "packages": loadBotPkgs(); break;
|
||||
case "messages": loadBotSettings(); break;
|
||||
case "users": loadBotUsers(); break;
|
||||
case "transactions": loadBotTxns(); break;
|
||||
function botStatus(id, message, ok) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
if (id === "botConfigStatus") {
|
||||
element.classList.toggle("is-ok", ok === true);
|
||||
element.classList.toggle("is-error", ok === false);
|
||||
} else {
|
||||
element.style.color = ok === false ? "var(--danger)" : "";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config ───
|
||||
function botBRL(cents) {
|
||||
return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(Number(cents || 0) / 100);
|
||||
}
|
||||
|
||||
function botNode(tag, options = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
if (options.className) element.className = options.className;
|
||||
if (options.text != null) element.textContent = String(options.text);
|
||||
if (options.title) element.title = options.title;
|
||||
if (options.type) element.type = options.type;
|
||||
for (const child of children) if (child) element.appendChild(child);
|
||||
return element;
|
||||
}
|
||||
|
||||
function botCell(content, className = "") {
|
||||
const cell = document.createElement("td");
|
||||
if (className) cell.className = className;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = String(content == null || content === "" ? "—" : content);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function botPrimaryCell(title, detail) {
|
||||
const wrapper = botNode("div", { className: "bot-primary-cell" });
|
||||
wrapper.appendChild(botNode("strong", { text: title || "—" }));
|
||||
if (detail) wrapper.appendChild(botNode("small", { text: detail }));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function botBadge(label, tone) {
|
||||
return botNode("span", { className: "bot-status " + tone, text: label });
|
||||
}
|
||||
|
||||
function botButton(label, handler, className = "btn btn-ghost btn-sm") {
|
||||
const button = botNode("button", { className, text: label, type: "button" });
|
||||
button.addEventListener("click", handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function botActions(buttons) {
|
||||
return botNode("div", { className: "bot-row-actions" }, buttons);
|
||||
}
|
||||
|
||||
function botEmptyRow(body, columns, message) {
|
||||
const row = botNode("tr", { className: "bot-empty-row" });
|
||||
const cell = botCell(message);
|
||||
cell.colSpan = columns;
|
||||
row.appendChild(cell);
|
||||
body.replaceChildren(row);
|
||||
}
|
||||
|
||||
async function botRequest(path, options = {}) {
|
||||
const response = await api(path, options);
|
||||
if (!response.ok) {
|
||||
const message = (await response.text()).trim();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function botHandleError(error, statusID, fallback) {
|
||||
if (error.message === "auth") {
|
||||
doAuthError();
|
||||
return;
|
||||
}
|
||||
botStatus(statusID, error.message || fallback, false);
|
||||
}
|
||||
|
||||
function botSetSection(section) {
|
||||
const allowedSections = new Set(["config", "plans", "packages", "messages", "users", "transactions"]);
|
||||
if (!allowedSections.has(section)) section = "config";
|
||||
botState.section = section;
|
||||
sessionStorage.setItem("BOT_SECTION", section);
|
||||
document.querySelectorAll("[data-bot-panel]").forEach(panel => panel.classList.toggle("active", panel.dataset.botPanel === section));
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.classList.toggle("active", button.dataset.botSection === section));
|
||||
const select = document.getElementById("botSection");
|
||||
if (select) select.value = section;
|
||||
}
|
||||
|
||||
function botUpdateMetrics() {
|
||||
const config = botState.config;
|
||||
const stateMetric = document.getElementById("botMetricState");
|
||||
if (stateMetric) stateMetric.textContent = config ? (config.enabled ? "Ativo" : "Pausado") : "Indisponível";
|
||||
const plansMetric = document.getElementById("botMetricPlans");
|
||||
if (plansMetric) plansMetric.textContent = String(botState.plans.filter(plan => plan.IsActive).length);
|
||||
const usersMetric = document.getElementById("botMetricUsers");
|
||||
if (usersMetric) usersMetric.textContent = String(botState.users.length);
|
||||
const pendingMetric = document.getElementById("botMetricPending");
|
||||
if (pendingMetric) pendingMetric.textContent = String(botState.transactions.filter(transaction => transaction.Status === "pending").length);
|
||||
}
|
||||
|
||||
async function loadBotTab() {
|
||||
botSetSection(botState.section);
|
||||
botStatus("botConfigStatus", "Atualizando dados…");
|
||||
await Promise.allSettled([
|
||||
loadBotConfig(), loadBotInbounds(), loadBotPlans(), loadBotPkgs(),
|
||||
loadBotUsers(), loadBotTxns(), loadBotSettings(),
|
||||
]);
|
||||
botUpdateMetrics();
|
||||
}
|
||||
|
||||
// Configuration
|
||||
async function loadBotConfig() {
|
||||
try {
|
||||
const res = await api("/api/bot/config");
|
||||
const c = await res.json();
|
||||
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; };
|
||||
const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; };
|
||||
chk("botEnabled", c.enabled);
|
||||
set("botMPConfirmMode", c.mp_confirm_mode);
|
||||
set("botMPPollInterval", c.mp_poll_interval);
|
||||
set("botPixExp", c.pix_expiration_minutes);
|
||||
chk("botTrialEnabled", c.trial_enabled);
|
||||
set("botTrialHours", c.trial_hours);
|
||||
set("botTrialMaxConns", c.trial_max_connections);
|
||||
set("botTrialKind", c.trial_kind);
|
||||
set("botTrialInbound", c.trial_inbound_tag);
|
||||
set("botAdminIDs", (c.admin_telegram_ids || []).join(","));
|
||||
set("botPublicHost", c.public_host);
|
||||
set("botXrayPublicHost", c.xray_public_host);
|
||||
const hint = (id, ok) => { const e = document.getElementById(id); if (e) e.textContent = ok ? "✓ configurado" : "não definido"; };
|
||||
hint("botHasTgToken", c.has_telegram_token);
|
||||
hint("botHasMpToken", c.has_mp_access_token);
|
||||
hint("botHasMpSecret", c.has_mp_webhook_secret);
|
||||
const config = await botRequest("/api/bot/config");
|
||||
botState.config = config;
|
||||
const setValue = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
const setChecked = (id, value) => { const field = document.getElementById(id); if (field) field.checked = !!value; };
|
||||
setChecked("botEnabled", config.enabled);
|
||||
setValue("botMPConfirmMode", config.mp_confirm_mode);
|
||||
setValue("botMPPollInterval", config.mp_poll_interval);
|
||||
setValue("botPixExp", config.pix_expiration_minutes);
|
||||
setChecked("botTrialEnabled", config.trial_enabled);
|
||||
setValue("botTrialHours", config.trial_hours);
|
||||
setValue("botTrialMaxConns", config.trial_max_connections);
|
||||
setValue("botTrialKind", config.trial_kind);
|
||||
setValue("botTrialInbound", config.trial_inbound_tag);
|
||||
setValue("botAdminIDs", (config.admin_telegram_ids || []).join(", "));
|
||||
setValue("botPublicHost", config.public_host);
|
||||
setValue("botXrayPublicHost", config.xray_public_host);
|
||||
botSetSecretState("botHasTgToken", config.has_telegram_token);
|
||||
botSetSecretState("botHasMpToken", config.has_mp_access_token);
|
||||
botSetSecretState("botHasMpSecret", config.has_mp_webhook_secret);
|
||||
botToggleMPWebhookBox();
|
||||
botStatus("botConfigStatus", "Carregado.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); }
|
||||
botStatus("botConfigStatus", config.enabled ? "Bot ativo" : "Bot pausado", true);
|
||||
botUpdateMetrics();
|
||||
return config;
|
||||
} catch (error) {
|
||||
botState.config = null;
|
||||
botHandleError(error, "botConfigStatus", "Erro ao carregar configuração.");
|
||||
botUpdateMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
function botSetSecretState(id, configured) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = configured ? "● protegido" : "○ não configurado";
|
||||
element.classList.toggle("is-set", !!configured);
|
||||
element.classList.toggle("is-missing", !configured);
|
||||
}
|
||||
|
||||
function botToggleMPWebhookBox() {
|
||||
const mode = document.getElementById("botMPConfirmMode")?.value;
|
||||
const box = document.getElementById("botMPWebhookBox");
|
||||
if (box) box.style.display = mode === "webhook" ? "" : "none";
|
||||
document.getElementById("botMPWebhookBox")?.classList.toggle("hidden", mode !== "webhook");
|
||||
const url = document.getElementById("botMPWebhookURL");
|
||||
if (url) url.textContent = location.origin + "/api/mp/webhook";
|
||||
}
|
||||
|
||||
async function saveBotConfig() {
|
||||
const val = id => (document.getElementById(id)?.value || "").trim();
|
||||
const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0;
|
||||
const chk = id => !!document.getElementById(id)?.checked;
|
||||
const ids = val("botAdminIDs").split(",").map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
||||
const body = {
|
||||
enabled: chk("botEnabled"),
|
||||
telegram_token: val("botTelegramToken"),
|
||||
mp_access_token: val("botMPToken"),
|
||||
mp_confirm_mode: val("botMPConfirmMode"),
|
||||
mp_webhook_secret: val("botMPWebhookSecret"),
|
||||
mp_poll_interval: val("botMPPollInterval"),
|
||||
pix_expiration_minutes: num("botPixExp"),
|
||||
trial_enabled: chk("botTrialEnabled"),
|
||||
trial_hours: num("botTrialHours"),
|
||||
trial_max_connections: num("botTrialMaxConns"),
|
||||
trial_kind: val("botTrialKind"),
|
||||
trial_inbound_tag: val("botTrialInbound"),
|
||||
admin_telegram_ids: ids,
|
||||
public_host: val("botPublicHost"),
|
||||
xray_public_host: val("botXrayPublicHost"),
|
||||
const value = id => (document.getElementById(id)?.value || "").trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id)?.value || "0", 10) || 0;
|
||||
const checked = id => !!document.getElementById(id)?.checked;
|
||||
const adminIDs = value("botAdminIDs").split(",").map(item => Number.parseInt(item.trim(), 10)).filter(Number.isSafeInteger);
|
||||
const payload = {
|
||||
enabled: checked("botEnabled"), telegram_token: value("botTelegramToken"),
|
||||
mp_access_token: value("botMPToken"), mp_confirm_mode: value("botMPConfirmMode"),
|
||||
mp_webhook_secret: value("botMPWebhookSecret"), mp_poll_interval: value("botMPPollInterval"),
|
||||
pix_expiration_minutes: number("botPixExp"), trial_enabled: checked("botTrialEnabled"),
|
||||
trial_hours: number("botTrialHours"), trial_max_connections: number("botTrialMaxConns"),
|
||||
trial_kind: value("botTrialKind"), trial_inbound_tag: value("botTrialInbound"),
|
||||
admin_telegram_ids: adminIDs, public_host: value("botPublicHost"), xray_public_host: value("botXrayPublicHost"),
|
||||
};
|
||||
botStatus("botConfigStatus", "Salvando e reiniciando…");
|
||||
try {
|
||||
await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) });
|
||||
["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; });
|
||||
botStatus("botConfigStatus", "Configuração salva e bot reiniciado.");
|
||||
loadBotConfig();
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); }
|
||||
await botRequest("/api/bot/config", { method: "POST", body: JSON.stringify(payload) });
|
||||
["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const field = document.getElementById(id); if (field) field.value = ""; });
|
||||
await loadBotConfig();
|
||||
botStatus("botConfigStatus", "Configuração salva", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao salvar configuração.");
|
||||
}
|
||||
}
|
||||
|
||||
async function testBot() {
|
||||
botStatus("botConfigStatus", "Testando...");
|
||||
const body = {
|
||||
botStatus("botConfigStatus", "Testando Telegram e Mercado Pago…");
|
||||
const payload = {
|
||||
telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(),
|
||||
mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(),
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/bot/test", { method: "POST", body: JSON.stringify(body) });
|
||||
const r = await res.json();
|
||||
const tg = r.telegram_ok ? `Telegram OK (${r.telegram_bot || ""})` : `Telegram: ${r.telegram_error || "falha"}`;
|
||||
const mp = r.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${r.mp_error || "falha"}`;
|
||||
botStatus("botConfigStatus", tg + " · " + mp, r.telegram_ok && r.mp_ok);
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro no teste.", false); }
|
||||
const result = await botRequest("/api/bot/test", { method: "POST", body: JSON.stringify(payload) });
|
||||
const telegram = result.telegram_ok ? `Telegram ${result.telegram_bot || "OK"}` : `Telegram: ${result.telegram_error || "falha"}`;
|
||||
const mercadoPago = result.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${result.mp_error || "falha"}`;
|
||||
botStatus("botConfigStatus", `${telegram} · ${mercadoPago}`, !!result.telegram_ok && !!result.mp_ok);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao testar integrações.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botCopyWebhook() {
|
||||
const value = document.getElementById("botMPWebhookURL")?.textContent || "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const button = document.getElementById("botCopyWebhookBtn");
|
||||
if (button) {
|
||||
button.textContent = "Copiado";
|
||||
setTimeout(() => { button.textContent = "Copiar"; }, 1400);
|
||||
}
|
||||
} catch {
|
||||
botStatus("botConfigStatus", "Não foi possível copiar a URL.", false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inbounds datalist ───
|
||||
async function loadBotInbounds() {
|
||||
try {
|
||||
const res = await api("/api/xray/inbounds");
|
||||
const list = await res.json();
|
||||
const dl = document.getElementById("botInboundList");
|
||||
if (dl) dl.innerHTML = (list || []).map(ib => `<option value="${botEsc(ib.tag)}">${botEsc(ib.protocol)}</option>`).join("");
|
||||
} catch (e) { /* xray may be off; ignore */ }
|
||||
const inbounds = await botRequest("/api/xray/inbounds");
|
||||
const datalist = document.getElementById("botInboundList");
|
||||
if (!datalist) return;
|
||||
datalist.replaceChildren(...(inbounds || []).map(inbound => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(inbound.tag || "");
|
||||
option.textContent = String(inbound.protocol || "");
|
||||
return option;
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error.message === "auth") doAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Plans ───
|
||||
// Plans
|
||||
async function loadBotPlans() {
|
||||
try {
|
||||
const res = await api("/api/bot/plans");
|
||||
const plans = await res.json() || [];
|
||||
document.getElementById("botPlanCount").textContent = plans.length;
|
||||
document.getElementById("botPlansBody").innerHTML = plans.map(p => `
|
||||
<tr>
|
||||
<td>${botEsc(p.Name)}</td><td>${botEsc(p.Kind)}</td><td>${p.Days}</td>
|
||||
<td>${brl(p.PriceCents)}</td><td>${p.CreditCost}</td>
|
||||
<td>${p.IsActive ? "✅" : "—"}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick='botEditPlan(${JSON.stringify(p)})'>Editar</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick='botDeletePlan(${p.ID})'>Excluir</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
botStatus("botPlansStatus", "Ready.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao carregar.", false); }
|
||||
}
|
||||
function botEditPlan(p) {
|
||||
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; };
|
||||
set("planId", p.ID); set("planName", p.Name); set("planKind", p.Kind); set("planDays", p.Days);
|
||||
set("planMaxConns", p.MaxConnections); set("planUpMbps", p.LimitMbpsUp); set("planDownMbps", p.LimitMbpsDown);
|
||||
set("planInbound", p.XrayInboundTag); set("planProtocol", p.XrayProtocol);
|
||||
set("planPrice", (p.PriceCents / 100).toFixed(2)); set("planCreditCost", p.CreditCost);
|
||||
set("planServerId", p.ServerID); set("planSort", p.SortOrder);
|
||||
document.getElementById("planActive").checked = !!p.IsActive;
|
||||
document.getElementById("botPlanFormTitle").textContent = "Editar Plano #" + p.ID;
|
||||
}
|
||||
function botClearPlanForm() {
|
||||
document.getElementById("botPlanForm").reset();
|
||||
document.getElementById("planId").value = "";
|
||||
document.getElementById("botPlanFormTitle").textContent = "Novo Plano";
|
||||
}
|
||||
async function botSavePlan(ev) {
|
||||
ev.preventDefault();
|
||||
const val = id => document.getElementById(id).value;
|
||||
const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const body = {
|
||||
id: num("planId"), name: val("planName"), kind: val("planKind"), days: num("planDays"),
|
||||
max_connections: num("planMaxConns"), limit_mbps_up: num("planUpMbps"), limit_mbps_down: num("planDownMbps"),
|
||||
xray_inbound_tag: val("planInbound"), xray_protocol: val("planProtocol"),
|
||||
price_cents: Math.round(parseFloat(val("planPrice") || "0") * 100),
|
||||
credit_cost: num("planCreditCost"), server_id: val("planServerId"), sort_order: num("planSort"),
|
||||
is_active: document.getElementById("planActive").checked,
|
||||
};
|
||||
// map to Go struct JSON tags (exported field names)
|
||||
const payload = {
|
||||
ID: body.id, Name: body.name, Kind: body.kind, Days: body.days, MaxConnections: body.max_connections,
|
||||
LimitMbpsUp: body.limit_mbps_up, LimitMbpsDown: body.limit_mbps_down, XrayInboundTag: body.xray_inbound_tag,
|
||||
XrayProtocol: body.xray_protocol, PriceCents: body.price_cents, CreditCost: body.credit_cost,
|
||||
ServerID: body.server_id, IsActive: body.is_active, SortOrder: body.sort_order,
|
||||
};
|
||||
try {
|
||||
await api("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPlanForm(); loadBotPlans(); botStatus("botPlansStatus", "Plano salvo.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao salvar.", false); }
|
||||
}
|
||||
async function botDeletePlan(id) {
|
||||
if (!confirm("Excluir este plano?")) return;
|
||||
try { await api("/api/bot/plans?id=" + id, { method: "DELETE" }); loadBotPlans(); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao excluir.", false); }
|
||||
const plans = await botRequest("/api/bot/plans");
|
||||
botState.plans = plans || [];
|
||||
renderBotPlans(botState.plans);
|
||||
botStatus("botPlansStatus", `${botState.plans.length} plano(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return plans;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao carregar planos.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Credit packages ───
|
||||
function renderBotPlans(plans) {
|
||||
const body = document.getElementById("botPlansBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPlanCount").textContent = String(plans.length);
|
||||
if (!plans.length) return botEmptyRow(body, 6, "Nenhum plano cadastrado. Crie o primeiro ao lado.");
|
||||
const rows = plans.map(plan => {
|
||||
const row = document.createElement("tr");
|
||||
const delivery = plan.Kind === "xray" ? `Xray${plan.XrayProtocol ? " · " + plan.XrayProtocol.toUpperCase() : ""}` : "SSH";
|
||||
const price = botPrimaryCell(botBRL(plan.PriceCents), `${plan.CreditCost || 0} crédito(s)`);
|
||||
row.append(
|
||||
botCell(botPrimaryCell(plan.Name, `#${plan.ID}`)), botCell(delivery),
|
||||
botCell(`${plan.Days} dias`), botCell(price),
|
||||
botCell(botBadge(plan.IsActive ? "Ativo" : "Oculto", plan.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPlan(plan)),
|
||||
botButton("Excluir", () => botDeletePlan(plan.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
function botEditPlan(plan) {
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
set("planId", plan.ID); set("planName", plan.Name); set("planKind", plan.Kind); set("planDays", plan.Days);
|
||||
set("planMaxConns", plan.MaxConnections); set("planUpMbps", plan.LimitMbpsUp); set("planDownMbps", plan.LimitMbpsDown);
|
||||
set("planInbound", plan.XrayInboundTag); set("planProtocol", plan.XrayProtocol); set("planPrice", (Number(plan.PriceCents) / 100).toFixed(2));
|
||||
set("planCreditCost", plan.CreditCost); set("planServerId", plan.ServerID); set("planSort", plan.SortOrder);
|
||||
document.getElementById("planActive").checked = !!plan.IsActive;
|
||||
document.getElementById("botPlanFormTitle").textContent = `Editar ${plan.Name}`;
|
||||
document.getElementById("planName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPlanForm() {
|
||||
document.getElementById("botPlanForm")?.reset();
|
||||
document.getElementById("planId").value = "";
|
||||
document.getElementById("planActive").checked = true;
|
||||
document.getElementById("botPlanFormTitle").textContent = "Novo plano";
|
||||
}
|
||||
|
||||
async function botSavePlan(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("planId"), Name: value("planName"), Kind: value("planKind"), Days: number("planDays"),
|
||||
MaxConnections: number("planMaxConns"), LimitMbpsUp: number("planUpMbps"), LimitMbpsDown: number("planDownMbps"),
|
||||
XrayInboundTag: value("planInbound"), XrayProtocol: value("planProtocol"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("planPrice")) || 0) * 100), CreditCost: number("planCreditCost"),
|
||||
ServerID: value("planServerId"), IsActive: document.getElementById("planActive").checked, SortOrder: number("planSort"),
|
||||
};
|
||||
botStatus("botPlansStatus", "Salvando plano…");
|
||||
try {
|
||||
await botRequest("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPlanForm();
|
||||
await loadBotPlans();
|
||||
botStatus("botPlansStatus", "Plano salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao salvar plano.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePlan(id) {
|
||||
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 {
|
||||
await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPlans();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao excluir plano.");
|
||||
}
|
||||
}
|
||||
|
||||
// Credit packages
|
||||
async function loadBotPkgs() {
|
||||
try {
|
||||
const res = await api("/api/bot/credit-packages");
|
||||
const pkgs = await res.json() || [];
|
||||
document.getElementById("botPkgCount").textContent = pkgs.length;
|
||||
document.getElementById("botPkgsBody").innerHTML = pkgs.map(p => `
|
||||
<tr>
|
||||
<td>${botEsc(p.Name)}</td><td>${p.Credits}</td><td>${brl(p.PriceCents)}</td>
|
||||
<td>${p.IsActive ? "✅" : "—"}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick='botEditPkg(${JSON.stringify(p)})'>Editar</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick='botDeletePkg(${p.ID})'>Excluir</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
botStatus("botPkgStatus", "Ready.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao carregar.", false); }
|
||||
}
|
||||
function botEditPkg(p) {
|
||||
const set = (id, v) => { document.getElementById(id).value = v ?? ""; };
|
||||
set("pkgId", p.ID); set("pkgName", p.Name); set("pkgCredits", p.Credits);
|
||||
set("pkgPrice", (p.PriceCents / 100).toFixed(2)); set("pkgSort", p.SortOrder);
|
||||
document.getElementById("pkgActive").checked = !!p.IsActive;
|
||||
}
|
||||
function botClearPkgForm() { document.getElementById("botPkgForm").reset(); document.getElementById("pkgId").value = ""; }
|
||||
async function botSavePkg(ev) {
|
||||
ev.preventDefault();
|
||||
const val = id => document.getElementById(id).value;
|
||||
const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: num("pkgId"), Name: val("pkgName"), Credits: num("pkgCredits"),
|
||||
PriceCents: Math.round(parseFloat(val("pkgPrice") || "0") * 100),
|
||||
SortOrder: num("pkgSort"), IsActive: document.getElementById("pkgActive").checked,
|
||||
};
|
||||
try { await api("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) }); botClearPkgForm(); loadBotPkgs(); botStatus("botPkgStatus", "Pacote salvo."); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao salvar.", false); }
|
||||
}
|
||||
async function botDeletePkg(id) {
|
||||
if (!confirm("Excluir este pacote?")) return;
|
||||
try { await api("/api/bot/credit-packages?id=" + id, { method: "DELETE" }); loadBotPkgs(); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao excluir.", false); }
|
||||
const packages = await botRequest("/api/bot/credit-packages");
|
||||
botState.packages = packages || [];
|
||||
renderBotPackages(botState.packages);
|
||||
botStatus("botPkgStatus", `${botState.packages.length} pacote(s) carregado(s).`, true);
|
||||
return packages;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao carregar pacotes.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bot users ───
|
||||
function renderBotPackages(packages) {
|
||||
const body = document.getElementById("botPkgsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPkgCount").textContent = String(packages.length);
|
||||
if (!packages.length) return botEmptyRow(body, 5, "Nenhum pacote de créditos cadastrado.");
|
||||
body.replaceChildren(...packages.map(item => {
|
||||
const row = document.createElement("tr");
|
||||
row.append(
|
||||
botCell(botPrimaryCell(item.Name, `#${item.ID}`)), botCell(`${item.Credits} créditos`), botCell(botBRL(item.PriceCents)),
|
||||
botCell(botBadge(item.IsActive ? "Ativo" : "Oculto", item.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPkg(item)),
|
||||
botButton("Excluir", () => botDeletePkg(item.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botEditPkg(item) {
|
||||
const set = (id, value) => { document.getElementById(id).value = value ?? ""; };
|
||||
set("pkgId", item.ID); set("pkgName", item.Name); set("pkgCredits", item.Credits);
|
||||
set("pkgPrice", (Number(item.PriceCents) / 100).toFixed(2)); set("pkgSort", item.SortOrder);
|
||||
document.getElementById("pkgActive").checked = !!item.IsActive;
|
||||
document.getElementById("pkgName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPkgForm() {
|
||||
document.getElementById("botPkgForm")?.reset();
|
||||
document.getElementById("pkgId").value = "";
|
||||
document.getElementById("pkgActive").checked = true;
|
||||
}
|
||||
|
||||
async function botSavePkg(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("pkgId"), Name: value("pkgName"), Credits: number("pkgCredits"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("pkgPrice")) || 0) * 100),
|
||||
SortOrder: number("pkgSort"), IsActive: document.getElementById("pkgActive").checked,
|
||||
};
|
||||
botStatus("botPkgStatus", "Salvando pacote…");
|
||||
try {
|
||||
await botRequest("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPkgForm();
|
||||
await loadBotPkgs();
|
||||
botStatus("botPkgStatus", "Pacote salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao salvar pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePkg(id) {
|
||||
const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir pacote", message:"Excluir este pacote de créditos?", confirmLabel:"Excluir pacote" });
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPkgs();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao excluir pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
// Users
|
||||
async function loadBotUsers() {
|
||||
try {
|
||||
const res = await api("/api/bot/users");
|
||||
const users = await res.json() || [];
|
||||
document.getElementById("botUserCount").textContent = users.length;
|
||||
document.getElementById("botUsersBody").innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.TelegramID}</td><td>${botEsc(u.FirstName)}</td><td>${botEsc(u.Username)}</td>
|
||||
<td>${botEsc(u.Role)}</td><td>${botEsc(u.LinkedAdminUsername)}</td><td>${u.CreditBalance}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick='botPromote(${u.TelegramID})'>Papel</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick='botCredit(${u.TelegramID})'>Créditos</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick='botBlock(${u.TelegramID}, ${u.Role === "blocked"})'>${u.Role === "blocked" ? "Desbloq." : "Bloquear"}</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
botStatus("botUsersStatus", "Ready.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro ao carregar.", false); }
|
||||
}
|
||||
async function botPromote(tid) {
|
||||
const role = prompt("Papel (customer / reseller / blocked):", "reseller");
|
||||
if (!role) return;
|
||||
let linked = "";
|
||||
if (role === "reseller") { linked = prompt("Username do revendedor (admin_users) vinculado:", "") || ""; }
|
||||
try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "set_role", role, linked_admin_username: linked }) }); loadBotUsers(); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); }
|
||||
}
|
||||
async function botCredit(tid) {
|
||||
const v = prompt("Ajuste de créditos (use negativo para remover):", "10");
|
||||
if (v === null) return;
|
||||
const n = parseInt(v, 10); if (isNaN(n)) return;
|
||||
try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "adjust_credits", credits: n }) }); loadBotUsers(); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro (saldo insuficiente?).", false); }
|
||||
}
|
||||
async function botBlock(tid, isBlocked) {
|
||||
try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: isBlocked ? "unblock" : "block" }) }); loadBotUsers(); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); }
|
||||
const users = await botRequest("/api/bot/users");
|
||||
botState.users = users || [];
|
||||
renderBotUsers(botState.users);
|
||||
botStatus("botUsersStatus", `${botState.users.length} cliente(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return users;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao carregar clientes.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transactions ───
|
||||
function renderBotUsers(users) {
|
||||
const body = document.getElementById("botUsersBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botUserCount").textContent = String(users.length);
|
||||
if (!users.length) return botEmptyRow(body, 6, "Nenhum cliente conversou com o bot ainda.");
|
||||
body.replaceChildren(...users.map(user => {
|
||||
const row = document.createElement("tr");
|
||||
const displayName = user.FirstName || user.Username || "Sem nome";
|
||||
const username = user.Username ? `@${user.Username}` : "Sem username";
|
||||
const isBlocked = user.Role === "blocked";
|
||||
row.append(
|
||||
botCell(botPrimaryCell(displayName, username)), botCell(user.TelegramID),
|
||||
botCell(botBadge(user.Role || "customer", user.Role || "customer")),
|
||||
botCell(user.LinkedAdminUsername || "—"), botCell(`${user.CreditBalance || 0} créditos`),
|
||||
botCell(botActions([
|
||||
botButton("Função", () => botOpenUserAction(user, "role")),
|
||||
botButton("Saldo", () => botOpenUserAction(user, "credits")),
|
||||
botButton(isBlocked ? "Desbloquear" : "Bloquear", () => botToggleBlock(user), isBlocked ? "btn btn-ghost btn-sm" : "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botOpenUserAction(user, mode) {
|
||||
document.getElementById("botActionTelegramID").value = String(user.TelegramID);
|
||||
document.getElementById("botActionMode").value = mode;
|
||||
document.getElementById("botUserActionTitle").textContent = mode === "role" ? "Alterar função" : "Ajustar créditos";
|
||||
document.getElementById("botUserActionSubtitle").textContent = `${user.FirstName || user.Username || "Cliente"} · ID ${user.TelegramID}`;
|
||||
document.getElementById("botRoleFields").classList.toggle("hidden", mode !== "role");
|
||||
document.getElementById("botCreditFields").classList.toggle("hidden", mode !== "credits");
|
||||
document.getElementById("botActionRole").value = user.Role || "customer";
|
||||
document.getElementById("botActionLinked").value = user.LinkedAdminUsername || "";
|
||||
document.getElementById("botActionCredits").value = "";
|
||||
botToggleLinkedAdminField();
|
||||
document.getElementById("botUserActionModal").classList.remove("hidden");
|
||||
document.body.classList.add("bot-modal-open");
|
||||
setTimeout(() => (mode === "role" ? document.getElementById("botActionRole") : document.getElementById("botActionCredits"))?.focus(), 0);
|
||||
}
|
||||
|
||||
function botCloseUserAction() {
|
||||
document.getElementById("botUserActionModal")?.classList.add("hidden");
|
||||
document.body.classList.remove("bot-modal-open");
|
||||
}
|
||||
|
||||
function botToggleLinkedAdminField() {
|
||||
const show = document.getElementById("botActionRole")?.value === "reseller";
|
||||
document.getElementById("botActionLinkedField")?.classList.toggle("hidden", !show);
|
||||
}
|
||||
|
||||
async function botSaveUserAction(event) {
|
||||
event.preventDefault();
|
||||
const telegramID = Number.parseInt(document.getElementById("botActionTelegramID").value, 10);
|
||||
const mode = document.getElementById("botActionMode").value;
|
||||
const payload = mode === "role" ? {
|
||||
telegram_id: telegramID, action: "set_role", role: document.getElementById("botActionRole").value,
|
||||
linked_admin_username: document.getElementById("botActionLinked").value.trim(),
|
||||
} : {
|
||||
telegram_id: telegramID, action: "adjust_credits", credits: Number.parseInt(document.getElementById("botActionCredits").value, 10) || 0,
|
||||
};
|
||||
try {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify(payload) });
|
||||
botCloseUserAction();
|
||||
await loadBotUsers();
|
||||
botStatus("botUsersStatus", "Cliente atualizado.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao atualizar cliente.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botToggleBlock(user) {
|
||||
const isBlocked = user.Role === "blocked";
|
||||
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 {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) });
|
||||
await loadBotUsers();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao alterar bloqueio.");
|
||||
}
|
||||
}
|
||||
|
||||
// Transactions
|
||||
async function loadBotTxns() {
|
||||
const filter = document.getElementById("botTxnFilter")?.value || "";
|
||||
try {
|
||||
const res = await api("/api/bot/transactions?limit=200&status=" + encodeURIComponent(filter));
|
||||
const txns = await res.json() || [];
|
||||
document.getElementById("botTxnCount").textContent = txns.length;
|
||||
document.getElementById("botTxnsBody").innerHTML = txns.map(t => `
|
||||
<tr>
|
||||
<td>${t.ID}</td><td>${t.TelegramID}</td><td>${botEsc(t.Type)}</td><td>${brl(t.AmountCents)}</td>
|
||||
<td>${botEsc(t.Status)}</td><td>${botEsc(t.TargetUsername)}</td>
|
||||
<td>${botEsc((t.CreatedAt || "").slice(0, 16).replace("T", " "))}</td>
|
||||
<td>
|
||||
${t.Status === "pending" || t.Status === "approved" ? `<button class="btn btn-ghost btn-sm" onclick='botReprocess(${t.ID})'>Reprocessar</button>` : ""}
|
||||
${t.Status !== "refunded" ? `<button class="btn btn-ghost btn-sm" onclick='botRefund(${t.ID})'>Estornar</button>` : ""}
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
botStatus("botTxnStatus", "Ready.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro ao carregar.", false); }
|
||||
}
|
||||
async function botReprocess(id) {
|
||||
try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) }); botStatus("botTxnStatus", "Reprocessando #" + id + "..."); setTimeout(loadBotTxns, 1500); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro (bot ativo?).", false); }
|
||||
}
|
||||
async function botRefund(id) {
|
||||
if (!confirm("Marcar pagamento #" + id + " como estornado?")) return;
|
||||
try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) }); loadBotTxns(); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro.", false); }
|
||||
const transactions = await botRequest(`/api/bot/transactions?limit=200&status=${encodeURIComponent(filter)}`);
|
||||
botState.transactions = transactions || [];
|
||||
renderBotTransactions(botState.transactions);
|
||||
botStatus("botTxnStatus", `${botState.transactions.length} pagamento(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao carregar pagamentos.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings (messages) ───
|
||||
function botTransactionType(type) {
|
||||
return ({ plan_purchase: "Compra de plano", plan_renewal: "Renovação", credit_topup: "Recarga" })[type] || type || "—";
|
||||
}
|
||||
|
||||
function renderBotTransactions(transactions) {
|
||||
const body = document.getElementById("botTxnsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botTxnCount").textContent = String(transactions.length);
|
||||
if (!transactions.length) return botEmptyRow(body, 8, "Nenhum pagamento encontrado para este filtro.");
|
||||
body.replaceChildren(...transactions.map(transaction => {
|
||||
const row = document.createElement("tr");
|
||||
const createdAt = transaction.CreatedAt ? new Date(transaction.CreatedAt).toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }) : "—";
|
||||
const buttons = [];
|
||||
if (transaction.Status === "pending" || transaction.Status === "approved") buttons.push(botButton("Reprocessar", () => botReprocess(transaction.ID)));
|
||||
if (transaction.Status !== "refunded") buttons.push(botButton("Marcar estornado", () => botRefund(transaction.ID), "btn btn-danger btn-sm"));
|
||||
row.append(
|
||||
botCell(botPrimaryCell(`#${transaction.ID}`, transaction.MPPaymentID ? `MP ${transaction.MPPaymentID}` : "Sem ID Mercado Pago")),
|
||||
botCell(transaction.TelegramID), botCell(botTransactionType(transaction.Type)), botCell(botBRL(transaction.AmountCents)),
|
||||
botCell(botBadge(transaction.Status || "unknown", transaction.Status || "inactive")), botCell(transaction.TargetUsername || "Aguardando"),
|
||||
botCell(createdAt), botCell(botActions(buttons)),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function botReprocess(id) {
|
||||
try {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) });
|
||||
botStatus("botTxnStatus", `Pagamento #${id} enviado para reprocessamento.`, true);
|
||||
setTimeout(loadBotTxns, 1400);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao reprocessar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botRefund(id) {
|
||||
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 {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) });
|
||||
await loadBotTxns();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao atualizar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
async function loadBotSettings() {
|
||||
try {
|
||||
const res = await api("/api/bot/settings");
|
||||
const s = await res.json() || {};
|
||||
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v || ""; };
|
||||
set("setWelcome", s.welcome_text); set("setContact", s.contact_text);
|
||||
set("setAppText", s.app_text); set("setAppUrl", s.app_url);
|
||||
botStatus("botSettingsStatus", "Ready.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao carregar.", false); }
|
||||
}
|
||||
async function saveBotSettings() {
|
||||
const val = id => document.getElementById(id)?.value || "";
|
||||
const payload = { welcome_text: val("setWelcome"), contact_text: val("setContact"), app_text: val("setAppText"), app_url: val("setAppUrl") };
|
||||
try { await api("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) }); botStatus("botSettingsStatus", "Mensagens salvas."); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao salvar.", false); }
|
||||
const settings = await botRequest("/api/bot/settings");
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value || ""; };
|
||||
set("setWelcome", settings.welcome_text); set("setContact", settings.contact_text);
|
||||
set("setAppText", settings.app_text); set("setAppUrl", settings.app_url);
|
||||
botStatus("botSettingsStatus", "Mensagens carregadas.", true);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao carregar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wiring ───
|
||||
async function saveBotSettings() {
|
||||
const value = id => document.getElementById(id)?.value || "";
|
||||
const payload = { welcome_text: value("setWelcome"), contact_text: value("setContact"), app_text: value("setAppText"), app_url: value("setAppUrl").trim() };
|
||||
botStatus("botSettingsStatus", "Salvando mensagens…");
|
||||
try {
|
||||
await botRequest("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) });
|
||||
botStatus("botSettingsStatus", "Mensagens salvas.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao salvar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
// Wiring
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.addEventListener("click", () => botSetSection(button.dataset.botSection)));
|
||||
document.getElementById("botSection")?.addEventListener("change", event => botSetSection(event.target.value));
|
||||
document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig);
|
||||
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig);
|
||||
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotTab);
|
||||
document.getElementById("botTestBtn")?.addEventListener("click", testBot);
|
||||
document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox);
|
||||
document.getElementById("botSection")?.addEventListener("change", e => botShowSection(e.target.value));
|
||||
document.getElementById("botCopyWebhookBtn")?.addEventListener("click", botCopyWebhook);
|
||||
document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans);
|
||||
document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan);
|
||||
document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs);
|
||||
document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botClearPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg);
|
||||
document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers);
|
||||
document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns);
|
||||
document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns);
|
||||
document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings);
|
||||
document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings);
|
||||
document.getElementById("botUserActionForm")?.addEventListener("submit", botSaveUserAction);
|
||||
document.getElementById("botActionRole")?.addEventListener("change", botToggleLinkedAdminField);
|
||||
document.querySelectorAll("[data-bot-modal-close]").forEach(element => element.addEventListener("click", botCloseUserAction));
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") botCloseUserAction(); });
|
||||
|
||||
botSetSection(botState.section);
|
||||
|
||||
+412
-346
@@ -16,7 +16,7 @@
|
||||
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713bot3"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713reseller10"/>
|
||||
</head>
|
||||
<body>
|
||||
<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="xray"><span class="nav-icon">◇</span><span>Xray Users</span></button>
|
||||
<div class="nav-group-label superadmin-only hidden">Administração</div>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn 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-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" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon">▣</span><span>Infraestrutura</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon">☰</span><span>Logs</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="bot"><span class="nav-icon">🤖</span><span>Bot / Vendas</span></button>
|
||||
<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 ═══════════ -->
|
||||
<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) -->
|
||||
<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>
|
||||
|
||||
<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 -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
@@ -262,16 +280,17 @@
|
||||
<tbody id="usersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="userStatus">Ready.</span><span id="lastReload"></span></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 -->
|
||||
<div class="card">
|
||||
<div class="card workspace-form-card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Create / update user</div>
|
||||
<button class="btn btn-ghost btn-sm" id="toggleFormBtn">Show form</button>
|
||||
<div class="card-title" id="userFormTitle">Criar usuário SSH</div>
|
||||
</div>
|
||||
<div id="userFormWrap" class="collapsible collapsed">
|
||||
<div id="userFormWrap">
|
||||
<form id="userForm">
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Username</label><input id="fUsername" required autocomplete="off"/></div>
|
||||
@@ -299,11 +318,49 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="statusbar workspace-section-status"><span id="userStatus">Ready.</span><span id="lastReload"></span></div>
|
||||
</div><!-- /tab-ssh -->
|
||||
|
||||
<!-- ═══════════ Xray Tab (superadmin only) ═══════════ -->
|
||||
<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) -->
|
||||
<div id="editXrayClientPanel" class="card hidden" style="margin-bottom:12px;border-color:rgba(245,158,11,.4);">
|
||||
<div class="card-hdr">
|
||||
@@ -322,6 +379,7 @@
|
||||
</div>
|
||||
<div id="editXrayClientStatus" class="hint" style="margin-top:4px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="card">
|
||||
<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>
|
||||
|
||||
<section class="workspace-section active" data-workspace-panel="xray" data-workspace-section-panel="users">
|
||||
<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>
|
||||
<!-- Inbounds & clients -->
|
||||
<div class="card" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Inbounds & Clients</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>
|
||||
</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 -->
|
||||
<div class="card xray-admin-only" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Xray Config</div>
|
||||
<div class="card-actions">
|
||||
@@ -393,7 +448,38 @@
|
||||
</div>
|
||||
<!-- Wizard pane -->
|
||||
<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">
|
||||
<label>Log Level</label>
|
||||
<select id="wzLogLevel">
|
||||
@@ -404,14 +490,13 @@
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
</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 style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||||
<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>
|
||||
<div id="wzInboundsList" class="visual-inbound-list"></div>
|
||||
<!-- 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="field">
|
||||
<label>Protocol</label>
|
||||
@@ -537,13 +622,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;">
|
||||
<button class="btn btn-sm" type="button" onclick="wzSaveInbound()">Add Inbound</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('wzAddInboundForm').classList.add('hidden')">Cancel</button>
|
||||
<button class="btn btn-sm" id="wzSaveInboundBtn" type="button" onclick="wzSaveInbound()">Adicionar inbound</button>
|
||||
<button class="btn btn-ghost btn-sm" id="wzCancelInboundBtn" type="button" onclick="wzCancelInbound()">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;">
|
||||
<button class="btn btn-sm" type="button" onclick="applyWizardConfig()">Save Config</button>
|
||||
<span class="hint" id="wzStatus" style="margin-left:8px;"></span>
|
||||
<div class="visual-save-bar">
|
||||
<span class="hint" id="wzStatus"></span>
|
||||
<button class="btn" type="button" onclick="applyWizardConfig()">Salvar configuração e reiniciar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- JSON pane (hidden by default) -->
|
||||
@@ -556,20 +641,45 @@
|
||||
</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 -->
|
||||
<div class="card xray-admin-only" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<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>
|
||||
<pre class="log-box" id="xLogsBox"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</div><!-- /tab-xray -->
|
||||
|
||||
<!-- ═══════════ Resellers Tab (superadmin only) ═══════════ -->
|
||||
<!-- ═══════════ Hierarchical reseller management ═══════════ -->
|
||||
<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 -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
@@ -582,16 +692,18 @@
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Username</th><th>Users (used/max)</th><th>Expires</th><th>Status</th><th>Actions</th>
|
||||
<th>Conta</th><th>Plano e uso</th><th>Validade</th><th>Status</th><th>Ações</th>
|
||||
</tr></thead>
|
||||
<tbody id="resellersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="resellerStatus">Ready.</span></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 -->
|
||||
<div class="card">
|
||||
<div class="card workspace-form-card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title" id="resellerFormTitle">Create Reseller</div>
|
||||
<button class="btn btn-ghost btn-sm" id="cancelResellerBtn">Cancel</button>
|
||||
@@ -600,26 +712,49 @@
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Username</label><input id="rUsername" required autocomplete="off"/></div>
|
||||
<div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="rPassword" type="password" autocomplete="new-password"/></div>
|
||||
<div class="field"><label>Max SSH users (0 = unlimited)</label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>Active</label><input id="rActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
|
||||
<div class="field superadmin-only" id="rParentField"><label>Revendedor pai <span class="hint">(vazio = principal)</span></label><select id="rParent"><option value="">Principal / sem pai</option></select></div>
|
||||
<div class="field"><label>Modo do plano</label><select id="rQuotaMode"><option value="slots">Validade / slots</option><option value="credits">Créditos</option></select><span class="hint" id="rQuotaModeHint">Sub-revendedores herdam o modo da conta pai.</span></div>
|
||||
<div class="field" id="rSlotsField"><label>Limite compartilhado <span class="hint">(0 = ilimitado só para principal)</span></label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field hidden" id="rCreditsField"><label>Saldo de créditos</label><input id="rCredits" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field" id="rExpiresField"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>WhatsApp</label><input id="rWhatsApp" autocomplete="tel" placeholder="+5511999999999"/></div>
|
||||
<div class="field"><label>Valor mensal</label><input id="rMonthlyPrice" type="number" min="0" step="0.01" placeholder="0,00"/></div>
|
||||
<label class="bot-check-field"><input id="rActive" type="checkbox" checked/> Acesso ativo</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit" id="saveResellerBtn">Save reseller</button>
|
||||
</div>
|
||||
</form>
|
||||
</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 -->
|
||||
|
||||
<!-- ═══════════ Servers Tab (superadmin only) ═══════════ -->
|
||||
<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 class="grid2 servers-grid">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<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 class="tbl-wrap">
|
||||
<table>
|
||||
@@ -841,38 +976,33 @@
|
||||
|
||||
<!-- ═══════════ Servers Status Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-servers-status">
|
||||
<div class="card servers-status-toolbar">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Servers Status <span class="chip" id="serversStatusCountChip">0</span></div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-ghost btn-sm" id="refreshServersStatusBtn" type="button">Refresh</button>
|
||||
</div>
|
||||
<section class="page-hero status-hero" data-tone="green">
|
||||
<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="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="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">▣</span><div><small>Total</small><strong id="serversStatusCountChip">0</strong></div></article>
|
||||
<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 class="statusbar">
|
||||
<span id="serversStatusPageStatus">Ready.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="infra-nav-mount"></div>
|
||||
<div class="servers-status-grid" id="serversStatusGrid"></div>
|
||||
</div><!-- /tab-servers-status -->
|
||||
|
||||
<!-- ═══════════ Stats Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-stats">
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title">Server Load</div><span class="hint" id="statsUpdated">--</span></div>
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="m-label">CPU</div>
|
||||
<div class="m-val"><span id="cpuVal">--%</span></div>
|
||||
<div class="bar"><div class="bar-inner" id="cpuBar" style="width:0%"></div></div>
|
||||
</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>
|
||||
<section class="page-hero status-hero" data-tone="cyan">
|
||||
<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="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="workspace-overview-grid">
|
||||
<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>
|
||||
<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>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">⇅</span><div><small>Rede agora</small><strong id="statsNetVal">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">#</span><div><small>Interfaces</small><strong id="statsIfaceVal">--</strong></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="infra-nav-mount"></div>
|
||||
<div class="card">
|
||||
<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>
|
||||
@@ -886,26 +1016,21 @@
|
||||
</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><!-- /tab-stats -->
|
||||
|
||||
<!-- ═══════════ VnStat Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-vnstat">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">VnStat Usage <span class="chip">daily / monthly</span></div>
|
||||
<div class="form-actions" style="margin-top:0">
|
||||
<button class="btn btn-ghost btn-sm" id="reloadVnstatBtn" type="button">Refresh</button>
|
||||
<button class="btn btn-danger btn-sm" id="resetVnstatBtn" type="button">Clean VnStat history</button>
|
||||
</div>
|
||||
<section class="page-hero status-hero" data-tone="purple">
|
||||
<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="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="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan">D</span><div><small>Hoje</small><strong id="vnTodayTotal">--</strong></div></article>
|
||||
<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>
|
||||
<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 class="metrics">
|
||||
<div class="metric"><div class="m-label">Today total</div><div class="m-val" id="vnTodayTotal">--</div></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>
|
||||
</section>
|
||||
<div class="infra-nav-mount"></div>
|
||||
|
||||
<div class="grid2" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
@@ -931,6 +1056,7 @@
|
||||
|
||||
<!-- ═══════════ Logs Tab (superadmin only) ═══════════ -->
|
||||
<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-hdr">
|
||||
<div class="card-title">System Logs</div>
|
||||
@@ -951,242 +1077,159 @@
|
||||
|
||||
<!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-bot">
|
||||
|
||||
<!-- Section switcher -->
|
||||
<div class="card">
|
||||
<div class="card-hdr" style="align-items:center;">
|
||||
<div class="card-title">🤖 Bot / Vendas</div>
|
||||
<select id="botSection" class="btn-sm" style="min-width:230px;font-weight:600;">
|
||||
<option value="config">⚙️ Configuração</option>
|
||||
<option value="plans">💎 Planos</option>
|
||||
<option value="packages">💳 Pacotes de Crédito</option>
|
||||
<option value="messages">✉️ Mensagens</option>
|
||||
<option value="users">👥 Clientes</option>
|
||||
<option value="transactions">💰 Pagamentos</option>
|
||||
</select>
|
||||
<section class="bot-hero">
|
||||
<div class="bot-hero-copy">
|
||||
<span class="bot-eyebrow">Central de automação</span>
|
||||
<h2>Bot de vendas</h2>
|
||||
<p>Telegram, PIX, planos e clientes em um único fluxo.</p>
|
||||
</div>
|
||||
<div class="bot-hero-actions">
|
||||
<span id="botConfigStatus" class="bot-live-status">Aguardando dados</span>
|
||||
<button class="btn btn-ghost btn-sm" id="botTestBtn" type="button">Testar integrações</button>
|
||||
<button class="btn btn-ghost btn-sm" id="botConfigReloadBtn" type="button">Atualizar tudo</button>
|
||||
</div>
|
||||
<div class="bot-overview-grid">
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon">●</span><div><small>Operação</small><strong id="botMetricState">Carregando</strong></div></article>
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon bot-purple">◆</span><div><small>Planos</small><strong id="botMetricPlans">0</strong></div></article>
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon bot-green">↗</span><div><small>Clientes</small><strong id="botMetricUsers">0</strong></div></article>
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon bot-amber">$</span><div><small>PIX pendentes</small><strong id="botMetricPending">0</strong></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bot-nav-shell">
|
||||
<nav class="bot-section-nav" id="botSectionNav" aria-label="Áreas do bot">
|
||||
<button class="active" type="button" data-bot-section="config"><span>⚙</span> Integrações</button>
|
||||
<button type="button" data-bot-section="plans"><span>◆</span> Planos</button>
|
||||
<button type="button" data-bot-section="packages"><span>▤</span> Créditos</button>
|
||||
<button type="button" data-bot-section="messages"><span>✉</span> Mensagens</button>
|
||||
<button type="button" data-bot-section="users"><span>◎</span> Clientes</button>
|
||||
<button type="button" data-bot-section="transactions"><span>$</span> Pagamentos</button>
|
||||
</nav>
|
||||
<select id="botSection" class="bot-section-select" aria-label="Área do bot">
|
||||
<option value="config">Integrações</option><option value="plans">Planos</option><option value="packages">Créditos</option>
|
||||
<option value="messages">Mensagens</option><option value="users">Clientes</option><option value="transactions">Pagamentos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section: Config ═══ -->
|
||||
<div class="bot-section" id="botSec-config">
|
||||
<!-- Config: sticky action bar -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">⚙️ Configuração do Bot</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
|
||||
<span id="botConfigStatus" class="hint">Pronto.</span>
|
||||
<button class="btn btn-ghost btn-sm" id="botTestBtn">Testar conexão</button>
|
||||
<button class="btn btn-ghost btn-sm" id="botConfigReloadBtn">Recarregar</button>
|
||||
<button class="btn btn-sm" id="botConfigSaveBtn">Salvar configuração</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin:2px 0 0;">1) Cole os tokens abaixo e clique <b>Testar</b> · 2) Crie os <b>Planos</b> · 3) Promova revendedores em <b>Clientes</b>. O Telegram funciona por long-polling — não precisa de domínio.</p>
|
||||
</div>
|
||||
<section class="bot-section active" id="botSec-config" data-bot-panel="config">
|
||||
<div class="bot-section-heading"><div><span>01 · Integrações</span><h3>Conecte os serviços</h3><p>Tokens ficam criptografados. Campos vazios mantêm o valor atual.</p></div><button class="btn btn-sm" id="botConfigSaveBtn" type="button">Salvar e reiniciar bot</button></div>
|
||||
<div class="bot-config-grid">
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon telegram">✈</div><div><h4>Telegram</h4><p>Canal de atendimento e entrega</p></div><label class="bot-switch"><input id="botEnabled" type="checkbox"/><span></span></label></div>
|
||||
<div class="field"><label>Token do @BotFather <span class="bot-secret-state" id="botHasTgToken"></span></label><input id="botTelegramToken" type="password" autocomplete="new-password" placeholder="Em branco para manter o token salvo"/></div>
|
||||
<div class="field"><label>IDs dos administradores</label><input id="botAdminIDs" inputmode="numeric" placeholder="111111111, 222222222"/><span class="hint">Separe mais de um ID por vírgula.</span></div>
|
||||
</article>
|
||||
|
||||
<div class="grid2">
|
||||
<!-- Telegram -->
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title">✈️ Telegram</div></div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px;cursor:pointer;">
|
||||
<input id="botEnabled" type="checkbox" style="width:16px;height:16px;"/> <span>Bot ativo</span>
|
||||
</label>
|
||||
<div class="field"><label>Token do bot <span class="hint" id="botHasTgToken"></span></label><input id="botTelegramToken" type="password" autocomplete="new-password" placeholder="cole o token do @BotFather (em branco = manter)"/></div>
|
||||
<div class="field"><label>IDs de admin <span class="hint">separados por vírgula</span></label><input id="botAdminIDs" placeholder="111111111,222222222"/></div>
|
||||
</div>
|
||||
|
||||
<!-- Mercado Pago -->
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title">💠 Mercado Pago (PIX)</div></div>
|
||||
<div class="field"><label>Access Token <span class="hint" id="botHasMpToken"></span></label><input id="botMPToken" type="password" autocomplete="new-password" placeholder="APP_USR-... (em branco = manter)"/></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Confirmação</label><select id="botMPConfirmMode"><option value="polling">Polling (sem domínio)</option><option value="webhook">Webhook</option></select></div>
|
||||
<div class="field"><label>Intervalo do polling</label><input id="botMPPollInterval" placeholder="20s"/></div>
|
||||
<div class="field"><label>Expiração do PIX (min)</label><input id="botPixExp" type="number" min="1" placeholder="30"/></div>
|
||||
</div>
|
||||
<div id="botMPWebhookBox" style="display:none;margin-top:6px;">
|
||||
<div class="field"><label>Webhook Secret <span class="hint" id="botHasMpSecret"></span></label><input id="botMPWebhookSecret" type="password" autocomplete="new-password" placeholder="(em branco = manter)"/></div>
|
||||
<p class="hint" style="margin:4px 0 0;">No Mercado Pago, cadastre a URL de notificação:<br><code id="botMPWebhookURL">https://SEU_HOST/api/mp/webhook</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<!-- Trial -->
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title">⏳ Teste Grátis</div></div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px;cursor:pointer;">
|
||||
<input id="botTrialEnabled" type="checkbox" style="width:16px;height:16px;"/> <span>Oferecer teste grátis</span>
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Duração (horas)</label><input id="botTrialHours" type="number" min="1" placeholder="1"/></div>
|
||||
<div class="field"><label>Conexões</label><input id="botTrialMaxConns" type="number" min="1" placeholder="1"/></div>
|
||||
<div class="field"><label>Tipo</label><select id="botTrialKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
|
||||
<div class="field"><label>Inbound (se Xray)</label><input id="botTrialInbound" list="botInboundList" placeholder="tag"/></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery host -->
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title">🌐 Host de entrega</div></div>
|
||||
<div class="field"><label>Host/IP para SSH</label><input id="botPublicHost" placeholder="seu.dominio ou IP público"/></div>
|
||||
<div class="field"><label>Host para links Xray</label><input id="botXrayPublicHost" placeholder="seu.dominio (vless/vmess)"/></div>
|
||||
<p class="hint" style="margin:4px 0 0;">Enviado ao cliente nas credenciais após o pagamento.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /botSec-config -->
|
||||
|
||||
<!-- ═══ Section: Plans ═══ -->
|
||||
<div class="bot-section" id="botSec-plans" style="display:none;">
|
||||
<div class="grid2">
|
||||
<!-- Plans -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">💎 Planos <span class="chip" id="botPlanCount">0</span></div>
|
||||
<div style="display:flex;gap:5px;">
|
||||
<button class="btn btn-ghost btn-sm" id="botNewPlanBtn">+ Novo</button>
|
||||
<button class="btn btn-ghost btn-sm" id="botReloadPlansBtn">Recarregar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Nome</th><th>Tipo</th><th>Dias</th><th>Preço</th><th>Créd.</th><th>Ativo</th><th>Ações</th></tr></thead>
|
||||
<tbody id="botPlansBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="botPlansStatus">Ready.</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Plan form -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title" id="botPlanFormTitle">Novo Plano</div>
|
||||
<button class="btn btn-ghost btn-sm" id="botCancelPlanBtn">Limpar</button>
|
||||
</div>
|
||||
<form id="botPlanForm">
|
||||
<input type="hidden" id="planId"/>
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon mercado">MP</div><div><h4>Mercado Pago</h4><p>Recebimentos PIX automáticos</p></div></div>
|
||||
<div class="field"><label>Access Token <span class="bot-secret-state" id="botHasMpToken"></span></label><input id="botMPToken" type="password" autocomplete="new-password" placeholder="APP_USR-… (vazio mantém o atual)"/></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Nome</label><input id="planName" required/></div>
|
||||
<div class="field"><label>Tipo</label><select id="planKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
|
||||
<div class="field"><label>Dias de validade</label><input id="planDays" type="number" min="1" value="30"/></div>
|
||||
<div class="field"><label>Máx. conexões</label><input id="planMaxConns" type="number" min="0" value="1"/></div>
|
||||
<div class="field"><label>Limite Mbps ↑ (SSH)</label><input id="planUpMbps" type="number" min="0" value="0"/></div>
|
||||
<div class="field"><label>Limite Mbps ↓ (SSH)</label><input id="planDownMbps" type="number" min="0" value="0"/></div>
|
||||
<div class="field"><label>Inbound Xray</label><input id="planInbound" list="botInboundList" placeholder="tag (se Xray)"/><datalist id="botInboundList"></datalist></div>
|
||||
<div class="field"><label>Protocolo Xray</label><select id="planProtocol"><option value="">-</option><option value="vless">vless</option><option value="vmess">vmess</option><option value="trojan">trojan</option></select></div>
|
||||
<div class="field"><label>Preço (R$)</label><input id="planPrice" type="number" step="0.01" min="0" value="0"/></div>
|
||||
<div class="field"><label>Custo em créditos</label><input id="planCreditCost" type="number" min="0" value="1"/></div>
|
||||
<div class="field"><label>Server ID <span class="hint">(vazio=local)</span></label><input id="planServerId"/></div>
|
||||
<div class="field"><label>Ordem</label><input id="planSort" type="number" value="0"/></div>
|
||||
<div class="field"><label>Ativo</label><input id="planActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
|
||||
<div class="field"><label>Confirmação</label><select id="botMPConfirmMode"><option value="polling">Polling — recomendado</option><option value="webhook">Webhook assinado</option></select></div>
|
||||
<div class="field"><label>Intervalo</label><input id="botMPPollInterval" value="20s" placeholder="20s"/></div>
|
||||
<div class="field"><label>Validade do PIX</label><div class="bot-input-suffix"><input id="botPixExp" type="number" min="5" max="1440" value="30"/><span>min</span></div></div>
|
||||
</div>
|
||||
<div class="form-actions"><button class="btn" type="submit">Salvar plano</button></div>
|
||||
<div id="botMPWebhookBox" class="bot-webhook-box hidden">
|
||||
<div class="field"><label>Webhook Secret <span class="bot-secret-state" id="botHasMpSecret"></span></label><input id="botMPWebhookSecret" type="password" autocomplete="new-password" placeholder="Mínimo de 16 caracteres"/></div>
|
||||
<div class="bot-copy-row"><code id="botMPWebhookURL">https://SEU_HOST/api/mp/webhook</code><button class="btn btn-ghost btn-sm" id="botCopyWebhookBtn" type="button">Copiar</button></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon trial">◷</div><div><h4>Teste grátis</h4><p>Entrada controlada para novos clientes</p></div><label class="bot-switch"><input id="botTrialEnabled" type="checkbox"/><span></span></label></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Duração</label><div class="bot-input-suffix"><input id="botTrialHours" type="number" min="1" max="720" value="1"/><span>h</span></div></div>
|
||||
<div class="field"><label>Conexões</label><input id="botTrialMaxConns" type="number" min="1" max="1000" value="1"/></div>
|
||||
<div class="field"><label>Tipo</label><select id="botTrialKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
|
||||
<div class="field"><label>Inbound Xray</label><input id="botTrialInbound" list="botInboundList" placeholder="Somente para Xray"/></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon host">⌁</div><div><h4>Entrega</h4><p>Endereços enviados após a compra</p></div></div>
|
||||
<div class="field"><label>Host/IP para SSH</label><input id="botPublicHost" placeholder="vpn.seudominio.com ou IP"/></div>
|
||||
<div class="field"><label>Host para links Xray</label><input id="botXrayPublicHost" placeholder="xray.seudominio.com"/></div>
|
||||
<div class="bot-note">Esses endereços aparecem nas credenciais entregues ao cliente.</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-plans" data-bot-panel="plans">
|
||||
<div class="bot-section-heading"><div><span>02 · Catálogo</span><h3>Planos de acesso</h3><p>Configure preço, validade, protocolo e limites.</p></div><div class="card-actions"><button class="btn btn-ghost btn-sm" id="botReloadPlansBtn" type="button">Atualizar</button><button class="btn btn-sm" id="botNewPlanBtn" type="button">Novo plano</button></div></div>
|
||||
<div class="bot-master-detail">
|
||||
<article class="card"><div class="bot-list-heading"><strong>Planos cadastrados</strong><span class="chip" id="botPlanCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Plano</th><th>Entrega</th><th>Validade</th><th>Preço</th><th>Status</th><th></th></tr></thead><tbody id="botPlansBody"></tbody></table></div><div class="statusbar"><span id="botPlansStatus">Aguardando.</span></div></article>
|
||||
<article class="card bot-editor-card"><div class="card-hdr"><div><div class="card-title" id="botPlanFormTitle">Novo plano</div><p class="hint">Defina como a conta será criada.</p></div><button class="btn btn-ghost btn-sm" id="botCancelPlanBtn" type="button">Limpar</button></div>
|
||||
<form id="botPlanForm"><input type="hidden" id="planId"/><div class="form-grid">
|
||||
<div class="field bot-span-2"><label>Nome do plano</label><input id="planName" maxlength="120" required placeholder="Ex.: Premium 30 dias"/></div>
|
||||
<div class="field"><label>Tipo</label><select id="planKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div><div class="field"><label>Dias</label><input id="planDays" type="number" min="1" max="3650" value="30"/></div>
|
||||
<div class="field"><label>Máx. conexões</label><input id="planMaxConns" type="number" min="0" max="10000" value="1"/></div><div class="field"><label>Preço (R$)</label><input id="planPrice" type="number" step="0.01" min="0" value="0"/></div>
|
||||
<div class="field"><label>Upload Mbps</label><input id="planUpMbps" type="number" min="0" value="0"/></div><div class="field"><label>Download Mbps</label><input id="planDownMbps" type="number" min="0" value="0"/></div>
|
||||
<div class="field"><label>Inbound Xray</label><input id="planInbound" list="botInboundList" placeholder="tag do inbound"/><datalist id="botInboundList"></datalist></div><div class="field"><label>Protocolo Xray</label><select id="planProtocol"><option value="">Automático</option><option value="vless">VLESS</option><option value="vmess">VMess</option><option value="trojan">Trojan</option></select></div>
|
||||
<div class="field"><label>Custo em créditos</label><input id="planCreditCost" type="number" min="0" value="1"/></div><div class="field"><label>Servidor</label><input id="planServerId" placeholder="Vazio = local"/></div>
|
||||
<div class="field"><label>Ordem</label><input id="planSort" type="number" value="0"/></div><label class="bot-check-field"><input id="planActive" type="checkbox" checked/><span>Plano visível no bot</span></label>
|
||||
</div><div class="form-actions"><button class="btn" type="submit">Salvar plano</button></div></form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-packages" data-bot-panel="packages">
|
||||
<div class="bot-section-heading"><div><span>03 · Revendedores</span><h3>Pacotes de créditos</h3><p>Crie recargas para revendedores comprarem planos.</p></div><div class="card-actions"><button class="btn btn-ghost btn-sm" id="botReloadPkgsBtn" type="button">Atualizar</button><button class="btn btn-sm" id="botNewPkgBtn" type="button">Novo pacote</button></div></div>
|
||||
<div class="bot-master-detail">
|
||||
<article class="card"><div class="bot-list-heading"><strong>Pacotes cadastrados</strong><span class="chip" id="botPkgCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Pacote</th><th>Créditos</th><th>Preço</th><th>Status</th><th></th></tr></thead><tbody id="botPkgsBody"></tbody></table></div><div class="statusbar"><span id="botPkgStatus">Aguardando.</span></div></article>
|
||||
<article class="card bot-editor-card"><div class="card-hdr"><div><div class="card-title">Editar pacote</div><p class="hint">Defina o saldo entregue e o preço PIX.</p></div></div><form id="botPkgForm"><input type="hidden" id="pkgId"/><div class="form-grid">
|
||||
<div class="field bot-span-2"><label>Nome</label><input id="pkgName" maxlength="120" required placeholder="Ex.: Recarga 50 créditos"/></div><div class="field"><label>Créditos</label><input id="pkgCredits" type="number" min="1" value="10"/></div><div class="field"><label>Preço (R$)</label><input id="pkgPrice" type="number" step="0.01" min="0" value="0"/></div><div class="field"><label>Ordem</label><input id="pkgSort" type="number" value="0"/></div><label class="bot-check-field"><input id="pkgActive" type="checkbox" checked/><span>Pacote visível</span></label>
|
||||
</div><div class="form-actions"><button class="btn" type="submit">Salvar pacote</button><button class="btn btn-ghost" id="botClearPkgBtn" type="button">Limpar</button></div></form></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-messages" data-bot-panel="messages">
|
||||
<div class="bot-section-heading"><div><span>04 · Conteúdo</span><h3>Mensagens do bot</h3><p>Personalize os textos vistos pelo cliente.</p></div><button class="btn btn-ghost btn-sm" id="botReloadSettingsBtn" type="button">Restaurar salvo</button></div>
|
||||
<article class="card bot-message-editor"><div class="bot-message-grid"><div class="field"><label>Mensagem de boas-vindas</label><textarea id="setWelcome" rows="7" maxlength="4096" placeholder="Olá, {name}! Escolha uma opção abaixo."></textarea><span class="hint">Use <code>{name}</code> para inserir o primeiro nome.</span></div><div class="field"><label>Contato e suporte</label><textarea id="setContact" rows="7" maxlength="4096" placeholder="Explique como falar com o suporte."></textarea></div><div class="field"><label>Texto para baixar o app</label><textarea id="setAppText" rows="5" maxlength="4096" placeholder="Baixe nosso aplicativo para conectar."></textarea></div><div class="field"><label>Link do aplicativo</label><input id="setAppUrl" type="url" maxlength="2048" placeholder="https://..."/><span class="hint">Somente links HTTP ou HTTPS.</span></div></div><div class="bot-save-row"><span id="botSettingsStatus" class="hint">Aguardando.</span><button class="btn" id="botSaveSettingsBtn" type="button">Salvar mensagens</button></div></article>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-users" data-bot-panel="users">
|
||||
<div class="bot-section-heading"><div><span>05 · Relacionamento</span><h3>Clientes do Telegram</h3><p>Gerencie funções, vínculos e saldo sem sair da lista.</p></div><button class="btn btn-ghost btn-sm" id="botReloadUsersBtn" type="button">Atualizar clientes</button></div>
|
||||
<article class="card"><div class="bot-list-heading"><div><strong>Base de clientes</strong><span class="hint"> Ações administrativas são registradas no livro de créditos.</span></div><span class="chip" id="botUserCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Cliente</th><th>Telegram ID</th><th>Função</th><th>Revenda vinculada</th><th>Saldo</th><th></th></tr></thead><tbody id="botUsersBody"></tbody></table></div><div class="statusbar"><span id="botUsersStatus">Aguardando.</span></div></article>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-transactions" data-bot-panel="transactions">
|
||||
<div class="bot-section-heading"><div><span>06 · Financeiro</span><h3>Pagamentos PIX</h3><p>Acompanhe confirmações e reprocessamentos.</p></div><div class="card-actions"><select id="botTxnFilter" class="input-sm"><option value="">Todos os status</option><option value="pending">Pendentes</option><option value="approved">Aprovados</option><option value="expired">Expirados</option><option value="refunded">Marcados como estornados</option><option value="error">Com erro</option></select><button class="btn btn-ghost btn-sm" id="botReloadTxnsBtn" type="button">Atualizar</button></div></div>
|
||||
<article class="card"><div class="bot-list-heading"><strong>Histórico recente</strong><span class="chip" id="botTxnCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Pagamento</th><th>Cliente</th><th>Tipo</th><th>Valor</th><th>Status</th><th>Entrega</th><th>Data</th><th></th></tr></thead><tbody id="botTxnsBody"></tbody></table></div><div class="statusbar"><span id="botTxnStatus">Aguardando.</span></div></article>
|
||||
</section>
|
||||
|
||||
<div class="bot-modal hidden" id="botUserActionModal" role="dialog" aria-modal="true" aria-labelledby="botUserActionTitle">
|
||||
<div class="bot-modal-backdrop" data-bot-modal-close></div>
|
||||
<div class="bot-modal-card">
|
||||
<div class="card-hdr"><div><div class="card-title" id="botUserActionTitle">Editar cliente</div><p class="hint" id="botUserActionSubtitle"></p></div><button class="btn btn-ghost btn-sm" type="button" data-bot-modal-close>Fechar</button></div>
|
||||
<form id="botUserActionForm"><input type="hidden" id="botActionTelegramID"/><input type="hidden" id="botActionMode"/>
|
||||
<div id="botRoleFields"><div class="field"><label>Função</label><select id="botActionRole"><option value="customer">Cliente</option><option value="reseller">Revendedor</option><option value="blocked">Bloqueado</option></select></div><div class="field" id="botActionLinkedField"><label>Usuário da revenda vinculada</label><input id="botActionLinked" placeholder="username em Revendedores"/></div></div>
|
||||
<div id="botCreditFields" class="hidden"><div class="field"><label>Ajuste de créditos</label><input id="botActionCredits" type="number" placeholder="Ex.: 10 ou -5"/><span class="hint">Use número negativo para remover saldo.</span></div></div>
|
||||
<div class="form-actions"><button class="btn" type="submit">Confirmar alteração</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /botSec-plans -->
|
||||
|
||||
<!-- ═══ Section: Credit packages ═══ -->
|
||||
<div class="bot-section" id="botSec-packages" style="display:none;">
|
||||
<!-- Credit packages -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">💳 Pacotes de Crédito <span class="chip" id="botPkgCount">0</span></div>
|
||||
<div style="display:flex;gap:5px;">
|
||||
<button class="btn btn-ghost btn-sm" id="botNewPkgBtn">+ Novo</button>
|
||||
<button class="btn btn-ghost btn-sm" id="botReloadPkgsBtn">Recarregar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Nome</th><th>Créditos</th><th>Preço</th><th>Ativo</th><th>Ações</th></tr></thead>
|
||||
<tbody id="botPkgsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="botPkgForm" style="margin-top:10px;">
|
||||
<input type="hidden" id="pkgId"/>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Nome</label><input id="pkgName" required/></div>
|
||||
<div class="field"><label>Créditos</label><input id="pkgCredits" type="number" min="1" value="10"/></div>
|
||||
<div class="field"><label>Preço (R$)</label><input id="pkgPrice" type="number" step="0.01" min="0" value="0"/></div>
|
||||
<div class="field"><label>Ordem</label><input id="pkgSort" type="number" value="0"/></div>
|
||||
<div class="field"><label>Ativo</label><input id="pkgActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
|
||||
</div>
|
||||
<div class="form-actions"><button class="btn" type="submit">Salvar pacote</button></div>
|
||||
</form>
|
||||
<div class="statusbar"><span id="botPkgStatus">Ready.</span></div>
|
||||
</div>
|
||||
</div><!-- /botSec-packages -->
|
||||
|
||||
<!-- ═══ Section: Messages ═══ -->
|
||||
<div class="bot-section" id="botSec-messages" style="display:none;">
|
||||
<!-- Messages -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">✉️ Mensagens do Bot</div>
|
||||
<button class="btn btn-ghost btn-sm" id="botReloadSettingsBtn">Recarregar</button>
|
||||
</div>
|
||||
<div class="field"><label>Boas-vindas <span class="hint">({name} = nome)</span></label><textarea id="setWelcome" rows="4"></textarea></div>
|
||||
<div class="field"><label>Texto de Contato</label><textarea id="setContact" rows="3"></textarea></div>
|
||||
<div class="field"><label>Texto Baixar APP</label><textarea id="setAppText" rows="2"></textarea></div>
|
||||
<div class="field"><label>URL do APP <span class="hint">(botão vira link)</span></label><input id="setAppUrl" placeholder="https://..."/></div>
|
||||
<div class="form-actions"><button class="btn" id="botSaveSettingsBtn">Salvar mensagens</button></div>
|
||||
<div class="statusbar"><span id="botSettingsStatus">Ready.</span></div>
|
||||
</div>
|
||||
</div><!-- /botSec-messages -->
|
||||
|
||||
<!-- ═══ Section: Users ═══ -->
|
||||
<div class="bot-section" id="botSec-users" style="display:none;">
|
||||
<!-- Bot users -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">👥 Clientes do Bot <span class="chip" id="botUserCount">0</span></div>
|
||||
<button class="btn btn-ghost btn-sm" id="botReloadUsersBtn">Recarregar</button>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Telegram ID</th><th>Nome</th><th>@user</th><th>Papel</th><th>Revenda</th><th>Créditos</th><th>Ações</th></tr></thead>
|
||||
<tbody id="botUsersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="botUsersStatus">Ready.</span></div>
|
||||
</div>
|
||||
</div><!-- /botSec-users -->
|
||||
|
||||
<!-- ═══ Section: Transactions ═══ -->
|
||||
<div class="bot-section" id="botSec-transactions" style="display:none;">
|
||||
<!-- Transactions -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">💰 Pagamentos <span class="chip" id="botTxnCount">0</span></div>
|
||||
<div style="display:flex;gap:5px;align-items:center;">
|
||||
<select id="botTxnFilter" class="btn-sm">
|
||||
<option value="">Todos</option>
|
||||
<option value="pending">Pendentes</option>
|
||||
<option value="approved">Aprovados</option>
|
||||
<option value="expired">Expirados</option>
|
||||
<option value="refunded">Estornados</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost btn-sm" id="botReloadTxnsBtn">Recarregar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Telegram</th><th>Tipo</th><th>Valor</th><th>Status</th><th>Conta</th><th>Data</th><th>Ações</th></tr></thead>
|
||||
<tbody id="botTxnsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="botTxnStatus">Ready.</span></div>
|
||||
</div>
|
||||
</div><!-- /botSec-transactions -->
|
||||
|
||||
</div><!-- /tab-bot -->
|
||||
|
||||
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
|
||||
<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">
|
||||
<!-- ── Left column ── -->
|
||||
<div>
|
||||
<div class="workspace-nav-shell" data-tone="blue">
|
||||
<nav class="workspace-section-nav" id="configSectionNav" aria-label="Áreas de configuração" style="--workspace-nav-columns:5">
|
||||
<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 -->
|
||||
<div class="card">
|
||||
@@ -1251,7 +1294,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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-title">SSH Banner</div>
|
||||
<span class="chip green">live</span>
|
||||
@@ -1263,11 +1306,11 @@
|
||||
<div class="hint" style="margin-top:6px;">Banner file: /opt/sshpanel/banner.txt</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /left -->
|
||||
|
||||
<!-- ── Right column ── -->
|
||||
<div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
@@ -1349,6 +1392,10 @@
|
||||
</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 -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
@@ -1394,6 +1441,10 @@
|
||||
</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 -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
@@ -1444,6 +1495,10 @@
|
||||
</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 -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
@@ -1470,8 +1525,8 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div><!-- /right -->
|
||||
</div><!-- /grid2 -->
|
||||
</section>
|
||||
</div><!-- /settings-workspace -->
|
||||
|
||||
<!-- Save bar -->
|
||||
<div class="save-bar">
|
||||
@@ -1491,20 +1546,31 @@
|
||||
</div><!-- /shell -->
|
||||
</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
|
||||
classic scripts sharing one global scope; `defer` preserves execution order,
|
||||
so behavior is identical to the old single file. Keep this load order. -->
|
||||
<script defer src="assets/js/01-core.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/01-core.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713reseller10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,33 +4,50 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
sessionTTL = 12 * time.Hour
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
QuotaModeSlots = "slots"
|
||||
QuotaModeCredit = "credits"
|
||||
sessionTTL = 12 * time.Hour
|
||||
adminBcryptCost = 12
|
||||
)
|
||||
|
||||
var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ---------- AdminUser ----------
|
||||
|
||||
type AdminUser struct {
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ParentUsername string
|
||||
QuotaMode string
|
||||
CreditBalance int
|
||||
WhatsApp string
|
||||
MonthlyPriceCents int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- Session store (in-memory) ----------
|
||||
@@ -50,9 +67,11 @@ type sessionStoreT struct {
|
||||
|
||||
var sessions = &sessionStoreT{m: make(map[string]*AdminSession)}
|
||||
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession {
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) (*AdminSession, error) {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
tok := hex.EncodeToString(b)
|
||||
sess := &AdminSession{
|
||||
Token: tok,
|
||||
@@ -64,7 +83,7 @@ func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession
|
||||
s.mu.Lock()
|
||||
s.m[tok] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) Get(token string) *AdminSession {
|
||||
@@ -86,6 +105,16 @@ func (s *sessionStoreT) Delete(token string) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) DeleteUser(userID int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for token, sess := range s.m {
|
||||
if sess.UserID == userID {
|
||||
delete(s.m, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -170,6 +199,14 @@ func sessionMiddleware(next http.Handler) http.Handler {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Re-check the account on every request. This immediately revokes sessions
|
||||
// after an account is suspended, expired, deleted, or has its role changed.
|
||||
u, ok := adminUsers.get(s.Username)
|
||||
if !ok || u.ID != s.UserID || u.Role != s.Role || adminAccountChainActive(s.Username) != nil {
|
||||
sessions.Delete(token)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), s)))
|
||||
})
|
||||
}
|
||||
@@ -194,11 +231,112 @@ func saSession(next http.Handler) http.Handler {
|
||||
|
||||
// ---------- Password hashing ----------
|
||||
|
||||
func hashAdminPassword(pw string) string {
|
||||
func legacyAdminPasswordHash(pw string) string {
|
||||
h := sha256.Sum256([]byte(pw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func hashAdminPassword(pw string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), adminBcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// verifyAdminPassword accepts bcrypt and the legacy unsalted SHA-256 format.
|
||||
// Legacy hashes are upgraded immediately after a successful login.
|
||||
func verifyAdminPassword(storedHash, password string) (valid bool, needsUpgrade bool) {
|
||||
if strings.HasPrefix(storedHash, "$2a$") || strings.HasPrefix(storedHash, "$2b$") || strings.HasPrefix(storedHash, "$2y$") {
|
||||
if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) != nil {
|
||||
return false, false
|
||||
}
|
||||
cost, err := bcrypt.Cost([]byte(storedHash))
|
||||
return true, err != nil || cost < adminBcryptCost
|
||||
}
|
||||
if len(storedHash) != sha256.Size*2 {
|
||||
return false, false
|
||||
}
|
||||
expected := legacyAdminPasswordHash(password)
|
||||
return subtle.ConstantTimeCompare([]byte(storedHash), []byte(expected)) == 1, true
|
||||
}
|
||||
|
||||
func validateAdminPassword(password string) error {
|
||||
if len(password) < 10 {
|
||||
return fmt.Errorf("password must contain at least 10 characters")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAdminUsername(username string) error {
|
||||
if !adminUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Login throttling ----------
|
||||
|
||||
type loginAttempt struct {
|
||||
Failures int
|
||||
FirstSeen time.Time
|
||||
BlockedTo time.Time
|
||||
}
|
||||
|
||||
type loginThrottleT struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]loginAttempt
|
||||
}
|
||||
|
||||
var loginThrottle = &loginThrottleT{attempts: make(map[string]loginAttempt)}
|
||||
|
||||
func loginAttemptKey(r *http.Request, username string) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
return host + "\x00" + strings.ToLower(username)
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) retryAfter(key string, now time.Time) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.attempts[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if !a.BlockedTo.IsZero() && now.Before(a.BlockedTo) {
|
||||
return time.Until(a.BlockedTo)
|
||||
}
|
||||
if now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) fail(key string, now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a := l.attempts[key]
|
||||
if a.FirstSeen.IsZero() || now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
a = loginAttempt{FirstSeen: now}
|
||||
}
|
||||
a.Failures++
|
||||
if a.Failures >= 5 {
|
||||
a.BlockedTo = now.Add(15 * time.Minute)
|
||||
}
|
||||
l.attempts[key] = a
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) success(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.attempts, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---------- DB methods on Store ----------
|
||||
|
||||
func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
@@ -213,7 +351,47 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS parent_username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS quota_mode TEXT NOT NULL DEFAULT 'slots'`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS credit_balance INT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS whatsapp TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS monthly_price_cents INT NOT NULL DEFAULT 0`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_users_parent ON admin_users(parent_username)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_username TEXT NOT NULL,
|
||||
target_username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_created ON reseller_audit_log(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_actor ON reseller_audit_log(actor_username, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_target ON reseller_audit_log(target_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_credit_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reseller_username TEXT NOT NULL,
|
||||
actor_username TEXT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_credit_ledger_owner ON reseller_credit_ledger(reseller_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_runtime_state (
|
||||
owner_username TEXT PRIMARY KEY,
|
||||
parent_username TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
// Older reseller-owned accounts used zero to mean "unlimited". The
|
||||
// reseller quota model charges at least one slot per account, so normalize
|
||||
// those rows once during schema setup instead of leaving a quota bypass.
|
||||
`UPDATE ssh_users SET max_connections = 1
|
||||
WHERE owner_username <> '' AND max_connections < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -223,45 +401,51 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
const adminUserSelectColumns = `id, username, password_hash, role, max_users,
|
||||
COALESCE(parent_username, ''), COALESCE(quota_mode, 'slots'), COALESCE(credit_balance, 0),
|
||||
COALESCE(whatsapp, ''), COALESCE(monthly_price_cents, 0), expires_at, is_active, created_at`
|
||||
|
||||
func scanAdminUser(scanner interface{ Scan(...interface{}) error }) (*AdminUser, error) {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users WHERE username = $1`, username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
&u.ParentUsername, &u.QuotaMode, &u.CreditBalance, &u.WhatsApp, &u.MonthlyPriceCents,
|
||||
&expiresAt, &u.IsActive, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
u.QuotaMode = normalizeQuotaMode(u.QuotaMode)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
u, err := scanAdminUser(s.db.QueryRowContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users WHERE username = $1`, username))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAdminUsers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users ORDER BY role, username`)
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users ORDER BY role, username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -274,15 +458,26 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
|
||||
}
|
||||
if u.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, parent_username,
|
||||
quota_mode, credit_balance, whatsapp, monthly_price_cents, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive,
|
||||
).Scan(&u.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4,
|
||||
expires_at=$5, is_active=$6 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive)
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4, parent_username=$5,
|
||||
quota_mode=$6, credit_balance=$7, whatsapp=$8, monthly_price_cents=$9,
|
||||
expires_at=$10, is_active=$11 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAdminPasswordHash(ctx context.Context, id int, passwordHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE admin_users SET password_hash=$2 WHERE id=$1`, id, passwordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -298,8 +493,7 @@ func (s *Store) SetAdminUserActive(ctx context.Context, username string, active
|
||||
|
||||
func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -311,8 +505,7 @@ func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error)
|
||||
|
||||
func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -325,15 +518,10 @@ func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUs
|
||||
func scanAdminUsers(rows *sql.Rows) ([]*AdminUser, error) {
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -352,11 +540,17 @@ func (s *Store) BootstrapSuperAdmin(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
b := make([]byte, 10)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate bootstrap password: %w", err)
|
||||
}
|
||||
pw := hex.EncodeToString(b)
|
||||
passwordHash, err := hashAdminPassword(pw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := &AdminUser{
|
||||
Username: "admin",
|
||||
PasswordHash: hashAdminPassword(pw),
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleSuperAdmin,
|
||||
MaxUsers: 0,
|
||||
IsActive: true,
|
||||
@@ -373,7 +567,12 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states, err := store.ListResellerRuntimeStates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUsers.replaceAll(users)
|
||||
resellerRuntimeStates.replaceAll(states)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -381,20 +580,10 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
|
||||
// ownerIsActive returns nil if an SSH user's reseller owner is active, or an error if suspended/expired.
|
||||
func ownerIsActive(ownerUsername string) error {
|
||||
if ownerUsername == "" {
|
||||
return nil
|
||||
if _, replicated := resellerRuntimeStates.get(ownerUsername); replicated {
|
||||
return resellerRuntimeChainActive(ownerUsername)
|
||||
}
|
||||
u, ok := adminUsers.get(ownerUsername)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
if !u.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
return nil
|
||||
return adminAccountChainActive(ownerUsername)
|
||||
}
|
||||
|
||||
// disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner.
|
||||
@@ -436,29 +625,38 @@ func startResellerExpiryChecker(store *Store) {
|
||||
}
|
||||
for _, u := range expired {
|
||||
log.Printf("reseller %s expired — suspending", u.Username)
|
||||
resellerLifecycleMu.Lock()
|
||||
all, listErr := store.ListAdminUsers(ctx)
|
||||
if listErr != nil {
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry hierarchy for %s: %v", u.Username, listErr)
|
||||
continue
|
||||
}
|
||||
quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(listResellerSubtree(all, u.Username)))
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, false); err != nil {
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = false
|
||||
adminUsers.set(u)
|
||||
disconnectOwnerUsers(u.Username)
|
||||
removeOwnerXrayClients(ctx, store, u.Username)
|
||||
sessions.DeleteUser(u.ID)
|
||||
if err := applyResellerSubtreeRuntime(ctx, store, u.Username, false); err != nil {
|
||||
log.Printf("reseller expiry runtime for %s: %v", u.Username, err)
|
||||
}
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
}
|
||||
|
||||
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
|
||||
renewed, err := store.ListInactiveButRenewedResellers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller renewal check: %v", err)
|
||||
}
|
||||
for _, u := range renewed {
|
||||
log.Printf("reseller %s renewed — reactivating", u.Username)
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, true); err != nil {
|
||||
log.Printf("reseller renewal: %v", err)
|
||||
continue
|
||||
// Replicated owner records on managed nodes also enforce expiration and
|
||||
// inherited parent suspension without contacting the master on each login.
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) != nil {
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, "suspend"); err != nil {
|
||||
log.Printf("replicated reseller expiry runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
u.IsActive = true
|
||||
adminUsers.set(u)
|
||||
}
|
||||
|
||||
sessions.cleanup()
|
||||
@@ -474,18 +672,33 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := loginAttemptKey(r, req.Username)
|
||||
now := time.Now()
|
||||
if retry := loginThrottle.retryAfter(key, now); retry > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(retry.Seconds()))))
|
||||
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := store.GetAdminUserByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
@@ -493,20 +706,41 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if u == nil || u.PasswordHash != hashAdminPassword(req.Password) {
|
||||
valid := false
|
||||
needsUpgrade := false
|
||||
if u != nil {
|
||||
valid, needsUpgrade = verifyAdminPassword(u.PasswordHash, req.Password)
|
||||
} else {
|
||||
// Keep roughly the same CPU cost for unknown users to reduce account probing.
|
||||
_, _ = hashAdminPassword(req.Password)
|
||||
}
|
||||
if !valid {
|
||||
loginThrottle.fail(key, now)
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !u.IsActive {
|
||||
http.Error(w, "account suspended", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
http.Error(w, "account expired", http.StatusForbidden)
|
||||
if adminAccountChainActive(u.Username) != nil {
|
||||
http.Error(w, "account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if needsUpgrade {
|
||||
if upgradedHash, hashErr := hashAdminPassword(req.Password); hashErr == nil {
|
||||
if updateErr := store.UpdateAdminPasswordHash(r.Context(), u.ID, upgradedHash); updateErr != nil {
|
||||
log.Printf("upgrade admin password hash for %s: %v", u.Username, updateErr)
|
||||
} else {
|
||||
u.PasswordHash = upgradedHash
|
||||
adminUsers.set(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
loginThrottle.success(key)
|
||||
sess, err := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if err != nil {
|
||||
log.Printf("create admin session: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": sess.Token,
|
||||
@@ -537,158 +771,35 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.Role == RoleReseller {
|
||||
if u, ok := adminUsers.get(s.Username); ok {
|
||||
childAllocation, childCount := 0, 0
|
||||
if statsStore != nil {
|
||||
childAllocation, _ = statsStore.directChildAllocation(r.Context(), s.Username, "")
|
||||
childCount = statsStore.directChildCount(r.Context(), s.Username)
|
||||
}
|
||||
resp["max_users"] = u.MaxUsers
|
||||
resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username)
|
||||
resp["used_ssh_users"] = countOwnedUsers(s.Username)
|
||||
resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username)
|
||||
usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), statsStore, s.Username)
|
||||
if usageErr != nil {
|
||||
usage = resellerQuotaUsage{
|
||||
Weighted: countOwnedQuota(r.Context(), statsStore, s.Username),
|
||||
SSHAccounts: countOwnedUsers(s.Username),
|
||||
XrayAccounts: countOwnedXrayClients(r.Context(), statsStore, s.Username),
|
||||
}
|
||||
}
|
||||
resp["used_users"] = usage.Weighted
|
||||
resp["used_ssh_users"] = usage.SSHAccounts
|
||||
resp["used_xray_users"] = usage.XrayAccounts
|
||||
resp["parent_username"] = u.ParentUsername
|
||||
resp["quota_mode"] = normalizeQuotaMode(u.QuotaMode)
|
||||
resp["credit_balance"] = u.CreditBalance
|
||||
resp["child_allocation"] = childAllocation
|
||||
resp["child_count"] = childCount
|
||||
resp["expires_at"] = u.ExpiresAt
|
||||
resp["is_active"] = u.IsActive
|
||||
resp["effective_active"] = adminAccountChainActive(u.Username) == nil
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ---------- Reseller management (superadmin only) ----------
|
||||
|
||||
type ResellerDTO struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
UsedUsers int `json:"used_users"`
|
||||
UsedSSH int `json:"used_ssh_users"`
|
||||
UsedXray int `json:"used_xray_users"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func handleListResellers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
users, err := store.ListAdminUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]ResellerDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, ResellerDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
MaxUsers: u.MaxUsers,
|
||||
UsedUsers: countOwnedQuota(r.Context(), store, u.Username),
|
||||
UsedSSH: countOwnedUsers(u.Username),
|
||||
UsedXray: countOwnedXrayClients(r.Context(), store, u.Username),
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
IsActive: u.IsActive,
|
||||
CreatedAt: u.CreatedAt,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
}
|
||||
|
||||
type ResellerPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p ResellerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", 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 != "" {
|
||||
u.PasswordHash = hashAdminPassword(p.Password)
|
||||
}
|
||||
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 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 := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
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)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
// Reseller management handlers live in reseller_management.go.
|
||||
|
||||
+175
-5
@@ -4,11 +4,73 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var botSettingKeys = map[string]int{
|
||||
"welcome_text": 4096,
|
||||
"contact_text": 4096,
|
||||
"app_text": 4096,
|
||||
"app_url": 2048,
|
||||
}
|
||||
|
||||
func botHasControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool {
|
||||
return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t'
|
||||
}) >= 0
|
||||
}
|
||||
|
||||
func botHasAnyControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, unicode.IsControl) >= 0
|
||||
}
|
||||
|
||||
func validateBotPlan(p *BotPlan) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.Kind = strings.ToLower(strings.TrimSpace(p.Kind))
|
||||
p.XrayProtocol = strings.ToLower(strings.TrimSpace(p.XrayProtocol))
|
||||
p.XrayInboundTag = strings.TrimSpace(p.XrayInboundTag)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("plan name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Kind != "ssh" && p.Kind != "xray" {
|
||||
return fmt.Errorf("plan kind must be ssh or xray")
|
||||
}
|
||||
if p.Days < 1 || p.Days > 3650 || p.MaxConnections < 0 || p.MaxConnections > 10000 {
|
||||
return fmt.Errorf("invalid plan duration or connection limit")
|
||||
}
|
||||
if p.LimitMbpsUp < 0 || p.LimitMbpsUp > 1000000 || p.LimitMbpsDown < 0 || p.LimitMbpsDown > 1000000 {
|
||||
return fmt.Errorf("invalid bandwidth limit")
|
||||
}
|
||||
if p.PriceCents < 0 || p.PriceCents > 1000000000 || p.CreditCost < 0 || p.CreditCost > 1000000000 {
|
||||
return fmt.Errorf("invalid plan price or credit cost")
|
||||
}
|
||||
if p.Kind == "xray" && p.XrayProtocol != "" && p.XrayProtocol != "vless" && p.XrayProtocol != "vmess" && p.XrayProtocol != "trojan" {
|
||||
return fmt.Errorf("invalid Xray protocol")
|
||||
}
|
||||
if len(p.XrayInboundTag) > 128 || len(p.ServerID) > 128 {
|
||||
return fmt.Errorf("inbound tag or server id is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBotPackage(p *BotCreditPackage) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("package name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Credits < 1 || p.Credits > 1000000000 || p.PriceCents < 0 || p.PriceCents > 1000000000 {
|
||||
return fmt.Errorf("invalid package credits or price")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func botWriteJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
@@ -57,7 +119,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
case http.MethodGet:
|
||||
cfg, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "load bot configuration", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, botConfigDTO{
|
||||
@@ -80,10 +142,69 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
})
|
||||
case http.MethodPost:
|
||||
var dto botConfigDTO
|
||||
if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&dto); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.MPConfirmMode = strings.ToLower(strings.TrimSpace(dto.MPConfirmMode))
|
||||
if dto.MPConfirmMode != "polling" && dto.MPConfirmMode != "webhook" {
|
||||
http.Error(w, "confirmation mode must be polling or webhook", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pollInterval, err := time.ParseDuration(strings.TrimSpace(dto.MPPollInterval))
|
||||
if err != nil || pollInterval < 5*time.Second || pollInterval > 5*time.Minute {
|
||||
http.Error(w, "poll interval must be between 5s and 5m", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if dto.PixExpirationMinutes < 5 || dto.PixExpirationMinutes > 1440 || dto.TrialHours < 1 || dto.TrialHours > 720 || dto.TrialMaxConnections < 1 || dto.TrialMaxConnections > 1000 {
|
||||
http.Error(w, "invalid PIX expiration or trial limits", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.TrialKind = strings.ToLower(strings.TrimSpace(dto.TrialKind))
|
||||
if dto.TrialKind != "ssh" && dto.TrialKind != "xray" {
|
||||
http.Error(w, "trial kind must be ssh or xray", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(dto.AdminTelegramIDs) > 100 {
|
||||
http.Error(w, "too many admin Telegram IDs", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, id := range dto.AdminTelegramIDs {
|
||||
if id <= 0 {
|
||||
http.Error(w, "admin Telegram IDs must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, value := range []string{dto.TelegramToken, dto.MPAccessToken, dto.MPWebhookSecret, dto.PublicHost, dto.XrayPublicHost, dto.TrialInboundTag} {
|
||||
if len(value) > 2048 || botHasAnyControlCharacters(value) {
|
||||
http.Error(w, "configuration contains an invalid value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
existing, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
http.Error(w, "load existing config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
effectiveTelegramToken := strings.TrimSpace(dto.TelegramToken)
|
||||
if effectiveTelegramToken == "" {
|
||||
effectiveTelegramToken = existing.TelegramToken
|
||||
}
|
||||
if dto.Enabled && effectiveTelegramToken == "" {
|
||||
http.Error(w, "Telegram token is required before enabling the bot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
effectiveWebhookSecret := strings.TrimSpace(dto.MPWebhookSecret)
|
||||
if effectiveWebhookSecret == "" {
|
||||
effectiveWebhookSecret = existing.MPWebhookSecret
|
||||
}
|
||||
if dto.MPConfirmMode == "webhook" && len(effectiveWebhookSecret) < 16 {
|
||||
http.Error(w, "a webhook secret of at least 16 characters is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg := &BotConfig{
|
||||
Enabled: dto.Enabled,
|
||||
TelegramToken: strings.TrimSpace(dto.TelegramToken),
|
||||
@@ -103,7 +224,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
|
||||
}
|
||||
if err := SaveBotConfig(ctx, store, cfg); err != nil {
|
||||
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "save bot configuration", err)
|
||||
return
|
||||
}
|
||||
reloadBotService(store)
|
||||
@@ -139,8 +260,12 @@ func handleBotPlans(store *Store) http.HandlerFunc {
|
||||
if p.Kind == "" {
|
||||
p.Kind = "ssh"
|
||||
}
|
||||
if err := validateBotPlan(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertPlan(ctx, &p); err != nil {
|
||||
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "save bot plan", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, p)
|
||||
@@ -183,6 +308,10 @@ func handleBotCreditPackages(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateBotPackage(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertCreditPackage(ctx, &p); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -238,6 +367,21 @@ func handleBotUsers(store *Store) http.HandlerFunc {
|
||||
if req.Role == "" {
|
||||
req.Role = "customer"
|
||||
}
|
||||
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if req.Role != "customer" && req.Role != "reseller" && req.Role != "blocked" {
|
||||
http.Error(w, "role must be customer, reseller, or blocked", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.LinkedAdminUsername = strings.TrimSpace(req.LinkedAdminUsername)
|
||||
if req.Role == "reseller" {
|
||||
linked, err := store.GetAdminUserByUsername(ctx, req.LinkedAdminUsername)
|
||||
if err != nil || linked == nil || linked.Role != RoleReseller {
|
||||
http.Error(w, "linked reseller account not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
req.LinkedAdminUsername = ""
|
||||
}
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, req.Role, req.LinkedAdminUsername); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -253,6 +397,10 @@ func handleBotUsers(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
case "adjust_credits":
|
||||
if req.Credits == 0 || req.Credits < -1000000000 || req.Credits > 1000000000 {
|
||||
http.Error(w, "invalid credit adjustment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := store.AdjustCredits(ctx, req.TelegramID, req.Credits, "admin_adjust", nil); err != nil {
|
||||
http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -278,8 +426,18 @@ func handleBotTransactions(store *Store) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
status := r.URL.Query().Get("status")
|
||||
status := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if status != "" && status != "pending" && status != "approved" && status != "expired" && status != "refunded" && status != "error" {
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
txns, err := store.ListTransactions(ctx, status, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
@@ -342,6 +500,18 @@ func handleBotSettings(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
for k, v := range kv {
|
||||
maxLen, ok := botSettingKeys[k]
|
||||
if !ok || len(v) > maxLen || botHasControlCharacters(v) {
|
||||
http.Error(w, "invalid bot setting", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if k == "app_url" && strings.TrimSpace(v) != "" {
|
||||
u, err := url.ParseRequestURI(strings.TrimSpace(v))
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
http.Error(w, "app_url must be an http or https URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := store.SetSetting(ctx, k, v); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+35
-4
@@ -129,7 +129,7 @@ func (c *mpClient) do(ctx context.Context, method, path string, body interface{}
|
||||
// Manifest: "id:<dataID>;request-id:<x-request-id>;ts:<ts>;" HMAC-SHA256(secret).
|
||||
func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
if secret == "" {
|
||||
return true // validation disabled
|
||||
return false
|
||||
}
|
||||
var ts, v1 string
|
||||
for _, part := range strings.Split(xSignature, ",") {
|
||||
@@ -147,7 +147,18 @@ func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
if ts == "" || v1 == "" {
|
||||
return false
|
||||
}
|
||||
manifest := fmt.Sprintf("id:%s;request-id:%s;ts:%s;", strings.ToLower(dataID), xRequestID, ts)
|
||||
if timestamp, err := strconv.ParseInt(ts, 10, 64); err != nil || timestamp <= 0 {
|
||||
return false
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if dataID != "" {
|
||||
parts = append(parts, "id:"+strings.ToLower(dataID))
|
||||
}
|
||||
if xRequestID != "" {
|
||||
parts = append(parts, "request-id:"+xRequestID)
|
||||
}
|
||||
parts = append(parts, "ts:"+ts)
|
||||
manifest := strings.Join(parts, ";") + ";"
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(manifest))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
@@ -157,6 +168,10 @@ func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
// handleMPWebhook is the public endpoint Mercado Pago calls on payment events.
|
||||
// It never trusts the body: it re-fetches the payment and fulfills idempotently.
|
||||
func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b := currentBot()
|
||||
if b == nil {
|
||||
w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries
|
||||
@@ -173,6 +188,7 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if dataID == "" {
|
||||
dataID = r.URL.Query().Get("id")
|
||||
}
|
||||
signatureDataID := dataID
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Action string `json:"action"`
|
||||
@@ -180,7 +196,12 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid webhook body", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if dataID == "" {
|
||||
@@ -191,8 +212,18 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if len(dataID) > 32 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, char := range dataID {
|
||||
if char < '0' || char > '9' {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), dataID, b.cfg.MPWebhookSecret) {
|
||||
if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), signatureDataID, b.cfg.MPWebhookSecret) {
|
||||
log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
+12
-24
@@ -104,18 +104,15 @@ get_env_value() {
|
||||
' "$ENV_FILE"
|
||||
}
|
||||
|
||||
update_env_password() {
|
||||
local new_password="$1"
|
||||
remove_legacy_env_password() {
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v line="ADMIN_PASSWORD=${new_password}" '
|
||||
BEGIN { done = 0 }
|
||||
/^ADMIN_PASSWORD=/ { print line; done = 1; next }
|
||||
awk '
|
||||
/^ADMIN_PASSWORD=/ { next }
|
||||
{ print }
|
||||
END { if (!done) print line }
|
||||
' "$ENV_FILE" > "$tmp"
|
||||
cat "$tmp" > "$ENV_FILE"
|
||||
rm -f "$tmp"
|
||||
install -m 600 "$tmp" "$ENV_FILE"
|
||||
rm -f -- "$tmp"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -135,15 +132,8 @@ generate_password() {
|
||||
|
||||
hash_password() {
|
||||
local pw="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | openssl dgst -sha256 -r | awk '{print $1}'
|
||||
else
|
||||
error "No SHA-256 tool found. Install coreutils or openssl."
|
||||
fi
|
||||
[[ -x "$INSTALL_DIR/sshpanel" ]] || error "Panel binary not found: $INSTALL_DIR/sshpanel"
|
||||
printf '%s' "$pw" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null
|
||||
}
|
||||
|
||||
PG_DSN="$(get_env_value PG_DSN)"
|
||||
@@ -168,12 +158,12 @@ elif [[ -z "$NEW_PASSWORD" ]]; then
|
||||
fi
|
||||
|
||||
[[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty."
|
||||
if [[ ${#NEW_PASSWORD} -lt 8 ]]; then
|
||||
error "Password must have at least 8 characters."
|
||||
if [[ ${#NEW_PASSWORD} -lt 10 ]]; then
|
||||
error "Password must have at least 10 characters."
|
||||
fi
|
||||
|
||||
PASSWORD_HASH="$(hash_password "$NEW_PASSWORD")"
|
||||
[[ ${#PASSWORD_HASH} -eq 64 ]] || error "Failed to generate valid SHA-256 password hash."
|
||||
[[ "$PASSWORD_HASH" == \$2* ]] || error "Failed to generate a valid bcrypt password hash."
|
||||
|
||||
info "Updating admin user '${ADMIN_USER}' in PostgreSQL..."
|
||||
psql "$PG_DSN" -v ON_ERROR_STOP=1 \
|
||||
@@ -201,10 +191,8 @@ ON CONFLICT (username) DO UPDATE SET
|
||||
SQL
|
||||
|
||||
if [[ "$ADMIN_USER" == "admin" ]]; then
|
||||
update_env_password "$NEW_PASSWORD"
|
||||
info "Updated ADMIN_PASSWORD inside $ENV_FILE"
|
||||
else
|
||||
warn "ADMIN_PASSWORD in $ENV_FILE was not changed because username is not 'admin'."
|
||||
remove_legacy_env_password
|
||||
info "Removed any legacy plaintext ADMIN_PASSWORD entry from $ENV_FILE"
|
||||
fi
|
||||
|
||||
if ! $NO_RESTART; then
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func sealCredential(prefix, plain string) (string, error) {
|
||||
if plain == "" || strings.HasPrefix(plain, prefix) {
|
||||
return plain, nil
|
||||
}
|
||||
enc, err := encryptSecret(plain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.RawStdEncoding.EncodeToString(enc), nil
|
||||
}
|
||||
|
||||
func openCredential(prefix, stored string) (string, error) {
|
||||
if !strings.HasPrefix(stored, prefix) {
|
||||
return stored, nil
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, prefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode encrypted credential: %w", err)
|
||||
}
|
||||
return decryptSecret(raw)
|
||||
}
|
||||
+8
-11
@@ -1532,7 +1532,6 @@ func sendLoop(ttConn *turbotunnel.QueuePacketConn, ch <-chan *record, maxEncoded
|
||||
atomic.AddUint64(&dnsttStats.RespSent, 1)
|
||||
atomic.AddUint64(&dnsttStats.RespSentBytes, uint64(len(buf)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeMaxEncodedPayload computes the maximum amount of downstream TXT RR
|
||||
@@ -1844,20 +1843,18 @@ func handleDnsttGenKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
privkey, err := noise.GeneratePrivkey()
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate DNSTT key", err)
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(dnsttKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
var encoded bytes.Buffer
|
||||
if err := noise.WriteKey(&encoded, privkey); err != nil {
|
||||
writeInternalError(w, "encode DNSTT key", err)
|
||||
return
|
||||
}
|
||||
if err := noise.WriteKey(f, privkey); err != nil {
|
||||
f.Close()
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(dnsttKeyFile, encoded.Bytes(), 0o600); err != nil {
|
||||
writeInternalError(w, "write DNSTT key", err)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -1880,13 +1877,13 @@ func handleDnsttGetPubKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
f, err := os.Open(keyPath)
|
||||
if err != nil {
|
||||
http.Error(w, "open key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "open DNSTT key", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
privkey, err := noise.ReadKey(f)
|
||||
if err != nil {
|
||||
http.Error(w, "read key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read DNSTT key", err)
|
||||
return
|
||||
}
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Trusted Go archives used by install.sh and update.sh.
|
||||
# Format: version architecture sha256
|
||||
1.25.12 amd64 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
1.25.12 arm64 8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2
|
||||
1.25.12 armv6l 6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1
|
||||
@@ -1,14 +1,14 @@
|
||||
module shell2
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.12
|
||||
|
||||
require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61
|
||||
github.com/xtaci/smux v1.5.50
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/time v0.15.0
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0
|
||||
)
|
||||
|
||||
@@ -18,6 +18,6 @@ require (
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
@@ -55,8 +55,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -68,8 +68,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -80,17 +80,17 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
|
||||
+73
-9
@@ -15,6 +15,7 @@ LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
|
||||
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
|
||||
GO_SHA256="${GO_SHA256:-}"
|
||||
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
|
||||
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
|
||||
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
|
||||
@@ -33,6 +34,38 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "$GO_SHA256" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -235,16 +268,21 @@ if command -v go &>/dev/null; then
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
GO_EXPECTED_SHA256=""
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
GO_EXPECTED_SHA256="$(trusted_go_sha256 "$GO_VERSION" "$GOARCH" "$SCRIPT_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$GO_EXPECTED_SHA256" ]] || error "No trusted Go checksum for ${GO_VERSION}/${GOARCH}; set GO_SHA256 explicitly"
|
||||
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||
info " Downloading $GO_URL"
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
|
||||
verify_sha256_file "$GO_EXPECTED_SHA256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
@@ -299,25 +337,51 @@ fi
|
||||
|
||||
# ── 6. Xray binary ──────────────────────────────────────────────────────────
|
||||
info "[6/10] Downloading Xray-core…"
|
||||
XRAY_VER=$(curl -sf "https://api.github.com/repos/XTLS/Xray-core/releases/latest" \
|
||||
| grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30")
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) XRAY_ARCH="64" ;;
|
||||
aarch64) XRAY_ARCH="arm64-v8a" ;;
|
||||
armv7l) XRAY_ARCH="arm32-v7a" ;;
|
||||
*) XRAY_ARCH="64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
XRAY_URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VER}/Xray-linux-${XRAY_ARCH}.zip"
|
||||
PYTHON_BIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
|
||||
[[ -n "$PYTHON_BIN" ]] || error "Python is required to validate Xray release metadata"
|
||||
XRAY_RELEASE_JSON=/tmp/xray-release.json
|
||||
curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \
|
||||
-o "$XRAY_RELEASE_JSON" https://api.github.com/repos/XTLS/Xray-core/releases/latest
|
||||
readarray -t XRAY_META < <("$PYTHON_BIN" -c '
|
||||
import json, re, sys
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
||||
release = json.load(handle)
|
||||
tag = release.get("tag_name", "")
|
||||
name = sys.argv[2]
|
||||
asset = next((item for item in release.get("assets", []) if item.get("name") == name), None)
|
||||
if not tag or not asset:
|
||||
raise SystemExit(2)
|
||||
url = asset.get("browser_download_url", "")
|
||||
digest = asset.get("digest", "")
|
||||
prefix = "https://github.com/XTLS/Xray-core/releases/download/" + tag + "/"
|
||||
if not url.startswith(prefix) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise SystemExit(3)
|
||||
print(tag)
|
||||
print(url)
|
||||
print(digest.split(":", 1)[1])
|
||||
' "$XRAY_RELEASE_JSON" "Xray-linux-${XRAY_ARCH}.zip")
|
||||
[[ ${#XRAY_META[@]} -eq 3 ]] || error "Xray release metadata is missing a trusted asset digest"
|
||||
XRAY_VER="${XRAY_META[0]}"
|
||||
XRAY_URL="${XRAY_META[1]}"
|
||||
XRAY_SHA256="${XRAY_META[2]}"
|
||||
info " Xray ${XRAY_VER} (${XRAY_ARCH})"
|
||||
wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL"
|
||||
verify_sha256_file "$XRAY_SHA256" /tmp/xray.zip
|
||||
info " Xray archive checksum verified"
|
||||
unzip -o /tmp/xray.zip xray -d "$INSTALL_DIR" > /dev/null 2>&1 || {
|
||||
mkdir -p /tmp/xray_extract
|
||||
unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1
|
||||
mv /tmp/xray_extract/xray "$INSTALL_DIR/xray"
|
||||
}
|
||||
chmod +x "$INSTALL_DIR/xray"
|
||||
rm -f /tmp/xray.zip
|
||||
rm -f /tmp/xray.zip "$XRAY_RELEASE_JSON"
|
||||
"$INSTALL_DIR/xray" version
|
||||
|
||||
# ── 7. PostgreSQL ────────────────────────────────────────────────────────────
|
||||
@@ -493,7 +557,8 @@ ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20 || true)
|
||||
if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then
|
||||
ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N)
|
||||
fi
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | sha256sum | awk '{print $1}')
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null)
|
||||
[[ "$ADMIN_PASSWORD_HASH" == \$2* ]] || error "Failed to generate admin bcrypt password hash"
|
||||
su -c "psql -d ${DB_NAME}" postgres <<SQL
|
||||
INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ('admin', '${ADMIN_PASSWORD_HASH}', 'superadmin', 0, NULL, TRUE)
|
||||
@@ -509,7 +574,6 @@ SQL
|
||||
cat > "$INSTALL_DIR/.env" <<EOF
|
||||
PG_DSN=postgres://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}?sslmode=disable
|
||||
ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
ADMIN_HTTP_ADDR=0.0.0.0:9090
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
@@ -703,7 +767,7 @@ echo -e " Admin login : ${YELLOW}admin${NC}"
|
||||
echo -e " Admin password: ${YELLOW}${ADMIN_PASSWORD}${NC}"
|
||||
echo -e " Admin token : ${YELLOW}${ADMIN_TOKEN}${NC}"
|
||||
echo ""
|
||||
echo -e " Token + DB creds stored in: ${INSTALL_DIR}/.env"
|
||||
echo -e " API token + DB credentials stored in: ${INSTALL_DIR}/.env"
|
||||
echo -e " Logs: journalctl -u ${SERVICE_NAME} -f"
|
||||
echo -e " tail -f ${INSTALL_DIR}/logs/panel.log"
|
||||
echo ""
|
||||
|
||||
@@ -1328,6 +1328,16 @@ func NewStore(dsn string) (*Store, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
const sshPasswordPrefix = "enc:v1:ssh:"
|
||||
|
||||
func sealSSHPassword(password string) (string, error) {
|
||||
return sealCredential(sshPasswordPrefix, password)
|
||||
}
|
||||
|
||||
func openSSHPassword(password string) (string, error) {
|
||||
return openCredential(sshPasswordPrefix, password)
|
||||
}
|
||||
|
||||
func (s *Store) EnsureUsersSchema(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS ssh_users (
|
||||
@@ -1355,6 +1365,39 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.migrateSSHPasswords(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) migrateSSHPasswords(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT username, password FROM ssh_users WHERE password <> '' AND password NOT LIKE 'enc:v1:ssh:%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyPassword struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
var legacy []legacyPassword
|
||||
for rows.Next() {
|
||||
var item legacyPassword
|
||||
if err := rows.Scan(&item.username, &item.password); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range legacy {
|
||||
sealed, err := sealSSHPassword(item.password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt SSH password for %s: %w", item.username, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE ssh_users SET password=$2 WHERE username=$1`, item.username, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1391,6 +1434,10 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password, err = openSSHPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt SSH password for %s: %w", username, err)
|
||||
}
|
||||
|
||||
cfg := UserConfig{
|
||||
Username: username,
|
||||
@@ -1426,7 +1473,11 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
|
||||
// UpsertUser creates or updates a row in ssh_users.
|
||||
func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
storedPassword, err := sealSSHPassword(u.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt SSH password: %w", err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO ssh_users (
|
||||
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
|
||||
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username
|
||||
@@ -1444,7 +1495,7 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
totp_digits = EXCLUDED.totp_digits,
|
||||
allow_static_password = EXCLUDED.allow_static_password`,
|
||||
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only.
|
||||
u.Username, u.Password, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
|
||||
u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
|
||||
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername)
|
||||
return err
|
||||
}
|
||||
@@ -1561,6 +1612,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
// SSH user management (session required; role-filtered inside handlers)
|
||||
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
|
||||
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
|
||||
mux.Handle("/api/users/renew", sessionMiddleware(http.HandlerFunc(handleRenewSSHUser(store))))
|
||||
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
|
||||
|
||||
// Server stats: visible to authenticated sessions; reset remains superadmin-only.
|
||||
@@ -1574,10 +1626,15 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats)))
|
||||
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
|
||||
|
||||
// Superadmin-only: reseller management
|
||||
mux.Handle("/api/resellers", saSession(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
// Hierarchical reseller management. Scope checks inside each handler limit a
|
||||
// reseller to its direct children; superadmins retain global management.
|
||||
mux.Handle("/api/resellers", sessionMiddleware(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", sessionMiddleware(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/action", sessionMiddleware(http.HandlerFunc(handleResellerAction(store))))
|
||||
mux.Handle("/api/resellers/delete", sessionMiddleware(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
mux.Handle("/api/resellers/audit", sessionMiddleware(http.HandlerFunc(handleResellerAudit(store))))
|
||||
// Called master-to-node with the managed server's superadmin session.
|
||||
mux.Handle("/api/resellers/runtime", saSession(http.HandlerFunc(handleResellerRuntime(store))))
|
||||
|
||||
// Master/slave server management. Superadmins can add slave nodes; all authenticated
|
||||
// users can read the enabled server list to pick where accounts are created.
|
||||
@@ -1597,6 +1654,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
|
||||
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
|
||||
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
|
||||
mux.Handle("/api/xray/clients/renew", sessionMiddleware(http.HandlerFunc(handleRenewXrayClient(store))))
|
||||
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
|
||||
|
||||
// Superadmin-only: TLS certificate generation
|
||||
@@ -1634,7 +1692,16 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
|
||||
go func() {
|
||||
log.Printf("Admin HTTP (panel + API) listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: securePanelHandler(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Printf("admin http error: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -1723,6 +1790,7 @@ type UserPayload struct {
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
PreserveExpires bool `json:"preserve_expires,omitempty"`
|
||||
}
|
||||
|
||||
func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
@@ -1736,39 +1804,148 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
var p UserPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
if err := validateSSHUserPayload(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableSSH {
|
||||
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
chargedCredits, creditCost, creditOwner := false, 0, ""
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
row, exists, ownerErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
currentOwner := ""
|
||||
if exists {
|
||||
currentOwner = strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
}
|
||||
if exists && currentOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.PreserveExpires = true
|
||||
p.MaxConnections = jsonInt(row["max_connections"])
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, jsonInt(row["max_connections"]), p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
if ownerErr != nil {
|
||||
writeResellerProvisionError(w, ownerErr)
|
||||
return
|
||||
}
|
||||
creditOwner = sess.Username
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
p.OwnerUsername = sess.Username
|
||||
}
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, sess.Username); syncErr != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
writeBadGatewayError(w, "create SSH account on managed server", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(ctx)
|
||||
var existingLocalExpiry string
|
||||
var existingLocalUser bool
|
||||
var existingLocalMax int
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
var existingOwner string
|
||||
var expiresAt sql.NullTime
|
||||
err := store.db.QueryRowContext(ctx,
|
||||
`SELECT owner_username, expires_at, max_connections FROM ssh_users WHERE username=$1`,
|
||||
p.Username).Scan(&existingOwner, &expiresAt, &existingLocalMax)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == nil && existingOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
existingLocalUser = err == nil
|
||||
if expiresAt.Valid {
|
||||
existingLocalExpiry = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if existingLocalUser {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" && resellerExpiryExtended(existingLocalExpiry, p.ExpiresAt) {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.ExpiresAt = existingLocalExpiry
|
||||
p.MaxConnections = existingLocalMax
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, existingLocalMax, p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.PreserveExpires && !existingLocalUser {
|
||||
var expiresAt sql.NullTime
|
||||
if err := store.db.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM ssh_users WHERE username=$1`, p.Username).Scan(&expiresAt); err == nil && expiresAt.Valid {
|
||||
p.ExpiresAt = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
// Decide what password to use:
|
||||
// - if payload has non-empty password -> use it
|
||||
// - else try to read existing password from DB
|
||||
@@ -1797,26 +1974,33 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
password = existing
|
||||
password, err = openSSHPassword(existing)
|
||||
if err != nil {
|
||||
log.Printf("failed to decrypt existing password for %s: %v", p.Username, err)
|
||||
http.Error(w, "stored credential is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine owner and enforce reseller quota
|
||||
sess := sessionFromCtx(ctx)
|
||||
// Determine owner and enforce reseller quota. Credit accounts spend one
|
||||
// credit per allowed connection (minimum one) and receive 31 days.
|
||||
ownerUsername := ""
|
||||
chargedCredits, creditCost := false, 0
|
||||
isNewUser := false
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
// Enforce user limit — only count on new user creation
|
||||
var existsInDB bool
|
||||
_ = store.db.QueryRowContext(ctx,
|
||||
`SELECT TRUE FROM ssh_users WHERE username=$1`, p.Username,
|
||||
).Scan(&existsInDB)
|
||||
if !existsInDB {
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
if !existingLocalUser {
|
||||
isNewUser = true
|
||||
var quotaErr error
|
||||
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
if quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
|
||||
ownerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
@@ -1838,6 +2022,9 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if isNewUser && chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, ownerUsername, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("failed to upsert user: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1862,15 +2049,15 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if err := validateAccountUsername(username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, username, sess.Username) {
|
||||
@@ -1880,7 +2067,7 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "delete SSH account from managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2218,7 +2405,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
return
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("%s:%d", req.Host, req.Port)
|
||||
target := net.JoinHostPort(req.Host, strconv.FormatUint(uint64(req.Port), 10))
|
||||
log.Printf("direct-tcpip: user=%s connecting to %s from %s:%d",
|
||||
u.Cfg.Username, target, req.OriginAddr, req.OriginPort)
|
||||
|
||||
@@ -2841,7 +3028,23 @@ func main() {
|
||||
configPath := flag.String("config", "", "path to JSON config file (default: ./config.json if present, otherwise /opt/sshpanel/config.json)")
|
||||
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
|
||||
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
|
||||
hashAdminPasswordStdin := flag.Bool("hash-admin-password-stdin", false, "read an admin password from stdin and print a bcrypt hash")
|
||||
flag.Parse()
|
||||
if *hashAdminPasswordStdin {
|
||||
password, readErr := io.ReadAll(io.LimitReader(os.Stdin, 1025))
|
||||
if readErr != nil {
|
||||
log.Fatalf("read admin password: %v", readErr)
|
||||
}
|
||||
if err := validateAdminPassword(string(password)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
hash, hashErr := hashAdminPassword(string(password))
|
||||
if hashErr != nil {
|
||||
log.Fatal(hashErr)
|
||||
}
|
||||
fmt.Println(hash)
|
||||
return
|
||||
}
|
||||
|
||||
resolvedConfigPath := resolveMainConfigPath(*configPath)
|
||||
cfg, userMap, err := loadConfig(resolvedConfigPath)
|
||||
@@ -2926,6 +3129,7 @@ func main() {
|
||||
} else {
|
||||
log.Printf("iface totals persistence disabled: %v", err)
|
||||
}
|
||||
startManagedResellerStateSync(store)
|
||||
}
|
||||
|
||||
// start background collector for CPU + interface stats
|
||||
@@ -2946,6 +3150,9 @@ func main() {
|
||||
|
||||
// Start the integrated Xray-core subprocess if configured.
|
||||
initXrayManager(cfg.Xray)
|
||||
if store != nil {
|
||||
reconcileLocalResellerRuntimeStates(store)
|
||||
}
|
||||
|
||||
// Global banner text (from config or file) — stored in a global so the
|
||||
// admin API can update it on the fly without a restart.
|
||||
|
||||
+300
-40
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -15,6 +16,44 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const managedCredentialPrefix = "enc:v1:"
|
||||
|
||||
func sealManagedCredential(plain string) (string, error) {
|
||||
return sealCredential(managedCredentialPrefix, plain)
|
||||
}
|
||||
|
||||
func openManagedCredential(stored string) (string, error) {
|
||||
return openCredential(managedCredentialPrefix, stored)
|
||||
}
|
||||
|
||||
func managedServerHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func remoteErrorSnippet(data []byte) string {
|
||||
const limit = 4096
|
||||
truncated := len(data) > limit
|
||||
if truncated {
|
||||
data = data[:limit]
|
||||
}
|
||||
value := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, string(data))
|
||||
value = strings.TrimSpace(value)
|
||||
if truncated {
|
||||
value += "…"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
@@ -66,7 +105,43 @@ func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateManagedServerCredentials(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) migrateManagedServerCredentials(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, admin_key FROM managed_servers WHERE admin_key <> '' AND admin_key NOT LIKE 'enc:v1:%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyCredential struct {
|
||||
id int
|
||||
key string
|
||||
}
|
||||
var legacy []legacyCredential
|
||||
for rows.Next() {
|
||||
var item legacyCredential
|
||||
if err := rows.Scan(&item.id, &item.key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range legacy {
|
||||
sealed, err := sealManagedCredential(item.key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt managed server credential %d: %w", item.id, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE managed_servers SET admin_key=$2 WHERE id=$1`, item.id, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) {
|
||||
@@ -83,6 +158,11 @@ func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error
|
||||
if err := rows.Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
out = append(out, ms)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -100,12 +180,17 @@ func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
baseURL := normalizeManagedServerBaseURL(p.BaseURL)
|
||||
baseURL, baseURLErr := validateManagedServerBaseURL(p.BaseURL)
|
||||
adminUsername := strings.TrimSpace(p.AdminUsername)
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
@@ -113,8 +198,17 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("server name required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("base url required")
|
||||
if len(name) > 120 || strings.IndexFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid server name")
|
||||
}
|
||||
if len(adminUsername) > 128 || strings.IndexFunc(adminUsername, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin username")
|
||||
}
|
||||
if len(p.AdminKey) > 4096 || strings.IndexFunc(p.AdminKey, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin credential")
|
||||
}
|
||||
if baseURLErr != nil {
|
||||
return nil, baseURLErr
|
||||
}
|
||||
if p.ID != "" && p.ID != "local" {
|
||||
id, err := strconv.Atoi(p.ID)
|
||||
@@ -127,10 +221,14 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
SET name=$2, base_url=$3, admin_username=$4, enable_ssh=$5, enable_xray=$6, is_active=$7, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
} else {
|
||||
sealedKey, sealErr := sealManagedCredential(p.AdminKey)
|
||||
if sealErr != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", sealErr)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, admin_key=$5, enable_ssh=$6, enable_xray=$7, is_active=$8, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -140,8 +238,12 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
return nil, fmt.Errorf("admin key/password required")
|
||||
}
|
||||
sealedKey, err := sealManagedCredential(p.AdminKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", err)
|
||||
}
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO managed_servers (name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (base_url) DO UPDATE SET
|
||||
@@ -152,7 +254,7 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
enable_xray=EXCLUDED.enable_xray,
|
||||
is_active=EXCLUDED.is_active,
|
||||
updated_at=NOW()
|
||||
RETURNING id`, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
RETURNING id`, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,21 +295,39 @@ func localManagedServerDTO() ManagedServerDTO {
|
||||
}
|
||||
|
||||
func normalizeManagedServerBaseURL(raw string) string {
|
||||
normalized, _ := validateManagedServerBaseURL(raw)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validateManagedServerBaseURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("base url required")
|
||||
}
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
lowerRaw := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "https://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("invalid base url")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("base url must use http or https")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("base url must not contain credentials")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("base url must not contain a path")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
|
||||
return "", fmt.Errorf("base url uses a forbidden address")
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func requestedServerID(r *http.Request) string {
|
||||
@@ -243,6 +363,36 @@ func managedServerFromID(ctx context.Context, store *Store, id string) (*Managed
|
||||
return ms, true, nil
|
||||
}
|
||||
|
||||
func writeManagedServerSelectionError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
switch err.Error() {
|
||||
case "invalid server id", "server not found", "server is disabled":
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case "database not configured":
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
default:
|
||||
writeInternalError(w, "select managed server", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeManagedServerSaveError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
message := err.Error()
|
||||
safe := message == "server name required" || message == "invalid server name" ||
|
||||
message == "invalid admin username" || message == "invalid admin credential" ||
|
||||
message == "invalid server id" || message == "admin key/password required" ||
|
||||
strings.HasPrefix(message, "base url") || message == "invalid base url"
|
||||
if safe {
|
||||
http.Error(w, message, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "save managed server", err)
|
||||
}
|
||||
|
||||
func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ms.BaseURL+"/api/auth/login", bytes.NewReader(body))
|
||||
@@ -250,7 +400,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
client := managedServerHTTPClient(15 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -258,7 +408,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("remote login failed: %s", strings.TrimSpace(string(data)))
|
||||
return "", fmt.Errorf("remote login failed with HTTP %d: %q", resp.StatusCode, remoteErrorSnippet(data))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
@@ -286,7 +436,7 @@ func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path str
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("X-Session-Token", token)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := managedServerHTTPClient(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
@@ -306,6 +456,13 @@ func handleManagedProxyOrLocal(store *Store, local http.HandlerFunc) http.Handle
|
||||
}
|
||||
|
||||
func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentType string) {
|
||||
if status >= http.StatusInternalServerError {
|
||||
if len(body) > 0 {
|
||||
log.Printf("managed server returned HTTP %d: %q", status, remoteErrorSnippet(body))
|
||||
}
|
||||
body = []byte("managed server request failed\n")
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
@@ -321,7 +478,7 @@ func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentT
|
||||
func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store *Store, remotePath string, body []byte, filterOwner string) bool {
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return true
|
||||
}
|
||||
if !remote {
|
||||
@@ -343,7 +500,7 @@ func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "proxy managed server request", err)
|
||||
return true
|
||||
}
|
||||
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") {
|
||||
@@ -434,7 +591,7 @@ func handleServers(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, err := store.UpsertManagedServer(r.Context(), p)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSaveError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -503,17 +660,17 @@ func handleServerTest(store *Store) http.HandlerFunc {
|
||||
}
|
||||
token, err := remoteLoginToken(r.Context(), ms)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server login", err)
|
||||
return
|
||||
}
|
||||
_ = token
|
||||
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server session", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
http.Error(w, strings.TrimSpace(string(data)), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server session", fmt.Errorf("HTTP %d: %q", status, remoteErrorSnippet(data)))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -543,7 +700,7 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
}
|
||||
if !remote {
|
||||
@@ -552,54 +709,157 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
|
||||
if err != nil {
|
||||
log.Printf("managed server config proxy %s: %v", ms.BaseURL, err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "proxy managed server configuration", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
if owner == "" || username == "" {
|
||||
return false
|
||||
func remoteSSHUserInfo(ctx context.Context, ms *ManagedServer, username string) (map[string]interface{}, bool, error) {
|
||||
if username == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return false
|
||||
return nil, false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(row["username"]) == username {
|
||||
return row, true, nil
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
if owner == "" || uuid == "" {
|
||||
return false
|
||||
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 {
|
||||
actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func remoteXrayClientInfo(ctx context.Context, ms *ManagedServer, uuid string) (map[string]interface{}, bool, error) {
|
||||
if uuid == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return false
|
||||
return nil, false, err
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid && fmt.Sprint(m["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(m["id"]) == uuid {
|
||||
m["inbound_tag"] = fmt.Sprint(ib["tag"])
|
||||
return m, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
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 {
|
||||
actualOwner, exists, err := remoteXrayClientOwner(ctx, ms, uuid)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
type resellerQuotaUsage struct {
|
||||
Weighted int
|
||||
SSHAccounts int
|
||||
XrayAccounts int
|
||||
}
|
||||
|
||||
func ownedQuotaUsageAcrossManagedServers(ctx context.Context, store *Store, owner string) (resellerQuotaUsage, error) {
|
||||
usage := resellerQuotaUsage{}
|
||||
if owner == "" {
|
||||
return usage, nil
|
||||
}
|
||||
usage.Weighted = countOwnedQuota(ctx, store, owner)
|
||||
usage.SSHAccounts = countOwnedUsers(owner)
|
||||
usage.XrayAccounts = countOwnedXrayClients(ctx, store, owner)
|
||||
if store == nil {
|
||||
return usage, nil
|
||||
}
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, ms := range servers {
|
||||
// Count every configured node and both account types. Temporarily disabling
|
||||
// a node or a protocol must not release its committed reseller quota.
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(user["max_connections"]))
|
||||
usage.SSHAccounts++
|
||||
}
|
||||
}
|
||||
status, data, _, err = proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(item["max_conns"]))
|
||||
usage.XrayAccounts++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
usage, err := ownedQuotaUsageAcrossManagedServers(ctx, store, owner)
|
||||
return usage.Weighted, err
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) {
|
||||
path := panelLogFilePath()
|
||||
maxBytes := panelLogMaxBytes()
|
||||
if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "clear panel log", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeQuotaMode(t *testing.T) {
|
||||
for input, expected := range map[string]string{
|
||||
"": QuotaModeSlots,
|
||||
"slots": QuotaModeSlots,
|
||||
"Validade": QuotaModeSlots,
|
||||
"credits": QuotaModeCredit,
|
||||
"Credito": QuotaModeCredit,
|
||||
} {
|
||||
if got := normalizeQuotaMode(input); got != expected {
|
||||
t.Fatalf("normalizeQuotaMode(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerProvisionCost(t *testing.T) {
|
||||
for input, expected := range map[int]int{-10: 1, 0: 1, 1: 1, 3: 3} {
|
||||
if got := resellerProvisionCost(input); got != expected {
|
||||
t.Fatalf("resellerProvisionCost(%d) = %d, want %d", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResellerSubtree(t *testing.T) {
|
||||
all := []*AdminUser{
|
||||
{Username: "root", Role: RoleReseller},
|
||||
{Username: "child-a", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "child-b", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "grandchild", Role: RoleReseller, ParentUsername: "child-a"},
|
||||
{Username: "admin", Role: RoleSuperAdmin},
|
||||
}
|
||||
got := listResellerSubtree(all, "root")
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("subtree size = %d, want 4", len(got))
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, user := range got {
|
||||
seen[user.Username] = true
|
||||
}
|
||||
for _, username := range []string{"root", "child-a", "child-b", "grandchild"} {
|
||||
if !seen[username] {
|
||||
t.Fatalf("subtree does not contain %q", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerCanManageOnlyDirectChildren(t *testing.T) {
|
||||
sess := &AdminSession{Username: "parent", Role: RoleReseller}
|
||||
if !resellerCanManage(sess, &AdminUser{Username: "child", Role: RoleReseller, ParentUsername: "parent"}) {
|
||||
t.Fatal("parent could not manage its direct child")
|
||||
}
|
||||
if resellerCanManage(sess, &AdminUser{Username: "grandchild", Role: RoleReseller, ParentUsername: "child"}) {
|
||||
t.Fatal("parent was allowed to skip a hierarchy level")
|
||||
}
|
||||
admin := &AdminSession{Username: "admin", Role: RoleSuperAdmin}
|
||||
if !resellerCanManage(admin, &AdminUser{Username: "any", Role: RoleReseller}) {
|
||||
t.Fatal("superadmin could not manage a reseller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerExpiryExtensionDetection(t *testing.T) {
|
||||
existing := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
|
||||
if resellerExpiryExtended(existing.Format(time.RFC3339), existing.Format(time.RFC3339)) {
|
||||
t.Fatal("unchanged expiration was treated as an extension")
|
||||
}
|
||||
if !resellerExpiryExtended(existing.Format(time.RFC3339), existing.Add(time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("later expiration was not treated as an extension")
|
||||
}
|
||||
if resellerTimeExtended(&existing, existing.Add(-time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("shorter expiration was treated as an extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalExpiryUsesLaterBase(t *testing.T) {
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
got := renewalExpiry(&future, 30)
|
||||
want := future.AddDate(0, 0, 30)
|
||||
if got.Sub(want) > time.Second || want.Sub(got) > time.Second {
|
||||
t.Fatalf("renewal expiry = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountChainUsesPasswordFreeRuntimeState(t *testing.T) {
|
||||
parent := "runtime-parent-test"
|
||||
child := "runtime-child-test"
|
||||
adminUsers.delete(parent)
|
||||
adminUsers.delete(child)
|
||||
defer resellerRuntimeStates.delete(parent)
|
||||
defer resellerRuntimeStates.delete(child)
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: true})
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: child, ParentUsername: parent, IsActive: true})
|
||||
if err := adminAccountChainActive(child); err != nil {
|
||||
t.Fatalf("active replicated hierarchy was rejected: %v", err)
|
||||
}
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: false})
|
||||
if err := adminAccountChainActive(child); err == nil {
|
||||
t.Fatal("child remained active while its replicated parent was suspended")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResellerRuntimeState is a password-free ownership record replicated from a
|
||||
// master panel to its managed nodes. It lets a node enforce reseller
|
||||
// suspension and parent hierarchy locally without copying login credentials.
|
||||
type ResellerRuntimeState struct {
|
||||
OwnerUsername string
|
||||
ParentUsername string
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type resellerRuntimeStateCacheT struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]ResellerRuntimeState
|
||||
}
|
||||
|
||||
var resellerRuntimeStates = &resellerRuntimeStateCacheT{m: make(map[string]ResellerRuntimeState)}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) get(username string) (ResellerRuntimeState, bool) {
|
||||
m.mu.RLock()
|
||||
state, ok := m.m[username]
|
||||
m.mu.RUnlock()
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) set(state ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m[state.OwnerUsername] = state
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) delete(username string) {
|
||||
m.mu.Lock()
|
||||
delete(m.m, username)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) list() []ResellerRuntimeState {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]ResellerRuntimeState, 0, len(m.m))
|
||||
for _, state := range m.m {
|
||||
out = append(out, state)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) replaceAll(states []ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m = make(map[string]ResellerRuntimeState, len(states))
|
||||
for _, state := range states {
|
||||
m.m[state.OwnerUsername] = state
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) ListResellerRuntimeStates(ctx context.Context) ([]ResellerRuntimeState, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT owner_username, parent_username, is_active, expires_at
|
||||
FROM reseller_runtime_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ResellerRuntimeState
|
||||
for rows.Next() {
|
||||
var state ResellerRuntimeState
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&state.OwnerUsername, &state.ParentUsername, &state.IsActive, &expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
state.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertResellerRuntimeState(ctx context.Context, state ResellerRuntimeState) error {
|
||||
var expiresAt interface{}
|
||||
if state.ExpiresAt != nil {
|
||||
expiresAt = *state.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO reseller_runtime_state
|
||||
(owner_username, parent_username, is_active, expires_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (owner_username) DO UPDATE SET
|
||||
parent_username=EXCLUDED.parent_username,
|
||||
is_active=EXCLUDED.is_active,
|
||||
expires_at=EXCLUDED.expires_at,
|
||||
updated_at=NOW()`,
|
||||
state.OwnerUsername, state.ParentUsername, state.IsActive, expiresAt)
|
||||
if err == nil {
|
||||
resellerRuntimeStates.set(state)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteResellerRuntimeState(ctx context.Context, owner string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM reseller_runtime_state WHERE owner_username=$1`, owner); err != nil {
|
||||
return err
|
||||
}
|
||||
resellerRuntimeStates.delete(owner)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeChainActive(username string) error {
|
||||
seen := make(map[string]bool)
|
||||
now := time.Now()
|
||||
for depth := 0; username != "" && depth < 128; depth++ {
|
||||
if seen[username] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[username] = true
|
||||
state, ok := resellerRuntimeStates.get(username)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller runtime state not found")
|
||||
}
|
||||
if !state.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if state.ExpiresAt != nil && now.After(*state.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
username = strings.TrimSpace(state.ParentUsername)
|
||||
}
|
||||
if username != "" {
|
||||
return fmt.Errorf("reseller hierarchy is too deep")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeStateFor(owner string, effectiveActive bool) (ResellerRuntimeState, error) {
|
||||
u, ok := adminUsers.get(owner)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return ResellerRuntimeState{}, fmt.Errorf("reseller account not found")
|
||||
}
|
||||
return ResellerRuntimeState{
|
||||
OwnerUsername: u.Username,
|
||||
ParentUsername: u.ParentUsername,
|
||||
IsActive: effectiveActive,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// syncOwnerChainToManagedServer makes account creation on a managed node safe:
|
||||
// every parent is installed before the child, and no password/hash is sent.
|
||||
func syncOwnerChainToManagedServer(ctx context.Context, ms *ManagedServer, owner string) error {
|
||||
var chain []*AdminUser
|
||||
seen := make(map[string]bool)
|
||||
for current := strings.TrimSpace(owner); current != ""; {
|
||||
if seen[current] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[current] = true
|
||||
u, ok := adminUsers.get(current)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
chain = append(chain, u)
|
||||
current = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
state, err := resellerRuntimeStateFor(chain[i].Username, adminAccountChainActive(chain[i].Username) == nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := resellerRuntimePayloadFromState(state, "sync")
|
||||
if err := sendResellerRuntimeToServer(ctx, ms, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncAllResellerRuntimeStates repairs legacy managed nodes after an upgrade.
|
||||
// It runs asynchronously and never prevents the local panel from starting.
|
||||
func startManagedResellerStateSync(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller state sync: %v", err)
|
||||
return
|
||||
}
|
||||
users := adminUsers.list()
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
return resellerHierarchyDepth(users[i].Username) < resellerHierarchyDepth(users[j].Username)
|
||||
})
|
||||
for _, ms := range servers {
|
||||
for _, u := range users {
|
||||
if u.Role != RoleReseller {
|
||||
continue
|
||||
}
|
||||
action := "suspend"
|
||||
active := adminAccountChainActive(u.Username) == nil
|
||||
if active {
|
||||
action = "reactivate"
|
||||
}
|
||||
state, stateErr := resellerRuntimeStateFor(u.Username, active)
|
||||
if stateErr != nil {
|
||||
continue
|
||||
}
|
||||
if sendErr := sendResellerRuntimeToServer(ctx, ms, resellerRuntimePayloadFromState(state, action)); sendErr != nil {
|
||||
log.Printf("reseller state sync to %s for %s: %v", ms.Name, u.Username, sendErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reconcileLocalResellerRuntimeStates reapplies replicated ownership state
|
||||
// after a managed node restarts.
|
||||
func reconcileLocalResellerRuntimeStates(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
action := "suspend"
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) == nil {
|
||||
action = "reactivate"
|
||||
}
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, action); err != nil {
|
||||
log.Printf("reconcile local reseller runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resellerHierarchyDepth(username string) int {
|
||||
seen := make(map[string]bool)
|
||||
depth := 0
|
||||
for username != "" && depth < 128 {
|
||||
if seen[username] {
|
||||
return 128
|
||||
}
|
||||
seen[username] = true
|
||||
u, ok := adminUsers.get(username)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
username = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminPasswordHashAndLegacyUpgrade(t *testing.T) {
|
||||
password := "correct-horse-battery-staple"
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if valid, upgrade := verifyAdminPassword(hash, password); !valid || upgrade {
|
||||
t.Fatalf("bcrypt verification = valid %v, upgrade %v", valid, upgrade)
|
||||
}
|
||||
if valid, _ := verifyAdminPassword(hash, "wrong-password"); valid {
|
||||
t.Fatal("wrong bcrypt password was accepted")
|
||||
}
|
||||
legacy := legacyAdminPasswordHash(password)
|
||||
if valid, upgrade := verifyAdminPassword(legacy, password); !valid || !upgrade {
|
||||
t.Fatalf("legacy verification = valid %v, upgrade %v", valid, upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSDomainRejectsTraversal(t *testing.T) {
|
||||
for _, value := range []string{"../root", `..\\root`, "/absolute", "host\nname"} {
|
||||
if _, _, err := normalizeTLSDomain(value, true); err == nil {
|
||||
t.Fatalf("normalizeTLSDomain(%q) accepted unsafe value", value)
|
||||
}
|
||||
}
|
||||
if domain, _, err := normalizeTLSDomain("vpn.example.com", false); err != nil || domain != "vpn.example.com" {
|
||||
t.Fatalf("valid domain rejected: %q, %v", domain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedServerURLValidation(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"ftp://example.com", "https://user:pass@example.com", "https://example.com/admin", "http://169.254.10.20",
|
||||
} {
|
||||
if _, err := validateManagedServerBaseURL(value); err == nil {
|
||||
t.Fatalf("validateManagedServerBaseURL(%q) accepted unsafe value", value)
|
||||
}
|
||||
}
|
||||
if got, err := validateManagedServerBaseURL("https://node.example.com/"); err != nil || got != "https://node.example.com" {
|
||||
t.Fatalf("valid managed server URL = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteErrorSnippetIsBoundedAndSingleLine(t *testing.T) {
|
||||
input := make([]byte, 5000)
|
||||
for i := range input {
|
||||
input[i] = 'x'
|
||||
}
|
||||
copy(input, []byte("first\nsecond\r\tsecret"))
|
||||
got := remoteErrorSnippet(input)
|
||||
if len(got) > 4100 {
|
||||
t.Fatalf("remote error snippet is too long: %d", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
t.Fatalf("remote error snippet retained control character %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMPSignatureRequiresSecretAndValidHMAC(t *testing.T) {
|
||||
const (
|
||||
secret = "test-secret-with-enough-entropy"
|
||||
dataID = "123456789"
|
||||
requestID = "request-123"
|
||||
)
|
||||
ts := time.Now().Format("150405")
|
||||
manifest := "id:" + dataID + ";request-id:" + requestID + ";ts:" + ts + ";"
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(manifest))
|
||||
signature := "ts=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if !verifyMPSignature(signature, requestID, dataID, secret) {
|
||||
t.Fatal("valid Mercado Pago signature was rejected")
|
||||
}
|
||||
if verifyMPSignature(signature, requestID, dataID, "") {
|
||||
t.Fatal("unsigned webhook mode was accepted")
|
||||
}
|
||||
if verifyMPSignature(signature, requestID, dataID, "wrong-secret") {
|
||||
t.Fatal("signature with wrong secret was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxAdminRequestBody = 8 << 20
|
||||
|
||||
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func writeBadGatewayError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "managed server request failed", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// securePanelHandler applies baseline browser protections and a global request
|
||||
// body ceiling. Endpoint-specific handlers may impose a smaller limit.
|
||||
func securePanelHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; img-src 'self' data:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestBody)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces a sensitive configuration file without leaving a
|
||||
// partially written file behind after a crash or interrupted request.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".dragoncore-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("replace %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func serverConfigGet(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
data, err := os.ReadFile(globalCfgPath)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read server configuration", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -78,11 +78,15 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "config path not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024+1))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > 512*1024 {
|
||||
http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
var newCfg Config
|
||||
if err := json.Unmarshal(body, &newCfg); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -110,8 +114,8 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "marshal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(globalCfgPath, out, 0o644); err != nil {
|
||||
http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil {
|
||||
writeInternalError(w, "write server configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+110
-37
@@ -4,21 +4,57 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tlsCertsDir = "/opt/sshpanel/certs"
|
||||
|
||||
var (
|
||||
tlsDNSNamePattern = regexp.MustCompile(`^(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
tlsStoreNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
)
|
||||
|
||||
func normalizeTLSDomain(raw string, allowWildcard bool) (domain string, dirName string, err error) {
|
||||
domain = strings.TrimSuffix(strings.TrimSpace(raw), ".")
|
||||
if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "/\\\x00\r\n") {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
if ip := net.ParseIP(domain); ip != nil {
|
||||
return domain, strings.ReplaceAll(domain, ":", "_"), nil
|
||||
}
|
||||
if strings.HasPrefix(domain, "*.") && !allowWildcard {
|
||||
return "", "", fmt.Errorf("wildcard domains are not supported by this operation")
|
||||
}
|
||||
if !tlsDNSNamePattern.MatchString(domain) {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
dirName = strings.ReplaceAll(domain, "*", "_wildcard_")
|
||||
return domain, dirName, nil
|
||||
}
|
||||
|
||||
func normalizeTLSStoreName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if !tlsStoreNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("name must use only letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// handleTLSGenerateSelfSigned generates a self-signed TLS certificate for the
|
||||
// given domain, writes it to /opt/sshpanel/certs/<domain>/, and returns the paths.
|
||||
func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,14 +65,20 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
|
||||
http.Error(w, "domain required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
domain, dirName, err := normalizeTLSDomain(req.Domain, true)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certDir := filepath.Join(tlsCertsDir, req.Domain)
|
||||
certDir := filepath.Join(tlsCertsDir, dirName)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
@@ -44,43 +86,48 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS private key", err)
|
||||
return
|
||||
}
|
||||
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialLimit)
|
||||
if err != nil {
|
||||
http.Error(w, "serial generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: req.Domain},
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{req.Domain},
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
if net.ParseIP(domain) != nil {
|
||||
tmpl.DNSNames = nil
|
||||
tmpl.IPAddresses = []net.IP{net.ParseIP(domain)}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS certificate", err)
|
||||
return
|
||||
}
|
||||
cf, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
cf.Close()
|
||||
|
||||
privDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "encode TLS private key", err)
|
||||
return
|
||||
}
|
||||
kf, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS private key", err)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
kf.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -100,21 +147,33 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" || req.Email == "" {
|
||||
http.Error(w, "domain and email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive",
|
||||
"--agree-tos", "-m", req.Email, "-d", req.Domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError)
|
||||
domain, _, err := normalizeTLSDomain(req.Domain, false)
|
||||
if err != nil || net.ParseIP(domain) != nil {
|
||||
http.Error(w, "a valid DNS domain is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
parsedEmail, err := mail.ParseAddress(email)
|
||||
if err != nil || parsedEmail.Address != email || len(email) > 254 {
|
||||
http.Error(w, "valid email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + req.Domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + req.Domain + "/privkey.pem"
|
||||
cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive",
|
||||
"--agree-tos", "-m", email, "-d", domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
writeInternalError(w, "obtain Let's Encrypt certificate", fmt.Errorf("certbot: %w: %s", err, strings.TrimSpace(string(out))))
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + domain + "/privkey.pem"
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -136,28 +195,42 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
Cert string `json:"cert"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" || req.Cert == "" || req.Key == "" {
|
||||
http.Error(w, "name, cert, and key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := filepath.Base(req.Name)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
http.Error(w, "invalid name", http.StatusBadRequest)
|
||||
name, err := normalizeTLSStoreName(req.Name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Cert) > 1024*1024 || len(req.Key) > 1024*1024 {
|
||||
http.Error(w, "certificate or key is too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(req.Cert), []byte(req.Key))
|
||||
if err != nil || len(pair.Certificate) == 0 {
|
||||
http.Error(w, "certificate and private key are invalid or do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||
http.Error(w, "invalid leaf certificate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
certDir := filepath.Join(tlsCertsDir, name)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create uploaded TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
keyFile := filepath.Join(certDir, "key.pem")
|
||||
if err := os.WriteFile(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS private key", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -53,6 +53,38 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "${GO_SHA256:-}" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -249,7 +281,7 @@ prepare_source_from_git() {
|
||||
}
|
||||
|
||||
install_go_if_needed() {
|
||||
local go_version machine goarch go_url current_go need_go
|
||||
local go_version machine goarch go_url go_expected_sha256 current_go need_go
|
||||
go_version="$(awk '$1 == "go" {print $2; exit}' "$SOURCE_DIR/go.mod" 2>/dev/null || echo "1.22.5")"
|
||||
need_go=true
|
||||
|
||||
@@ -270,12 +302,16 @@ install_go_if_needed() {
|
||||
x86_64) goarch="amd64" ;;
|
||||
aarch64) goarch="arm64" ;;
|
||||
armv7l) goarch="armv6l" ;;
|
||||
*) goarch="amd64" ;;
|
||||
*) error "Unsupported CPU architecture: $machine" ;;
|
||||
esac
|
||||
go_expected_sha256="$(trusted_go_sha256 "$go_version" "$goarch" "$SOURCE_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$go_expected_sha256" ]] || error "No trusted Go checksum for ${go_version}/${goarch}; set GO_SHA256 explicitly"
|
||||
go_url="https://go.dev/dl/go${go_version}.linux-${goarch}.tar.gz"
|
||||
info " Downloading Go ${go_version} (${goarch})..."
|
||||
need_cmd wget
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$go_url"
|
||||
verify_sha256_file "$go_expected_sha256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
|
||||
+167
-10
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -46,6 +48,10 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS last_active TIMESTAMPTZ`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS active_connections INT NOT NULL DEFAULT 0`,
|
||||
// Keep legacy reseller-owned Xray accounts aligned with weighted quota
|
||||
// accounting. A reseller account always consumes at least one slot.
|
||||
`UPDATE xray_clients SET max_conns = 1
|
||||
WHERE owner_username <> '' AND max_conns < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -231,8 +237,41 @@ func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername stri
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) SumXrayClientQuotaByOwner(ctx context.Context, ownerUsername string) (int, error) {
|
||||
if s == nil || ownerUsername == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var total int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(GREATEST(max_conns, 1)), 0)
|
||||
FROM xray_clients WHERE owner_username=$1`, ownerUsername).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func countOwnedSSHQuota(ownerUsername string) int {
|
||||
total := 0
|
||||
for _, user := range userMgr.List() {
|
||||
if user.Cfg.OwnerUsername == ownerUsername {
|
||||
total += resellerProvisionCost(user.Cfg.MaxConnections)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedXrayQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return 0
|
||||
}
|
||||
total, err := store.SumXrayClientQuotaByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
log.Printf("sum Xray quota for %s: %v", ownerUsername, err)
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
|
||||
return countOwnedSSHQuota(ownerUsername) + countOwnedXrayQuota(ctx, store, ownerUsername)
|
||||
}
|
||||
|
||||
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
|
||||
@@ -262,8 +301,111 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker runs a background goroutine that removes expired
|
||||
// Xray clients from both the config file and the database every 5 minutes.
|
||||
// suspendOwnerXrayClients removes an owner's clients from the live Xray config
|
||||
// while keeping their metadata. That makes reseller suspension reversible.
|
||||
func suspendOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.InboundTag == "" || !present[client.InboundTag][client.UUID] {
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(client.InboundTag, client.UUID); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("suspend Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreOwnerXrayClients restores metadata-backed clients after a reseller is
|
||||
// reactivated. Existing entries are left untouched, so retries are idempotent.
|
||||
func restoreOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.ExpiresAt != nil && time.Now().After(*client.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
if client.InboundTag == "" {
|
||||
continue
|
||||
}
|
||||
clientsForInbound, ok := present[client.InboundTag]
|
||||
if !ok {
|
||||
failures = append(failures, client.UUID+": inbound "+client.InboundTag+" no longer exists")
|
||||
continue
|
||||
}
|
||||
if clientsForInbound[client.UUID] {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(client.Email)
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(client.Name)
|
||||
}
|
||||
if email == "" {
|
||||
email = client.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(client.InboundTag, client.UUID, email); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
clientsForInbound[client.UUID] = true
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("restore Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker removes expired clients from the live config.
|
||||
// Reseller-owned metadata is retained so a paid renewal can restore access.
|
||||
func startXrayClientExpiryChecker(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
@@ -282,19 +424,34 @@ func startXrayClientExpiryChecker(store *Store) {
|
||||
continue
|
||||
}
|
||||
needRestart := false
|
||||
present := make(map[string]map[string]bool)
|
||||
if inbounds, listErr := xrayMgr.ListInbounds(); listErr == nil {
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range expired {
|
||||
tag := m.InboundTag
|
||||
if tag == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
if m.OwnerUsername == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
if present[tag][m.UUID] {
|
||||
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
|
||||
if m.OwnerUsername == "" {
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
|
||||
}
|
||||
|
||||
+209
-50
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -1103,7 +1104,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.WriteFile(configFile, pretty, 0o600)
|
||||
_ = writeFileAtomic(configFile, pretty, 0o600)
|
||||
return pretty, nil
|
||||
}
|
||||
}
|
||||
@@ -1115,7 +1116,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
|
||||
return nil, err
|
||||
} else if changed {
|
||||
data = forced
|
||||
_ = os.WriteFile(configFile, forced, 0o600)
|
||||
_ = writeFileAtomic(configFile, forced, 0o600)
|
||||
xrayLogf("xray: forced debug log output in file config")
|
||||
}
|
||||
return data, nil
|
||||
@@ -1141,7 +1142,7 @@ func (m *XrayManager) writeConfigLocked(data []byte) error {
|
||||
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
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "saved xray config")
|
||||
@@ -1226,7 +1227,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
|
||||
return err
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "database xray config")
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
@@ -1252,7 +1253,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
|
||||
if err := statsStore.UpsertXrayConfig(ctx, key, pretty); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
@@ -1319,7 +1320,7 @@ func (m *XrayManager) syncConfigFileFromStoreLocked() error {
|
||||
return err
|
||||
}
|
||||
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
|
||||
@@ -1341,6 +1342,11 @@ func (m *XrayManager) SetConfig(data []byte) error {
|
||||
if changed {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1878,7 +1884,7 @@ func handleXrayStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Start(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "start Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1893,7 +1899,7 @@ func handleXrayStop(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Stop(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "stop Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1908,7 +1914,7 @@ func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "restart Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1933,7 +1939,7 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
data, err := xrayMgr.GetConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read Xray configuration", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1945,8 +1951,19 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if !json.Valid(body) || json.Unmarshal(body, &raw) != nil || raw == nil {
|
||||
http.Error(w, "invalid Xray JSON configuration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if xrayMgr.useNativeMode() {
|
||||
if err := validateNativeInboundBindings(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := xrayMgr.SetConfig(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeInternalError(w, "save Xray configuration", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1967,13 +1984,13 @@ func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) {
|
||||
wasRunning := xrayMgr.isRunningSnapshot()
|
||||
changed, err := xrayMgr.EnsureStatsAPIConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeInternalError(w, "repair Xray statistics configuration", err)
|
||||
return
|
||||
}
|
||||
restarted := false
|
||||
if wasRunning {
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, "config repaired but restart failed: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "restart Xray after repairing statistics", err)
|
||||
return
|
||||
}
|
||||
restarted = true
|
||||
@@ -2288,7 +2305,7 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "list Xray inbounds", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2441,32 +2458,88 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.InboundTag == "" || req.UUID == "" {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
req.InboundTag = strings.TrimSpace(req.InboundTag)
|
||||
req.UUID = strings.TrimSpace(req.UUID)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.OwnerUsername = strings.TrimSpace(req.OwnerUsername)
|
||||
if err := validateXrayClientFields(req.UUID, req.InboundTag, req.Email, req.Name, req.ExpiresAt, req.MaxConnections, true); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(req.OwnerUsername); err != nil {
|
||||
http.Error(w, "invalid owner username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableXray {
|
||||
http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
chargedCredits, creditCost, creditOwner := false, 0, ""
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
_, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, "UUID already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
|
||||
if ownerErr != nil {
|
||||
writeResellerProvisionError(w, ownerErr)
|
||||
return
|
||||
}
|
||||
creditOwner = sess.Username
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
req.ExpiresAt = expiry
|
||||
}
|
||||
req.OwnerUsername = sess.Username
|
||||
}
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(r.Context(), ms, sess.Username); syncErr != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
writeBadGatewayError(w, "create Xray account on managed server", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
@@ -2480,21 +2553,17 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ownerUsername := ""
|
||||
chargedCredits, creditCost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
if statsStore == nil {
|
||||
http.Error(w, "storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) {
|
||||
if err := adminAccountChainActive(sess.Username); err != nil {
|
||||
http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if owner.MaxUsers > 0 && countOwnedQuota(r.Context(), statsStore, sess.Username) >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(req.OwnerUsername) != "" {
|
||||
ownerUsername = strings.TrimSpace(req.OwnerUsername)
|
||||
}
|
||||
@@ -2504,12 +2573,35 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "UUID already exists in database", http.StatusBadRequest)
|
||||
return
|
||||
} else if err != sql.ErrNoRows {
|
||||
http.Error(w, "database error: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "check Xray client metadata", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
var quotaErr error
|
||||
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
|
||||
if quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
req.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
lowerErr := strings.ToLower(err.Error())
|
||||
if strings.Contains(lowerErr, "already exists") {
|
||||
http.Error(w, "UUID already exists", http.StatusConflict)
|
||||
} else if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
|
||||
http.Error(w, "inbound not found", http.StatusBadRequest)
|
||||
} else {
|
||||
writeInternalError(w, "add Xray client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
@@ -2535,7 +2627,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
xrayLogf("xray: save meta for %s: %v", req.UUID, err)
|
||||
_ = xrayMgr.RemoveXrayClient(req.InboundTag, req.UUID)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
http.Error(w, "could not save Xray client", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
@@ -2550,34 +2647,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
PreserveExpires bool `json:"preserve_expires,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.UUID == "" {
|
||||
http.Error(w, "uuid required", http.StatusBadRequest)
|
||||
req.UUID = strings.TrimSpace(req.UUID)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if err := validateXrayClientFields(req.UUID, "", req.Email, req.Name, req.ExpiresAt, req.MaxConnections, false); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, req.UUID, sess.Username) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
row, exists, infoErr := remoteXrayClientInfo(r.Context(), ms, req.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(row["owner_username"])) != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
oldMaxConnections := jsonInt(row["max_conns"])
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(req.ExpiresAt) != "" {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
req.PreserveExpires = true
|
||||
req.MaxConnections = oldMaxConnections
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, oldMaxConnections, req.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "update Xray account on managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2588,16 +2721,34 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "client metadata not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(req.ExpiresAt) != "" && resellerTimeExtended(existing.ExpiresAt, req.ExpiresAt) {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
req.PreserveExpires = true
|
||||
req.MaxConnections = existing.MaxConns
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, existing.MaxConns, req.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
meta := XrayClientMeta{
|
||||
UUID: req.UUID,
|
||||
@@ -2607,6 +2758,9 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerUsername: existing.OwnerUsername,
|
||||
MaxConns: req.MaxConnections,
|
||||
}
|
||||
if req.PreserveExpires {
|
||||
meta.ExpiresAt = existing.ExpiresAt
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, req.ExpiresAt); err == nil {
|
||||
@@ -2616,7 +2770,7 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "update Xray client metadata", err)
|
||||
return
|
||||
}
|
||||
if req.Email != "" {
|
||||
@@ -2635,13 +2789,13 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
inboundTag := r.URL.Query().Get("inbound_tag")
|
||||
uuid := r.URL.Query().Get("uuid")
|
||||
if inboundTag == "" || uuid == "" {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
uuid := strings.TrimSpace(r.URL.Query().Get("uuid"))
|
||||
if err := validateXrayClientFields(uuid, inboundTag, "", "", "", 0, true); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, uuid, sess.Username) {
|
||||
@@ -2651,7 +2805,7 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
remotePath := "/api/xray/clients/remove?inbound_tag=" + url.QueryEscape(inboundTag) + "&uuid=" + url.QueryEscape(uuid)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "delete Xray account from managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2675,7 +2829,12 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
lowerErr := strings.ToLower(err.Error())
|
||||
if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
|
||||
http.Error(w, "inbound not found", http.StatusBadRequest)
|
||||
} else {
|
||||
writeInternalError(w, "remove Xray client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
|
||||
+125
-14
@@ -110,6 +110,18 @@ type nativeXrayServer struct {
|
||||
|
||||
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.
|
||||
func (s *nativeXrayServer) nativeRunning() bool {
|
||||
s.mu.Lock()
|
||||
@@ -140,7 +152,18 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
|
||||
var opened []net.Listener
|
||||
active := make(map[string]*nativeInbound, len(inbounds))
|
||||
xhttpGroups := make(map[string][]*nativeInbound)
|
||||
var xhttpOrder []string
|
||||
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))
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
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)
|
||||
}
|
||||
|
||||
serveLn := ln
|
||||
if ib.isXHTTP() {
|
||||
// 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) })
|
||||
}
|
||||
opened = append(opened, ln)
|
||||
xrayGo(fmt.Sprintf("native xray accept loop %s", addr), func() { ib.acceptLoop(ln) })
|
||||
active[ib.tag] = ib
|
||||
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())
|
||||
}
|
||||
|
||||
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.inboundsByTag = active
|
||||
s.running = true
|
||||
@@ -941,6 +979,79 @@ type nativeInboundJSON struct {
|
||||
} `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
|
||||
// per servable client-bearing inbound. Unsupported inbounds (api dokodemo-door,
|
||||
// freedom, etc.) are silently skipped.
|
||||
|
||||
+75
-13
@@ -10,12 +10,62 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"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
|
||||
// its port and a cleanup func.
|
||||
func startEchoServer(t *testing.T) (int, func()) {
|
||||
@@ -1068,18 +1118,30 @@ func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) {
|
||||
t.Fatalf("write fast mux frame: %v", err)
|
||||
}
|
||||
|
||||
meta, err := readNativeMuxMetadata(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err)
|
||||
}
|
||||
if meta.sessionID != 2 || meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 {
|
||||
t.Fatalf("expected session 2 keep-data frame, got: %#v", meta)
|
||||
}
|
||||
got, err := readNativeMuxDataBlock(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session payload: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("fast session echo mismatch: got %q want %q", got, want)
|
||||
for {
|
||||
meta, err := readNativeMuxMetadata(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err)
|
||||
}
|
||||
var got []byte
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
got, err = readNativeMuxDataBlock(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read mux response payload: %v", err)
|
||||
}
|
||||
}
|
||||
// Some networks reject TEST-NET immediately instead of letting the dial
|
||||
// time out. Ignore that session-1 error frame and keep waiting for the
|
||||
// independent fast session.
|
||||
if meta.sessionID != 2 {
|
||||
continue
|
||||
}
|
||||
if meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 {
|
||||
t.Fatalf("expected session 2 keep-data frame, got: %#v", meta)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("fast session echo mismatch: got %q want %q", got, want)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
+92
-13
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -97,34 +98,112 @@ func mergeNativeXHTTPSettings(primary, fallback nativeXHTTPSettingsJSON) nativeX
|
||||
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) {
|
||||
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{}
|
||||
handler := http.Handler(ib)
|
||||
handler := http.Handler(g)
|
||||
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
|
||||
// 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
|
||||
// XHTTP handler, which makes the proxy look dead with no useful target logs.
|
||||
if ib.security != "tls" {
|
||||
handler = h2c.NewHandler(ib, h2s)
|
||||
if g.security != "tls" {
|
||||
handler = h2c.NewHandler(g, h2s)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 4 * time.Second,
|
||||
MaxHeaderBytes: ib.xhttpServerMaxHeaderBytes(),
|
||||
MaxHeaderBytes: g.headerSize,
|
||||
}
|
||||
if ib.security == "tls" && ib.tlsConfig != nil {
|
||||
srv.TLSConfig = ib.tlsConfig
|
||||
if g.security == "tls" && g.tlsConfig != nil {
|
||||
srv.TLSConfig = g.tlsConfig
|
||||
_ = http2.ConfigureServer(srv, h2s)
|
||||
}
|
||||
// Backstop reaper for connected sessions whose client vanished without the
|
||||
// request context ever firing. Lives for the lifetime of this listener.
|
||||
// Each path has its own session namespace and idle sweeper.
|
||||
stopSweep := make(chan struct{})
|
||||
defer close(stopSweep)
|
||||
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: XHTTP server for inbound %q stopped: %v", ib.tag, err)
|
||||
for _, ib := range g.inbounds {
|
||||
ib := ib
|
||||
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
|
||||
@@ -537,7 +616,7 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "xhttp session queue failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user