Compare commits

...
7 Commits
Author SHA1 Message Date
penguinehis 9e2bb4ceeb Fix admin panel 2026-08-06 21:44:13 -03:00
penguinehis 809e2aeb82 SSL Cert FIX 2026-08-06 00:21:59 -03:00
penguinehis 54981f7348 Speed meter 2026-08-04 18:08:44 -03:00
penguinehis 8df19f01e0 Fix xray panel 2026-07-24 16:09:27 -03:00
penguinehis 6c2fc33fff Fix xray panel 2026-07-24 16:04:20 -03:00
penguinehis 44f5b2b09c Merge branch 'main' of https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB 2026-07-24 14:46:56 -03:00
penguinehis 1576ce9038 Fix DNSTT Default 2026-07-24 14:46:42 -03:00
21 changed files with 2216 additions and 260 deletions
+24 -7
View File
@@ -16,6 +16,8 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
- Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual - Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual
- Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel - Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel
- Listas de usuários SSH e Xray com botões de ordenação e filtros por status, conexão, uso, validade e cota, além de cabeçalhos clicáveis - Listas de usuários SSH e Xray com botões de ordenação e filtros por status, conexão, uso, validade e cota, além de cabeçalhos clicáveis
- Velocidade ao vivo (subida/descida) por conta nas listas SSH e Xray, somando todas as conexões do usuário, com ordenação por velocidade
- Listas de usuários em formato de cartão no celular: cada linha vira um cartão com rótulos, sem rolagem lateral
- Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray - Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray
- Contas de revendedor (reseller) com cota de usuários e escopo próprio - Contas de revendedor (reseller) com cota de usuários e escopo próprio
- Gerenciamento multi-servidor (master/slave) direto pelo painel - Gerenciamento multi-servidor (master/slave) direto pelo painel
@@ -199,7 +201,7 @@ Também é possível editar diretamente o `config.json`:
```json ```json
"dnstt": { "dnstt": {
"domain": "t.example.com", "domain": "t.example.com",
"udp_listen": "[::]:5300", "udp_listen": "0.0.0.0:5300",
"privkey_file": "/opt/sshpanel/dnstt.key", "privkey_file": "/opt/sshpanel/dnstt.key",
"auto_restart_interval": "6h", "auto_restart_interval": "6h",
"auto_restart_grace": "2s" "auto_restart_grace": "2s"
@@ -219,7 +221,7 @@ Exemplo:
"t.example.com", "t.example.com",
"t.local.lan" "t.local.lan"
], ],
"udp_listen": "[::]:5300", "udp_listen": "0.0.0.0:5300",
"privkey_file": "/opt/sshpanel/dnstt.key" "privkey_file": "/opt/sshpanel/dnstt.key"
} }
``` ```
@@ -557,6 +559,8 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
- Native shared XHTTP endpoint: VLESS **or** VMess on `/` and SSH on `/ssh`, using the same domain/port/TLS - 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 - Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher
- Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations - Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations
- Live per-account up/down speed in the SSH and Xray user lists, summed across every connection the account has open, sortable by speed
- User lists collapse into labelled cards on phones, so there is no sideways scrolling
- Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray - Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray
- Reseller accounts with a user quota and self-scoped access - Reseller accounts with a user quota and self-scoped access
- Multi-server (master/slave) management directly from the panel - Multi-server (master/slave) management directly from the panel
@@ -738,7 +742,7 @@ You can also edit `config.json` directly:
```json ```json
"dnstt": { "dnstt": {
"domain": "t.example.com", "domain": "t.example.com",
"udp_listen": "[::]:5300", "udp_listen": "0.0.0.0:5300",
"privkey_file": "/opt/sshpanel/dnstt.key", "privkey_file": "/opt/sshpanel/dnstt.key",
"auto_restart_interval": "6h", "auto_restart_interval": "6h",
"auto_restart_grace": "2s" "auto_restart_grace": "2s"
@@ -758,7 +762,7 @@ Example:
"t.example.com", "t.example.com",
"t.local.lan" "t.local.lan"
], ],
"udp_listen": "[::]:5300", "udp_listen": "0.0.0.0:5300",
"privkey_file": "/opt/sshpanel/dnstt.key" "privkey_file": "/opt/sshpanel/dnstt.key"
} }
``` ```
@@ -1145,7 +1149,7 @@ curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN"
#### `GET /api/users` — session #### `GET /api/users` — session
- Optional query: `server_id`. Resellers see only their own users; superadmins see all. - Optional query: `server_id`. Resellers see only their own users; superadmins see all.
- `200`: array of user objects: `username` (string), `active_conns` (int), `max_connections` (int), `expires_at` (string/null), `limit_mbps_up` (int), `limit_mbps_down` (int), `data_quota_bytes` (int64), `quota_action` (`block` or `throttle`), `quota_throttle_mbps` (int), `total_uplink_bytes`, `total_downlink_bytes`, `total_bytes`, `quota_exceeded`, `totp_secret` (string, omitempty), `totp_period` (int), `totp_window` (int), `totp_digits` (int), `allow_static_password` (bool), `totp_enabled` (bool), `owner_username` (string, omitempty), `server_id` (string, omitempty). - `200`: array of user objects: `username` (string), `active_conns` (int), `max_connections` (int), `expires_at` (string/null), `limit_mbps_up` (int), `limit_mbps_down` (int), `data_quota_bytes` (int64), `quota_action` (`block` or `throttle`), `quota_throttle_mbps` (int), `total_uplink_bytes`, `total_downlink_bytes`, `total_bytes`, `up_bytes_per_sec` (float, live account-wide upload speed), `down_bytes_per_sec` (float, live account-wide download speed), `quota_exceeded`, `totp_secret` (string, omitempty), `totp_period` (int), `totp_window` (int), `totp_digits` (int), `allow_static_password` (bool), `totp_enabled` (bool), `owner_username` (string, omitempty), `server_id` (string, omitempty).
#### `POST /api/users/create` — session #### `POST /api/users/create` — session
Creates or updates (upsert) an SSH user. Creates or updates (upsert) an SSH user.
@@ -1265,7 +1269,7 @@ Read/write a managed server's `config.json`. Query: `server_id`. Local delegates
#### `GET /api/xray/inbounds` — session #### `GET /api/xray/inbounds` — session
- Optional `server_id`. Lists only inbounds that carry client lists (vless/vmess/trojan). Resellers see all inbounds but only their own clients. Clients are enriched with DB metadata and runtime stats. - Optional `server_id`. Lists only inbounds that carry client lists (vless/vmess/trojan). Resellers see all inbounds but only their own clients. Clients are enriched with DB metadata and runtime stats.
- `200`: array of `{ "tag": string, "protocol": string, "port": <raw>, "listen": string, "clients": [ XrayClientInfo ] }`. - `200`: array of `{ "tag": string, "protocol": string, "port": <raw>, "listen": string, "clients": [ XrayClientInfo ] }`.
- **XrayClientInfo**: `id` (string, the UUID), `password` (string, omitempty), `email` (string), `level` (int), `online` (bool), `last_active` (string/null), `uplink_bytes` (int64), `downlink_bytes` (int64), `total_bytes` (int64), `active_connections` (int), `name` (string), `expires_at` (string/null), `expiration_days` (int; `-1` = no expiry, `0` = expired), `max_conns`, `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `quota_exceeded`, `owner_username`, `expired`. - **XrayClientInfo**: `id` (string, the UUID), `password` (string, omitempty), `email` (string), `level` (int), `online` (bool), `last_active` (string/null), `uplink_bytes` (int64), `downlink_bytes` (int64), `total_bytes` (int64), `up_bytes_per_sec` (float, live client-wide upload speed), `down_bytes_per_sec` (float, live client-wide download speed), `active_connections` (int), `name` (string), `expires_at` (string/null), `expiration_days` (int; `-1` = no expiry, `0` = expired), `max_conns`, `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `quota_exceeded`, `owner_username`, `expired`.
#### `POST /api/xray/clients/add` — session #### `POST /api/xray/clients/add` — session
- Body: `inbound_tag` (string, required), `uuid` (valid UUID, required), `email` (string, optional — defaults to name then uuid), `name` (string, optional), `expires_at` (RFC3339, `YYYY-MM-DDThh:mm`, or `YYYY-MM-DD`), `max_connections` (010000), `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `owner_username` (string, optional — superadmin only), `server_id` (string, optional). - Body: `inbound_tag` (string, required), `uuid` (valid UUID, required), `email` (string, optional — defaults to name then uuid), `name` (string, optional), `expires_at` (RFC3339, `YYYY-MM-DDThh:mm`, or `YYYY-MM-DD`), `max_connections` (010000), `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `owner_username` (string, optional — superadmin only), `server_id` (string, optional).
@@ -1286,7 +1290,7 @@ Read/write a managed server's `config.json`. Query: `server_id`. Local delegates
### TLS certificates (superadmin only) ### TLS certificates (superadmin only)
All three accept `POST` only and support `server_id` proxying. All endpoints support `server_id` proxying, so a certificate can also be listed/updated on a managed slave node. The three issue/upload endpoints below accept `POST` only.
#### `POST /api/tls/generate-selfsigned` #### `POST /api/tls/generate-selfsigned`
- Body: `domain` (string, required). Writes a self-signed ECDSA (P-256) cert (10-year validity) to `/opt/sshpanel/certs/<domain>/`. - Body: `domain` (string, required). Writes a self-signed ECDSA (P-256) cert (10-year validity) to `/opt/sshpanel/certs/<domain>/`.
@@ -1300,6 +1304,19 @@ All three accept `POST` only and support `server_id` proxying.
- Body: `name` (string, required), `cert` (string, required — PEM), `key` (string, required — PEM). Saves to `/opt/sshpanel/certs/<name>/`. - Body: `name` (string, required), `cert` (string, required — PEM), `key` (string, required — PEM). Saves to `/opt/sshpanel/certs/<name>/`.
- `200`: `{ "cert_file": string, "key_file": string }`. Errors: `400 name, cert, and key required` / `invalid name`; `500`. - `200`: `{ "cert_file": string, "key_file": string }`. Errors: `400 name, cert, and key required` / `invalid name`; `500`.
#### `GET /api/tls/certs`
Lists every certificate this node knows about: the ones stored under `/opt/sshpanel/certs/`, the ones referenced by `tls_forwarders`, and the ones referenced by Xray inbound `tlsSettings` (inbounds that enable TLS without naming a certificate are reported against the first TLS forwarder's material, which is what `buildInboundTLS` falls back to).
- `200`: `{ "certs_dir": string, "certs": [ { "name", "cert_file", "key_file", "managed", "exists", "subject", "issuer", "domains": [string], "not_before", "not_after", "days_left", "expired", "expiring", "self_signed", "chain_length", "key_type", "key_ok", "modified", "error", "used_by": [ { "kind": "tls_forwarder"|"xray_inbound", "ref": string } ] } ] }`.
#### `POST /api/tls/certs/update`
Replaces a certificate's `fullchain.pem` + `privkey.pem`. The panel's **Configuração → TLS → Certificados TLS** card uses this for renewals.
- Body: `fullchain` (string, required — PEM; `cert` accepted as alias), `privkey` (string, required — PEM; `key` accepted as alias), plus **either** `cert_file` (+ optional `key_file`) to replace an existing certificate in place, **or** `name` to create/replace `/opt/sshpanel/certs/<name>/`. Optional `reload` (bool, default `true`) and `force` (bool, default `false`).
- The pair is validated with `tls.X509KeyPair` before anything is written; the previous content is kept as `<file>.bak`; existing file modes are preserved; symlinked targets (certbot layout) are followed so the link structure survives.
- `cert_file` must be inside `/opt/sshpanel/certs/` or already referenced by the running config / Xray config — this endpoint is not an arbitrary file-write primitive.
- Because the paths do not change, no other configuration needs editing. With `reload` on, the TLS forwarders serving the certificate are rebound (established connections are untouched) and Xray is restarted if one of its inbounds uses it.
- `200`: `{ "cert_file": string, "key_file": string, "cert": <same shape as the list entry>, "reloaded": { "tls_forwarders": [string], "xray_inbounds": [string], "xray_restarted": bool }, "warnings": [string] }`. Warnings cover a leaf-only PEM (no intermediates), a not-yet-valid certificate, a domain change versus the previous certificate, and certbot-managed paths.
- Errors: `400` for a missing/mismatched pair, an expired certificate without `force=true`, or a path outside the allowed set; `413` for PEM over 1 MiB; `500` on write failure.
--- ---
### Panel config ### Panel config
+61 -7
View File
@@ -686,19 +686,18 @@ select:disabled {
.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)} .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 */ /* 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)} .xray-inbound-launcher{--hero-accent:139,92,246;position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:17px;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} .xray-inbound-launcher-copy,.xray-inbound-launcher-actions,.azion-preset-summary,.xray-inbound-launcher>.hint{position:relative;z-index:1}.xray-inbound-launcher-copy h3,.visual-editor-heading h3{margin-top:5px;font-size:1.12rem;letter-spacing:-.02em}.xray-inbound-launcher-copy p{max-width:760px;margin-top:5px;color:var(--muted);font-size:.75rem;line-height:1.5}.xray-inbound-launcher-copy p strong{color:var(--text-2)}.xray-inbound-launcher-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}.xray-inbound-launcher-actions .btn-soft{border-color:rgba(49,214,123,.28);background:linear-gradient(135deg,rgba(49,214,123,.16),rgba(34,211,238,.08));color:#8af0b5}
.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)} .azion-preset-summary{grid-column:1/-1;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;padding-top:15px;border-top:1px solid rgba(148,163,184,.1)}.azion-preset-summary span{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 11px;border:1px solid rgba(139,92,246,.14);border-radius:13px;background:rgba(139,92,246,.055)}.azion-preset-summary small{color:var(--muted);font-size:.65rem}.azion-preset-summary strong{color:var(--text-2);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.7rem}.xray-inbound-launcher>.hint{grid-column:1/-1}
.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} .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-config-toolbar{display:grid;grid-template-columns:180px minmax(0,1fr);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)} .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} .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)} .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: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: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))}.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: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}.xray-inbound-launcher{grid-template-columns:1fr;padding:15px}.xray-inbound-launcher-actions,.visual-save-bar{align-items:stretch;flex-direction:column}.xray-inbound-launcher-actions{justify-content:flex-start}.xray-inbound-launcher-actions .btn,.visual-save-bar .btn{width:100%}.azion-preset-summary{grid-column:auto;grid-template-columns:repeat(2,minmax(0,1fr))}.xray-inbound-launcher>.hint{grid-column:auto}.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}} @media(max-width:460px){.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:1fr}.workspace-hero-actions .btn{flex:1}.workspace-toolbar-actions .btn{flex:1}.workspace-overview-card{padding:11px 12px}}
/* --- Bot sales workspace --- */ /* --- Bot sales workspace --- */
@@ -762,3 +761,58 @@ th[data-sort-key].sort-desc::after{content:" \25BC";}
.user-list-controls{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:12px;border:1px solid rgba(var(--section-accent,139,92,246),.18);border-radius:16px;background:rgba(var(--section-accent,139,92,246),.055)} .user-list-controls{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:12px;border:1px solid rgba(var(--section-accent,139,92,246),.18);border-radius:16px;background:rgba(var(--section-accent,139,92,246),.055)}
.user-list-control-group{display:flex;flex-direction:column;gap:6px;min-width:0}.user-list-control-label{color:var(--muted);font-size:.63rem;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.user-list-buttons{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.user-list-filter-btn{min-height:30px;padding:5px 9px;border:1px solid rgba(148,163,184,.16);border-radius:10px;background:rgba(255,255,255,.025);color:var(--muted);font-size:.68rem;font-weight:850;cursor:pointer;transition:.15s ease}.user-list-filter-btn:hover{color:var(--text);border-color:rgba(var(--section-accent,139,92,246),.38);background:rgba(var(--section-accent,139,92,246),.09)}.user-list-filter-btn.active{color:#fff;border-color:rgba(var(--section-accent,139,92,246),.44);background:linear-gradient(135deg,rgba(var(--section-accent,139,92,246),.3),rgba(34,211,238,.1));box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}.user-list-filter-btn[data-direction]::after{margin-left:4px;font-size:.7em}.user-list-filter-btn[data-direction="asc"]::after{content:"\25B2"}.user-list-filter-btn[data-direction="desc"]::after{content:"\25BC"}.user-list-count{margin-left:auto;white-space:nowrap} .user-list-control-group{display:flex;flex-direction:column;gap:6px;min-width:0}.user-list-control-label{color:var(--muted);font-size:.63rem;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.user-list-buttons{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.user-list-filter-btn{min-height:30px;padding:5px 9px;border:1px solid rgba(148,163,184,.16);border-radius:10px;background:rgba(255,255,255,.025);color:var(--muted);font-size:.68rem;font-weight:850;cursor:pointer;transition:.15s ease}.user-list-filter-btn:hover{color:var(--text);border-color:rgba(var(--section-accent,139,92,246),.38);background:rgba(var(--section-accent,139,92,246),.09)}.user-list-filter-btn.active{color:#fff;border-color:rgba(var(--section-accent,139,92,246),.44);background:linear-gradient(135deg,rgba(var(--section-accent,139,92,246),.3),rgba(34,211,238,.1));box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}.user-list-filter-btn[data-direction]::after{margin-left:4px;font-size:.7em}.user-list-filter-btn[data-direction="asc"]::after{content:"\25B2"}.user-list-filter-btn[data-direction="desc"]::after{content:"\25BC"}.user-list-count{margin-left:auto;white-space:nowrap}
@media(max-width:760px){.user-list-controls{align-items:stretch}.user-list-control-group{width:100%}.user-list-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.user-list-filter-btn{width:100%}.user-list-count{margin-left:0;align-self:flex-start}} @media(max-width:760px){.user-list-controls{align-items:stretch}.user-list-control-group{width:100%}.user-list-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.user-list-filter-btn{width:100%}.user-list-count{margin-left:0;align-self:flex-start}}
/* Live per-account speed (whole account, all connections summed) */
.speed-cell{display:inline-flex;flex-direction:column;gap:1px;line-height:1.25;font-variant-numeric:tabular-nums;font-weight:850;white-space:nowrap;}
.speed-cell .speed-down{color:var(--accent-3);}
.speed-cell .speed-up{color:var(--accent);}
/* Card tables: when the space left for these wide user lists is too small to
show every column, the table stops scrolling sideways and each row becomes a
labelled card. Labels come from each cell's data-label.
The switch is driven by the card's own width instead of the viewport, so it
also catches 1366/1440-class monitors, where the sidebar plus paddings leave
the table ~900px and the last columns end up cut off. */
.card:has(table.table-cards){container-type:inline-size;container-name:usertbl;}
@container usertbl (max-width:1120px){
.tbl-wrap:has(table.table-cards){overflow:visible;border:0;border-radius:0;background:transparent;}
table.table-cards{display:block;min-width:0;width:100%;font-size:.78rem;}
table.table-cards thead{display:none;}
table.table-cards tbody{display:flex;flex-direction:column;gap:10px;}
table.table-cards tr{
display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px 12px;
padding:13px 14px;border:1px solid rgba(148,163,184,.14);border-radius:18px;
background:rgba(3,6,10,.55);
}
table.table-cards tbody tr:hover{background:rgba(34,211,238,.05);}
table.table-cards td{
display:flex;flex-direction:column;gap:3px;min-width:0;
padding:0;border:0;font-size:.78rem!important;overflow-wrap:anywhere;
}
table.table-cards td::before{
content:attr(data-label);color:var(--muted);font-size:.6rem;font-weight:900;
letter-spacing:.1em;text-transform:uppercase;
}
table.table-cards td[colspan]{grid-column:1/-1;text-align:center;}
table.table-cards td:not([data-label])::before{display:none;}
table.table-cards td.cell-primary{grid-column:1/-1;font-size:.98rem!important;font-weight:900;color:var(--text);}
table.table-cards td.cell-primary::before{display:none;}
table.table-cards td.cell-wide{grid-column:span 2;}
table.table-cards td.cell-actions{
grid-column:1/-1;flex-direction:row;flex-wrap:wrap;gap:6px;
padding-top:4px;white-space:normal!important;
}
table.table-cards td.cell-actions::before{display:none;}
table.table-cards td.cell-actions .btn{margin:0!important;min-height:36px;}
table.table-cards .table-meter{max-width:none;}
table.table-cards .speed-cell{flex-direction:row;gap:12px;}
}
@container usertbl (max-width:860px){
table.table-cards tr{grid-template-columns:repeat(3,minmax(0,1fr));}
}
@container usertbl (max-width:560px){
table.table-cards tr{grid-template-columns:repeat(2,minmax(0,1fr));}
table.table-cards td.cell-wide{grid-column:1/-1;}
table.table-cards td.cell-actions .btn{flex:1 1 auto;}
}
+39
View File
@@ -711,6 +711,34 @@ function clientTrafficHTML(c) {
return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`; return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
} }
// ─── Live bandwidth ───────────────────────────────────────────────────────────
// The API reports the account's current speed in bytes per second, summed over
// every connection it has open. Speeds are shown in bits per second because
// that is the unit the per-user limits use.
function formatSpeed(bytesPerSec) {
const bits = Number(bytesPerSec || 0) * 8;
if (!Number.isFinite(bits) || bits < 1000) return "0";
if (bits < 1e6) return `${Math.round(bits / 1e3)} kbps`;
if (bits < 1e9) return `${(bits / 1e6).toFixed(bits < 1e7 ? 2 : 1)} Mbps`;
return `${(bits / 1e9).toFixed(2)} Gbps`;
}
function isIdleSpeed(upBytesPerSec, downBytesPerSec) {
return Number(upBytesPerSec || 0) * 8 < 1000 && Number(downBytesPerSec || 0) * 8 < 1000;
}
function speedHTML(upBytesPerSec, downBytesPerSec) {
if (isIdleSpeed(upBytesPerSec, downBytesPerSec)) return `<span class="hint">${t("idle")}</span>`;
return `<span class="speed-cell">`
+ `<span class="speed-down">↓ ${escapeHTML(formatSpeed(downBytesPerSec))}</span>`
+ `<span class="speed-up">↑ ${escapeHTML(formatSpeed(upBytesPerSec))}</span>`
+ `</span>`;
}
function speedTotalBytesPerSec(entry) {
return Number(entry?.up_bytes_per_sec || 0) + Number(entry?.down_bytes_per_sec || 0);
}
function updateCell(row, name, html) { function updateCell(row, name, html) {
const cell = row?.querySelector?.(`[data-cell="${name}"]`); const cell = row?.querySelector?.(`[data-cell="${name}"]`);
if (cell && cell.innerHTML !== html) cell.innerHTML = html; if (cell && cell.innerHTML !== html) cell.innerHTML = html;
@@ -747,6 +775,7 @@ function patchRenderedInbounds(inbounds) {
updateCell(row, "status", clientStatusHTML(c)); updateCell(row, "status", clientStatusHTML(c));
updateCell(row, "online", clientOnlineHTML(c)); updateCell(row, "online", clientOnlineHTML(c));
updateCell(row, "connections", escapeHTML(c.active_connections || 0)); updateCell(row, "connections", escapeHTML(c.active_connections || 0));
updateCell(row, "speed", speedHTML(c.up_bytes_per_sec, c.down_bytes_per_sec));
updateCell(row, "traffic", clientTrafficHTML(c)); updateCell(row, "traffic", clientTrafficHTML(c));
updateCell(row, "max", escapeHTML(c.max_conns || "∞")); updateCell(row, "max", escapeHTML(c.max_conns || "∞"));
} }
@@ -782,3 +811,13 @@ Object.assign(I18N_TEXT["pt-BR"], {
"Apply safe defaults":"Aplicar padrões seguros", "Apply safe defaults":"Aplicar padrões seguros",
"XHTTP is handled as VPN tunnel traffic: packet requests and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally.":"O XHTTP é tratado como tráfego de túnel VPN: requisições de pacotes e remontagem usam somente backpressure com limite de bytes, nunca limite por quantidade de requisições. Limites web antigos já salvos são ignorados automaticamente após a atualização. As regras por usuário de max_conns, cota e banda continuam funcionando normalmente." "XHTTP is handled as VPN tunnel traffic: packet requests and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally.":"O XHTTP é tratado como tráfego de túnel VPN: requisições de pacotes e remontagem usam somente backpressure com limite de bytes, nunca limite por quantidade de requisições. Limites web antigos já salvos são ignorados automaticamente após a atualização. As regras por usuário de max_conns, cota e banda continuam funcionando normalmente."
}); });
// Live per-account bandwidth column, shared by the SSH and Xray user lists.
Object.assign(I18N_TEXT["en-US"], {
"Speed":"Speed", "Limit up":"Limit up", "Limit down":"Limit down",
"Current up/down speed of the whole account, across all of its connections.":"Current up/down speed of the whole account, across all of its connections.",
});
Object.assign(I18N_TEXT["pt-BR"], {
"Speed":"Velocidade", "Limit up":"Limite de envio", "Limit down":"Limite de download",
"Current up/down speed of the whole account, across all of its connections.":"Velocidade atual de envio/recebimento da conta inteira, somando todas as conexões.",
});
+1
View File
@@ -97,6 +97,7 @@ function setWorkspaceSection(workspace, section, options = {}) {
if (!options.silent) { if (!options.silent) {
if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig(); if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig();
if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs(); if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs();
if (workspace === "config" && section === "tls" && currentRole === "superadmin" && typeof loadTLSCertificates === "function") loadTLSCertificates();
} }
return true; return true;
} }
+29 -17
View File
@@ -75,16 +75,17 @@ const USER_SORT_EXTRACT = {
max: u => u.max_connections || 0, max: u => u.max_connections || 0,
up: u => u.limit_mbps_up || 0, up: u => u.limit_mbps_up || 0,
down: u => u.limit_mbps_down || 0, down: u => u.limit_mbps_down || 0,
speed: u => speedTotalBytesPerSec(u),
usage: u => Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0), usage: u => Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0),
expires: u => u.expires_at ? new Date(u.expires_at).getTime() : Infinity, expires: u => u.expires_at ? new Date(u.expires_at).getTime() : Infinity,
owner: u => String(u.owner_username || "").toLowerCase(), owner: u => String(u.owner_username || "").toLowerCase(),
}; };
// Columns that default to descending on first click (most/online first). // Columns that default to descending on first click (most/online first).
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "usage"]); const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "speed", "usage"]);
const USER_SORT_OPTIONS = [ const USER_SORT_OPTIONS = [
["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"], ["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"],
["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"], ["up", "Up"], ["down", "Dn"], ["speed", "Speed"], ["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"],
["owner", "Owner"], ["up", "Up"], ["down", "Dn"], ["owner", "Owner"],
]; ];
const USER_FILTER_OPTIONS = [ const USER_FILTER_OPTIONS = [
["all", "All"], ["online", "Online"], ["offline", "Offline"], ["all", "All"], ["online", "Online"], ["offline", "Offline"],
@@ -227,25 +228,36 @@ function renderUsers(users) {
users.forEach(u => { users.forEach(u => {
const on = (u.active_conns || 0) > 0; const on = (u.active_conns || 0) > 0;
const tr = document.createElement("tr"); const tr = document.createElement("tr");
// Every cell carries its column label so the table can collapse into
// labelled cards on phones instead of scrolling sideways. "wide" cells span
// the full card width there.
const cells = [ const cells = [
u.username, { label:"User", text:u.username, cls:"cell-primary" },
on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`, { label:"Status", html: on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>` },
u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password"), { label:"Auth", text: u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password") },
u.active_conns ?? 0, { label:"Conn", text: String(u.active_conns ?? 0) },
u.max_connections || 0, { label:"Max", text: String(u.max_connections || 0) },
u.limit_mbps_up || 0, // "Up"/"Dn" are speed limits, not current speed: spell that out on the
u.limit_mbps_down || 0, // card layout where the label sits right next to the live speed.
sshTrafficHTML(u), { label:"Up", cardLabel:"Limit up", text: String(u.limit_mbps_up || 0) },
u.expires_at ? fmtDate(u.expires_at) : "—", { label:"Dn", cardLabel:"Limit down", text: String(u.limit_mbps_down || 0) },
{ label:"Speed", html: speedHTML(u.up_bytes_per_sec, u.down_bytes_per_sec), small:true, cls:"cell-wide" },
{ label:"Traffic", html: sshTrafficHTML(u), small:true, cls:"cell-wide" },
{ label:"Expires", text: u.expires_at ? fmtDate(u.expires_at) : "—" },
]; ];
if (isSA) cells.push(u.owner_username || "—"); if (isSA) cells.push({ label:"Owner", text: u.owner_username || "—" });
cells.forEach((c, i) => { cells.forEach(cell => {
const td = document.createElement("td"); const td = document.createElement("td");
if (i === 1 || i === 7) td.innerHTML = c; else td.textContent = c; td.dataset.label = t(cell.cardLabel || cell.label);
if (i === 7) td.style.fontSize = ".7rem"; if (cell.cls) td.className = cell.cls;
if (cell.html !== undefined) td.innerHTML = cell.html;
else td.textContent = cell.text ?? "—";
if (cell.small) td.style.fontSize = ".7rem";
tr.appendChild(td); tr.appendChild(td);
}); });
const tdA = document.createElement("td"); const tdA = document.createElement("td");
tdA.dataset.label = t("Actions");
tdA.className = "cell-actions";
const editBtn = Object.assign(document.createElement("button"), { const editBtn = Object.assign(document.createElement("button"), {
className:"btn btn-ghost btn-sm", textContent:t("Edit"), className:"btn btn-ghost btn-sm", textContent:t("Edit"),
onclick: () => fillUserForm(u), onclick: () => fillUserForm(u),
@@ -268,7 +280,7 @@ function renderUsers(users) {
if (!users.length) { if (!users.length) {
const row = document.createElement("tr"); const row = document.createElement("tr");
const cell = document.createElement("td"); const cell = document.createElement("td");
cell.colSpan = isSA ? 11 : 10; cell.colSpan = isSA ? 12 : 11;
cell.className = "hint"; cell.className = "hint";
cell.style.cssText = "padding:24px;text-align:center;"; cell.style.cssText = "padding:24px;text-align:center;";
cell.textContent = t("No SSH users match this filter."); cell.textContent = t("No SSH users match this filter.");
+18 -12
View File
@@ -26,14 +26,15 @@ const XRAY_CLIENT_SORT_EXTRACT = {
status: c => c.expired ? 0 : 1, status: c => c.expired ? 0 : 1,
online: c => c.online ? 1 : 0, online: c => c.online ? 1 : 0,
connections: c => Number(c.active_connections || 0), connections: c => Number(c.active_connections || 0),
speed: c => speedTotalBytesPerSec(c),
usage: c => Number(c.total_bytes || ((c.uplink_bytes || 0) + (c.downlink_bytes || 0)) || 0), usage: c => Number(c.total_bytes || ((c.uplink_bytes || 0) + (c.downlink_bytes || 0)) || 0),
expiry: c => c.expires_at ? new Date(c.expires_at).getTime() : Infinity, expiry: c => c.expires_at ? new Date(c.expires_at).getTime() : Infinity,
max: c => Number(c.max_conns || 0), max: c => Number(c.max_conns || 0),
}; };
const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "usage", "max"]); const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "speed", "usage", "max"]);
const XRAY_CLIENT_SORT_OPTIONS = [ const XRAY_CLIENT_SORT_OPTIONS = [
["name", "Name"], ["status", "Status"], ["online", "Online"], ["name", "Name"], ["status", "Status"], ["online", "Online"],
["connections", "Connections"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"], ["connections", "Connections"], ["speed", "Speed"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"],
]; ];
const XRAY_CLIENT_FILTER_OPTIONS = [ const XRAY_CLIENT_FILTER_OPTIONS = [
["all", "All"], ["online", "Online"], ["offline", "Offline"], ["all", "All"], ["online", "Online"], ["offline", "Offline"],
@@ -354,22 +355,27 @@ function renderInbounds(inbounds, options = {}) {
tblWrap.innerHTML = `<div class="hint" style="padding:4px 0;">${t("No clients.")}</div>`; tblWrap.innerHTML = `<div class="hint" style="padding:4px 0;">${t("No clients.")}</div>`;
} else { } else {
const tbl = document.createElement("table"); const tbl = document.createElement("table");
tbl.innerHTML = `<thead><tr><th data-sort-key="name">${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th data-sort-key="expiry">${t("Expiry")}</th><th data-sort-key="status">${t("Status")}</th><th data-sort-key="online">${t("Online")}</th><th data-sort-key="connections">${t("Conn")}</th><th data-sort-key="usage">${t("Traffic")}</th><th data-sort-key="max">${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`; tbl.className = "table-cards";
tbl.innerHTML = `<thead><tr><th data-sort-key="name">${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th data-sort-key="expiry">${t("Expiry")}</th><th data-sort-key="status">${t("Status")}</th><th data-sort-key="online">${t("Online")}</th><th data-sort-key="connections">${t("Conn")}</th><th data-sort-key="speed" title="${escapeHTML(t("Current up/down speed of the whole account, across all of its connections."))}">${t("Speed")}</th><th data-sort-key="usage">${t("Traffic")}</th><th data-sort-key="max">${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
const tbody = document.createElement("tbody"); const tbody = document.createElement("tbody");
clients.forEach(c => { clients.forEach(c => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.dataset.clientId = String(c.id || ""); tr.dataset.clientId = String(c.id || "");
// data-label drives the labelled card layout used on narrow screens.
tr.innerHTML = ` tr.innerHTML = `
<td data-cell="name">${escapeHTML(c.name || "—")}</td> <td data-cell="name" data-label="${escapeHTML(t("Name"))}" class="cell-primary">${escapeHTML(c.name || "—")}</td>
<td data-cell="uuid" style="font-family:monospace;font-size:.65rem;">${escapeHTML(c.id || "—")}</td> <td data-cell="uuid" data-label="UUID" class="cell-wide" style="font-family:monospace;font-size:.65rem;word-break:break-all;">${escapeHTML(c.id || "—")}</td>
<td data-cell="email">${escapeHTML(c.email || "—")}</td> <td data-cell="email" data-label="${escapeHTML(t("Email"))}" class="cell-wide">${escapeHTML(c.email || "—")}</td>
<td data-cell="expiry" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td> <td data-cell="expiry" data-label="${escapeHTML(t("Expiry"))}" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
<td data-cell="status">${clientStatusHTML(c)}</td> <td data-cell="status" data-label="${escapeHTML(t("Status"))}">${clientStatusHTML(c)}</td>
<td data-cell="online">${clientOnlineHTML(c)}</td> <td data-cell="online" data-label="${escapeHTML(t("Online"))}">${clientOnlineHTML(c)}</td>
<td data-cell="connections" style="font-size:.7rem;">${escapeHTML(c.active_connections || 0)}</td> <td data-cell="connections" data-label="${escapeHTML(t("Conn"))}" style="font-size:.7rem;">${escapeHTML(c.active_connections || 0)}</td>
<td data-cell="traffic" style="font-size:.7rem;">${clientTrafficHTML(c)}</td> <td data-cell="speed" data-label="${escapeHTML(t("Speed"))}" class="cell-wide" style="font-size:.7rem;">${speedHTML(c.up_bytes_per_sec, c.down_bytes_per_sec)}</td>
<td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`; <td data-cell="traffic" data-label="${escapeHTML(t("Traffic"))}" class="cell-wide" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
<td data-cell="max" data-label="${escapeHTML(t("Max"))}" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
const actTd = document.createElement("td"); const actTd = document.createElement("td");
actTd.dataset.label = t("Actions");
actTd.className = "cell-actions";
actTd.style.whiteSpace = "nowrap"; actTd.style.whiteSpace = "nowrap";
const copyBtn = document.createElement("button"); const copyBtn = document.createElement("button");
copyBtn.className = "btn btn-ghost btn-sm"; copyBtn.className = "btn btn-ghost btn-sm";
+2 -2
View File
@@ -426,7 +426,7 @@ async function loadManagedServerConfig(id) {
toggleManagedDnsttFields(hasDnstt); toggleManagedDnsttFields(hasDnstt);
const d = c.dnstt || {}; const d = c.dnstt || {};
document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d); document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d);
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || ""; document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "0.0.0.0:5300";
document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || ""; document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || "";
document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
@@ -494,7 +494,7 @@ function managedConfigFromForm() {
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? { dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
domain: dnsttDomains[0] || "", domain: dnsttDomains[0] || "",
domains: dnsttDomains, domains: dnsttDomains,
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(), udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim() || "0.0.0.0:5300",
fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked, fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked,
fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(), fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(),
fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(), fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(),
+234 -4
View File
@@ -122,7 +122,7 @@ async function loadServerConfig() {
toggleDnsttFields(hasDnstt); toggleDnsttFields(hasDnstt);
const d = c.dnstt || {}; const d = c.dnstt || {};
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d); document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
document.getElementById("cfgDnsttUDP").value = d.udp_listen || ""; document.getElementById("cfgDnsttUDP").value = d.udp_listen || "0.0.0.0:5300";
document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || ""; document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
@@ -158,6 +158,7 @@ async function loadServerConfig() {
// TLS forwarders // TLS forwarders
tlsForwardersState = c.tls_forwarders || []; tlsForwardersState = c.tls_forwarders || [];
renderTLSForwarders(); renderTLSForwarders();
loadTLSCertificates();
// Xray // Xray
const x = c.xray || {}; const x = c.xray || {};
@@ -201,7 +202,7 @@ async function saveServerConfig() {
dnstt: document.getElementById("cfgDnsttEnabled").checked ? { dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
domain: dnsttDomains[0] || "", domain: dnsttDomains[0] || "",
domains: dnsttDomains, domains: dnsttDomains,
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), udp_listen: document.getElementById("cfgDnsttUDP").value.trim() || "0.0.0.0:5300",
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked, fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked,
fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(), fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(),
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(), fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(),
@@ -288,6 +289,235 @@ function renderTLSForwarders() {
}); });
} }
// ─── TLS Certificates (renew fullchain + privkey) ─────────────────────────────
let tlsCertsState = [];
async function loadTLSCertificates() {
const st = document.getElementById("tlsCertsStatus");
const list = document.getElementById("tlsCertsList");
if (!list) return;
if (st) st.textContent = "Carregando certificados…";
try {
const res = await api("/api/tls/certs");
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
tlsCertsState = data.certs || [];
renderTLSCertificates();
if (st) st.textContent = tlsCertsState.length
? `${tlsCertsState.length} certificado(s). Pasta do painel: ${data.certs_dir || "/opt/sshpanel/certs"}`
: "Nenhum certificado encontrado.";
} catch (e) {
if (e.message === "auth") doAuthError();
else if (st) st.textContent = "Erro: " + e.message;
}
}
function certExpiryChip(c) {
if (!c.exists) return '<span class="chip red">arquivo ausente</span>';
if (c.error) return `<span class="chip red">${escapeHTML(c.error)}</span>`;
if (c.expired) return '<span class="chip red">expirado</span>';
if (c.expiring) return `<span class="chip warn">expira em ${c.days_left} dia(s)</span>`;
return `<span class="chip green">válido por ${c.days_left} dia(s)</span>`;
}
function renderTLSCertificates() {
const list = document.getElementById("tlsCertsList");
const chip = document.getElementById("tlsCertsCountChip");
if (!list) return;
if (chip) chip.textContent = tlsCertsState.length;
if (!tlsCertsState.length) {
list.innerHTML = '<div class="hint" style="padding:4px 0;">Nenhum certificado encontrado neste servidor.</div>';
return;
}
list.innerHTML = "";
tlsCertsState.forEach((c, i) => {
const row = document.createElement("div");
row.style = "padding:8px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
const usedBy = (c.used_by || []).map(u => {
const label = u.kind === "tls_forwarder" ? "TLS " + u.ref : "Xray " + u.ref;
return `<span class="chip">${escapeHTML(label)}</span>`;
}).join(" ") || '<span class="hint">não referenciado na configuração</span>';
const head = document.createElement("div");
head.style = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
head.innerHTML = `<strong style="font-size:.78rem;">${escapeHTML(c.name || "cert")}</strong>
${certExpiryChip(c)}
${c.self_signed ? '<span class="chip warn">autoassinado</span>' : ""}
${c.managed ? '<span class="chip">painel</span>' : ""}
<span style="flex:1"></span>`;
const btn = document.createElement("button");
btn.className = "btn btn-ghost btn-sm";
btn.type = "button";
btn.textContent = "Atualizar certificado";
btn.onclick = () => toggleCertRenewForm(i);
head.appendChild(btn);
row.appendChild(head);
const meta = document.createElement("div");
meta.className = "hint";
meta.style = "margin-top:3px;font-family:monospace;word-break:break-all;";
const domains = (c.domains || []).join(", ") || "sem SAN";
meta.innerHTML = `${escapeHTML(domains)}<br/>${escapeHTML(c.cert_file || "")}<br/>${escapeHTML(c.key_file || "sem chave")}`;
row.appendChild(meta);
const extra = document.createElement("div");
extra.className = "hint";
extra.style = "margin-top:3px;";
const bits = [];
if (c.issuer) bits.push("emissor: " + c.issuer);
if (c.key_type) bits.push("chave: " + c.key_type);
if (c.chain_length) bits.push("cadeia: " + c.chain_length + " cert(s)");
if (c.not_after) bits.push("expira: " + c.not_after.replace("T", " ").replace("Z", " UTC"));
extra.textContent = bits.join(" · ");
row.appendChild(extra);
const usage = document.createElement("div");
usage.style = "margin-top:5px;display:flex;gap:4px;flex-wrap:wrap;align-items:center;";
usage.innerHTML = `<span class="hint">em uso por:</span> ${usedBy}`;
row.appendChild(usage);
const panel = document.createElement("div");
panel.id = "certRenewPanel-" + i;
panel.className = "hidden";
panel.style = "border:1px solid var(--border);border-radius:8px;padding:10px;margin-top:8px;";
panel.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<div class="field"><label>fullchain.pem <span class="hint">(certificado + intermediários)</span></label>
<textarea id="certRenewFullchain-${i}" rows="6" placeholder="-----BEGIN CERTIFICATE-----&#10;…" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
<div class="field"><label>privkey.pem <span class="hint">(chave privada)</span></label>
<textarea id="certRenewPrivkey-${i}" rows="6" placeholder="-----BEGIN PRIVATE KEY-----&#10;…" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
</div>
<div class="hint" style="margin-top:6px;">Grava em <code>${escapeHTML(c.cert_file || "")}</code> e <code>${escapeHTML(c.key_file || "")}</code>. O conteúdo anterior fica salvo como <code>.bak</code>.</div>
<div class="form-actions" style="margin-top:8px;">
<button class="btn btn-sm" type="button" onclick="submitCertRenew(${i})">Salvar e recarregar</button>
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleCertRenewForm(${i})">Cancelar</button>
</div>
<div id="certRenewStatus-${i}" class="hint" style="margin-top:4px;"></div>`;
row.appendChild(panel);
list.appendChild(row);
});
}
function toggleCertRenewForm(i) {
const panel = document.getElementById("certRenewPanel-" + i);
if (!panel) return;
panel.classList.toggle("hidden");
if (!panel.classList.contains("hidden")) {
document.getElementById("certRenewStatus-" + i).textContent = "";
document.getElementById("certRenewFullchain-" + i).focus();
}
}
function reportCertUpdate(statusEl, data) {
const r = data?.reloaded || {};
const applied = [];
if ((r.tls_forwarders || []).length) applied.push("TLS " + r.tls_forwarders.join(", "));
if (r.xray_restarted) applied.push("Xray reiniciado (" + (r.xray_inbounds || []).join(", ") + ")");
const warnings = data?.warnings || [];
const cert = data?.cert || {};
const parts = ["Certificado gravado."];
if (cert.not_after) parts.push("Válido até " + cert.not_after.replace("T", " ").replace("Z", " UTC") + ".");
if (applied.length) parts.push("Recarregado: " + applied.join(" | ") + ".");
else parts.push("Nenhum listener em uso precisou recarregar.");
if (warnings.length) parts.push("Avisos: " + warnings.join(" | "));
statusEl.textContent = parts.join(" ");
showPanelToast(
warnings.length ? "Certificado atualizado com avisos." : "Certificado atualizado e aplicado.",
warnings.length ? "warning" : "success",
);
}
async function submitCertRenew(i) {
const c = tlsCertsState[i];
const st = document.getElementById("certRenewStatus-" + i);
if (!c || !st) return;
const fullchain = document.getElementById("certRenewFullchain-" + i).value.trim();
const privkey = document.getElementById("certRenewPrivkey-" + i).value.trim();
if (!fullchain || !privkey) { st.textContent = "Cole o fullchain.pem e o privkey.pem."; return; }
const usedBy = (c.used_by || []).length;
const ok = await panelConfirm({
title: "Atualizar certificado",
message: `Substituir o certificado de ${c.name || c.cert_file}?`,
detail: usedBy
? "Os listeners TLS que usam este certificado serão reabertos e o Xray será reiniciado se algum inbound usar o certificado. Conexões já estabelecidas não são encerradas."
: "Os arquivos serão substituídos (backup .bak).",
confirmLabel: "Atualizar",
});
if (!ok) return;
st.textContent = "Gravando e recarregando…";
await postCertUpdate({ cert_file: c.cert_file, key_file: c.key_file, fullchain, privkey }, st, () => {
document.getElementById("certRenewPanel-" + i)?.classList.add("hidden");
loadTLSCertificates();
});
}
async function postCertUpdate(payload, st, onDone) {
try {
let res = await api("/api/tls/certs/update", { method: "POST", body: JSON.stringify(payload) });
if (!res.ok) {
const text = await res.text();
if (res.status === 400 && text.includes("force=true")) {
const force = await panelConfirm({
title: "Certificado expirado",
message: text.split(";")[0],
detail: "Gravar mesmo assim? Clientes não conseguirão validar um certificado expirado.",
confirmLabel: "Gravar mesmo assim",
danger: true,
});
if (!force) { st.textContent = "Cancelado."; return; }
res = await api("/api/tls/certs/update", { method: "POST", body: JSON.stringify({ ...payload, force: true }) });
if (!res.ok) throw new Error(await res.text());
} else {
throw new Error(text);
}
}
const data = await res.json();
reportCertUpdate(st, data);
onDone?.();
} catch (e) {
if (e.message === "auth") doAuthError();
else st.textContent = "Erro: " + e.message;
}
}
function toggleNewCertForm() {
const panel = document.getElementById("newCertPanel");
if (!panel) return;
panel.classList.toggle("hidden");
if (!panel.classList.contains("hidden")) {
document.getElementById("newCertStatus").textContent = "";
document.getElementById("newCertName").value = "";
document.getElementById("newCertFullchain").value = "";
document.getElementById("newCertPrivkey").value = "";
}
}
async function saveNewCert() {
const st = document.getElementById("newCertStatus");
const name = document.getElementById("newCertName").value.trim();
const fullchain = document.getElementById("newCertFullchain").value.trim();
const privkey = document.getElementById("newCertPrivkey").value.trim();
if (!name || !fullchain || !privkey) { st.textContent = "Nome, fullchain.pem e privkey.pem são obrigatórios."; return; }
st.textContent = "Gravando…";
await postCertUpdate({ name, fullchain, privkey }, st, () => {
document.getElementById("newCertFullchain").value = "";
document.getElementById("newCertPrivkey").value = "";
loadTLSCertificates();
});
}
// Inline onclick handlers in index.html need these exposed explicitly.
window.loadTLSCertificates = loadTLSCertificates;
window.toggleCertRenewForm = toggleCertRenewForm;
window.submitCertRenew = submitCertRenew;
window.toggleNewCertForm = toggleNewCertForm;
window.saveNewCert = saveNewCert;
function toggleAddTLSForm() { function toggleAddTLSForm() {
const panel = document.getElementById("addTLSPanel"); const panel = document.getElementById("addTLSPanel");
panel.classList.toggle("hidden"); panel.classList.toggle("hidden");
@@ -397,7 +627,7 @@ async function wzSavePastedCert() {
if (!name || !cert || !key) { st.textContent = "Name, cert, and key required."; return; } if (!name || !cert || !key) { st.textContent = "Name, cert, and key required."; return; }
st.textContent = "Saving…"; st.textContent = "Saving…";
try { try {
const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) }); const res = await api(withServerParam("/api/tls/upload-pem", selectedXrayServer()), { method:"POST", body: JSON.stringify({ name, cert, key }) });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
document.getElementById("wzTLSCert").value = data.cert_file; document.getElementById("wzTLSCert").value = data.cert_file;
@@ -415,7 +645,7 @@ async function wzGenerateCert() {
if (!domain) { st.textContent = "Domain required."; return; } if (!domain) { st.textContent = "Domain required."; return; }
st.textContent = "Generating…"; st.textContent = "Generating…";
try { try {
const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) }); const res = await api(withServerParam("/api/tls/generate-selfsigned", selectedXrayServer()), { method:"POST", body: JSON.stringify({ domain }) });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
document.getElementById("wzTLSCert").value = data.cert_file; document.getElementById("wzTLSCert").value = data.cert_file;
+163 -136
View File
@@ -92,9 +92,11 @@ function loadWizardFromConfig() {
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning"; document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || []; wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
wzEditingIndex = -1; wzEditingIndex = -1;
wzCancelInbound();
renderWzInbounds(); renderWzInbounds();
loadSharedEndpointForm();
wzDirty = false; wzDirty = false;
const presetStatus = document.getElementById("wzAzionDefaultStatus");
if (presetStatus) presetStatus.textContent = "O padrão cria o certificado autoassinado, habilita TLS e salva/reinicia o Xray automaticamente.";
if (st) st.textContent = `Config loaded from ${target}.`; if (st) st.textContent = `Config loaded from ${target}.`;
}).catch(e => { }).catch(e => {
wzLoadedServerID = null; wzLoadedServerID = null;
@@ -102,8 +104,8 @@ function loadWizardFromConfig() {
wzLoadedFullConfig = null; wzLoadedFullConfig = null;
wzInbounds = []; wzInbounds = [];
wzEditingIndex = -1; wzEditingIndex = -1;
wzCancelInbound();
renderWzInbounds(); renderWzInbounds();
loadSharedEndpointForm();
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
else if (st) st.textContent = "Error: " + e.message; else if (st) st.textContent = "Error: " + e.message;
}); });
@@ -116,7 +118,7 @@ function renderWzInbounds() {
if (!wzInbounds.length) { if (!wzInbounds.length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "hint visual-empty-state"; empty.className = "hint visual-empty-state";
empty.textContent = "Nenhum inbound configurado. Crie um endpoint compartilhado ou adicione um inbound."; empty.textContent = "Nenhum inbound configurado. Use “Adicionar inbound” ou “Criar padrão Azion XHTTP”.";
list.appendChild(empty); list.appendChild(empty);
return; return;
} }
@@ -201,7 +203,6 @@ function renderWzInbounds() {
else if (wzEditingIndex > i) wzEditingIndex--; else if (wzEditingIndex > i) wzEditingIndex--;
wzDirty = true; wzDirty = true;
renderWzInbounds(); renderWzInbounds();
loadSharedEndpointForm();
}; };
actions.append(duplicateBtn, editBtn, delBtn); actions.append(duplicateBtn, editBtn, delBtn);
row.appendChild(actions); row.appendChild(actions);
@@ -209,12 +210,29 @@ function renderWzInbounds() {
}); });
} }
function wzToggleAddInbound() { function mountWzInboundEditor() {
const form = document.getElementById("wzAddInboundForm"); const form = document.getElementById("wzAddInboundForm");
const anchor = document.getElementById("wzInboundEditorAnchor");
if (form && anchor && form.previousElementSibling !== anchor) {
anchor.insertAdjacentElement("afterend", form);
}
return form;
}
function openWzInboundEditor(scrollBlock = "nearest") {
const form = mountWzInboundEditor();
if (!form) return null;
form.classList.remove("hidden");
requestAnimationFrame(() => form.scrollIntoView({ behavior:"smooth", block:scrollBlock }));
return form;
}
function wzToggleAddInbound() {
const form = mountWzInboundEditor();
if (!form) return;
if (!form.classList.contains("hidden") && wzEditingIndex < 0) return wzCancelInbound(); if (!form.classList.contains("hidden") && wzEditingIndex < 0) return wzCancelInbound();
resetWzInboundForm(); resetWzInboundForm();
form.classList.remove("hidden"); openWzInboundEditor("nearest");
form.scrollIntoView({ behavior:"smooth", block:"nearest" });
} }
function setWzValue(id, value) { function setWzValue(id, value) {
@@ -242,7 +260,8 @@ function resetWzInboundForm() {
setWzValue("wzTLS", "none"); setWzValue("wzTLS", "none");
["wzTLSCert", "wzTLSKey", "wzTLSCertPath", "wzTLSKeyPath", "wzRealityDest", "wzRealitySNI", "wzRealityPriv", "wzRealityShortID", "wzTrojanPass", "wzSSPass"].forEach(id => setWzValue(id, "")); ["wzTLSCert", "wzTLSKey", "wzTLSCertPath", "wzTLSKeyPath", "wzRealityDest", "wzRealitySNI", "wzRealityPriv", "wzRealityShortID", "wzTrojanPass", "wzSSPass"].forEach(id => setWzValue(id, ""));
setWzValue("wzSSMethod", "chacha20-ietf-poly1305"); setWzValue("wzSSMethod", "chacha20-ietf-poly1305");
document.getElementById("wzInboundFormTitle").textContent = "Novo inbound"; document.getElementById("wzInboundFormKicker").textContent = "Novo inbound";
document.getElementById("wzInboundFormTitle").textContent = "Adicionar inbound";
document.getElementById("wzSaveInboundBtn").textContent = "Adicionar inbound"; document.getElementById("wzSaveInboundBtn").textContent = "Adicionar inbound";
document.getElementById("wzEditingBadge").classList.add("hidden"); document.getElementById("wzEditingBadge").classList.add("hidden");
onWzProtoChange("vless"); onWzProtoChange("vless");
@@ -301,11 +320,10 @@ function editWzInbound(index) {
setWzValue("wzSSMethod", ib.settings?.method || "chacha20-ietf-poly1305"); setWzValue("wzSSMethod", ib.settings?.method || "chacha20-ietf-poly1305");
document.getElementById("wzInboundFormTitle").textContent = `Editar ${ib.tag || "inbound"}`; document.getElementById("wzInboundFormTitle").textContent = `Editar ${ib.tag || "inbound"}`;
document.getElementById("wzInboundFormKicker").textContent = "Editar inbound existente";
document.getElementById("wzSaveInboundBtn").textContent = "Salvar alterações"; document.getElementById("wzSaveInboundBtn").textContent = "Salvar alterações";
document.getElementById("wzEditingBadge").classList.remove("hidden"); document.getElementById("wzEditingBadge").classList.remove("hidden");
const form = document.getElementById("wzAddInboundForm"); openWzInboundEditor("start");
form.classList.remove("hidden");
form.scrollIntoView({ behavior:"smooth", block:"start" });
} }
function duplicateWzInbound(index) { function duplicateWzInbound(index) {
@@ -525,148 +543,158 @@ function validateVisualInbounds(inbounds) {
} }
} }
function findSharedEndpointPair() { const azionPresetProxyTags = new Set(["azion-vless-xhttp", "shared-proxy-xhttp"]);
const roots = wzInbounds.filter(ib => { const azionPresetSSHTags = new Set(["azion-ssh-xhttp", "shared-ssh-xhttp"]);
const xh = visualXHTTPSettings(ib);
return !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/"; function findAzionPresetInbound(tags) {
return wzInbounds.find(ib => tags.has(String(ib?.tag || ""))) || null;
}
function azionVLESSClients(existingProxy) {
const clients = Array.isArray(existingProxy?.settings?.clients) ? cloneJsonSafe(existingProxy.settings.clients) : [];
if (String(existingProxy?.protocol || "").toLowerCase() === "vless") return clients;
return clients.map(client => {
const converted = { id:client?.id };
if (client?.email) converted.email = client.email;
if (client?.flow) converted.flow = client.flow;
return converted;
}).filter(client => client.id);
}
function buildAzionPresetInbounds(certFile, keyFile) {
const existingProxy = findAzionPresetInbound(azionPresetProxyTags);
const existingSSH = findAzionPresetInbound(azionPresetSSHTags);
const managed = new Set([existingProxy, existingSSH].filter(Boolean));
const others = wzInbounds.filter(ib => !managed.has(ib));
const portConflict = others.find(ib => Number(ib?.port) === 443);
if (portConflict) {
throw new Error(`A porta 443 já é usada pelo inbound ${portConflict.tag || "sem tag"}. Edite ou remova esse inbound antes de criar o padrão Azion.`);
}
const stream = path => ({
network:"xhttp",
security:"tls",
xhttpSettings:{ path, mode:"auto" },
tlsSettings:{ certificates:[{ certificateFile:certFile, keyFile }] },
}); });
for (const proxy of roots) { const proxyInbound = {
const ssh = wzInbounds.find(ib => String(ib?.protocol || "").toLowerCase() === "ssh" && tag:"azion-vless-xhttp",
String(ib.listen || "0.0.0.0") === String(proxy.listen || "0.0.0.0") && String(ib.port) === String(proxy.port) && listen:"0.0.0.0",
normalizeVisualPath(visualXHTTPSettings(ib)?.path) === "/ssh"); port:443,
if (ssh) return { proxy, ssh }; protocol:"vless",
} settings:{ clients:azionVLESSClients(existingProxy), decryption:"none" },
const proxy = wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || null; streamSettings:stream("/"),
const ssh = wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || null; };
return proxy && ssh ? { proxy, ssh } : null; const sshInbound = {
tag:"azion-ssh-xhttp",
listen:"0.0.0.0",
port:443,
protocol:"ssh",
settings:{},
streamSettings:stream("/ssh"),
};
return [...others, proxyInbound, sshInbound];
} }
function loadSharedEndpointForm() { async function createAzionDefaultXHTTP() {
const status = document.getElementById("sharedXHTTPStatus"); const status = document.getElementById("wzAzionDefaultStatus");
if (!status) return; const button = document.getElementById("wzAzionDefaultBtn");
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"; const selectedID = selectedXrayServer() || "local";
const target = selectedXrayServerLabel();
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) { if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
status.textContent = "Carregue a configuração do servidor selecionado antes de editar."; if (status) status.textContent = `Carregue a configuração de ${target} antes de criar o padrão Azion.`;
return; return;
} }
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") { if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
status.textContent = "O endpoint compartilhado requer o modo Xray nativo."; if (status) status.textContent = "O padrão Azion com SSH 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; return;
} }
const pair = findSharedEndpointPair(); let nextInbounds;
const sameEndpoint = ib => String(ib?.listen || "0.0.0.0") === listen && String(ib?.port) === String(port); try {
const existingProxy = pair?.proxy || wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || wzInbounds.find(ib => { nextInbounds = buildAzionPresetInbounds(
const xh = visualXHTTPSettings(ib); "/opt/sshpanel/certs/example.com/cert.pem",
return sameEndpoint(ib) && !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/"; "/opt/sshpanel/certs/example.com/key.pem",
}) || null; );
const existingSSH = pair?.ssh || wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || wzInbounds.find(ib => { validateVisualInbounds(nextInbounds);
const xh = visualXHTTPSettings(ib); } catch (error) {
return sameEndpoint(ib) && !!xh && String(ib?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh"; if (status) status.textContent = error.message;
}) || 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; return;
} }
const buildSharedStream = (existing, path) => { const existingProxy = findAzionPresetInbound(azionPresetProxyTags);
const stream = cloneJsonSafe(existing?.streamSettings || {}); const accepted = await panelConfirm({
stream.network = "xhttp"; tone:"success",
stream.xhttpSettings = Object.assign({}, stream.xhttpSettings || stream.splithttpSettings || {}, { path, mode }); icon:"AZ",
delete stream.splithttpSettings; eyebrow:"Azion XHTTP",
if (host) stream.xhttpSettings.host = host; title:existingProxy ? "Atualizar padrão Azion" : "Criar padrão Azion",
else delete stream.xhttpSettings.host; message:existingProxy
if (security === "tls") { ? "Atualizar o endpoint padrão e manter os clientes VLESS existentes?"
stream.security = "tls"; : "Criar o endpoint padrão completo neste servidor?",
stream.tlsSettings = Object.assign({}, stream.tlsSettings || {}, { certificates:[{ certificateFile:cert, keyFile:key }] }); detail:[
} else { `Servidor: ${target}`,
delete stream.security; "Listen: 0.0.0.0:443",
delete stream.tlsSettings; "TLS autoassinado: example.com",
"VLESS XHTTP: /",
"SSH XHTTP: /ssh",
"O Xray será salvo e reiniciado automaticamente.",
].join("\n"),
confirmLabel:existingProxy ? "Atualizar padrão" : "Criar padrão",
});
if (!accepted) return;
const previousInbounds = cloneJsonSafe(wzInbounds);
const previousDirty = wzDirty;
let presetApplied = false;
let configSaved = false;
if (button) {
button.disabled = true;
button.textContent = "Criando padrão…";
} }
delete stream.realitySettings; if (status) status.textContent = `Gerando certificado example.com em ${target}`;
return stream; try {
}; const certResponse = await api(withServerParam("/api/tls/generate-selfsigned", selectedID), {
const previousClients = Array.isArray(existingProxy?.settings?.clients) ? cloneJsonSafe(existingProxy.settings.clients) : []; method:"POST",
const proxyInbound = cloneJsonSafe(existingProxy || {}); body:JSON.stringify({ domain:"example.com" }),
proxyInbound.tag = existingProxy?.tag || "shared-proxy-xhttp"; });
proxyInbound.listen = listen; if (!certResponse.ok) throw new Error(await certResponse.text());
proxyInbound.port = port; const cert = await certResponse.json();
proxyInbound.protocol = protocol; if (!cert?.cert_file || !cert?.key_file) throw new Error("o servidor não retornou os caminhos do certificado");
proxyInbound.settings = existingProxy?.protocol === protocol ? cloneJsonSafe(existingProxy.settings || {}) : {};
proxyInbound.settings.clients = previousClients; wzInbounds = buildAzionPresetInbounds(cert.cert_file, cert.key_file);
if (protocol === "vless") proxyInbound.settings.decryption = "none"; validateVisualInbounds(wzInbounds);
else delete proxyInbound.settings.decryption; presetApplied = true;
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; wzDirty = true;
wzCancelInbound();
renderWzInbounds(); renderWzInbounds();
loadSharedEndpointForm(); if (status) status.textContent = "Certificado criado. Salvando configuração e reiniciando o Xray…";
status.textContent = "Endpoint atualizado no rascunho. Clique em Salvar configuração e reiniciar para aplicar."; const result = await applyWizardConfig();
if (!result?.saved) throw new Error(result?.error || "não foi possível salvar a configuração");
configSaved = true;
if (status) status.textContent = result.restarted
? "Padrão Azion ativo: TLS example.com, VLESS / e SSH /ssh em 0.0.0.0:443."
: "O padrão foi salvo, mas o Xray não reiniciou. Verifique os logs e use Reiniciar.";
if (typeof showPanelToast === "function") {
showPanelToast(
result.restarted ? "Padrão Azion XHTTP criado e ativo." : "Padrão Azion salvo; reinício pendente.",
result.restarted ? "success" : "warning",
"Azion XHTTP",
);
}
} catch (error) {
if (presetApplied && !configSaved) {
wzInbounds = previousInbounds;
wzDirty = previousDirty;
renderWzInbounds();
}
if (error.message === "auth") doAuthError();
else if (status) status.textContent = "Erro ao criar padrão Azion: " + error.message;
} finally {
if (button) {
button.disabled = false;
button.textContent = "Criar padrão Azion XHTTP";
}
}
} }
document.getElementById("sharedXHTTPProtocol")?.addEventListener("change", updateSharedEndpointControls);
document.getElementById("sharedXHTTPSecurity")?.addEventListener("change", updateSharedEndpointControls);
document.getElementById("sharedXHTTPApplyBtn")?.addEventListener("click", applySharedXHTTPEndpoint);
updateSharedEndpointControls();
function onWzProtoChange(val) { function onWzProtoChange(val) {
const isSSH = val === "ssh"; const isSSH = val === "ssh";
@@ -876,7 +904,6 @@ function wzSaveInbound() {
else wzInbounds.push(ib); else wzInbounds.push(ib);
wzDirty = true; wzDirty = true;
renderWzInbounds(); renderWzInbounds();
loadSharedEndpointForm();
st.textContent = original ? `Inbound ${tag} atualizado no rascunho.` : `Inbound ${tag} adicionado ao rascunho.`; st.textContent = original ? `Inbound ${tag} atualizado no rascunho.` : `Inbound ${tag} adicionado ao rascunho.`;
wzCancelInbound(); wzCancelInbound();
} }
+55 -31
View File
@@ -16,7 +16,7 @@
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})(); })();
</script> </script>
<link rel="stylesheet" href="assets/app.css?v=20260720sshfilters1"/> <link rel="stylesheet" href="assets/app.css?v=20260724xrayinboundsr3"/>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -281,10 +281,10 @@
<span class="chip user-list-count" id="sshListCount"></span> <span class="chip user-list-count" id="sshListCount"></span>
</div> </div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table class="table-cards">
<thead><tr> <thead><tr>
<th data-sort-key="username">User</th><th data-sort-key="status">Status</th><th data-sort-key="auth">Auth</th> <th data-sort-key="username">User</th><th data-sort-key="status">Status</th><th data-sort-key="auth">Auth</th>
<th data-sort-key="conn">Conn</th><th data-sort-key="max">Max</th><th data-sort-key="up">Up</th><th data-sort-key="down">Dn</th><th data-sort-key="usage">Traffic</th><th data-sort-key="expires">Expires</th> <th data-sort-key="conn">Conn</th><th data-sort-key="max">Max</th><th data-sort-key="up">Up</th><th data-sort-key="down">Dn</th><th data-sort-key="speed" title="Current up/down speed of the whole account, across all of its connections.">Speed</th><th data-sort-key="usage">Traffic</th><th data-sort-key="expires">Expires</th>
<th id="ownerColHead" data-sort-key="owner" class="superadmin-only hidden">Owner</th> <th id="ownerColHead" data-sort-key="owner" class="superadmin-only hidden">Owner</th>
<th>Actions</th> <th>Actions</th>
</tr></thead> </tr></thead>
@@ -483,29 +483,28 @@
</div> </div>
<!-- Wizard pane --> <!-- Wizard pane -->
<div id="xrayWizardPane"> <div id="xrayWizardPane">
<section class="shared-endpoint-card"> <section class="xray-inbound-launcher">
<div class="shared-endpoint-head"> <div class="xray-inbound-launcher-copy">
<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="page-kicker">Gerenciar inbounds</span>
<span class="chip green">path routing</span> <h3>Adicionar um novo inbound</h3>
<p>Crie um inbound em branco ou instale automaticamente o padrão usado com Azion XHTTP. Para alterar um inbound existente, use somente o botão <strong>Editar</strong> no cartão dele.</p>
</div> </div>
<div class="shared-route-preview" aria-label="Shared endpoint route preview"> <div class="xray-inbound-launcher-actions">
<span><strong id="sharedProxyRouteLabel">VLESS</strong><code>/</code></span> <button class="btn" id="wzAddInboundBtn" type="button" onclick="wzToggleAddInbound()">+ Adicionar inbound</button>
<i></i> <button class="btn btn-soft" id="wzAzionDefaultBtn" type="button" onclick="createAzionDefaultXHTTP()">Criar padrão Azion XHTTP</button>
<span><strong>SSH</strong><code>/ssh</code></span>
</div> </div>
<div class="form-grid shared-endpoint-grid"> <div class="azion-preset-summary" aria-label="Configuração criada pelo padrão Azion XHTTP">
<div class="field"><label>Protocolo em /</label><select id="sharedXHTTPProtocol"><option value="vless">VLESS</option><option value="vmess">VMess</option></select></div> <span><small>Listen</small><strong>0.0.0.0:443</strong></span>
<div class="field"><label>Porta compartilhada</label><input id="sharedXHTTPPort" type="number" min="1" max="65535" value="443"/></div> <span><small>TLS</small><strong>example.com</strong></span>
<div class="field"><label>IP de listen</label><input id="sharedXHTTPListen" value="0.0.0.0" placeholder="0.0.0.0"/></div> <span><small>VLESS</small><strong>/</strong></span>
<div class="field"><label>Host HTTP <span class="hint">opcional</span></label><input id="sharedXHTTPHost" placeholder="vpn.seudominio.com"/></div> <span><small>SSH</small><strong>/ssh</strong></span>
<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>
<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> <span id="wzAzionDefaultStatus" class="hint">O padrão cria o certificado autoassinado, habilita TLS e salva/reinicia o Xray automaticamente.</span>
</section> </section>
<!-- The shared add/edit editor is mounted here when opened. -->
<div id="wzInboundEditorAnchor"></div>
<aside class="legacy-xhttp-migration" aria-label="Migração de configuração XHTTP antiga"> <aside class="legacy-xhttp-migration" aria-label="Migração de configuração XHTTP antiga">
<span class="legacy-xhttp-icon" aria-hidden="true">SSH+</span> <span class="legacy-xhttp-icon" aria-hidden="true">SSH+</span>
<div> <div>
@@ -525,13 +524,12 @@
<option value="debug">debug</option> <option value="debug">debug</option>
</select> </select>
</div> </div>
<div class="visual-config-toolbar-copy"><strong>Inbounds configurados</strong><span>Edite qualquer cartão visualmente ou use JSON para campos avançados.</span></div> <div class="visual-config-toolbar-copy"><strong>Inbounds configurados</strong><span>O editor permanece fechado. Clique em Editar somente no inbound que deseja alterar.</span></div>
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Novo inbound</button>
</div> </div>
<div id="wzInboundsList" class="visual-inbound-list"></div> <div id="wzInboundsList" class="visual-inbound-list"></div>
<!-- Add inbound form --> <!-- Shared add/edit form: hidden until the user explicitly adds or edits. -->
<div id="wzAddInboundForm" class="visual-inbound-editor hidden"> <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="visual-editor-heading"><div><span class="page-kicker" id="wzInboundFormKicker">Novo inbound</span><h3 id="wzInboundFormTitle">Adicionar inbound</h3></div><span id="wzEditingBadge" class="chip hidden">Editando</span></div>
<div class="form-grid"> <div class="form-grid">
<div class="field"> <div class="field">
<label>Protocol</label> <label>Protocol</label>
@@ -867,7 +865,7 @@
</div> </div>
<div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;"> <div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
<div class="field" style="grid-column:1/-1"><label>NS / Root Domains <span class="hint">one per line</span></label><textarea id="managedCfgDnsttDomains" rows="3" placeholder="t.example.com&#10;t.local.lan"></textarea></div> <div class="field" style="grid-column:1/-1"><label>NS / Root Domains <span class="hint">one per line</span></label><textarea id="managedCfgDnsttDomains" rows="3" placeholder="t.example.com&#10;t.local.lan"></textarea></div>
<div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="[::]:5300"/></div> <div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="0.0.0.0:5300"/></div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS</label> <label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS</label>
<div class="field"><label>Local DNS Listen <span class="hint">IPv6 ok</span></label><input type="text" id="managedCfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/></div> <div class="field"><label>Local DNS Listen <span class="hint">IPv6 ok</span></label><input type="text" id="managedCfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/></div>
<div class="field"><label>Local DNS Domain</label><input type="text" id="managedCfgDnsttFakeDomain" placeholder="t.local.lan"/></div> <div class="field"><label>Local DNS Domain</label><input type="text" id="managedCfgDnsttFakeDomain" placeholder="t.local.lan"/></div>
@@ -1344,7 +1342,7 @@
</div> </div>
<div class="field"> <div class="field">
<label>UDP Listen</label> <label>UDP Listen</label>
<input type="text" id="cfgDnsttUDP" placeholder="[::]:5300"/> <input type="text" id="cfgDnsttUDP" placeholder="0.0.0.0:5300"/>
</div> </div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"> <label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
<input type="checkbox" id="cfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS <input type="checkbox" id="cfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS
@@ -1461,7 +1459,33 @@
</section> </section>
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="tls"> <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> <div class="workspace-section-heading"><div><span>04 · Segurança</span><h3>Encaminhadores TLS</h3><p>Gerencie os certificados do servidor e crie listeners TLS com certificado automático, colado ou armazenado em arquivo.</p></div></div>
<!-- TLS Certificates -->
<div class="card" style="margin-top:12px">
<div class="card-hdr">
<div class="card-title">Certificados TLS <span class="chip" id="tlsCertsCountChip">0</span></div>
<span class="chip green">live</span>
<button class="btn btn-ghost btn-sm" type="button" onclick="loadTLSCertificates()">Recarregar lista</button>
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleNewCertForm()">+ Novo</button>
</div>
<div class="hint" style="margin-top:2px;">Cole o <code>fullchain.pem</code> e o <code>privkey.pem</code> para renovar um certificado. Os arquivos são substituídos no mesmo caminho (com backup <code>.bak</code>), então nenhuma configuração precisa ser alterada, e os listeners TLS e inbounds Xray que usam o certificado são recarregados na hora.</div>
<div id="tlsCertsList" style="margin-top:8px;"></div>
<div id="newCertPanel" class="hidden" style="border:1px solid var(--border);border-radius:8px;padding:10px;margin-top:8px;">
<div class="field"><label>Nome <span class="hint">(pasta de armazenamento, ex.: meu-dominio)</span></label><input type="text" id="newCertName" placeholder="meu-dominio"/></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;">
<div class="field"><label>fullchain.pem <span class="hint">(certificado + intermediários)</span></label><textarea id="newCertFullchain" rows="6" placeholder="-----BEGIN CERTIFICATE-----&#10;…" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
<div class="field"><label>privkey.pem <span class="hint">(chave privada)</span></label><textarea id="newCertPrivkey" rows="6" placeholder="-----BEGIN PRIVATE KEY-----&#10;…" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
</div>
<div class="form-actions" style="margin-top:8px;">
<button class="btn btn-sm" type="button" onclick="saveNewCert()">Salvar certificado</button>
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleNewCertForm()">Cancelar</button>
</div>
<div id="newCertStatus" class="hint" style="margin-top:4px;"></div>
</div>
<div id="tlsCertsStatus" class="hint" style="margin-top:6px;"></div>
</div>
<!-- TLS Forwarders --> <!-- TLS Forwarders -->
<div class="card" style="margin-top:12px"> <div class="card" style="margin-top:12px">
<div class="card-hdr"> <div class="card-hdr">
@@ -1579,14 +1603,14 @@
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. --> so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260722xhttpunlimited2"></script> <script defer src="assets/js/01-core.js?v=20260722xhttpunlimited2"></script>
<script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script> <script defer src="assets/js/02-shell.js?v=20260805certupdate1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260720sshfilters1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260720sshfilters1"></script>
<script defer src="assets/js/04-xray.js?v=20260720sshfilters1"></script> <script defer src="assets/js/04-xray.js?v=20260720sshfilters1"></script>
<script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script> <script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script> <script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script> <script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script>
<script defer src="assets/js/08-server-config.js?v=20260722xhttpunlimited2"></script> <script defer src="assets/js/08-server-config.js?v=20260805certupdate1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260714quota1"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260724xrayinboundsr4"></script>
<script defer src="assets/js/11-update-status.js?v=20260714pamfix1"></script> <script defer src="assets/js/11-update-status.js?v=20260714pamfix1"></script>
<script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script> <script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script>
<script defer src="assets/js/10-boot.js?v=20260714pamfix1"></script> <script defer src="assets/js/10-boot.js?v=20260714pamfix1"></script>
+231
View File
@@ -0,0 +1,231 @@
package main
// Live per-account bandwidth. The panel already keeps cumulative uploaded and
// downloaded byte counters for every SSH user and Xray client; this file turns
// those counters into a current speed so the UI can show "↑ 12 Mbps ↓ 40 Mbps"
// for the whole account instead of only lifetime totals. Speeds are always the
// sum of every connection the account has open, because the counters they are
// derived from are per account, not per connection.
import (
"math"
"strings"
"sync"
"sync/atomic"
"time"
)
// bandwidthRate is a smoothed instantaneous speed in bytes per second.
type bandwidthRate struct {
UpBytesPerSec float64
DownBytesPerSec float64
}
func (r bandwidthRate) isZero() bool {
return r.UpBytesPerSec == 0 && r.DownBytesPerSec == 0
}
type bandwidthSample struct {
up int64
down int64
at time.Time
rate bandwidthRate
}
// bandwidthSampler converts monotonically increasing byte counters into a
// speed. Deltas smaller than minSampleInterval are ignored so a double sample
// cannot divide by an almost-zero interval, and a counter that moves backwards
// (traffic reset, account recreated) re-baselines instead of reporting a
// nonsensical negative or huge rate.
type bandwidthSampler struct {
mu sync.Mutex
samples map[string]bandwidthSample
// tau is the exponential smoothing time constant. Larger values give a
// calmer number; zero disables smoothing.
tau time.Duration
// staleAfter makes Rate report zero for accounts that stopped being
// sampled (idle Xray clients dropped by the stats poller, for example),
// instead of freezing the last speed on screen forever.
staleAfter time.Duration
}
const minBandwidthSampleInterval = 250 * time.Millisecond
func newBandwidthSampler(tau, staleAfter time.Duration) *bandwidthSampler {
return &bandwidthSampler{
samples: make(map[string]bandwidthSample),
tau: tau,
staleAfter: staleAfter,
}
}
// Observe records the current cumulative counters for key. The first
// observation only establishes a baseline; the rate stays zero until a second
// one arrives.
func (s *bandwidthSampler) Observe(key string, up, down int64, now time.Time) {
if s == nil {
return
}
key = strings.TrimSpace(key)
if key == "" {
return
}
if up < 0 {
up = 0
}
if down < 0 {
down = 0
}
s.mu.Lock()
defer s.mu.Unlock()
prev, ok := s.samples[key]
if !ok {
s.samples[key] = bandwidthSample{up: up, down: down, at: now}
return
}
// Counters went backwards: the account's traffic was reset or the entry was
// recycled. Start over from this value.
if up < prev.up || down < prev.down {
s.samples[key] = bandwidthSample{up: up, down: down, at: now}
return
}
dt := now.Sub(prev.at)
if dt < minBandwidthSampleInterval {
return
}
seconds := dt.Seconds()
instant := bandwidthRate{
UpBytesPerSec: float64(up-prev.up) / seconds,
DownBytesPerSec: float64(down-prev.down) / seconds,
}
next := instant
if s.tau > 0 && !prev.rate.isZero() {
// alpha derived from the real interval so an irregular sampling
// cadence still converges on the true average.
alpha := 1 - math.Exp(-seconds/s.tau.Seconds())
if alpha > 1 {
alpha = 1
}
next = bandwidthRate{
UpBytesPerSec: prev.rate.UpBytesPerSec + alpha*(instant.UpBytesPerSec-prev.rate.UpBytesPerSec),
DownBytesPerSec: prev.rate.DownBytesPerSec + alpha*(instant.DownBytesPerSec-prev.rate.DownBytesPerSec),
}
}
if next.UpBytesPerSec < 0 {
next.UpBytesPerSec = 0
}
if next.DownBytesPerSec < 0 {
next.DownBytesPerSec = 0
}
s.samples[key] = bandwidthSample{up: up, down: down, at: now, rate: next}
}
// Rate returns the last known speed for key. Stale entries report zero.
func (s *bandwidthSampler) Rate(key string) (bandwidthRate, bool) {
if s == nil {
return bandwidthRate{}, false
}
key = strings.TrimSpace(key)
if key == "" {
return bandwidthRate{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.rateLocked(key)
}
// RateForKeys returns the first known speed among keys. Xray clients are
// tracked under their UUID in native mode and under their email in external
// mode, so callers pass every identifier the client may be stored under.
func (s *bandwidthSampler) RateForKeys(keys ...string) (bandwidthRate, bool) {
if s == nil {
return bandwidthRate{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if rate, ok := s.rateLocked(key); ok {
return rate, true
}
}
return bandwidthRate{}, false
}
func (s *bandwidthSampler) rateLocked(key string) (bandwidthRate, bool) {
sample, ok := s.samples[key]
if !ok {
return bandwidthRate{}, false
}
if s.staleAfter > 0 && !sample.at.IsZero() && time.Since(sample.at) > s.staleAfter {
return bandwidthRate{}, true
}
return sample.rate, true
}
// Retain drops every tracked key that is not in keep, so the map cannot grow
// forever as accounts are deleted or recreated.
func (s *bandwidthSampler) Retain(keep map[string]struct{}) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for key := range s.samples {
if _, ok := keep[key]; !ok {
delete(s.samples, key)
}
}
}
// ---- SSH accounts ----
const sshBandwidthSampleInterval = 2 * time.Second
var sshBandwidth = newBandwidthSampler(5*time.Second, 20*time.Second)
func startSSHUserRateSampler() {
go func() {
ticker := time.NewTicker(sshBandwidthSampleInterval)
defer ticker.Stop()
for range ticker.C {
sampleSSHUserRates(time.Now())
}
}()
}
func sampleSSHUserRates(now time.Time) {
if userMgr == nil {
return
}
states := userMgr.List()
active := make(map[string]struct{}, len(states))
for _, u := range states {
if u == nil {
continue
}
u.mu.Lock()
username := strings.TrimSpace(u.Cfg.Username)
u.mu.Unlock()
if username == "" {
continue
}
sshBandwidth.Observe(
username,
atomic.LoadInt64(&u.TotalUplinkBytes),
atomic.LoadInt64(&u.TotalDownlinkBytes),
now,
)
active[username] = struct{}{}
}
sshBandwidth.Retain(active)
}
func sshUserRate(username string) bandwidthRate {
rate, _ := sshBandwidth.Rate(username)
return rate
}
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"testing"
"time"
)
func TestBandwidthSamplerFirstObservationIsBaselineOnly(t *testing.T) {
s := newBandwidthSampler(0, time.Minute)
now := time.Now()
s.Observe("bob", 10_000, 20_000, now)
rate, ok := s.Rate("bob")
if !ok {
t.Fatal("expected the account to be tracked after the first observation")
}
if !rate.isZero() {
t.Fatalf("first observation must not report a speed, got %+v", rate)
}
}
func TestBandwidthSamplerComputesBytesPerSecond(t *testing.T) {
s := newBandwidthSampler(0, time.Minute) // no smoothing: exact delta/dt
now := time.Now()
s.Observe("bob", 0, 0, now)
// 2 MB up and 10 MB down over 2 seconds.
s.Observe("bob", 2<<20, 10<<20, now.Add(2*time.Second))
rate, _ := s.Rate("bob")
if wantUp := float64(1 << 20); rate.UpBytesPerSec != wantUp {
t.Fatalf("up = %v, want %v", rate.UpBytesPerSec, wantUp)
}
if wantDown := float64(5 << 20); rate.DownBytesPerSec != wantDown {
t.Fatalf("down = %v, want %v", rate.DownBytesPerSec, wantDown)
}
}
func TestBandwidthSamplerIgnoresSamplesTakenTooCloseTogether(t *testing.T) {
s := newBandwidthSampler(0, time.Minute)
now := time.Now()
s.Observe("bob", 0, 0, now)
s.Observe("bob", 5<<20, 5<<20, now.Add(10*time.Millisecond))
rate, _ := s.Rate("bob")
if !rate.isZero() {
t.Fatalf("a 10ms interval must not produce a speed, got %+v", rate)
}
}
func TestBandwidthSamplerRebaselinesAfterTrafficReset(t *testing.T) {
s := newBandwidthSampler(0, time.Minute)
now := time.Now()
s.Observe("bob", 0, 0, now)
s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second))
// Panel reset the account's traffic: counters go back to zero.
s.Observe("bob", 0, 0, now.Add(4*time.Second))
rate, _ := s.Rate("bob")
if !rate.isZero() {
t.Fatalf("counters moving backwards must reset the speed, got %+v", rate)
}
s.Observe("bob", 2<<20, 0, now.Add(6*time.Second))
rate, _ = s.Rate("bob")
if wantUp := float64(1 << 20); rate.UpBytesPerSec != wantUp {
t.Fatalf("up after reset = %v, want %v", rate.UpBytesPerSec, wantUp)
}
}
func TestBandwidthSamplerReportsZeroWhenIdle(t *testing.T) {
s := newBandwidthSampler(0, time.Minute)
now := time.Now()
s.Observe("bob", 0, 0, now)
s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second))
s.Observe("bob", 4<<20, 4<<20, now.Add(4*time.Second))
rate, _ := s.Rate("bob")
if !rate.isZero() {
t.Fatalf("unchanged counters must report an idle account, got %+v", rate)
}
}
func TestBandwidthSamplerDropsStaleSpeeds(t *testing.T) {
s := newBandwidthSampler(0, time.Second)
now := time.Now().Add(-time.Hour)
s.Observe("bob", 0, 0, now)
s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second))
rate, ok := s.Rate("bob")
if !ok {
t.Fatal("expected the account to still be tracked")
}
if !rate.isZero() {
t.Fatalf("an hour-old sample must not still report a speed, got %+v", rate)
}
}
func TestBandwidthSamplerSmoothsWithTimeConstant(t *testing.T) {
s := newBandwidthSampler(5*time.Second, time.Minute)
now := time.Now()
s.Observe("bob", 0, 0, now)
// First real sample has no previous rate to blend with, so it lands exactly.
s.Observe("bob", 2<<20, 0, now.Add(2*time.Second))
first, _ := s.Rate("bob")
if first.UpBytesPerSec != float64(1<<20) {
t.Fatalf("first speed = %v, want %v", first.UpBytesPerSec, float64(1<<20))
}
// Traffic stops: the smoothed value has to fall without jumping to zero.
s.Observe("bob", 2<<20, 0, now.Add(4*time.Second))
second, _ := s.Rate("bob")
if second.UpBytesPerSec <= 0 || second.UpBytesPerSec >= first.UpBytesPerSec {
t.Fatalf("smoothed speed = %v, want a value between 0 and %v", second.UpBytesPerSec, first.UpBytesPerSec)
}
}
func TestBandwidthSamplerRateForKeysAndRetain(t *testing.T) {
s := newBandwidthSampler(0, time.Minute)
now := time.Now()
s.Observe("uuid-1", 0, 0, now)
s.Observe("uuid-1", 1<<20, 0, now.Add(1*time.Second))
if _, ok := s.RateForKeys("", "unknown@example", "uuid-1"); !ok {
t.Fatal("RateForKeys must find the client under any of its identifiers")
}
s.Retain(map[string]struct{}{"uuid-2": {}})
if _, ok := s.Rate("uuid-1"); ok {
t.Fatal("Retain must drop accounts that no longer exist")
}
}
+28 -4
View File
@@ -11,7 +11,7 @@ import (
const ( const (
defaultMainListen = "0.0.0.0:80" defaultMainListen = "0.0.0.0:80"
defaultExtraListen = "0.0.0.0:8080" defaultExtraListen = "0.0.0.0:8080"
defaultDNSTTListen = "[::]:5300" defaultDNSTTListen = "0.0.0.0:5300"
defaultUDPGWListen = "0.0.0.0:7400" defaultUDPGWListen = "0.0.0.0:7400"
) )
@@ -109,9 +109,10 @@ func normalizeRuntimePorts(cfg *Config) []string {
} }
} }
cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen) var migratedLegacyDNSTTWildcard bool
if cfg.DNSTT.UDPListen == "" { cfg.DNSTT.UDPListen, migratedLegacyDNSTTWildcard = normalizeDNSTTListenDefault(cfg.DNSTT.UDPListen)
cfg.DNSTT.UDPListen = defaultDNSTTListen if migratedLegacyDNSTTWildcard {
warn("DNSTT legacy default [::]:5300 is IPv6-only; using IPv4 default %s", cfg.DNSTT.UDPListen)
} }
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err != nil { if err := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err != nil {
old := cfg.DNSTT.UDPListen old := cfg.DNSTT.UDPListen
@@ -295,6 +296,29 @@ func normalizeDNSTTDomainList(primary string, domains []string) []string {
return out return out
} }
// normalizeDNSTTListenDefault keeps explicit IPv4 and concrete IPv6 listeners,
// but migrates the old wildcard IPv6 default. listenDNSTTPacket deliberately
// opens IPv6 addresses with udp6, so [::]:5300 never receives IPv4 queries.
// Existing installations commonly inherited that value from the old default;
// moving only that wildcard/default-port combination makes them work after an
// update without changing intentionally selected IPv6 interface addresses.
func normalizeDNSTTListenDefault(addr string) (string, bool) {
addr = strings.TrimSpace(addr)
if addr == "" {
return defaultDNSTTListen, false
}
host, port, err := net.SplitHostPort(addr)
if err != nil || port != "5300" {
return addr, false
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip != nil && ip.To4() == nil && ip.IsUnspecified() {
return defaultDNSTTListen, true
}
return addr, false
}
func udpAddrAvailableForDNSTT(addr string) error { func udpAddrAvailableForDNSTT(addr string) error {
if addr == "" { if addr == "" {
return nil return nil
+30
View File
@@ -0,0 +1,30 @@
package main
import "testing"
func TestNormalizeDNSTTListenDefault(t *testing.T) {
tests := []struct {
name string
input string
want string
migrated bool
}{
{name: "empty uses IPv4 default", input: "", want: "0.0.0.0:5300"},
{name: "whitespace uses IPv4 default", input: " ", want: "0.0.0.0:5300"},
{name: "legacy IPv6 wildcard migrates", input: "[::]:5300", want: "0.0.0.0:5300", migrated: true},
{name: "expanded legacy wildcard migrates", input: "[0:0:0:0:0:0:0:0]:5300", want: "0.0.0.0:5300", migrated: true},
{name: "explicit IPv4 remains", input: "192.0.2.10:53", want: "192.0.2.10:53"},
{name: "IPv4 wildcard remains", input: "0.0.0.0:5300", want: "0.0.0.0:5300"},
{name: "concrete IPv6 remains", input: "[2001:db8::10]:53", want: "[2001:db8::10]:53"},
{name: "IPv6 wildcard on custom port remains", input: "[::]:5301", want: "[::]:5301"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, migrated := normalizeDNSTTListenDefault(tt.input)
if got != tt.want || migrated != tt.migrated {
t.Fatalf("normalizeDNSTTListenDefault(%q) = (%q, %v), want (%q, %v)", tt.input, got, migrated, tt.want, tt.migrated)
}
})
}
}
+1 -4
View File
@@ -595,11 +595,8 @@ func startDNSTTInstance(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error {
return err return err
} }
udpListen := cfg.UDPListen udpListen, _ := normalizeDNSTTListenDefault(cfg.UDPListen)
if udpListen == "" {
udpListen = defaultDNSTTListen
cfg.UDPListen = udpListen cfg.UDPListen = udpListen
}
fakeDomains := domains fakeDomains := domains
if cfg.FakeDNSEnabled { if cfg.FakeDNSEnabled {
+26
View File
@@ -199,6 +199,32 @@ func (p *tlsListenerPool) Has(addr string) bool {
return ok return ok
} }
// Drop closes the listeners for the given addresses so a following Sync rebinds
// them. Used after a certificate is replaced on disk: tls.Listen captures the
// certificate when the listener is created, so the socket has to be recreated
// for new material to be served. Accepted connections are not owned by the pool
// and keep running.
func (p *tlsListenerPool) Drop(addrs []string, reason string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for _, addr := range addrs {
entry, ok := p.entries[addr]
if !ok {
continue
}
_ = entry.Close()
delete(p.entries, addr)
if reason != "" {
log.Printf("hotreload: dropped TLS %s (%s)", addr, reason)
} else {
log.Printf("hotreload: dropped TLS %s", addr)
}
}
}
func (p *tlsListenerPool) StopAll(reason string) { func (p *tlsListenerPool) StopAll(reason string) {
if p == nil { if p == nil {
return return
+13
View File
@@ -1736,6 +1736,8 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/tls/generate-selfsigned", saSession(handleManagedProxyOrLocal(store, handleTLSGenerateSelfSigned))) mux.Handle("/api/tls/generate-selfsigned", saSession(handleManagedProxyOrLocal(store, handleTLSGenerateSelfSigned)))
mux.Handle("/api/tls/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt))) mux.Handle("/api/tls/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt)))
mux.Handle("/api/tls/upload-pem", saSession(handleManagedProxyOrLocal(store, handleTLSUploadPEM))) mux.Handle("/api/tls/upload-pem", saSession(handleManagedProxyOrLocal(store, handleTLSUploadPEM)))
mux.Handle("/api/tls/certs", saSession(handleManagedProxyOrLocal(store, handleTLSCertList)))
mux.Handle("/api/tls/certs/update", saSession(handleManagedProxyOrLocal(store, handleTLSCertUpdate)))
// Superadmin-only: DNSTT key management // Superadmin-only: DNSTT key management
mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey))) mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey)))
@@ -1796,6 +1798,10 @@ type UserDTO struct {
TotalUplinkBytes int64 `json:"total_uplink_bytes"` TotalUplinkBytes int64 `json:"total_uplink_bytes"`
TotalDownlinkBytes int64 `json:"total_downlink_bytes"` TotalDownlinkBytes int64 `json:"total_downlink_bytes"`
TotalBytes int64 `json:"total_bytes"` TotalBytes int64 `json:"total_bytes"`
// Live account-wide speed in bytes per second, summed across every
// connection the user has open.
UpBytesPerSec float64 `json:"up_bytes_per_sec"`
DownBytesPerSec float64 `json:"down_bytes_per_sec"`
QuotaExceeded bool `json:"quota_exceeded"` QuotaExceeded bool `json:"quota_exceeded"`
TOTPSecret string `json:"totp_secret,omitempty"` TOTPSecret string `json:"totp_secret,omitempty"`
TOTPPeriod int `json:"totp_period"` TOTPPeriod int `json:"totp_period"`
@@ -1840,6 +1846,8 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
continue continue
} }
rate := sshUserRate(cfg.Username)
out = append(out, UserDTO{ out = append(out, UserDTO{
Username: cfg.Username, Username: cfg.Username,
ActiveConns: c, ActiveConns: c,
@@ -1853,6 +1861,8 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
TotalUplinkBytes: totalUp, TotalUplinkBytes: totalUp,
TotalDownlinkBytes: totalDown, TotalDownlinkBytes: totalDown,
TotalBytes: totalBytes, TotalBytes: totalBytes,
UpBytesPerSec: rate.UpBytesPerSec,
DownBytesPerSec: rate.DownBytesPerSec,
QuotaExceeded: cfg.DataQuotaBytes > 0 && totalBytes >= cfg.DataQuotaBytes, QuotaExceeded: cfg.DataQuotaBytes > 0 && totalBytes >= cfg.DataQuotaBytes,
TOTPSecret: cfg.TOTPSecret, TOTPSecret: cfg.TOTPSecret,
TOTPPeriod: cfg.TOTPPeriod, TOTPPeriod: cfg.TOTPPeriod,
@@ -3298,6 +3308,9 @@ func main() {
primeCurrentStats() primeCurrentStats()
startStatsCollector() startStatsCollector()
// Turn the per-account byte counters into live up/down speeds for the panel.
startSSHUserRateSampler()
adminAddr := os.Getenv("ADMIN_HTTP_ADDR") adminAddr := os.Getenv("ADMIN_HTTP_ADDR")
if adminAddr == "" { if adminAddr == "" {
adminAddr = "0.0.0.0:9090" adminAddr = "0.0.0.0:9090"
+3 -1
View File
@@ -22,7 +22,9 @@ import (
"time" "time"
) )
const tlsCertsDir = "/opt/sshpanel/certs" // tlsCertsDir holds panel-managed certificates. It is a var so tests can point
// it at a temporary directory.
var tlsCertsDir = "/opt/sshpanel/certs"
var ( 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])?$`) 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])?$`)
+715
View File
@@ -0,0 +1,715 @@
package main
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Certificate management for the panel: list the TLS material this node already
// uses and replace it in place (fullchain + privkey) when an operator renews a
// certificate. Replacing in place is what makes renewal painless — every place
// that references the old paths (TLS forwarders, Xray inbounds) keeps working,
// and only the listeners that actually serve the certificate are rebound.
const (
tlsCertFileName = "cert.pem"
tlsKeyFileName = "key.pem"
// Two PEM blobs plus JSON overhead. Certificates are a few KB; RSA chains
// with several intermediates still stay far below this.
maxTLSCertRequestBody = 4 << 20
maxTLSPEMBytes = 1 << 20
// Certificates expiring inside this window are flagged in the panel.
tlsCertExpiryWarnDays = 21
)
// tlsCertUsage records one consumer of a certificate so the panel can show what
// a replacement is going to affect.
type tlsCertUsage struct {
Kind string `json:"kind"` // tls_forwarder | xray_inbound
Ref string `json:"ref"` // listen address or inbound tag
}
type tlsCertInfo struct {
Name string `json:"name"`
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
Managed bool `json:"managed"` // stored under /opt/sshpanel/certs
Exists bool `json:"exists"`
Subject string `json:"subject,omitempty"`
Issuer string `json:"issuer,omitempty"`
Domains []string `json:"domains"`
NotBefore string `json:"not_before,omitempty"`
NotAfter string `json:"not_after,omitempty"`
DaysLeft int `json:"days_left"`
Expired bool `json:"expired"`
Expiring bool `json:"expiring"`
SelfSigned bool `json:"self_signed"`
ChainLen int `json:"chain_length"`
KeyType string `json:"key_type,omitempty"`
KeyOK bool `json:"key_ok"`
Modified string `json:"modified,omitempty"`
Error string `json:"error,omitempty"`
UsedBy []tlsCertUsage `json:"used_by"`
}
type tlsCertRef struct {
certFile string
keyFile string
managed bool
usage []tlsCertUsage
}
type tlsCertRefSet struct {
byCert map[string]*tlsCertRef
order []string
}
func newTLSCertRefSet() *tlsCertRefSet {
return &tlsCertRefSet{byCert: map[string]*tlsCertRef{}}
}
func (s *tlsCertRefSet) add(certFile, keyFile string, usage ...tlsCertUsage) *tlsCertRef {
certFile = strings.TrimSpace(certFile)
if certFile == "" {
return nil
}
certFile = filepath.Clean(certFile)
ref, ok := s.byCert[certFile]
if !ok {
ref = &tlsCertRef{certFile: certFile, managed: isUnderTLSCertsDir(certFile)}
s.byCert[certFile] = ref
s.order = append(s.order, certFile)
}
if ref.keyFile == "" && strings.TrimSpace(keyFile) != "" {
ref.keyFile = filepath.Clean(strings.TrimSpace(keyFile))
}
for _, u := range usage {
if u.Kind == "" {
continue
}
dup := false
for _, have := range ref.usage {
if have == u {
dup = true
break
}
}
if !dup {
ref.usage = append(ref.usage, u)
}
}
return ref
}
func (s *tlsCertRefSet) list() []*tlsCertRef {
out := make([]*tlsCertRef, 0, len(s.order))
for _, key := range s.order {
out = append(out, s.byCert[key])
}
return out
}
func isUnderTLSCertsDir(path string) bool {
rel, err := filepath.Rel(filepath.Clean(tlsCertsDir), filepath.Clean(path))
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// samePathRef compares two file paths, following symlinks when both sides can be
// resolved. /etc/letsencrypt/live/<domain>/fullchain.pem is a symlink, so a
// plain string compare is not enough to match a config reference to a real file.
func samePathRef(a, b string) bool {
a, b = strings.TrimSpace(a), strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
if filepath.Clean(a) == filepath.Clean(b) {
return true
}
ra, errA := filepath.EvalSymlinks(a)
rb, errB := filepath.EvalSymlinks(b)
return errA == nil && errB == nil && ra == rb
}
// collectTLSCertRefs gathers every certificate this node knows about: the ones
// stored in the panel's cert directory plus the ones referenced by the running
// config (TLS forwarders) and the Xray config (inbound tlsSettings).
func collectTLSCertRefs() *tlsCertRefSet {
set := newTLSCertRefSet()
gc := getGlobalCfg()
var fallbackCert, fallbackKey string
if gc != nil {
for _, fwd := range gc.TLSForwarders {
if strings.TrimSpace(fwd.CertFile) == "" {
continue
}
if fallbackCert == "" {
fallbackCert, fallbackKey = fwd.CertFile, fwd.KeyFile
}
listen := strings.TrimSpace(fwd.Listen)
if listen == "" {
listen = "(unbound)"
}
set.add(fwd.CertFile, fwd.KeyFile, tlsCertUsage{Kind: "tls_forwarder", Ref: listen})
}
}
for _, u := range xrayInboundCertUsage(fallbackCert, fallbackKey) {
set.add(u.certFile, u.keyFile, tlsCertUsage{Kind: "xray_inbound", Ref: u.tag})
}
// Panel-managed certificates (self-signed, pasted, or previously updated).
entries, err := os.ReadDir(tlsCertsDir)
if err == nil {
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
certFile := filepath.Join(tlsCertsDir, name, tlsCertFileName)
if _, err := os.Stat(certFile); err != nil {
continue
}
set.add(certFile, filepath.Join(tlsCertsDir, name, tlsKeyFileName))
}
}
return set
}
type xrayCertRef struct {
tag string
certFile string
keyFile string
}
// xrayInboundCertUsage returns the certificate each TLS-enabled Xray inbound
// serves. Inbounds that enable TLS without naming a certificate inherit the
// first TLS forwarder's material (see buildInboundTLS), so they are reported
// against that path — replacing it does affect them.
func xrayInboundCertUsage(fallbackCert, fallbackKey string) []xrayCertRef {
if xrayMgr == nil {
return nil
}
data, err := xrayMgr.GetConfig()
if err != nil || len(data) == 0 {
return nil
}
var cf struct {
Inbounds []struct {
Tag string `json:"tag"`
StreamSettings struct {
Security string `json:"security"`
TLSSettings struct {
Certificates []struct {
CertificateFile string `json:"certificateFile"`
KeyFile string `json:"keyFile"`
} `json:"certificates"`
} `json:"tlsSettings"`
} `json:"streamSettings"`
} `json:"inbounds"`
}
if err := json.Unmarshal(data, &cf); err != nil {
return nil
}
var out []xrayCertRef
for i, in := range cf.Inbounds {
security := strings.ToLower(strings.TrimSpace(in.StreamSettings.Security))
certs := in.StreamSettings.TLSSettings.Certificates
if security != "tls" && len(certs) == 0 {
continue
}
tag := strings.TrimSpace(in.Tag)
if tag == "" {
tag = fmt.Sprintf("inbound-%d", i+1)
}
if len(certs) > 0 && strings.TrimSpace(certs[0].CertificateFile) != "" {
out = append(out, xrayCertRef{tag: tag, certFile: certs[0].CertificateFile, keyFile: certs[0].KeyFile})
continue
}
if security == "tls" && strings.TrimSpace(fallbackCert) != "" {
out = append(out, xrayCertRef{tag: tag + " (herda do TLS forwarder)", certFile: fallbackCert, keyFile: fallbackKey})
}
}
return out
}
func tlsCertDisplayName(certFile string) string {
dir := filepath.Base(filepath.Dir(certFile))
if dir == "" || dir == "." || dir == string(filepath.Separator) {
return filepath.Base(certFile)
}
if dir == "live" || dir == "certs" {
return filepath.Base(certFile)
}
return dir
}
func tlsKeyTypeName(key interface{}) string {
switch k := key.(type) {
case *rsa.PrivateKey:
return fmt.Sprintf("RSA %d", k.N.BitLen())
case *ecdsa.PrivateKey:
return "ECDSA " + k.Curve.Params().Name
case ed25519.PrivateKey:
return "Ed25519"
}
return ""
}
func parsePEMCertChain(data []byte) ([]*x509.Certificate, error) {
var chain []*x509.Certificate
rest := data
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
crt, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
chain = append(chain, crt)
}
if len(chain) == 0 {
return nil, fmt.Errorf("no CERTIFICATE block found")
}
return chain, nil
}
func certDomains(leaf *x509.Certificate) []string {
seen := map[string]bool{}
out := make([]string, 0, len(leaf.DNSNames)+len(leaf.IPAddresses)+1)
for _, d := range leaf.DNSNames {
if d = strings.TrimSpace(d); d != "" && !seen[d] {
seen[d] = true
out = append(out, d)
}
}
for _, ip := range leaf.IPAddresses {
s := ip.String()
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
if len(out) == 0 && strings.TrimSpace(leaf.Subject.CommonName) != "" {
out = append(out, strings.TrimSpace(leaf.Subject.CommonName))
}
return out
}
func describeTLSCert(ref *tlsCertRef) tlsCertInfo {
info := tlsCertInfo{
Name: tlsCertDisplayName(ref.certFile),
CertFile: ref.certFile,
KeyFile: ref.keyFile,
Managed: ref.managed,
Domains: []string{},
UsedBy: ref.usage,
}
if info.UsedBy == nil {
info.UsedBy = []tlsCertUsage{}
}
st, err := os.Stat(ref.certFile)
if err != nil {
info.Error = "arquivo não encontrado"
return info
}
info.Exists = true
info.Modified = st.ModTime().UTC().Format(time.RFC3339)
certPEM, err := os.ReadFile(ref.certFile)
if err != nil {
info.Error = "leitura do certificado: " + err.Error()
return info
}
chain, err := parsePEMCertChain(certPEM)
if err != nil {
info.Error = "certificado inválido: " + err.Error()
return info
}
leaf := chain[0]
info.ChainLen = len(chain)
info.Subject = leaf.Subject.CommonName
info.Issuer = leaf.Issuer.CommonName
if info.Issuer == "" && len(leaf.Issuer.Organization) > 0 {
info.Issuer = leaf.Issuer.Organization[0]
}
info.Domains = certDomains(leaf)
info.NotBefore = leaf.NotBefore.UTC().Format(time.RFC3339)
info.NotAfter = leaf.NotAfter.UTC().Format(time.RFC3339)
info.SelfSigned = string(leaf.RawIssuer) == string(leaf.RawSubject)
now := time.Now()
info.Expired = now.After(leaf.NotAfter)
info.DaysLeft = int(leaf.NotAfter.Sub(now).Hours() / 24)
info.Expiring = !info.Expired && info.DaysLeft <= tlsCertExpiryWarnDays
if ref.keyFile != "" {
keyPEM, err := os.ReadFile(ref.keyFile)
if err != nil {
info.Error = "leitura da chave: " + err.Error()
return info
}
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
info.Error = "a chave privada não corresponde ao certificado"
return info
}
info.KeyOK = true
info.KeyType = tlsKeyTypeName(pair.PrivateKey)
} else {
info.Error = "nenhuma chave privada associada"
}
return info
}
// handleTLSCertList returns every certificate this node uses, with expiry and
// the listeners/inbounds that serve it.
func handleTLSCertList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
refs := collectTLSCertRefs().list()
out := make([]tlsCertInfo, 0, len(refs))
for _, ref := range refs {
out = append(out, describeTLSCert(ref))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"certs_dir": tlsCertsDir,
"certs": out,
})
}
type tlsCertUpdateRequest struct {
// Name creates or replaces a panel-managed certificate under
// /opt/sshpanel/certs/<name>/. Ignored when CertFile is set.
Name string `json:"name"`
// CertFile/KeyFile target an existing certificate in place so every
// reference to those paths keeps working after the renewal.
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
// Fullchain/Privkey hold the PEM text. cert/key are accepted as aliases.
Fullchain string `json:"fullchain"`
Privkey string `json:"privkey"`
Cert string `json:"cert"`
Key string `json:"key"`
Reload *bool `json:"reload"`
Force bool `json:"force"`
}
type tlsCertReloadResult struct {
TLSForwarders []string `json:"tls_forwarders"`
XrayInbounds []string `json:"xray_inbounds"`
XrayRestarted bool `json:"xray_restarted"`
}
func normalizeTLSFilePath(raw string) (string, error) {
p := strings.TrimSpace(raw)
if p == "" {
return "", fmt.Errorf("caminho vazio")
}
if strings.ContainsAny(p, "\x00\r\n") {
return "", fmt.Errorf("caminho inválido")
}
if !filepath.IsAbs(p) {
return "", fmt.Errorf("o caminho precisa ser absoluto")
}
return filepath.Clean(p), nil
}
func normalizePEMText(raw string) string {
s := strings.ReplaceAll(strings.TrimSpace(raw), "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
if s == "" {
return ""
}
return s + "\n"
}
// resolveTLSCertTarget decides which files the new PEM material is written to
// and rejects paths that are neither panel-managed nor already referenced by the
// running configuration. Without that check this endpoint would be an arbitrary
// root file-write primitive.
func resolveTLSCertTarget(req tlsCertUpdateRequest) (certFile, keyFile string, warnings []string, err error) {
if strings.TrimSpace(req.CertFile) != "" {
certFile, err = normalizeTLSFilePath(req.CertFile)
if err != nil {
return "", "", nil, err
}
refs := collectTLSCertRefs()
var known *tlsCertRef
for _, ref := range refs.list() {
if samePathRef(ref.certFile, certFile) {
known = ref
break
}
}
if known == nil && !isUnderTLSCertsDir(certFile) {
return "", "", nil, fmt.Errorf("caminho não gerenciado pelo painel: use um certificado já referenciado na configuração ou informe um nome para armazenar em %s", tlsCertsDir)
}
if strings.TrimSpace(req.KeyFile) != "" {
keyFile, err = normalizeTLSFilePath(req.KeyFile)
if err != nil {
return "", "", nil, err
}
} else if known != nil && known.keyFile != "" {
keyFile = known.keyFile
} else {
keyFile = filepath.Join(filepath.Dir(certFile), tlsKeyFileName)
}
if !isUnderTLSCertsDir(keyFile) {
keyKnown := known != nil && samePathRef(known.keyFile, keyFile)
if !keyKnown && filepath.Dir(keyFile) != filepath.Dir(certFile) {
return "", "", nil, fmt.Errorf("a chave precisa estar na mesma pasta do certificado ou já estar referenciada na configuração")
}
}
return certFile, keyFile, warnings, nil
}
name, nameErr := normalizeTLSStoreName(req.Name)
if nameErr != nil {
return "", "", nil, fmt.Errorf("informe cert_file de um certificado existente ou um nome para armazenar: %v", nameErr)
}
dir := filepath.Join(tlsCertsDir, name)
return filepath.Join(dir, tlsCertFileName), filepath.Join(dir, tlsKeyFileName), warnings, nil
}
// writeTLSMaterial replaces path with data, keeping a .bak copy of the previous
// content and preserving the existing file mode. Symlinked targets (certbot
// layout) are followed so the link structure survives the update.
func writeTLSMaterial(path string, data []byte, defaultMode os.FileMode) (string, []string, error) {
var warnings []string
target := path
if lst, err := os.Lstat(path); err == nil && lst.Mode()&os.ModeSymlink != 0 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
target = resolved
warnings = append(warnings, fmt.Sprintf("%s é um link para %s; o conteúdo real foi substituído", path, resolved))
}
}
mode := defaultMode
if st, err := os.Stat(target); err == nil {
mode = st.Mode().Perm()
if old, err := os.ReadFile(target); err == nil {
if err := writeFileAtomic(target+".bak", old, mode); err != nil {
warnings = append(warnings, "não foi possível gravar backup de "+filepath.Base(target)+": "+err.Error())
}
}
}
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return target, warnings, err
}
if err := writeFileAtomic(target, data, mode); err != nil {
return target, warnings, err
}
return target, warnings, nil
}
// handleTLSCertUpdate replaces a certificate's fullchain + private key and
// reloads whatever serves it, so a renewal takes effect without touching any
// other configuration.
func handleTLSCertUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxTLSCertRequestBody)
var req tlsCertUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "corpo inválido: "+err.Error(), http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Fullchain) == "" {
req.Fullchain = req.Cert
}
if strings.TrimSpace(req.Privkey) == "" {
req.Privkey = req.Key
}
certPEM := normalizePEMText(req.Fullchain)
keyPEM := normalizePEMText(req.Privkey)
if certPEM == "" || keyPEM == "" {
http.Error(w, "fullchain (certificado) e privkey (chave privada) são obrigatórios", http.StatusBadRequest)
return
}
if len(certPEM) > maxTLSPEMBytes || len(keyPEM) > maxTLSPEMBytes {
http.Error(w, "certificado ou chave muito grandes", http.StatusRequestEntityTooLarge)
return
}
pair, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil || len(pair.Certificate) == 0 {
http.Error(w, "certificado e chave privada inválidos ou não correspondentes", http.StatusBadRequest)
return
}
chain, err := parsePEMCertChain([]byte(certPEM))
if err != nil {
http.Error(w, "certificado inválido: "+err.Error(), http.StatusBadRequest)
return
}
leaf := chain[0]
now := time.Now()
if now.After(leaf.NotAfter) && !req.Force {
http.Error(w, fmt.Sprintf("este certificado expirou em %s; envie force=true para gravar mesmo assim",
leaf.NotAfter.UTC().Format("2006-01-02")), http.StatusBadRequest)
return
}
certFile, keyFile, warnings, err := resolveTLSCertTarget(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(chain) < 2 && !leaf.IsCA && string(leaf.RawIssuer) != string(leaf.RawSubject) {
warnings = append(warnings, "o PEM enviado contém apenas o certificado final; cole o fullchain.pem completo para evitar erros de cadeia em alguns clientes")
}
if now.Before(leaf.NotBefore) {
warnings = append(warnings, "o certificado só é válido a partir de "+leaf.NotBefore.UTC().Format("2006-01-02 15:04")+" UTC")
}
if now.After(leaf.NotAfter) {
warnings = append(warnings, "certificado já expirado — gravado por causa de force=true")
}
// Domain mismatch is usually a wrong paste, but a domain change can be
// intentional, so it is reported rather than blocked.
if oldPEM, err := os.ReadFile(certFile); err == nil {
if oldChain, err := parsePEMCertChain(oldPEM); err == nil {
oldDomains, newDomains := certDomains(oldChain[0]), certDomains(leaf)
if strings.Join(oldDomains, ",") != strings.Join(newDomains, ",") {
warnings = append(warnings, fmt.Sprintf("os domínios mudaram: antes %s, agora %s",
strings.Join(oldDomains, ", "), strings.Join(newDomains, ", ")))
}
}
}
writtenCert, certWarn, err := writeTLSMaterial(certFile, []byte(certPEM), 0o600)
warnings = append(warnings, certWarn...)
if err != nil {
http.Error(w, "gravar certificado: "+err.Error(), http.StatusInternalServerError)
return
}
writtenKey, keyWarn, err := writeTLSMaterial(keyFile, []byte(keyPEM), 0o600)
warnings = append(warnings, keyWarn...)
if err != nil {
http.Error(w, "gravar chave: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("tls: certificate updated cert=%s key=%s cn=%q not_after=%s",
writtenCert, writtenKey, leaf.Subject.CommonName, leaf.NotAfter.UTC().Format(time.RFC3339))
reload := tlsCertReloadResult{TLSForwarders: []string{}, XrayInbounds: []string{}}
if req.Reload == nil || *req.Reload {
var reloadWarn []string
reload, reloadWarn = reloadTLSCertConsumers(certFile, keyFile)
warnings = append(warnings, reloadWarn...)
}
info := describeTLSCert(&tlsCertRef{
certFile: certFile,
keyFile: keyFile,
managed: isUnderTLSCertsDir(certFile),
usage: certUsageFor(certFile, keyFile),
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"cert_file": certFile,
"key_file": keyFile,
"cert": info,
"reloaded": reload,
"warnings": warnings,
})
}
func certUsageFor(certFile, keyFile string) []tlsCertUsage {
for _, ref := range collectTLSCertRefs().list() {
if samePathRef(ref.certFile, certFile) {
return ref.usage
}
}
return nil
}
// reloadTLSCertConsumers rebinds the TLS forwarders that serve the replaced
// certificate and restarts Xray when one of its inbounds uses it. Certificates
// are read once when a listener is created, so nothing short of rebinding picks
// up new material. Established connections are not owned by the listeners and
// keep running.
func reloadTLSCertConsumers(certFile, keyFile string) (tlsCertReloadResult, []string) {
result := tlsCertReloadResult{TLSForwarders: []string{}, XrayInbounds: []string{}}
var warnings []string
gc := getGlobalCfg()
var fallbackCert, fallbackKey string
if gc != nil {
for _, fwd := range gc.TLSForwarders {
if strings.TrimSpace(fwd.CertFile) != "" {
fallbackCert, fallbackKey = fwd.CertFile, fwd.KeyFile
break
}
}
var affected []string
for _, fwd := range gc.TLSForwarders {
if samePathRef(fwd.CertFile, certFile) || samePathRef(fwd.KeyFile, keyFile) {
if listen := strings.TrimSpace(fwd.Listen); listen != "" {
affected = append(affected, listen)
}
}
}
if len(affected) > 0 && tlsPool != nil {
tlsPool.Drop(affected, "certificate updated")
for _, e := range tlsPool.Sync(gc.TLSForwarders) {
warnings = append(warnings, fmt.Sprintf("recarregar TLS forwarder: %v", e))
}
for _, addr := range affected {
if tlsPool.Has(addr) {
result.TLSForwarders = append(result.TLSForwarders, addr)
} else {
warnings = append(warnings, "o TLS forwarder "+addr+" não voltou a escutar; verifique os logs")
}
}
}
}
for _, u := range xrayInboundCertUsage(fallbackCert, fallbackKey) {
if samePathRef(u.certFile, certFile) || samePathRef(u.keyFile, keyFile) {
result.XrayInbounds = append(result.XrayInbounds, u.tag)
}
}
if len(result.XrayInbounds) > 0 && xrayMgr != nil {
st := xrayMgr.Status()
if st.Enabled && st.Running {
if err := xrayMgr.Restart(); err != nil {
warnings = append(warnings, fmt.Sprintf("reiniciar Xray: %v", err))
} else {
result.XrayRestarted = true
}
} else if st.Enabled {
warnings = append(warnings, "o Xray usa este certificado mas não está em execução")
}
}
return result, warnings
}
+306
View File
@@ -0,0 +1,306 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// makeTestCertPair returns PEM cert/key material for the given domain.
func makeTestCertPair(t *testing.T, domain string, notBefore, notAfter time.Time) (certPEM, keyPEM string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("keygen: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: domain},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{domain},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("certgen: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
return certPEM, keyPEM
}
func useTempCertsDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
old := tlsCertsDir
tlsCertsDir = dir
t.Cleanup(func() { tlsCertsDir = old })
oldCfg := getGlobalCfg()
t.Cleanup(func() { setGlobalCfg(oldCfg) })
return dir
}
func postCertUpdate(t *testing.T, body map[string]interface{}) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/tls/certs/update", strings.NewReader(string(raw)))
rec := httptest.NewRecorder()
handleTLSCertUpdate(rec, req)
return rec
}
func TestCertUpdateStoresNamedCertAndReportsExpiry(t *testing.T) {
dir := useTempCertsDir(t)
certPEM, keyPEM := makeTestCertPair(t, "panel.example.com", time.Now().Add(-time.Hour), time.Now().Add(30*24*time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"name": "panel-example",
"fullchain": certPEM,
"privkey": keyPEM,
"reload": false,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
Cert tlsCertInfo `json:"cert"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
wantCert := filepath.Join(dir, "panel-example", tlsCertFileName)
if filepath.Clean(resp.CertFile) != wantCert {
t.Fatalf("cert_file = %q, want %q", resp.CertFile, wantCert)
}
if !resp.Cert.KeyOK {
t.Fatalf("expected key to match certificate: %+v", resp.Cert)
}
if resp.Cert.Expired || resp.Cert.DaysLeft < 25 {
t.Fatalf("unexpected expiry data: %+v", resp.Cert)
}
if len(resp.Cert.Domains) != 1 || resp.Cert.Domains[0] != "panel.example.com" {
t.Fatalf("domains = %v", resp.Cert.Domains)
}
data, err := os.ReadFile(wantCert)
if err != nil || !strings.Contains(string(data), "BEGIN CERTIFICATE") {
t.Fatalf("cert not written: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "panel-example", tlsKeyFileName)); err != nil {
t.Fatalf("key not written: %v", err)
}
}
func TestCertUpdateReplacesInPlaceAndKeepsBackup(t *testing.T) {
dir := useTempCertsDir(t)
oldCert, oldKey := makeTestCertPair(t, "old.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
if rec := postCertUpdate(t, map[string]interface{}{
"name": "renew-me", "fullchain": oldCert, "privkey": oldKey, "reload": false,
}); rec.Code != http.StatusOK {
t.Fatalf("seed failed: %s", rec.Body.String())
}
certFile := filepath.Join(dir, "renew-me", tlsCertFileName)
keyFile := filepath.Join(dir, "renew-me", tlsKeyFileName)
newCert, newKey := makeTestCertPair(t, "new.example.com", time.Now().Add(-time.Hour), time.Now().Add(90*24*time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"cert_file": certFile, "key_file": keyFile,
"fullchain": newCert, "privkey": newKey, "reload": false,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Cert tlsCertInfo `json:"cert"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Cert.Domains[0] != "new.example.com" {
t.Fatalf("cert was not replaced: %+v", resp.Cert)
}
backup, err := os.ReadFile(certFile + ".bak")
if err != nil {
t.Fatalf("no backup written: %v", err)
}
if strings.TrimSpace(string(backup)) != strings.TrimSpace(oldCert) {
t.Fatal("backup does not hold the previous certificate")
}
if _, err := os.Stat(keyFile + ".bak"); err != nil {
t.Fatalf("no key backup: %v", err)
}
joined := strings.Join(resp.Warnings, " | ")
if !strings.Contains(joined, "domínios mudaram") {
t.Fatalf("expected a domain-change warning, got %q", joined)
}
}
func TestCertUpdateRejectsBadInput(t *testing.T) {
dir := useTempCertsDir(t)
certPEM, keyPEM := makeTestCertPair(t, "a.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
_, otherKey := makeTestCertPair(t, "b.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
expiredCert, expiredKey := makeTestCertPair(t, "old.example.com", time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour))
// Absolute, but neither panel-managed nor referenced by the configuration.
unmanaged := t.TempDir()
cases := []struct {
name string
body map[string]interface{}
want string
}{
{"missing key", map[string]interface{}{"name": "x", "fullchain": certPEM}, "obrigatórios"},
{"mismatched pair", map[string]interface{}{"name": "x", "fullchain": certPEM, "privkey": otherKey}, "não correspondentes"},
{"expired without force", map[string]interface{}{"name": "x", "fullchain": expiredCert, "privkey": expiredKey}, "expirou"},
{"unmanaged path", map[string]interface{}{
"cert_file": filepath.Join(unmanaged, "cert.pem"),
"key_file": filepath.Join(unmanaged, "key.pem"),
"fullchain": certPEM, "privkey": keyPEM,
}, "não gerenciado"},
{"relative path", map[string]interface{}{"cert_file": "certs/cert.pem", "fullchain": certPEM, "privkey": keyPEM}, "absoluto"},
{"bad name", map[string]interface{}{"name": "../escape", "fullchain": certPEM, "privkey": keyPEM}, "nome"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := postCertUpdate(t, tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), tc.want) {
t.Fatalf("body %q does not mention %q", rec.Body.String(), tc.want)
}
})
}
if entries, err := os.ReadDir(dir); err == nil && len(entries) != 0 {
t.Fatalf("rejected requests wrote %d entries to the certs dir", len(entries))
}
}
func TestCertUpdateForceAcceptsExpiredCert(t *testing.T) {
useTempCertsDir(t)
expiredCert, expiredKey := makeTestCertPair(t, "old.example.com", time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"name": "forced", "fullchain": expiredCert, "privkey": expiredKey, "reload": false, "force": true,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Cert tlsCertInfo `json:"cert"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Cert.Expired {
t.Fatal("expected the stored certificate to be reported as expired")
}
if !strings.Contains(strings.Join(resp.Warnings, " | "), "expirado") {
t.Fatalf("expected an expiry warning, got %v", resp.Warnings)
}
}
// A certificate referenced only by the running config (for example a certbot
// path outside the panel directory) must still be updatable in place, because
// that is what makes a renewal invisible to the rest of the configuration.
func TestCertUpdateAllowsPathReferencedByConfig(t *testing.T) {
useTempCertsDir(t)
external := t.TempDir()
certFile := filepath.Join(external, "fullchain.pem")
keyFile := filepath.Join(external, "privkey.pem")
oldCert, oldKey := makeTestCertPair(t, "tunnel.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour))
if err := os.WriteFile(certFile, []byte(oldCert), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyFile, []byte(oldKey), 0o600); err != nil {
t.Fatal(err)
}
setGlobalCfg(&Config{TLSForwarders: []TLSForwarderConfig{{
Listen: "0.0.0.0:8443", CertFile: certFile, KeyFile: keyFile,
}}})
newCert, newKey := makeTestCertPair(t, "tunnel.example.com", time.Now().Add(-time.Hour), time.Now().Add(60*24*time.Hour))
rec := postCertUpdate(t, map[string]interface{}{
"cert_file": certFile, "key_file": keyFile,
"fullchain": newCert, "privkey": newKey, "reload": false,
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
stored, err := os.ReadFile(certFile)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(stored)) != strings.TrimSpace(newCert) {
t.Fatal("external certificate path was not updated")
}
var resp struct {
Cert tlsCertInfo `json:"cert"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Cert.UsedBy) != 1 || resp.Cert.UsedBy[0].Ref != "0.0.0.0:8443" {
t.Fatalf("expected the TLS forwarder to be reported as consumer, got %+v", resp.Cert.UsedBy)
}
}
func TestTLSCertListReportsConfiguredAndManagedCerts(t *testing.T) {
dir := useTempCertsDir(t)
certPEM, keyPEM := makeTestCertPair(t, "listed.example.com", time.Now().Add(-time.Hour), time.Now().Add(10*24*time.Hour))
if err := os.MkdirAll(filepath.Join(dir, "listed"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "listed", tlsCertFileName), []byte(certPEM), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "listed", tlsKeyFileName), []byte(keyPEM), 0o600); err != nil {
t.Fatal(err)
}
setGlobalCfg(&Config{})
req := httptest.NewRequest(http.MethodGet, "/api/tls/certs", nil)
rec := httptest.NewRecorder()
handleTLSCertList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Certs []tlsCertInfo `json:"certs"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Certs) != 1 {
t.Fatalf("expected 1 cert, got %d (%+v)", len(resp.Certs), resp.Certs)
}
got := resp.Certs[0]
if got.Name != "listed" || !got.Managed || !got.KeyOK || !got.SelfSigned {
t.Fatalf("unexpected cert info: %+v", got)
}
if !got.Expiring || got.Expired {
t.Fatalf("a cert expiring in 10 days should be flagged as expiring: %+v", got)
}
}
+86 -5
View File
@@ -257,6 +257,7 @@ type XrayManager struct {
lastStatsErr string lastStatsErr string
lastStatsPoll time.Time lastStatsPoll time.Time
pollStarted bool pollStarted bool
rateSamplerStarted bool
nativeDBMu sync.Mutex nativeDBMu sync.Mutex
nativeTrafficPersistMu sync.Mutex nativeTrafficPersistMu sync.Mutex
@@ -319,6 +320,7 @@ func initXrayManager(cfg *XrayConfig) {
// external `xray api statsquery` poller is not started (it would overwrite // external `xray api statsquery` poller is not started (it would overwrite
// the native counters with errors from a non-existent CLI endpoint). // the native counters with errors from a non-existent CLI endpoint).
xrayMgr.startNativeStatsFlusher() xrayMgr.startNativeStatsFlusher()
xrayMgr.startRateSampler()
if !cfg.UseNative() { if !cfg.UseNative() {
xrayMgr.startStatsPoller() xrayMgr.startStatsPoller()
} }
@@ -886,6 +888,73 @@ func (m *XrayManager) startStatsPoller() {
}() }()
} }
// Live per-client speed, derived from the same cumulative counters the panel
// already reports as lifetime traffic.
var xrayBandwidth = newBandwidthSampler(6*time.Second, 45*time.Second)
const xrayNativeRateSampleInterval = 2 * time.Second
// startRateSampler keeps xrayBandwidth fresh in native mode, where the
// in-process runtime updates the counters continuously. In external mode the
// counters only move once per stats poll (15s by default), so refreshRuntimeStats
// feeds the sampler at its own cadence instead — sampling faster than the source
// updates would show alternating spikes and zeros. The mode is re-checked on
// every tick because a hot reload can switch it while running.
func (m *XrayManager) startRateSampler() {
m.mu.Lock()
if m.rateSamplerStarted {
m.mu.Unlock()
return
}
m.rateSamplerStarted = true
m.mu.Unlock()
go func() {
ticker := time.NewTicker(xrayNativeRateSampleInterval)
defer ticker.Stop()
for range ticker.C {
if !m.usesNativeSnapshot() {
continue
}
m.sampleRuntimeRates(time.Now())
}
}()
}
func (m *XrayManager) usesNativeSnapshot() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg != nil && m.cfg.UseNative()
}
func (m *XrayManager) sampleRuntimeRates(now time.Time) {
type counterSnapshot struct {
key string
uplink int64
downlink int64
}
m.statsMu.RLock()
snapshots := make([]counterSnapshot, 0, len(m.statsByEmail))
for key, st := range m.statsByEmail {
snapshots = append(snapshots, counterSnapshot{key: key, uplink: st.Uplink, downlink: st.Downlink})
}
m.statsMu.RUnlock()
active := make(map[string]struct{}, len(snapshots))
for _, snapshot := range snapshots {
xrayBandwidth.Observe(snapshot.key, snapshot.uplink, snapshot.downlink, now)
active[snapshot.key] = struct{}{}
}
xrayBandwidth.Retain(active)
}
// RuntimeRateForKeys resolves a client's live speed. Clients are tracked under
// their UUID in native mode and under their stats-API email in external mode,
// so callers pass every identifier the client may be stored under.
func (m *XrayManager) RuntimeRateForKeys(keys ...string) (bandwidthRate, bool) {
return xrayBandwidth.RateForKeys(keys...)
}
func (m *XrayManager) isRunningSnapshot() bool { func (m *XrayManager) isRunningSnapshot() bool {
m.mu.Lock() m.mu.Lock()
native := m.cfg != nil && m.cfg.UseNative() native := m.cfg != nil && m.cfg.UseNative()
@@ -965,9 +1034,12 @@ func (m *XrayManager) refreshRuntimeStats() {
if m.statsByEmail == nil { if m.statsByEmail == nil {
m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic)) m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic))
} }
seen := make(map[string]bool, len(traffic)) // External mode: the counters only move once per poll, so this is also the
// natural cadence for the live speed sampler.
active := make(map[string]struct{}, len(traffic))
for email, counters := range traffic { for email, counters := range traffic {
seen[email] = true active[email] = struct{}{}
xrayBandwidth.Observe(email, counters.Uplink, counters.Downlink, now)
prev := m.statsByEmail[email] prev := m.statsByEmail[email]
st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive, ActiveConnections: prev.ActiveConnections} st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive, ActiveConnections: prev.ActiveConnections}
changed := counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink changed := counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink
@@ -979,9 +1051,10 @@ func (m *XrayManager) refreshRuntimeStats() {
} }
m.statsByEmail[email] = st m.statsByEmail[email] = st
} }
// Keep old entries, but do not delete them immediately. Xray may omit zero // Keep old stat entries, but do not delete them immediately: Xray may omit
// counters for users that have not moved traffic yet. // zero counters for users that have not moved traffic yet. Speed samples are
_ = seen // dropped for absent users because a missing baseline only costs one poll.
xrayBandwidth.Retain(active)
} }
func (m *XrayManager) refreshRuntimeStatsIfStale(maxAge time.Duration) { func (m *XrayManager) refreshRuntimeStatsIfStale(maxAge time.Duration) {
@@ -2161,6 +2234,10 @@ type XrayClientInfo struct {
DownlinkBytes int64 `json:"downlink_bytes,omitempty"` DownlinkBytes int64 `json:"downlink_bytes,omitempty"`
TotalBytes int64 `json:"total_bytes,omitempty"` TotalBytes int64 `json:"total_bytes,omitempty"`
ActiveConnections int `json:"active_connections,omitempty"` ActiveConnections int `json:"active_connections,omitempty"`
// Live speed in bytes per second for the whole client, summed across every
// connection it has open.
UpBytesPerSec float64 `json:"up_bytes_per_sec"`
DownBytesPerSec float64 `json:"down_bytes_per_sec"`
// Metadata from PostgreSQL (enriched by handleXrayInbounds) // Metadata from PostgreSQL (enriched by handleXrayInbounds)
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
@@ -2565,6 +2642,10 @@ func applyXrayRuntimeStats(c *XrayClientInfo) {
if c == nil { if c == nil {
return return
} }
if rate, ok := xrayMgr.RuntimeRateForKeys(c.Email, c.UUID, c.Name); ok {
c.UpBytesPerSec = rate.UpBytesPerSec
c.DownBytesPerSec = rate.DownBytesPerSec
}
st, ok := xrayMgr.RuntimeStatsForKeys(c.Email, c.UUID, c.Name) st, ok := xrayMgr.RuntimeStatsForKeys(c.Email, c.UUID, c.Name)
if !ok { if !ok {
return return