Compare commits
5
Commits
44f5b2b09c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2bb4ceeb | ||
|
|
809e2aeb82 | ||
|
|
54981f7348 | ||
|
|
8df19f01e0 | ||
|
|
6c2fc33fff |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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` (0–10000), `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` (0–10000), `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
@@ -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;}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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 || {};
|
||||||
@@ -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----- …" 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----- …" 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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-29
@@ -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>
|
||||||
@@ -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----- …" 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----- …" 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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])?$`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user