Compare commits
12
Commits
2f4cb008ae
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2bb4ceeb | ||
|
|
809e2aeb82 | ||
|
|
54981f7348 | ||
|
|
8df19f01e0 | ||
|
|
6c2fc33fff | ||
|
|
44f5b2b09c | ||
|
|
1576ce9038 | ||
|
|
decf48992e | ||
|
|
7c51ea3f86 | ||
|
|
3d64d6394b | ||
|
|
b903775fb7 | ||
|
|
9c5bbaf55d |
@@ -15,6 +15,9 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
|
|||||||
- Endpoint XHTTP compartilhado no modo nativo: VLESS **ou** VMess em `/` e SSH em `/ssh`, usando o mesmo domínio/porta/TLS
|
- Endpoint XHTTP compartilhado no modo nativo: VLESS **ou** VMess em `/` e SSH em `/ssh`, usando o mesmo domínio/porta/TLS
|
||||||
- Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual
|
- Á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
|
||||||
|
- 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
|
||||||
@@ -70,13 +73,13 @@ A confirmação dessa migração é exibida dentro do próprio painel. Se a grav
|
|||||||
|
|
||||||
Contas SSH e clientes VLESS/VMess do modo nativo podem usar `data_quota_bytes` com ação `block` ou `throttle`. O botão **Reset/Zerar tráfego** limpa apenas os contadores; não renova validade, senha ou configuração da conta. Não existe reset periódico automático no servidor: qualquer período comercial mostrado no site é independente e o reset ocorre somente por ação explícita no painel/API. O valor `max_conns` é aplicado no momento em que o usuário VLESS/VMess é autenticado e vale em conjunto para TCP, UDP, WebSocket, XHTTP e conexões Mux (uma conexão Mux autenticada conta como uma conexão, independentemente dos streams filhos).
|
Contas SSH e clientes VLESS/VMess do modo nativo podem usar `data_quota_bytes` com ação `block` ou `throttle`. O botão **Reset/Zerar tráfego** limpa apenas os contadores; não renova validade, senha ou configuração da conta. Não existe reset periódico automático no servidor: qualquer período comercial mostrado no site é independente e o reset ocorre somente por ação explícita no painel/API. O valor `max_conns` é aplicado no momento em que o usuário VLESS/VMess é autenticado e vale em conjunto para TCP, UDP, WebSocket, XHTTP e conexões Mux (uma conexão Mux autenticada conta como uma conexão, independentemente dos streams filhos).
|
||||||
|
|
||||||
O runtime nativo também possui limites globais para impedir crescimento sem controle de sockets, goroutines e sessões HTTP:
|
As antigas chaves globais de admissão continuam no JSON somente para compatibilidade, mas são sempre normalizadas para `-1` (ilimitado), inclusive quando um `config.json` antigo ainda contém `4096`, `8192`, `32768` ou outro valor positivo:
|
||||||
|
|
||||||
- `max_concurrent_connections`: conexões de transporte TCP/TLS/WebSocket/XHTTP; padrão `32768`;
|
- `max_concurrent_connections`: sem limite global de conexões de transporte;
|
||||||
- `max_concurrent_xhttp_requests`: mantido apenas para compatibilidade de configuração; o limite de requisições web fica desativado (`-1`) no XHTTP;
|
- `max_concurrent_xhttp_requests`: sem limite global de requisições XHTTP;
|
||||||
- `xhttp_max_sessions`: sessões XHTTP ativas; padrão `32768`.
|
- `xhttp_max_sessions`: sem limite global de sessões XHTTP.
|
||||||
|
|
||||||
Esses campos ficam em **Configurações → Xray → Native Xray scale tuning**. O XHTTP é tratado como transporte VPN: rajadas de packet-up usam backpressure cancelável e buffers de bytes limitados, sem respostas `429` nem semântica de “too many requests”. Ao atingir o teto de transporte, novos sockets permanecem no backlog do kernel em vez de serem aceitos e resetados. Conexões HTTP/2 mantêm um limite de fluxo de 1024 streams simultâneos por conexão. Cada transporte Mux aceita no máximo 64 sessões filhas, com limite global padrão de 32768. Sockets WebSocket incompletos têm timeout de handshake, conexões HTTP ociosas têm timeout, e parar/reiniciar o Xray nativo fecha conexões e sessões existentes. Atualizações de tráfego e de conexões ativas são agregadas e persistidas em lote a cada cinco segundos, sem criar uma goroutine ou consulta PostgreSQL por conexão. Entradas pendentes de usuários removidos são descartadas para manter os mapas de retry limitados ao conjunto atual de contas.
|
O painel não expõe mais esses três controles como limites ajustáveis. Xray XHTTP e XHTTP SSH usam o mesmo listener VPN sem teto por quantidade de requisições, streams HTTP/2, conexões de transporte ou sessões XHTTP. Rajadas de `packet-up` e a remontagem fora de ordem usam backpressure cancelável contabilizado em bytes; até pacotes vazios consomem um custo mínimo de memória contabilizada, portanto remover o limite por quantidade não cria uma fila de metadados sem limite. Não existem respostas `429` nem rejeições `503` por capacidade global. As políticas reais por usuário (`max_conns`, cota e banda) continuam ativas. Cada transporte Mux aceita no máximo 64 sessões filhas, com limite global padrão de 32768. Parar/reiniciar o Xray nativo fecha conexões e sessões existentes. Atualizações de tráfego e de conexões ativas são agregadas e persistidas em lote a cada cinco segundos, sem criar uma goroutine ou consulta PostgreSQL por conexão. Entradas pendentes de usuários removidos são descartadas para manter os mapas de retry limitados ao conjunto atual de contas.
|
||||||
|
|
||||||
### Requisitos
|
### Requisitos
|
||||||
|
|
||||||
@@ -198,7 +201,7 @@ Também é possível editar diretamente o `config.json`:
|
|||||||
```json
|
```json
|
||||||
"dnstt": {
|
"dnstt": {
|
||||||
"domain": "t.example.com",
|
"domain": "t.example.com",
|
||||||
"udp_listen": "[::]:5300",
|
"udp_listen": "0.0.0.0:5300",
|
||||||
"privkey_file": "/opt/sshpanel/dnstt.key",
|
"privkey_file": "/opt/sshpanel/dnstt.key",
|
||||||
"auto_restart_interval": "6h",
|
"auto_restart_interval": "6h",
|
||||||
"auto_restart_grace": "2s"
|
"auto_restart_grace": "2s"
|
||||||
@@ -218,7 +221,7 @@ Exemplo:
|
|||||||
"t.example.com",
|
"t.example.com",
|
||||||
"t.local.lan"
|
"t.local.lan"
|
||||||
],
|
],
|
||||||
"udp_listen": "[::]:5300",
|
"udp_listen": "0.0.0.0:5300",
|
||||||
"privkey_file": "/opt/sshpanel/dnstt.key"
|
"privkey_file": "/opt/sshpanel/dnstt.key"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -556,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
|
||||||
@@ -611,13 +616,13 @@ The migration confirmation is rendered inside the panel. If saving fails, the te
|
|||||||
|
|
||||||
SSH accounts and native-mode VLESS/VMess clients can use `data_quota_bytes` with either the `block` or `throttle` action. The **Reset traffic** action clears only usage counters; it does not renew expiry, change a password, or alter account settings. The server does not perform an automatic periodic reset: any commercial period shown on the website is independent, and counters reset only through an explicit panel/API action. `max_conns` is enforced when a native VLESS/VMess user is authenticated and is shared across TCP, UDP, WebSocket, XHTTP, and Mux transports (one authenticated Mux transport counts as one connection, regardless of its child streams).
|
SSH accounts and native-mode VLESS/VMess clients can use `data_quota_bytes` with either the `block` or `throttle` action. The **Reset traffic** action clears only usage counters; it does not renew expiry, change a password, or alter account settings. The server does not perform an automatic periodic reset: any commercial period shown on the website is independent, and counters reset only through an explicit panel/API action. `max_conns` is enforced when a native VLESS/VMess user is authenticated and is shared across TCP, UDP, WebSocket, XHTTP, and Mux transports (one authenticated Mux transport counts as one connection, regardless of its child streams).
|
||||||
|
|
||||||
The native runtime also has global ceilings that prevent unbounded socket, goroutine, and HTTP-session growth:
|
The old global admission keys remain in JSON for compatibility, but they are always normalized to `-1` (unlimited), including when an old `config.json` still contains `4096`, `8192`, `32768`, or any other positive value:
|
||||||
|
|
||||||
- `max_concurrent_connections`: TCP/TLS/WebSocket/XHTTP transport connections; default `32768`;
|
- `max_concurrent_connections`: no global transport-connection count cap;
|
||||||
- `max_concurrent_xhttp_requests`: retained for configuration compatibility; the web-request cap is disabled (`-1`) for XHTTP;
|
- `max_concurrent_xhttp_requests`: no global XHTTP-request count cap;
|
||||||
- `xhttp_max_sessions`: active XHTTP sessions; default `32768`.
|
- `xhttp_max_sessions`: no global XHTTP-session count cap.
|
||||||
|
|
||||||
These fields are available under **Settings → Xray → Native Xray scale tuning**. XHTTP is treated as VPN transport traffic: packet-up bursts use cancelable backpressure and bounded byte buffers, with no `429` or “too many requests” behavior. At the transport ceiling, new sockets remain in the kernel backlog instead of being accepted and reset. HTTP/2 connections retain a 1024-stream flow-control guard per connection. Each Mux transport accepts at most 64 child sessions, with a default global ceiling of 32768. Incomplete WebSocket handshakes time out, idle HTTP connections time out, and stopping/restarting native Xray closes existing transports and XHTTP sessions. Traffic and active-connection changes are aggregated and written in five-second batches rather than creating a PostgreSQL query or goroutine for every connection. Pending retry entries for deleted clients are removed so retry maps stay bounded by the current account set.
|
The panel no longer exposes those three controls as adjustable ceilings. Xray XHTTP and XHTTP SSH share the same VPN listener with no count ceiling for requests, HTTP/2 streams, transport connections, or XHTTP sessions. Packet-up bursts and out-of-order reassembly use cancelable byte-accounted backpressure; even empty packets are charged a minimum accounted-memory cost, so removing the request-count limit does not create an unbounded metadata queue. There are no `429` responses or global-capacity `503` rejections. Real per-user policies (`max_conns`, quota, and bandwidth) remain active. Each Mux transport accepts at most 64 child sessions, with a default global ceiling of 32768. Stopping/restarting native Xray closes existing transports and XHTTP sessions. Traffic and active-connection changes are aggregated and written in five-second batches rather than creating a PostgreSQL query or goroutine for every connection. Pending retry entries for deleted clients are removed so retry maps stay bounded by the current account set.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
@@ -737,7 +742,7 @@ You can also edit `config.json` directly:
|
|||||||
```json
|
```json
|
||||||
"dnstt": {
|
"dnstt": {
|
||||||
"domain": "t.example.com",
|
"domain": "t.example.com",
|
||||||
"udp_listen": "[::]:5300",
|
"udp_listen": "0.0.0.0:5300",
|
||||||
"privkey_file": "/opt/sshpanel/dnstt.key",
|
"privkey_file": "/opt/sshpanel/dnstt.key",
|
||||||
"auto_restart_interval": "6h",
|
"auto_restart_interval": "6h",
|
||||||
"auto_restart_grace": "2s"
|
"auto_restart_grace": "2s"
|
||||||
@@ -757,7 +762,7 @@ Example:
|
|||||||
"t.example.com",
|
"t.example.com",
|
||||||
"t.local.lan"
|
"t.local.lan"
|
||||||
],
|
],
|
||||||
"udp_listen": "[::]:5300",
|
"udp_listen": "0.0.0.0:5300",
|
||||||
"privkey_file": "/opt/sshpanel/dnstt.key"
|
"privkey_file": "/opt/sshpanel/dnstt.key"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1144,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.
|
||||||
@@ -1264,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).
|
||||||
@@ -1285,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>/`.
|
||||||
@@ -1299,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
|
||||||
|
|||||||
+66
-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 --- */
|
||||||
@@ -757,3 +756,63 @@ th[data-sort-key]:hover{color:var(--accent);}
|
|||||||
th[data-sort-key]::after{content:"";display:inline-block;width:.9em;font-size:.72em;opacity:.85;}
|
th[data-sort-key]::after{content:"";display:inline-block;width:.9em;font-size:.72em;opacity:.85;}
|
||||||
th[data-sort-key].sort-asc::after{content:" \25B2";}
|
th[data-sort-key].sort-asc::after{content:" \25B2";}
|
||||||
th[data-sort-key].sort-desc::after{content:" \25BC";}
|
th[data-sort-key].sort-desc::after{content:" \25BC";}
|
||||||
|
|
||||||
|
/* Shared SSH/Xray user list sorting and filtering */
|
||||||
|
.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}
|
||||||
|
@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;}
|
||||||
|
}
|
||||||
|
|||||||
+50
-10
@@ -277,6 +277,8 @@ function applyLanguage(lang, options = {}) {
|
|||||||
if (languageSelect) languageSelect.value = currentLang;
|
if (languageSelect) languageSelect.value = currentLang;
|
||||||
updatePageHeading();
|
updatePageHeading();
|
||||||
translateStatic(document.body);
|
translateStatic(document.body);
|
||||||
|
if (typeof window.renderSSHListControls === "function") window.renderSSHListControls();
|
||||||
|
if (typeof window.renderXrayListControls === "function") window.renderXrayListControls();
|
||||||
document.documentElement.classList.remove("i18n-pending");
|
document.documentElement.classList.remove("i18n-pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,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;
|
||||||
@@ -744,6 +774,8 @@ function patchRenderedInbounds(inbounds) {
|
|||||||
updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
|
updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
|
||||||
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, "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 || "∞"));
|
||||||
}
|
}
|
||||||
@@ -754,30 +786,38 @@ function patchRenderedInbounds(inbounds) {
|
|||||||
// Native XHTTP/VPN tuning labels introduced by the high-traffic backpressure
|
// Native XHTTP/VPN tuning labels introduced by the high-traffic backpressure
|
||||||
// update. Keep this block close to the UI code so both languages stay complete.
|
// update. Keep this block close to the UI code so both languages stay complete.
|
||||||
Object.assign(I18N_TEXT["en-US"], {
|
Object.assign(I18N_TEXT["en-US"], {
|
||||||
|
"Sort by":"Sort by","Show":"Show","All":"All","Offline":"Offline","Connections":"Connections","Usage":"Usage","Quota reached":"Quota reached","{visible} of {total} users":"{visible} of {total} users","No Xray users match this filter.":"No Xray users match this filter.","No SSH users match this filter.":"No SSH users match this filter.",
|
||||||
"Native Xray scale tuning":"Native Xray scale tuning",
|
"Native Xray scale tuning":"Native Xray scale tuning",
|
||||||
"Go CPU threads (GOMAXPROCS)":"Go CPU threads (GOMAXPROCS)",
|
"Go CPU threads (GOMAXPROCS)":"Go CPU threads (GOMAXPROCS)",
|
||||||
"Global mux backend sessions":"Global mux backend sessions",
|
"Global mux backend sessions":"Global mux backend sessions",
|
||||||
"Global transport connections":"Global transport connections",
|
"Transport and XHTTP admission":"Transport and XHTTP admission",
|
||||||
"XHTTP web request cap":"XHTTP web request cap",
|
"Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.":"Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.",
|
||||||
"disabled for VPN traffic":"disabled for VPN traffic",
|
|
||||||
"Active XHTTP sessions":"Active XHTTP sessions",
|
|
||||||
"Trace every XHTTP/mux packet":"Trace every XHTTP/mux packet",
|
"Trace every XHTTP/mux packet":"Trace every XHTTP/mux packet",
|
||||||
"debug only, slows QUIC":"debug only, slows QUIC",
|
"debug only, slows QUIC":"debug only, slows QUIC",
|
||||||
"Apply high-traffic VPN defaults":"Apply high-traffic VPN defaults",
|
"Apply high-traffic VPN defaults":"Apply high-traffic VPN defaults",
|
||||||
"Apply safe defaults":"Apply safe defaults",
|
"Apply safe defaults":"Apply safe defaults",
|
||||||
"XHTTP is handled as VPN tunnel traffic: packet requests use bounded backpressure and are never rejected by an HTTP request-rate ceiling. At the transport ceiling, new sockets wait in the kernel backlog instead of being reset. Keep the safe defaults unless the server is sized and load-tested for the high-traffic profile. HTTP/2 retains a 1024-stream flow-control guard per connection, while upload memory stays globally bounded. Saved in the panel config and applied live on restart/reload.":"XHTTP is handled as VPN tunnel traffic: packet requests use bounded backpressure and are never rejected by an HTTP request-rate ceiling. At the transport ceiling, new sockets wait in the kernel backlog instead of being reset. Keep the safe defaults unless the server is sized and load-tested for the high-traffic profile. HTTP/2 retains a 1024-stream flow-control guard per connection, while upload memory stays globally bounded. Saved in the panel config and applied live on restart/reload."
|
"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.":"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."
|
||||||
});
|
});
|
||||||
Object.assign(I18N_TEXT["pt-BR"], {
|
Object.assign(I18N_TEXT["pt-BR"], {
|
||||||
|
"Sort by":"Ordenar por","Show":"Mostrar","All":"Todos","Offline":"Offline","Connections":"Conexões","Usage":"Uso","Quota reached":"Cota atingida","{visible} of {total} users":"{visible} de {total} usuários","No Xray users match this filter.":"Nenhum usuário Xray corresponde a este filtro.","No SSH users match this filter.":"Nenhum usuário SSH corresponde a este filtro.",
|
||||||
"Native Xray scale tuning":"Ajustes de escala do Xray nativo",
|
"Native Xray scale tuning":"Ajustes de escala do Xray nativo",
|
||||||
"Go CPU threads (GOMAXPROCS)":"Threads de CPU do Go (GOMAXPROCS)",
|
"Go CPU threads (GOMAXPROCS)":"Threads de CPU do Go (GOMAXPROCS)",
|
||||||
"Global mux backend sessions":"Sessões globais de backend Mux",
|
"Global mux backend sessions":"Sessões globais de backend Mux",
|
||||||
"Global transport connections":"Conexões globais de transporte",
|
"Transport and XHTTP admission":"Admissão de transporte e XHTTP",
|
||||||
"XHTTP web request cap":"Limite web de requisições XHTTP",
|
"Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.":"Ilimitado para tráfego VPN. Não existe limite global por quantidade de requisições HTTP, streams HTTP/2, conexões de transporte ou sessões XHTTP.",
|
||||||
"disabled for VPN traffic":"desativado para tráfego VPN",
|
|
||||||
"Active XHTTP sessions":"Sessões XHTTP ativas",
|
|
||||||
"Trace every XHTTP/mux packet":"Registrar cada pacote XHTTP/Mux",
|
"Trace every XHTTP/mux packet":"Registrar cada pacote XHTTP/Mux",
|
||||||
"debug only, slows QUIC":"somente debug, reduz a velocidade do QUIC",
|
"debug only, slows QUIC":"somente debug, reduz a velocidade do QUIC",
|
||||||
"Apply high-traffic VPN defaults":"Aplicar padrão VPN de alto tráfego",
|
"Apply high-traffic VPN defaults":"Aplicar padrão VPN de alto tráfego",
|
||||||
"Apply safe defaults":"Aplicar padrões seguros",
|
"Apply safe defaults":"Aplicar padrões seguros",
|
||||||
"XHTTP is handled as VPN tunnel traffic: packet requests use bounded backpressure and are never rejected by an HTTP request-rate ceiling. At the transport ceiling, new sockets wait in the kernel backlog instead of being reset. Keep the safe defaults unless the server is sized and load-tested for the high-traffic profile. HTTP/2 retains a 1024-stream flow-control guard per connection, while upload memory stays globally bounded. Saved in the panel config and applied live on restart/reload.":"O XHTTP é tratado como tráfego de túnel VPN: as requisições de pacotes usam backpressure com memória limitada e nunca são rejeitadas por um limite de requisições web. Ao atingir o teto de transporte, novos sockets aguardam no backlog do kernel em vez de serem resetados. Mantenha os padrões seguros, exceto se o servidor estiver dimensionado e testado para o perfil de alto tráfego. O HTTP/2 mantém um controle de fluxo de 1024 streams por conexão, e a memória de upload continua limitada globalmente. Salvo na configuração do painel e aplicado ao vivo ao reiniciar ou recarregar."
|
"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;
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-27
@@ -75,16 +75,40 @@ 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 = [
|
||||||
|
["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"],
|
||||||
|
["speed", "Speed"], ["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"],
|
||||||
|
["up", "Up"], ["down", "Dn"], ["owner", "Owner"],
|
||||||
|
];
|
||||||
|
const USER_FILTER_OPTIONS = [
|
||||||
|
["all", "All"], ["online", "Online"], ["offline", "Offline"],
|
||||||
|
["active", "Active"], ["expired", "Expired"], ["quota", "Quota reached"],
|
||||||
|
];
|
||||||
|
|
||||||
let userSort = { key: "username", dir: "asc" };
|
let userSort = { key: "username", dir: "asc" };
|
||||||
|
let userFilter = "all";
|
||||||
let lastUsersData = [];
|
let lastUsersData = [];
|
||||||
|
|
||||||
|
function userMatchesFilter(user) {
|
||||||
|
const online = Number(user.active_conns || 0) > 0;
|
||||||
|
const expired = isExpiredDate(user.expires_at);
|
||||||
|
switch (userFilter) {
|
||||||
|
case "online": return online;
|
||||||
|
case "offline": return !online;
|
||||||
|
case "active": return !expired;
|
||||||
|
case "expired": return expired;
|
||||||
|
case "quota": return !!user.quota_exceeded;
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sortUsers(list) {
|
function sortUsers(list) {
|
||||||
const ext = USER_SORT_EXTRACT[userSort.key] || USER_SORT_EXTRACT.username;
|
const ext = USER_SORT_EXTRACT[userSort.key] || USER_SORT_EXTRACT.username;
|
||||||
const dir = userSort.dir === "desc" ? -1 : 1;
|
const dir = userSort.dir === "desc" ? -1 : 1;
|
||||||
@@ -110,6 +134,44 @@ function updateSortIndicators() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSSHListControls() {
|
||||||
|
const sortLabel = document.getElementById("sshSortLabel");
|
||||||
|
const filterLabel = document.getElementById("sshFilterLabel");
|
||||||
|
const sortButtons = document.getElementById("sshSortButtons");
|
||||||
|
const filterButtons = document.getElementById("sshFilterButtons");
|
||||||
|
const count = document.getElementById("sshListCount");
|
||||||
|
if (sortLabel) sortLabel.textContent = t("Sort by");
|
||||||
|
if (filterLabel) filterLabel.textContent = t("Show");
|
||||||
|
if (sortButtons) {
|
||||||
|
const options = USER_SORT_OPTIONS.filter(([key]) => key !== "owner" || currentRole === "superadmin");
|
||||||
|
sortButtons.replaceChildren(...options.map(([key, label]) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "user-list-filter-btn" + (userSort.key === key ? " active" : "");
|
||||||
|
button.textContent = t(label);
|
||||||
|
button.setAttribute("aria-pressed", userSort.key === key ? "true" : "false");
|
||||||
|
if (userSort.key === key) button.dataset.direction = userSort.dir;
|
||||||
|
button.addEventListener("click", () => setUserSort(key));
|
||||||
|
return button;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (filterButtons) {
|
||||||
|
filterButtons.replaceChildren(...USER_FILTER_OPTIONS.map(([key, label]) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "user-list-filter-btn" + (userFilter === key ? " active" : "");
|
||||||
|
button.textContent = t(label);
|
||||||
|
button.setAttribute("aria-pressed", userFilter === key ? "true" : "false");
|
||||||
|
button.addEventListener("click", () => setUserFilter(key));
|
||||||
|
return button;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (count) {
|
||||||
|
const visible = lastUsersData.filter(userMatchesFilter).length;
|
||||||
|
count.textContent = t("{visible} of {total} users", {visible, total:lastUsersData.length});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setUserSort(key) {
|
function setUserSort(key) {
|
||||||
if (!USER_SORT_EXTRACT[key]) return;
|
if (!USER_SORT_EXTRACT[key]) return;
|
||||||
if (userSort.key === key) {
|
if (userSort.key === key) {
|
||||||
@@ -118,7 +180,12 @@ function setUserSort(key) {
|
|||||||
userSort.key = key;
|
userSort.key = key;
|
||||||
userSort.dir = USER_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc";
|
userSort.dir = USER_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc";
|
||||||
}
|
}
|
||||||
updateSortIndicators();
|
renderUsers(lastUsersData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserFilter(filter) {
|
||||||
|
if (!USER_FILTER_OPTIONS.some(([key]) => key === filter)) return;
|
||||||
|
userFilter = filter;
|
||||||
renderUsers(lastUsersData);
|
renderUsers(lastUsersData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +198,8 @@ function setUserSort(key) {
|
|||||||
updateSortIndicators();
|
updateSortIndicators();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
renderSSHListControls();
|
||||||
|
|
||||||
function sshTrafficHTML(u) {
|
function sshTrafficHTML(u) {
|
||||||
const up = Number(u.total_uplink_bytes || 0);
|
const up = Number(u.total_uplink_bytes || 0);
|
||||||
const down = Number(u.total_downlink_bytes || 0);
|
const down = Number(u.total_downlink_bytes || 0);
|
||||||
@@ -146,39 +215,49 @@ function sshTrafficHTML(u) {
|
|||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
// Cache the raw list so a header click can re-sort without refetching, and
|
// Cache the raw list so a header click can re-sort without refetching, and
|
||||||
// order by the active column so rows don't shuffle on each live poll.
|
// order by the active column so rows don't shuffle on each live poll.
|
||||||
lastUsersData = users || [];
|
lastUsersData = Array.isArray(users) ? users : [];
|
||||||
users = sortUsers(lastUsersData);
|
users = sortUsers(lastUsersData.filter(userMatchesFilter));
|
||||||
updateDashboardFromUsers(users);
|
updateDashboardFromUsers(lastUsersData);
|
||||||
|
renderSSHListControls();
|
||||||
|
updateSortIndicators();
|
||||||
const isSA = currentRole === "superadmin";
|
const isSA = currentRole === "superadmin";
|
||||||
userCountChip.textContent = users.length;
|
ownerColHead.classList.toggle("hidden", !isSA);
|
||||||
if (isSA) ownerColHead.classList.remove("hidden");
|
|
||||||
usersBody.innerHTML = "";
|
usersBody.innerHTML = "";
|
||||||
let online = 0;
|
const online = lastUsersData.filter(u => Number(u.active_conns || 0) > 0).length;
|
||||||
let expiredCount = 0;
|
const expiredCount = lastUsersData.filter(u => isExpiredDate(u.expires_at)).length;
|
||||||
users.forEach(u => {
|
users.forEach(u => {
|
||||||
const on = (u.active_conns || 0) > 0;
|
const on = (u.active_conns || 0) > 0;
|
||||||
if (on) online++;
|
|
||||||
if (isExpiredDate(u.expires_at)) expiredCount++;
|
|
||||||
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),
|
||||||
@@ -198,9 +277,19 @@ function renderUsers(users) {
|
|||||||
tr.appendChild(tdA);
|
tr.appendChild(tdA);
|
||||||
usersBody.appendChild(tr);
|
usersBody.appendChild(tr);
|
||||||
});
|
});
|
||||||
const activeCount = Math.max(0, users.length - expiredCount);
|
if (!users.length) {
|
||||||
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
|
const row = document.createElement("tr");
|
||||||
if (sshMetricTotal) sshMetricTotal.textContent = String(users.length);
|
const cell = document.createElement("td");
|
||||||
|
cell.colSpan = isSA ? 12 : 11;
|
||||||
|
cell.className = "hint";
|
||||||
|
cell.style.cssText = "padding:24px;text-align:center;";
|
||||||
|
cell.textContent = t("No SSH users match this filter.");
|
||||||
|
row.appendChild(cell);
|
||||||
|
usersBody.appendChild(row);
|
||||||
|
}
|
||||||
|
const activeCount = Math.max(0, lastUsersData.length - expiredCount);
|
||||||
|
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count:lastUsersData.length, active:activeCount, online});
|
||||||
|
if (sshMetricTotal) sshMetricTotal.textContent = String(lastUsersData.length);
|
||||||
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
|
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
|
||||||
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
|
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
|
||||||
if (sshMetricState) sshMetricState.textContent = t("Online");
|
if (sshMetricState) sshMetricState.textContent = t("Online");
|
||||||
|
|||||||
+159
-16
@@ -18,6 +18,134 @@ document.getElementById("xCreateUUIDBtn")?.addEventListener("click", () => {
|
|||||||
document.getElementById("xCreateInbound")?.addEventListener("change", updateXrayCreatorInboundLabel);
|
document.getElementById("xCreateInbound")?.addEventListener("change", updateXrayCreatorInboundLabel);
|
||||||
document.getElementById("xCreateClientForm")?.addEventListener("submit", submitXrayClientCreator);
|
document.getElementById("xCreateClientForm")?.addEventListener("submit", submitXrayClientCreator);
|
||||||
|
|
||||||
|
// Keep Xray list controls consistent with the sortable SSH user table while
|
||||||
|
// preserving the inbound grouping. Sorting is applied inside each inbound;
|
||||||
|
// filters apply across all inbound groups.
|
||||||
|
const XRAY_CLIENT_SORT_EXTRACT = {
|
||||||
|
name: c => String(c.name || c.email || c.id || "").toLowerCase(),
|
||||||
|
status: c => c.expired ? 0 : 1,
|
||||||
|
online: c => c.online ? 1 : 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),
|
||||||
|
expiry: c => c.expires_at ? new Date(c.expires_at).getTime() : Infinity,
|
||||||
|
max: c => Number(c.max_conns || 0),
|
||||||
|
};
|
||||||
|
const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "speed", "usage", "max"]);
|
||||||
|
const XRAY_CLIENT_SORT_OPTIONS = [
|
||||||
|
["name", "Name"], ["status", "Status"], ["online", "Online"],
|
||||||
|
["connections", "Connections"], ["speed", "Speed"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"],
|
||||||
|
];
|
||||||
|
const XRAY_CLIENT_FILTER_OPTIONS = [
|
||||||
|
["all", "All"], ["online", "Online"], ["offline", "Offline"],
|
||||||
|
["active", "Active"], ["expired", "Expired"], ["quota", "Quota reached"],
|
||||||
|
];
|
||||||
|
let xrayClientSort = { key: "name", dir: "asc" };
|
||||||
|
let xrayClientFilter = "all";
|
||||||
|
let lastXrayInboundsData = [];
|
||||||
|
|
||||||
|
function xrayClientMatchesFilter(client) {
|
||||||
|
switch (xrayClientFilter) {
|
||||||
|
case "online": return !!client.online;
|
||||||
|
case "offline": return !client.online;
|
||||||
|
case "active": return !client.expired;
|
||||||
|
case "expired": return !!client.expired;
|
||||||
|
case "quota": return !!client.quota_exceeded;
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortXrayClients(clients = []) {
|
||||||
|
const extract = XRAY_CLIENT_SORT_EXTRACT[xrayClientSort.key] || XRAY_CLIENT_SORT_EXTRACT.name;
|
||||||
|
const direction = xrayClientSort.dir === "desc" ? -1 : 1;
|
||||||
|
return clients.slice().sort((a, b) => {
|
||||||
|
const left = extract(a), right = extract(b);
|
||||||
|
let comparison;
|
||||||
|
if (typeof left === "number" && typeof right === "number") comparison = left - right;
|
||||||
|
else comparison = String(left).localeCompare(String(right));
|
||||||
|
if (comparison === 0) comparison = String(a.name || a.email || a.id || "").localeCompare(String(b.name || b.email || b.id || ""));
|
||||||
|
return comparison * direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareXrayInboundsForList(inbounds = []) {
|
||||||
|
return (inbounds || []).map(inbound => ({
|
||||||
|
...inbound,
|
||||||
|
clients: sortXrayClients((inbound.clients || []).filter(xrayClientMatchesFilter)),
|
||||||
|
})).filter(inbound => xrayClientFilter === "all" || inbound.clients.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xrayClientListCounts(inbounds = []) {
|
||||||
|
const clients = (inbounds || []).flatMap(inbound => inbound.clients || []);
|
||||||
|
return { total: clients.length, visible: clients.filter(xrayClientMatchesFilter).length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderXrayListControls() {
|
||||||
|
const sortLabel = document.getElementById("xraySortLabel");
|
||||||
|
const filterLabel = document.getElementById("xrayFilterLabel");
|
||||||
|
const sortButtons = document.getElementById("xraySortButtons");
|
||||||
|
const filterButtons = document.getElementById("xrayFilterButtons");
|
||||||
|
const count = document.getElementById("xrayListCount");
|
||||||
|
if (sortLabel) sortLabel.textContent = t("Sort by");
|
||||||
|
if (filterLabel) filterLabel.textContent = t("Show");
|
||||||
|
if (sortButtons) {
|
||||||
|
sortButtons.replaceChildren(...XRAY_CLIENT_SORT_OPTIONS.map(([key, label]) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "user-list-filter-btn" + (xrayClientSort.key === key ? " active" : "");
|
||||||
|
button.textContent = t(label);
|
||||||
|
button.setAttribute("aria-pressed", xrayClientSort.key === key ? "true" : "false");
|
||||||
|
if (xrayClientSort.key === key) button.dataset.direction = xrayClientSort.dir;
|
||||||
|
button.addEventListener("click", () => setXrayClientSort(key));
|
||||||
|
return button;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (filterButtons) {
|
||||||
|
filterButtons.replaceChildren(...XRAY_CLIENT_FILTER_OPTIONS.map(([key, label]) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "user-list-filter-btn" + (xrayClientFilter === key ? " active" : "");
|
||||||
|
button.textContent = t(label);
|
||||||
|
button.setAttribute("aria-pressed", xrayClientFilter === key ? "true" : "false");
|
||||||
|
button.addEventListener("click", () => setXrayClientFilter(key));
|
||||||
|
return button;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (count) {
|
||||||
|
const counts = xrayClientListCounts(lastXrayInboundsData);
|
||||||
|
count.textContent = t("{visible} of {total} users", counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setXrayClientSort(key) {
|
||||||
|
if (!XRAY_CLIENT_SORT_EXTRACT[key]) return;
|
||||||
|
if (xrayClientSort.key === key) xrayClientSort.dir = xrayClientSort.dir === "asc" ? "desc" : "asc";
|
||||||
|
else {
|
||||||
|
xrayClientSort.key = key;
|
||||||
|
xrayClientSort.dir = XRAY_CLIENT_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc";
|
||||||
|
}
|
||||||
|
renderInbounds(lastXrayInboundsData, { force:true, fromControls:true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setXrayClientFilter(filter) {
|
||||||
|
if (!XRAY_CLIENT_FILTER_OPTIONS.some(([key]) => key === filter)) return;
|
||||||
|
xrayClientFilter = filter;
|
||||||
|
renderInbounds(lastXrayInboundsData, { force:true, fromControls:true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindXrayTableSortHeaders(table) {
|
||||||
|
table.querySelectorAll("th[data-sort-key]").forEach(header => {
|
||||||
|
const key = header.dataset.sortKey;
|
||||||
|
const selected = key === xrayClientSort.key;
|
||||||
|
header.classList.toggle("sort-asc", selected && xrayClientSort.dir === "asc");
|
||||||
|
header.classList.toggle("sort-desc", selected && xrayClientSort.dir === "desc");
|
||||||
|
header.setAttribute("aria-sort", selected ? (xrayClientSort.dir === "asc" ? "ascending" : "descending") : "none");
|
||||||
|
header.addEventListener("click", () => setXrayClientSort(key));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderXrayListControls();
|
||||||
|
|
||||||
|
|
||||||
async function loadXrayStatus() {
|
async function loadXrayStatus() {
|
||||||
if (xrayChip) {
|
if (xrayChip) {
|
||||||
@@ -167,25 +295,33 @@ async function copyText(text) {
|
|||||||
|
|
||||||
function renderInbounds(inbounds, options = {}) {
|
function renderInbounds(inbounds, options = {}) {
|
||||||
const { silent = false, force = false } = options || {};
|
const { silent = false, force = false } = options || {};
|
||||||
updateDashboardXray(inbounds);
|
lastXrayInboundsData = Array.isArray(inbounds) ? inbounds : [];
|
||||||
syncXrayCreatorInbounds(inbounds);
|
updateDashboardXray(lastXrayInboundsData);
|
||||||
const nextStructure = inboundStructure(inbounds);
|
syncXrayCreatorInbounds(lastXrayInboundsData);
|
||||||
|
const listInbounds = prepareXrayInboundsForList(lastXrayInboundsData);
|
||||||
|
const nextStructure = inboundStructure(listInbounds);
|
||||||
|
renderXrayListControls();
|
||||||
|
|
||||||
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return;
|
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(listInbounds)) return;
|
||||||
if (silent && !force && isXrayClientEditorActive()) {
|
if (silent && !force && isXrayClientEditorActive()) {
|
||||||
patchRenderedInbounds(inbounds);
|
patchRenderedInbounds(listInbounds);
|
||||||
if (xStatus) xStatus.textContent = t("New client data is available; editing was preserved.");
|
if (xStatus) xStatus.textContent = t("New client data is available; editing was preserved.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inbounds.length) {
|
if (!lastXrayInboundsData.length) {
|
||||||
inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("No VLESS/VMess/Trojan inbounds found.")}</div>`;
|
inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("No VLESS/VMess/Trojan inbounds found.")}</div>`;
|
||||||
lastInboundsStructure = nextStructure;
|
lastInboundsStructure = nextStructure;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!listInbounds.length) {
|
||||||
|
inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("No Xray users match this filter.")}</div>`;
|
||||||
|
lastInboundsStructure = nextStructure;
|
||||||
|
return;
|
||||||
|
}
|
||||||
inboundsContainer.innerHTML = "";
|
inboundsContainer.innerHTML = "";
|
||||||
lastInboundsStructure = nextStructure;
|
lastInboundsStructure = nextStructure;
|
||||||
inbounds.forEach(ib => {
|
listInbounds.forEach(ib => {
|
||||||
const section = document.createElement("div");
|
const section = document.createElement("div");
|
||||||
section.dataset.inboundTag = String(ib.tag || "");
|
section.dataset.inboundTag = String(ib.tag || "");
|
||||||
section.dataset.inboundProtocol = String(ib.protocol || "");
|
section.dataset.inboundProtocol = String(ib.protocol || "");
|
||||||
@@ -219,21 +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>${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th>${t("Expiry")}</th><th>${t("Status")}</th><th>${t("Online")}</th><th>${t("Traffic")}</th><th>${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="traffic" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
|
<td data-cell="connections" data-label="${escapeHTML(t("Conn"))}" style="font-size:.7rem;">${escapeHTML(c.active_connections || 0)}</td>
|
||||||
<td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</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="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";
|
||||||
@@ -260,6 +402,7 @@ function renderInbounds(inbounds, options = {}) {
|
|||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
tbl.appendChild(tbody);
|
tbl.appendChild(tbody);
|
||||||
|
bindXrayTableSortHeaders(tbl);
|
||||||
tblWrap.appendChild(tbl);
|
tblWrap.appendChild(tbl);
|
||||||
}
|
}
|
||||||
section.appendChild(tblWrap);
|
section.appendChild(tblWrap);
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ async function loadManagedServerConfig(id) {
|
|||||||
toggleManagedDnsttFields(hasDnstt);
|
toggleManagedDnsttFields(hasDnstt);
|
||||||
const d = c.dnstt || {};
|
const d = c.dnstt || {};
|
||||||
document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d);
|
document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d);
|
||||||
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "";
|
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "0.0.0.0:5300";
|
||||||
document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||||
document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||||
document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||||
@@ -494,7 +494,7 @@ function managedConfigFromForm() {
|
|||||||
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
|
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
|
||||||
domain: dnsttDomains[0] || "",
|
domain: dnsttDomains[0] || "",
|
||||||
domains: dnsttDomains,
|
domains: dnsttDomains,
|
||||||
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(),
|
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim() || "0.0.0.0:5300",
|
||||||
fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked,
|
fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked,
|
||||||
fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(),
|
fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(),
|
||||||
fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(),
|
fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(),
|
||||||
|
|||||||
@@ -29,24 +29,24 @@ function toggleUdpgwFields(on) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Only operator-safe knobs remain. Transport buffers (HTTP/2 flow control, XHTTP
|
// Only operator-safe knobs remain. Global transport/XHTTP count admission is
|
||||||
// reorder buffer, mux/UDP buffers) are fixed to xray-core defaults in the backend
|
// fixed to unlimited; byte backpressure and protocol buffers stay internal so a
|
||||||
// and are no longer exposed here, so they cannot be misconfigured.
|
// panel value cannot turn normal VPN traffic into HTTP overload responses.
|
||||||
const XRAY_NATIVE_TUNING_DEFAULTS = {
|
const XRAY_NATIVE_TUNING_DEFAULTS = {
|
||||||
safe: {
|
safe: {
|
||||||
runtime_gomaxprocs: 0,
|
runtime_gomaxprocs: 0,
|
||||||
mux_global_sessions: 32768,
|
mux_global_sessions: 32768,
|
||||||
max_concurrent_connections: 32768,
|
max_concurrent_connections: -1,
|
||||||
max_concurrent_xhttp_requests: -1,
|
max_concurrent_xhttp_requests: -1,
|
||||||
xhttp_max_sessions: 32768,
|
xhttp_max_sessions: -1,
|
||||||
trace_packets: false,
|
trace_packets: false,
|
||||||
},
|
},
|
||||||
"high": {
|
"high": {
|
||||||
runtime_gomaxprocs: 0,
|
runtime_gomaxprocs: 0,
|
||||||
mux_global_sessions: 65536,
|
mux_global_sessions: 65536,
|
||||||
max_concurrent_connections: 65536,
|
max_concurrent_connections: -1,
|
||||||
max_concurrent_xhttp_requests: -1,
|
max_concurrent_xhttp_requests: -1,
|
||||||
xhttp_max_sessions: 65536,
|
xhttp_max_sessions: -1,
|
||||||
trace_packets: false,
|
trace_packets: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -54,9 +54,6 @@ const XRAY_NATIVE_TUNING_DEFAULTS = {
|
|||||||
const XRAY_NATIVE_TUNING_FIELDS = {
|
const XRAY_NATIVE_TUNING_FIELDS = {
|
||||||
runtime_gomaxprocs: "cfgXrayRuntimeGomaxprocs",
|
runtime_gomaxprocs: "cfgXrayRuntimeGomaxprocs",
|
||||||
mux_global_sessions: "cfgXrayMuxGlobalSessions",
|
mux_global_sessions: "cfgXrayMuxGlobalSessions",
|
||||||
max_concurrent_connections: "cfgXrayMaxConnections",
|
|
||||||
max_concurrent_xhttp_requests: "cfgXrayMaxXHTTPRequests",
|
|
||||||
xhttp_max_sessions: "cfgXrayMaxXHTTPSessions",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function setXrayNativeTuningDefaults(profile = "high") {
|
function setXrayNativeTuningDefaults(profile = "high") {
|
||||||
@@ -80,6 +77,12 @@ function readXrayNativeTuning() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
out[key] = parseInt(el?.value || "0", 10) || 0;
|
out[key] = parseInt(el?.value || "0", 10) || 0;
|
||||||
});
|
});
|
||||||
|
// These legacy JSON keys are intentionally fixed at unlimited. Keeping them
|
||||||
|
// in saved configs makes upgrades/downgrades explicit without exposing web
|
||||||
|
// request ceilings that do not belong on a VPN transport.
|
||||||
|
out.max_concurrent_connections = -1;
|
||||||
|
out.max_concurrent_xhttp_requests = -1;
|
||||||
|
out.xhttp_max_sessions = -1;
|
||||||
out.trace_packets = !!document.getElementById("cfgXrayTracePackets")?.checked;
|
out.trace_packets = !!document.getElementById("cfgXrayTracePackets")?.checked;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -119,7 +122,7 @@ async function loadServerConfig() {
|
|||||||
toggleDnsttFields(hasDnstt);
|
toggleDnsttFields(hasDnstt);
|
||||||
const d = c.dnstt || {};
|
const d = c.dnstt || {};
|
||||||
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
|
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
|
||||||
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
|
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "0.0.0.0:5300";
|
||||||
document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||||
document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||||
document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||||
@@ -155,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 || {};
|
||||||
@@ -198,7 +202,7 @@ async function saveServerConfig() {
|
|||||||
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
|
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
|
||||||
domain: dnsttDomains[0] || "",
|
domain: dnsttDomains[0] || "",
|
||||||
domains: dnsttDomains,
|
domains: dnsttDomains,
|
||||||
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
|
udp_listen: document.getElementById("cfgDnsttUDP").value.trim() || "0.0.0.0:5300",
|
||||||
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked,
|
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked,
|
||||||
fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(),
|
fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(),
|
||||||
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(),
|
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(),
|
||||||
@@ -285,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");
|
||||||
@@ -394,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;
|
||||||
@@ -412,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;
|
||||||
|
|||||||
+165
-138
@@ -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,149 +543,159 @@ 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…";
|
||||||
|
}
|
||||||
|
if (status) status.textContent = `Gerando certificado example.com em ${target}…`;
|
||||||
|
try {
|
||||||
|
const certResponse = await api(withServerParam("/api/tls/generate-selfsigned", selectedID), {
|
||||||
|
method:"POST",
|
||||||
|
body:JSON.stringify({ domain:"example.com" }),
|
||||||
|
});
|
||||||
|
if (!certResponse.ok) throw new Error(await certResponse.text());
|
||||||
|
const cert = await certResponse.json();
|
||||||
|
if (!cert?.cert_file || !cert?.key_file) throw new Error("o servidor não retornou os caminhos do certificado");
|
||||||
|
|
||||||
|
wzInbounds = buildAzionPresetInbounds(cert.cert_file, cert.key_file);
|
||||||
|
validateVisualInbounds(wzInbounds);
|
||||||
|
presetApplied = true;
|
||||||
|
wzDirty = true;
|
||||||
|
wzCancelInbound();
|
||||||
|
renderWzInbounds();
|
||||||
|
if (status) status.textContent = "Certificado criado. Salvando configuração e reiniciando o Xray…";
|
||||||
|
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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
delete stream.realitySettings;
|
} catch (error) {
|
||||||
return stream;
|
if (presetApplied && !configSaved) {
|
||||||
};
|
wzInbounds = previousInbounds;
|
||||||
const previousClients = Array.isArray(existingProxy?.settings?.clients) ? cloneJsonSafe(existingProxy.settings.clients) : [];
|
wzDirty = previousDirty;
|
||||||
const proxyInbound = cloneJsonSafe(existingProxy || {});
|
renderWzInbounds();
|
||||||
proxyInbound.tag = existingProxy?.tag || "shared-proxy-xhttp";
|
}
|
||||||
proxyInbound.listen = listen;
|
if (error.message === "auth") doAuthError();
|
||||||
proxyInbound.port = port;
|
else if (status) status.textContent = "Erro ao criar padrão Azion: " + error.message;
|
||||||
proxyInbound.protocol = protocol;
|
} finally {
|
||||||
proxyInbound.settings = existingProxy?.protocol === protocol ? cloneJsonSafe(existingProxy.settings || {}) : {};
|
if (button) {
|
||||||
proxyInbound.settings.clients = previousClients;
|
button.disabled = false;
|
||||||
if (protocol === "vless") proxyInbound.settings.decryption = "none";
|
button.textContent = "Criar padrão Azion XHTTP";
|
||||||
else delete proxyInbound.settings.decryption;
|
}
|
||||||
proxyInbound.streamSettings = buildSharedStream(existingProxy, "/");
|
}
|
||||||
const sshInbound = cloneJsonSafe(existingSSH || {});
|
|
||||||
sshInbound.tag = existingSSH?.tag || "shared-ssh-xhttp";
|
|
||||||
sshInbound.listen = listen;
|
|
||||||
sshInbound.port = port;
|
|
||||||
sshInbound.protocol = "ssh";
|
|
||||||
sshInbound.settings = {};
|
|
||||||
sshInbound.streamSettings = buildSharedStream(existingSSH, "/ssh");
|
|
||||||
wzInbounds = [...others, proxyInbound, sshInbound];
|
|
||||||
wzDirty = true;
|
|
||||||
renderWzInbounds();
|
|
||||||
loadSharedEndpointForm();
|
|
||||||
status.textContent = "Endpoint atualizado no rascunho. Clique em Salvar configuração e reiniciar para aplicar.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("sharedXHTTPProtocol")?.addEventListener("change", updateSharedEndpointControls);
|
|
||||||
document.getElementById("sharedXHTTPSecurity")?.addEventListener("change", updateSharedEndpointControls);
|
|
||||||
document.getElementById("sharedXHTTPApplyBtn")?.addEventListener("click", applySharedXHTTPEndpoint);
|
|
||||||
updateSharedEndpointControls();
|
|
||||||
|
|
||||||
function onWzProtoChange(val) {
|
function onWzProtoChange(val) {
|
||||||
const isSSH = val === "ssh";
|
const isSSH = val === "ssh";
|
||||||
// SSH tunnels reuse the VLESS/VMess transport block to expose the XHTTP
|
// SSH tunnels reuse the VLESS/VMess transport block to expose the XHTTP
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-38
@@ -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=20260714pamfix1"/>
|
<link rel="stylesheet" href="assets/app.css?v=20260724xrayinboundsr3"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
@@ -269,11 +269,22 @@
|
|||||||
<button class="btn btn-ghost btn-sm" id="reloadUsersBtn">Reload</button>
|
<button class="btn btn-ghost btn-sm" id="reloadUsersBtn">Reload</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="user-list-controls" id="sshListControls" aria-label="SSH user list controls">
|
||||||
|
<div class="user-list-control-group">
|
||||||
|
<span class="user-list-control-label" id="sshSortLabel"></span>
|
||||||
|
<div class="user-list-buttons" id="sshSortButtons"></div>
|
||||||
|
</div>
|
||||||
|
<div class="user-list-control-group">
|
||||||
|
<span class="user-list-control-label" id="sshFilterLabel"></span>
|
||||||
|
<div class="user-list-buttons" id="sshFilterButtons"></div>
|
||||||
|
</div>
|
||||||
|
<span class="chip user-list-count" id="sshListCount"></span>
|
||||||
|
</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>
|
||||||
@@ -420,6 +431,17 @@
|
|||||||
<div class="card-title">Inbounds & Clients</div>
|
<div class="card-title">Inbounds & Clients</div>
|
||||||
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div>
|
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="user-list-controls" id="xrayListControls" aria-label="Xray user list controls">
|
||||||
|
<div class="user-list-control-group">
|
||||||
|
<span class="user-list-control-label" id="xraySortLabel"></span>
|
||||||
|
<div class="user-list-buttons" id="xraySortButtons"></div>
|
||||||
|
</div>
|
||||||
|
<div class="user-list-control-group">
|
||||||
|
<span class="user-list-control-label" id="xrayFilterLabel"></span>
|
||||||
|
<div class="user-list-buttons" id="xrayFilterButtons"></div>
|
||||||
|
</div>
|
||||||
|
<span class="chip user-list-count" id="xrayListCount"></span>
|
||||||
|
</div>
|
||||||
<div id="inboundsContainer">
|
<div id="inboundsContainer">
|
||||||
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,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>
|
||||||
@@ -503,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>
|
||||||
@@ -845,7 +865,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
|
<div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
|
||||||
<div class="field" style="grid-column:1/-1"><label>NS / Root Domains <span class="hint">one per line</span></label><textarea id="managedCfgDnsttDomains" rows="3" placeholder="t.example.com t.local.lan"></textarea></div>
|
<div class="field" style="grid-column:1/-1"><label>NS / Root Domains <span class="hint">one per line</span></label><textarea id="managedCfgDnsttDomains" rows="3" placeholder="t.example.com t.local.lan"></textarea></div>
|
||||||
<div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="[::]:5300"/></div>
|
<div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="0.0.0.0:5300"/></div>
|
||||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS</label>
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS</label>
|
||||||
<div class="field"><label>Local DNS Listen <span class="hint">IPv6 ok</span></label><input type="text" id="managedCfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/></div>
|
<div class="field"><label>Local DNS Listen <span class="hint">IPv6 ok</span></label><input type="text" id="managedCfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/></div>
|
||||||
<div class="field"><label>Local DNS Domain</label><input type="text" id="managedCfgDnsttFakeDomain" placeholder="t.local.lan"/></div>
|
<div class="field"><label>Local DNS Domain</label><input type="text" id="managedCfgDnsttFakeDomain" placeholder="t.local.lan"/></div>
|
||||||
@@ -1322,7 +1342,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>UDP Listen</label>
|
<label>UDP Listen</label>
|
||||||
<input type="text" id="cfgDnsttUDP" placeholder="[::]:5300"/>
|
<input type="text" id="cfgDnsttUDP" placeholder="0.0.0.0:5300"/>
|
||||||
</div>
|
</div>
|
||||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
||||||
<input type="checkbox" id="cfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS
|
<input type="checkbox" id="cfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS
|
||||||
@@ -1439,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">
|
||||||
@@ -1510,15 +1556,13 @@
|
|||||||
<div class="grid2" style="margin-top:10px;gap:8px;">
|
<div class="grid2" style="margin-top:10px;gap:8px;">
|
||||||
<div class="field"><label>Go CPU threads (GOMAXPROCS)</label><input type="number" min="0" id="cfgXrayRuntimeGomaxprocs" placeholder="0 = all CPU cores"/></div>
|
<div class="field"><label>Go CPU threads (GOMAXPROCS)</label><input type="number" min="0" id="cfgXrayRuntimeGomaxprocs" placeholder="0 = all CPU cores"/></div>
|
||||||
<div class="field"><label>Global mux backend sessions</label><input type="number" min="1" id="cfgXrayMuxGlobalSessions" placeholder="32768"/></div>
|
<div class="field"><label>Global mux backend sessions</label><input type="number" min="1" id="cfgXrayMuxGlobalSessions" placeholder="32768"/></div>
|
||||||
<div class="field"><label>Global transport connections <span class="hint">0=32768, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxConnections" placeholder="32768"/></div>
|
<div class="field" style="grid-column:1/-1;"><label>Transport and XHTTP admission</label><div class="hint">Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.</div></div>
|
||||||
<div class="field"><label>XHTTP web request cap <span class="hint">disabled for VPN traffic</span></label><input type="number" min="-1" id="cfgXrayMaxXHTTPRequests" value="-1" readonly/></div>
|
|
||||||
<div class="field"><label>Active XHTTP sessions <span class="hint">0=32768, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxXHTTPSessions" placeholder="32768"/></div>
|
|
||||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="cfgXrayTracePackets"/> Trace every XHTTP/mux packet <span class="hint">debug only, slows QUIC</span></label>
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="cfgXrayTracePackets"/> Trace every XHTTP/mux packet <span class="hint">debug only, slows QUIC</span></label>
|
||||||
<div class="card-actions" style="grid-column:1/-1;">
|
<div class="card-actions" style="grid-column:1/-1;">
|
||||||
<button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('high')">Apply high-traffic VPN defaults</button>
|
<button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('high')">Apply high-traffic VPN defaults</button>
|
||||||
<button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('safe')">Apply safe defaults</button>
|
<button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('safe')">Apply safe defaults</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">XHTTP is handled as VPN tunnel traffic: packet requests use bounded backpressure and are never rejected by an HTTP request-rate ceiling. At the transport ceiling, new sockets wait in the kernel backlog instead of being reset. Keep the safe defaults unless the server is sized and load-tested for the high-traffic profile. HTTP/2 retains a 1024-stream flow-control guard per connection, while upload memory stays globally bounded. Saved in the panel config and applied live on restart/reload.</div>
|
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">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.</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -1558,15 +1602,15 @@
|
|||||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||||
classic scripts sharing one global scope; `defer` preserves execution order,
|
classic scripts sharing one global scope; `defer` preserves execution order,
|
||||||
so behavior is identical to the old single file. Keep this load order. -->
|
so behavior is identical to the old single file. Keep this load order. -->
|
||||||
<script defer src="assets/js/01-core.js?v=20260719xhttp502fix1"></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=20260719quotaaudit1"></script>
|
<script defer src="assets/js/03-ssh-users.js?v=20260720sshfilters1"></script>
|
||||||
<script defer src="assets/js/04-xray.js?v=20260715quotareset1"></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=20260719xhttp502fix1"></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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-4
@@ -11,7 +11,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultMainListen = "0.0.0.0:80"
|
defaultMainListen = "0.0.0.0:80"
|
||||||
defaultExtraListen = "0.0.0.0:8080"
|
defaultExtraListen = "0.0.0.0:8080"
|
||||||
defaultDNSTTListen = "[::]:5300"
|
defaultDNSTTListen = "0.0.0.0:5300"
|
||||||
defaultUDPGWListen = "0.0.0.0:7400"
|
defaultUDPGWListen = "0.0.0.0:7400"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,9 +109,10 @@ func normalizeRuntimePorts(cfg *Config) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen)
|
var migratedLegacyDNSTTWildcard bool
|
||||||
if cfg.DNSTT.UDPListen == "" {
|
cfg.DNSTT.UDPListen, migratedLegacyDNSTTWildcard = normalizeDNSTTListenDefault(cfg.DNSTT.UDPListen)
|
||||||
cfg.DNSTT.UDPListen = defaultDNSTTListen
|
if migratedLegacyDNSTTWildcard {
|
||||||
|
warn("DNSTT legacy default [::]:5300 is IPv6-only; using IPv4 default %s", cfg.DNSTT.UDPListen)
|
||||||
}
|
}
|
||||||
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err != nil {
|
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err != nil {
|
||||||
old := cfg.DNSTT.UDPListen
|
old := cfg.DNSTT.UDPListen
|
||||||
@@ -295,6 +296,29 @@ func normalizeDNSTTDomainList(primary string, domains []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeDNSTTListenDefault keeps explicit IPv4 and concrete IPv6 listeners,
|
||||||
|
// but migrates the old wildcard IPv6 default. listenDNSTTPacket deliberately
|
||||||
|
// opens IPv6 addresses with udp6, so [::]:5300 never receives IPv4 queries.
|
||||||
|
// Existing installations commonly inherited that value from the old default;
|
||||||
|
// moving only that wildcard/default-port combination makes them work after an
|
||||||
|
// update without changing intentionally selected IPv6 interface addresses.
|
||||||
|
func normalizeDNSTTListenDefault(addr string) (string, bool) {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return defaultDNSTTListen, false
|
||||||
|
}
|
||||||
|
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil || port != "5300" {
|
||||||
|
return addr, false
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||||
|
if ip != nil && ip.To4() == nil && ip.IsUnspecified() {
|
||||||
|
return defaultDNSTTListen, true
|
||||||
|
}
|
||||||
|
return addr, false
|
||||||
|
}
|
||||||
|
|
||||||
func udpAddrAvailableForDNSTT(addr string) error {
|
func udpAddrAvailableForDNSTT(addr string) error {
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeDNSTTListenDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
migrated bool
|
||||||
|
}{
|
||||||
|
{name: "empty uses IPv4 default", input: "", want: "0.0.0.0:5300"},
|
||||||
|
{name: "whitespace uses IPv4 default", input: " ", want: "0.0.0.0:5300"},
|
||||||
|
{name: "legacy IPv6 wildcard migrates", input: "[::]:5300", want: "0.0.0.0:5300", migrated: true},
|
||||||
|
{name: "expanded legacy wildcard migrates", input: "[0:0:0:0:0:0:0:0]:5300", want: "0.0.0.0:5300", migrated: true},
|
||||||
|
{name: "explicit IPv4 remains", input: "192.0.2.10:53", want: "192.0.2.10:53"},
|
||||||
|
{name: "IPv4 wildcard remains", input: "0.0.0.0:5300", want: "0.0.0.0:5300"},
|
||||||
|
{name: "concrete IPv6 remains", input: "[2001:db8::10]:53", want: "[2001:db8::10]:53"},
|
||||||
|
{name: "IPv6 wildcard on custom port remains", input: "[::]:5301", want: "[::]:5301"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, migrated := normalizeDNSTTListenDefault(tt.input)
|
||||||
|
if got != tt.want || migrated != tt.migrated {
|
||||||
|
t.Fatalf("normalizeDNSTTListenDefault(%q) = (%q, %v), want (%q, %v)", tt.input, got, migrated, tt.want, tt.migrated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,17 @@ func TestSSHIdleTimeoutExplicitValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNativeXHTTPConnectedIdleSweepDisabled(t *testing.T) {
|
// The connected-session sweeper is the backstop that reaps XHTTP->SSH sessions
|
||||||
if got := nativeXHTTPIdleTimeout(); got != 0 {
|
// whose stream-down GET context never fires (silent client drop behind a CDN).
|
||||||
t.Fatalf("native XHTTP idle timeout = %s, want disabled", got)
|
// Without it those sessions leak fds/goroutines until a process restart, which
|
||||||
|
// is what produced the recurring reboot-only XHTTP 502s. It must stay enabled;
|
||||||
|
// the window is generous so only zero-traffic (dead) sessions are reaped.
|
||||||
|
func TestNativeXHTTPConnectedIdleSweepEnabled(t *testing.T) {
|
||||||
|
got := nativeXHTTPIdleTimeout()
|
||||||
|
if got <= 0 {
|
||||||
|
t.Fatalf("native XHTTP idle timeout = %s, want a positive backstop window", got)
|
||||||
|
}
|
||||||
|
if got != 20*time.Minute {
|
||||||
|
t.Fatalf("native XHTTP idle timeout = %s, want 20m backstop", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -595,11 +595,8 @@ func startDNSTTInstance(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
udpListen := cfg.UDPListen
|
udpListen, _ := normalizeDNSTTListenDefault(cfg.UDPListen)
|
||||||
if udpListen == "" {
|
cfg.UDPListen = udpListen
|
||||||
udpListen = defaultDNSTTListen
|
|
||||||
cfg.UDPListen = udpListen
|
|
||||||
}
|
|
||||||
|
|
||||||
fakeDomains := domains
|
fakeDomains := domains
|
||||||
if cfg.FakeDNSEnabled {
|
if cfg.FakeDNSEnabled {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -674,7 +674,7 @@ ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
User=root
|
User=root
|
||||||
LimitNOFILE=65536
|
LimitNOFILE=1048576
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
@@ -1784,28 +1786,32 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
|||||||
|
|
||||||
// UserDTO is returned by the admin API for listing.
|
// UserDTO is returned by the admin API for listing.
|
||||||
type UserDTO struct {
|
type UserDTO struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ActiveConns int `json:"active_conns"`
|
ActiveConns int `json:"active_conns"`
|
||||||
MaxConnections int `json:"max_connections"`
|
MaxConnections int `json:"max_connections"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
LimitUpMbps int `json:"limit_mbps_up"`
|
LimitUpMbps int `json:"limit_mbps_up"`
|
||||||
LimitDownMbps int `json:"limit_mbps_down"`
|
LimitDownMbps int `json:"limit_mbps_down"`
|
||||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||||
QuotaAction string `json:"quota_action"`
|
QuotaAction string `json:"quota_action"`
|
||||||
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
||||||
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"`
|
||||||
QuotaExceeded bool `json:"quota_exceeded"`
|
// Live account-wide speed in bytes per second, summed across every
|
||||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
// connection the user has open.
|
||||||
TOTPPeriod int `json:"totp_period"`
|
UpBytesPerSec float64 `json:"up_bytes_per_sec"`
|
||||||
TOTPWindow int `json:"totp_window"`
|
DownBytesPerSec float64 `json:"down_bytes_per_sec"`
|
||||||
TOTPDigits int `json:"totp_digits"`
|
QuotaExceeded bool `json:"quota_exceeded"`
|
||||||
AllowStaticPassword bool `json:"allow_static_password"`
|
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||||
UsePAM bool `json:"use_pam"`
|
TOTPPeriod int `json:"totp_period"`
|
||||||
TOTPEnabled bool `json:"totp_enabled"`
|
TOTPWindow int `json:"totp_window"`
|
||||||
OwnerUsername string `json:"owner_username,omitempty"`
|
TOTPDigits int `json:"totp_digits"`
|
||||||
ServerID string `json:"server_id,omitempty"`
|
AllowStaticPassword bool `json:"allow_static_password"`
|
||||||
|
UsePAM bool `json:"use_pam"`
|
||||||
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
|
OwnerUsername string `json:"owner_username,omitempty"`
|
||||||
|
ServerID string `json:"server_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -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"
|
||||||
|
|||||||
+253
-51
@@ -7,6 +7,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -217,11 +218,7 @@ func TestTrackedNativeConnectionsAreClosedOnShutdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloseAllXHTTPSessionsReleasesGlobalSlots(t *testing.T) {
|
func TestXHTTPSessionsIgnoreLegacyGlobalCapAndReleaseCounters(t *testing.T) {
|
||||||
oldLimit := nativeTuneXHTTPMaxSessions.Load()
|
|
||||||
nativeTuneXHTTPMaxSessions.Store(8)
|
|
||||||
defer nativeTuneXHTTPMaxSessions.Store(oldLimit)
|
|
||||||
|
|
||||||
before := nativeXHTTPSessions.Load()
|
before := nativeXHTTPSessions.Load()
|
||||||
ib := &nativeInbound{xhttpMaxBufferedPosts: 2}
|
ib := &nativeInbound{xhttpMaxBufferedPosts: 2}
|
||||||
for _, id := range []string{"one", "two"} {
|
for _, id := range []string{"one", "two"} {
|
||||||
@@ -244,29 +241,21 @@ func TestCloseAllXHTTPSessionsReleasesGlobalSlots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNegativeXHTTPSessionLimitMeansUnlimited(t *testing.T) {
|
func TestXHTTPSessionSafetyWindowIsAboveProductionScale(t *testing.T) {
|
||||||
old := nativeTuneXHTTPMaxSessions.Load()
|
if got := (&nativeInbound{}).xhttpMaxActiveSessions(); got != fixedNativeMaxXHTTPSessions {
|
||||||
nativeTuneXHTTPMaxSessions.Store(0)
|
t.Fatalf("XHTTP session safety window = %d, want %d", got, fixedNativeMaxXHTTPSessions)
|
||||||
defer nativeTuneXHTTPMaxSessions.Store(old)
|
}
|
||||||
if got := (&nativeInbound{}).xhttpMaxActiveSessions(); got != 0 {
|
if got := nativeXHTTPSessionLimit(); got < 8_000 {
|
||||||
t.Fatalf("unlimited XHTTP session limit normalized to %d", got)
|
t.Fatalf("XHTTP session safety window = %d, want room for at least 8K users", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNativeProtocolGuardsRemainFinite(t *testing.T) {
|
func TestNativeHTTP2StreamsUseFiniteTransportBackpressure(t *testing.T) {
|
||||||
oldRequests := nativeTuneMaxXHTTPRequests.Load()
|
if got := nativeHTTP2MaxConcurrentStreams(); got != fixedNativeHTTP2ConcurrentStreams {
|
||||||
defer nativeTuneMaxXHTTPRequests.Store(oldRequests)
|
t.Fatalf("HTTP/2 stream setting = %d, want %d", got, fixedNativeHTTP2ConcurrentStreams)
|
||||||
|
|
||||||
// Zero is the internal representation of an explicitly disabled application
|
|
||||||
// request counter. HTTP/2 must still retain a finite per-connection guard.
|
|
||||||
nativeTuneMaxXHTTPRequests.Store(0)
|
|
||||||
if got := nativeHTTP2MaxConcurrentStreams(); got != defaultNativeHTTP2MaxStreams {
|
|
||||||
t.Fatalf("HTTP/2 stream guard = %d, want %d", got, defaultNativeHTTP2MaxStreams)
|
|
||||||
}
|
}
|
||||||
|
if got := nativeHTTP2MaxConcurrentStreams(); got < 1024 {
|
||||||
nativeTuneMaxXHTTPRequests.Store(32)
|
t.Fatalf("HTTP/2 stream setting = %d, too small for XHTTP packet bursts", got)
|
||||||
if got := nativeHTTP2MaxConcurrentStreams(); got != 32 {
|
|
||||||
t.Fatalf("HTTP/2 stream guard did not honor lower request cap: %d", got)
|
|
||||||
}
|
}
|
||||||
if got := nativeMuxMaxSessionLimit(); got != 64 {
|
if got := nativeMuxMaxSessionLimit(); got != 64 {
|
||||||
t.Fatalf("per-transport Mux session guard = %d, want 64", got)
|
t.Fatalf("per-transport Mux session guard = %d, want 64", got)
|
||||||
@@ -274,15 +263,6 @@ func TestNativeProtocolGuardsRemainFinite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestXHTTPHandlerDoesNotApplyWebRequestCeiling(t *testing.T) {
|
func TestXHTTPHandlerDoesNotApplyWebRequestCeiling(t *testing.T) {
|
||||||
oldLimit := nativeTuneMaxXHTTPRequests.Load()
|
|
||||||
oldActive := nativeXHTTPRequests.Load()
|
|
||||||
nativeTuneMaxXHTTPRequests.Store(1)
|
|
||||||
nativeXHTTPRequests.Store(1)
|
|
||||||
defer func() {
|
|
||||||
nativeTuneMaxXHTTPRequests.Store(oldLimit)
|
|
||||||
nativeXHTTPRequests.Store(oldActive)
|
|
||||||
}()
|
|
||||||
|
|
||||||
ib := &nativeInbound{transport: "xhttp", path: "/"}
|
ib := &nativeInbound{transport: "xhttp", path: "/"}
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -292,18 +272,32 @@ func TestXHTTPHandlerDoesNotApplyWebRequestCeiling(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLegacyXHTTPTuningMigratesToVPNDefaults(t *testing.T) {
|
func TestXHTTPHandlerSafetySlotIsReleased(t *testing.T) {
|
||||||
|
before := nativeXHTTPRequests.Load()
|
||||||
|
ib := &nativeInbound{transport: "xhttp", path: "/"}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ib.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty XHTTP request status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if got := nativeXHTTPRequests.Load(); got != before {
|
||||||
|
t.Fatalf("XHTTP handler counter after return = %d, want %d", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistedXHTTPAdmissionTuningIsAlwaysUnlimited(t *testing.T) {
|
||||||
got := normalizeNativeXrayTuning(&XrayNativeTuning{
|
got := normalizeNativeXrayTuning(&XrayNativeTuning{
|
||||||
MuxGlobalSessions: 8192,
|
MuxGlobalSessions: 8192,
|
||||||
MaxConcurrentConnections: 4096,
|
MaxConcurrentConnections: 4096,
|
||||||
MaxConcurrentXHTTPRequests: 8192,
|
MaxConcurrentXHTTPRequests: 8192,
|
||||||
XHTTPMaxSessions: 4096,
|
XHTTPMaxSessions: 4096,
|
||||||
})
|
})
|
||||||
if got.MuxGlobalSessions != defaultNativeMuxGlobalSessions ||
|
if got.MuxGlobalSessions != 8192 ||
|
||||||
got.MaxConcurrentConnections != defaultNativeMaxConnections ||
|
got.MaxConcurrentConnections != -1 ||
|
||||||
got.MaxConcurrentXHTTPRequests != defaultNativeMaxXHTTPRequests ||
|
got.MaxConcurrentXHTTPRequests != defaultNativeMaxXHTTPRequests ||
|
||||||
got.XHTTPMaxSessions != defaultNativeXHTTPMaxSessions {
|
got.XHTTPMaxSessions != -1 {
|
||||||
t.Fatalf("legacy tuning was not migrated: %+v", got)
|
t.Fatalf("persisted admission limits were not removed: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,20 +319,20 @@ func TestXHTTPMetadataLengthIsBoundedBeforeSessionAllocation(t *testing.T) {
|
|||||||
|
|
||||||
func TestXHTTPUploadMemoryIsReleasedOnReadAndClose(t *testing.T) {
|
func TestXHTTPUploadMemoryIsReleasedOnReadAndClose(t *testing.T) {
|
||||||
before := nativeXHTTPBufferedBytes.Load()
|
before := nativeXHTTPBufferedBytes.Load()
|
||||||
q := newNativeXHTTPUploadQueue(4, 8)
|
q := newNativeXHTTPUploadQueue(4, 512)
|
||||||
|
|
||||||
lease, ok := acquireNativeXHTTPMemory(8)
|
accounted := nativeXHTTPAccountedPacketBytes(4)
|
||||||
|
lease, ok := acquireNativeXHTTPMemory(accounted)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("failed to reserve XHTTP test memory")
|
t.Fatal("failed to reserve XHTTP test memory")
|
||||||
}
|
}
|
||||||
lease.shrink(4)
|
|
||||||
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("test"), Seq: 0}, lease); err != nil {
|
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("test"), Seq: 0}, lease); err != nil {
|
||||||
lease.release()
|
lease.release()
|
||||||
t.Fatalf("queue push failed: %v", err)
|
t.Fatalf("queue push failed: %v", err)
|
||||||
}
|
}
|
||||||
lease.release() // transferred leases are a no-op for the producer.
|
lease.release() // transferred leases are a no-op for the producer.
|
||||||
if got := nativeXHTTPBufferedBytes.Load(); got != before+4 {
|
if got := nativeXHTTPBufferedBytes.Load(); got != before+accounted {
|
||||||
t.Fatalf("buffered bytes after push = %d, want %d", got, before+4)
|
t.Fatalf("buffered bytes after push = %d, want %d", got, before+accounted)
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := make([]byte, 4)
|
buf := make([]byte, 4)
|
||||||
@@ -349,7 +343,7 @@ func TestXHTTPUploadMemoryIsReleasedOnReadAndClose(t *testing.T) {
|
|||||||
t.Fatalf("buffered bytes after read = %d, want %d", got, before)
|
t.Fatalf("buffered bytes after read = %d, want %d", got, before)
|
||||||
}
|
}
|
||||||
|
|
||||||
lease, ok = acquireNativeXHTTPMemory(3)
|
lease, ok = acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(3))
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("failed to reserve second XHTTP test memory")
|
t.Fatal("failed to reserve second XHTTP test memory")
|
||||||
}
|
}
|
||||||
@@ -366,10 +360,10 @@ func TestXHTTPUploadMemoryIsReleasedOnReadAndClose(t *testing.T) {
|
|||||||
|
|
||||||
func TestXHTTPUploadQueueEnforcesPerSessionByteBudget(t *testing.T) {
|
func TestXHTTPUploadQueueEnforcesPerSessionByteBudget(t *testing.T) {
|
||||||
before := nativeXHTTPBufferedBytes.Load()
|
before := nativeXHTTPBufferedBytes.Load()
|
||||||
q := newNativeXHTTPUploadQueue(4, 4)
|
q := newNativeXHTTPUploadQueue(4, nativeXHTTPMinPacketAccountingBytes-1)
|
||||||
defer q.close()
|
defer q.close()
|
||||||
|
|
||||||
lease, ok := acquireNativeXHTTPMemory(5)
|
lease, ok := acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(5))
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("failed to reserve XHTTP test memory")
|
t.Fatal("failed to reserve XHTTP test memory")
|
||||||
}
|
}
|
||||||
@@ -386,10 +380,10 @@ func TestXHTTPUploadQueueEnforcesPerSessionByteBudget(t *testing.T) {
|
|||||||
|
|
||||||
func TestXHTTPUploadQueueBackpressuresInsteadOfRejectingBurst(t *testing.T) {
|
func TestXHTTPUploadQueueBackpressuresInsteadOfRejectingBurst(t *testing.T) {
|
||||||
before := nativeXHTTPBufferedBytes.Load()
|
before := nativeXHTTPBufferedBytes.Load()
|
||||||
q := newNativeXHTTPUploadQueue(2, 4)
|
q := newNativeXHTTPUploadQueue(2, nativeXHTTPMinPacketAccountingBytes)
|
||||||
defer q.close()
|
defer q.close()
|
||||||
|
|
||||||
first, ok := acquireNativeXHTTPMemory(4)
|
first, ok := acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(4))
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("failed to reserve first XHTTP payload")
|
t.Fatal("failed to reserve first XHTTP payload")
|
||||||
}
|
}
|
||||||
@@ -399,7 +393,7 @@ func TestXHTTPUploadQueueBackpressuresInsteadOfRejectingBurst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
first.release()
|
first.release()
|
||||||
|
|
||||||
second, ok := acquireNativeXHTTPMemory(4)
|
second, ok := acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(4))
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("failed to reserve second XHTTP payload")
|
t.Fatal("failed to reserve second XHTTP payload")
|
||||||
}
|
}
|
||||||
@@ -438,11 +432,124 @@ func TestXHTTPUploadQueueBackpressuresInsteadOfRejectingBurst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestXHTTPGlobalMemoryBackpressureWakesWaitersFIFO(t *testing.T) {
|
||||||
|
before := nativeXHTTPBufferedBytes.Load()
|
||||||
|
fillBytes := nativeXHTTPMaxBufferedGlobalBytes - before
|
||||||
|
filler, ok := acquireNativeXHTTPMemory(fillBytes)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("failed to fill XHTTP memory budget for waiter test")
|
||||||
|
}
|
||||||
|
defer filler.release()
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
lease *nativeXHTTPMemoryLease
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
startWaiter := func() <-chan result {
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
lease, err := acquireNativeXHTTPMemoryContext(context.Background(), nativeXHTTPMinPacketAccountingBytes)
|
||||||
|
done <- result{lease: lease, err: err}
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
waitForWaiters := func(want int) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
nativeXHTTPMemoryWait.Lock()
|
||||||
|
got := nativeXHTTPMemoryWait.queued
|
||||||
|
nativeXHTTPMemoryWait.Unlock()
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("memory waiters = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstDone := startWaiter()
|
||||||
|
waitForWaiters(1)
|
||||||
|
secondDone := startWaiter()
|
||||||
|
waitForWaiters(2)
|
||||||
|
|
||||||
|
filler.shrink(fillBytes - nativeXHTTPMinPacketAccountingBytes)
|
||||||
|
first := <-firstDone
|
||||||
|
if first.err != nil || first.lease == nil {
|
||||||
|
t.Fatalf("first memory waiter = (%v, %v)", first.lease, first.err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case second := <-secondDone:
|
||||||
|
if second.lease != nil {
|
||||||
|
second.lease.release()
|
||||||
|
}
|
||||||
|
t.Fatalf("second waiter woke before FIFO capacity was released: %v", second.err)
|
||||||
|
case <-time.After(25 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
first.lease.release()
|
||||||
|
select {
|
||||||
|
case second := <-secondDone:
|
||||||
|
if second.err != nil || second.lease == nil {
|
||||||
|
t.Fatalf("second memory waiter = (%v, %v)", second.lease, second.err)
|
||||||
|
}
|
||||||
|
second.lease.release()
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("second memory waiter did not wake after first released")
|
||||||
|
}
|
||||||
|
|
||||||
|
filler.release()
|
||||||
|
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||||
|
t.Fatalf("FIFO waiter test leaked %d buffered bytes (baseline %d)", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXHTTPReassemblyHasNoPacketRequestCountCeiling(t *testing.T) {
|
||||||
|
before := nativeXHTTPBufferedBytes.Load()
|
||||||
|
q := newNativeXHTTPUploadQueue(1, 4*nativeXHTTPMinPacketAccountingBytes)
|
||||||
|
defer q.close()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
for _, seq := range []uint64{3, 2, 1, 0} {
|
||||||
|
lease, ok := acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(1))
|
||||||
|
if !ok {
|
||||||
|
done <- errors.New("could not reserve packet memory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte{byte('a' + seq)}, Seq: seq}, lease)
|
||||||
|
lease.release()
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for want := byte('a'); want <= byte('d'); want++ {
|
||||||
|
n, err := q.Read(buf)
|
||||||
|
if err != nil || n != 1 || buf[0] != want {
|
||||||
|
t.Fatalf("reassembled packet = (%d, %v, %q), want %q", n, err, buf[:n], []byte{want})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatalf("out-of-order burst was rejected: %v", err)
|
||||||
|
}
|
||||||
|
q.close()
|
||||||
|
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||||
|
t.Fatalf("reassembly test leaked %d buffered bytes (baseline %d)", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestXHTTPBodyReservationUsesActualContentLength(t *testing.T) {
|
func TestXHTTPBodyReservationUsesActualContentLength(t *testing.T) {
|
||||||
ib := &nativeInbound{xhttpMaxEachPostBytes: 1_000_000}
|
ib := &nativeInbound{xhttpMaxEachPostBytes: 1_000_000}
|
||||||
req := httptest.NewRequest(http.MethodPost, "/session/0", strings.NewReader("small"))
|
req := httptest.NewRequest(http.MethodPost, "/session/0", strings.NewReader("small"))
|
||||||
if got := ib.xhttpUploadReservationBytes(req); got != 5 {
|
if got := ib.xhttpUploadReservationBytes(req); got != nativeXHTTPMinPacketAccountingBytes {
|
||||||
t.Fatalf("body reservation = %d, want actual payload length 5", got)
|
t.Fatalf("body reservation = %d, want minimum accounted packet size", got)
|
||||||
}
|
}
|
||||||
req.ContentLength = -1
|
req.ContentLength = -1
|
||||||
if got := ib.xhttpUploadReservationBytes(req); got != 1_000_000 {
|
if got := ib.xhttpUploadReservationBytes(req); got != 1_000_000 {
|
||||||
@@ -666,3 +773,98 @@ func TestNativeXHTTPQueueCloseClosesQueuedStreamReader(t *testing.T) {
|
|||||||
t.Fatal("queued stream reader was not closed during queue shutdown")
|
t.Fatal("queued stream reader was not closed during queue shutdown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNativeXHTTPQueueSkipsEmptyPacketsWithoutZeroProgressRead(t *testing.T) {
|
||||||
|
before := nativeXHTTPBufferedBytes.Load()
|
||||||
|
q := newNativeXHTTPUploadQueue(2, 2*nativeXHTTPMinPacketAccountingBytes)
|
||||||
|
defer q.close()
|
||||||
|
|
||||||
|
for seq, payload := range [][]byte{nil, []byte("x")} {
|
||||||
|
accounted := nativeXHTTPAccountedPacketBytes(int64(len(payload)))
|
||||||
|
lease, ok := acquireNativeXHTTPMemory(accounted)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("failed to reserve packet memory")
|
||||||
|
}
|
||||||
|
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: payload, Seq: uint64(seq)}, lease); err != nil {
|
||||||
|
lease.release()
|
||||||
|
t.Fatalf("queue packet %d: %v", seq, err)
|
||||||
|
}
|
||||||
|
lease.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
n, err := q.Read(buf)
|
||||||
|
if err != nil || n != 1 || string(buf[:n]) != "x" {
|
||||||
|
t.Fatalf("queue read after empty packet = (%d, %v, %q), want (1, nil, x)", n, err, buf[:n])
|
||||||
|
}
|
||||||
|
q.close()
|
||||||
|
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||||
|
t.Fatalf("empty-packet test leaked %d buffered bytes (baseline %d)", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type deadlineUnblockingResponseWriter struct {
|
||||||
|
header http.Header
|
||||||
|
writeStart chan struct{}
|
||||||
|
unblock chan struct{}
|
||||||
|
startOnce sync.Once
|
||||||
|
unblockOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDeadlineUnblockingResponseWriter() *deadlineUnblockingResponseWriter {
|
||||||
|
return &deadlineUnblockingResponseWriter{
|
||||||
|
header: make(http.Header),
|
||||||
|
writeStart: make(chan struct{}),
|
||||||
|
unblock: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *deadlineUnblockingResponseWriter) Header() http.Header { return w.header }
|
||||||
|
func (w *deadlineUnblockingResponseWriter) WriteHeader(int) {}
|
||||||
|
func (w *deadlineUnblockingResponseWriter) Flush() {}
|
||||||
|
func (w *deadlineUnblockingResponseWriter) Write([]byte) (int, error) {
|
||||||
|
w.startOnce.Do(func() { close(w.writeStart) })
|
||||||
|
<-w.unblock
|
||||||
|
return 0, os.ErrDeadlineExceeded
|
||||||
|
}
|
||||||
|
func (w *deadlineUnblockingResponseWriter) SetWriteDeadline(deadline time.Time) error {
|
||||||
|
if !deadline.IsZero() && !deadline.After(time.Now().Add(10*time.Millisecond)) {
|
||||||
|
w.unblockOnce.Do(func() { close(w.unblock) })
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeXHTTPResponseCloseInterruptsStalledWrite(t *testing.T) {
|
||||||
|
underlying := newDeadlineUnblockingResponseWriter()
|
||||||
|
writer := newNativeXHTTPResponseWriter(underlying)
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := writer.Write([]byte("blocked"))
|
||||||
|
writeDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-underlying.writeStart:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("response write did not start")
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
writer.close()
|
||||||
|
close(closeDone)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-closeDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("response close blocked behind stalled write")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-writeDone:
|
||||||
|
if !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||||
|
t.Fatalf("stalled write error = %v, want deadline exceeded", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("stalled response write was not interrupted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -480,6 +480,7 @@ write_sshpanel_systemd_override() {
|
|||||||
echo "[Service]"
|
echo "[Service]"
|
||||||
echo "Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log"
|
echo "Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log"
|
||||||
echo "Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}"
|
echo "Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}"
|
||||||
|
echo "LimitNOFILE=1048576"
|
||||||
echo "ExecStartPre="
|
echo "ExecStartPre="
|
||||||
echo "ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs"
|
echo "ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs"
|
||||||
echo "ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'"
|
echo "ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'"
|
||||||
|
|||||||
+91
-10
@@ -252,11 +252,12 @@ type XrayManager struct {
|
|||||||
startTime time.Time
|
startTime time.Time
|
||||||
lastErr string
|
lastErr string
|
||||||
|
|
||||||
statsMu sync.RWMutex
|
statsMu sync.RWMutex
|
||||||
statsByEmail map[string]xrayRuntimeStat
|
statsByEmail map[string]xrayRuntimeStat
|
||||||
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
|
||||||
|
|||||||
+3
-3
@@ -204,9 +204,9 @@ func (s *nativeXrayServer) start(configFile string) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err)
|
return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err)
|
||||||
}
|
}
|
||||||
// Apply the global pre-authentication ceiling before net/http can spawn a
|
// Track accepted sockets so stop/reload can close them. VPN transports are
|
||||||
// goroutine or begin a TLS handshake for the accepted socket.
|
// not subject to a global website-style connection ceiling.
|
||||||
serveLn := limitNativeListener(ln)
|
serveLn := trackNativeListener(ln)
|
||||||
if group.security == "tls" {
|
if group.security == "tls" {
|
||||||
serveLn = tls.NewListener(serveLn, group.tlsConfig)
|
serveLn = tls.NewListener(serveLn, group.tlsConfig)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-45
@@ -34,14 +34,12 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
nativeTransportConnections atomic.Int64
|
nativeTransportConnections atomic.Int64
|
||||||
nativeTransportRejected atomic.Int64
|
nativeXHTTPSessions atomic.Int64
|
||||||
nativeXHTTPRequests atomic.Int64
|
nativeXHTTPRequests atomic.Int64
|
||||||
nativeXHTTPRequestsRejected atomic.Int64
|
nativeClientConnsRejected atomic.Int64
|
||||||
nativeXHTTPSessions atomic.Int64
|
nativePreAuthRejected atomic.Int64
|
||||||
nativeXHTTPSessionsRejected atomic.Int64
|
nativeXHTTPRejected atomic.Int64
|
||||||
nativeClientConnsRejected atomic.Int64
|
|
||||||
nativePreAuthRejected atomic.Int64
|
|
||||||
|
|
||||||
nativeTransportAccepting atomic.Bool
|
nativeTransportAccepting atomic.Bool
|
||||||
nativeTransportRegistry = struct {
|
nativeTransportRegistry = struct {
|
||||||
@@ -50,9 +48,9 @@ var (
|
|||||||
}{conns: make(map[*nativeCountedConn]struct{})}
|
}{conns: make(map[*nativeCountedConn]struct{})}
|
||||||
)
|
)
|
||||||
|
|
||||||
// acquireNativeCounter reserves one slot without blocking. Blocking the accept
|
// acquireNativeCounter tracks a counted resource and returns an exactly-once
|
||||||
// loop or an HTTP handler when the process is already at its safety ceiling
|
// release function. Limits here are simultaneous resource-safety windows, not
|
||||||
// would retain yet more sockets/goroutines, so overload is rejected promptly.
|
// traffic-volume or request-rate ceilings.
|
||||||
func acquireNativeCounter(active *atomic.Int64, limit int) (func(), bool) {
|
func acquireNativeCounter(active *atomic.Int64, limit int) (func(), bool) {
|
||||||
for {
|
for {
|
||||||
current := active.Load()
|
current := active.Load()
|
||||||
@@ -100,30 +98,15 @@ func logNativePreAuthRejection(format string, args ...interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func acquireNativeTransportConnection() (func(), bool) {
|
func acquireNativeTransportConnection() (func(), bool) {
|
||||||
limit := nativeMaxConnectionLimit()
|
return acquireNativeCounter(&nativeTransportConnections, nativeTransportConnectionLimit())
|
||||||
release, ok := acquireNativeCounter(&nativeTransportConnections, limit)
|
|
||||||
if !ok {
|
|
||||||
logNativeLimitRejection("transport connection", &nativeTransportRejected, limit)
|
|
||||||
}
|
|
||||||
return release, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func acquireNativeXHTTPRequest() (func(), bool) {
|
|
||||||
limit := nativeMaxXHTTPRequestLimit()
|
|
||||||
release, ok := acquireNativeCounter(&nativeXHTTPRequests, limit)
|
|
||||||
if !ok {
|
|
||||||
logNativeLimitRejection("XHTTP request", &nativeXHTTPRequestsRejected, limit)
|
|
||||||
}
|
|
||||||
return release, ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func acquireNativeXHTTPSession() (func(), bool) {
|
func acquireNativeXHTTPSession() (func(), bool) {
|
||||||
limit := nativeXHTTPMaxSessionLimit()
|
return acquireNativeCounter(&nativeXHTTPSessions, nativeXHTTPSessionLimit())
|
||||||
release, ok := acquireNativeCounter(&nativeXHTTPSessions, limit)
|
}
|
||||||
if !ok {
|
|
||||||
logNativeLimitRejection("XHTTP session", &nativeXHTTPSessionsRejected, limit)
|
func acquireNativeXHTTPRequest() (func(), bool) {
|
||||||
}
|
return acquireNativeCounter(&nativeXHTTPRequests, nativeXHTTPRequestLimit())
|
||||||
return release, ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configureNativeTransportSocket(c net.Conn) {
|
func configureNativeTransportSocket(c net.Conn) {
|
||||||
@@ -215,9 +198,9 @@ func registerTrackedNativeTransportConn(c net.Conn, release func()) (net.Conn, b
|
|||||||
return counted, true
|
return counted, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitWrapTrackedNativeTransportConn is used by raw native accept loops. It
|
// waitWrapTrackedNativeTransportConn is used by raw native accept loops. Waiting
|
||||||
// holds at most one already-accepted socket while capacity is busy, leaving the
|
// here, before another connection is admitted to the protocol handler, applies
|
||||||
// rest in the kernel backlog instead of creating origin-side resets/502s.
|
// socket/kernel backpressure instead of creating an unbounded goroutine backlog.
|
||||||
func waitWrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
|
func waitWrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
@@ -254,20 +237,19 @@ func closeAllNativeTransportConnections() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nativeLimitedListener applies the same pre-authentication ceiling to XHTTP
|
// nativeTrackingListener registers every accepted XHTTP socket so a live
|
||||||
// listeners. net/http receives only sockets that own a slot; when capacity is
|
// stop/reload can close it. It reserves capacity before Accept so overload stays
|
||||||
// busy, new sockets remain in the kernel backlog until a slot becomes available.
|
// in the kernel accept queue rather than allocating more Go handlers.
|
||||||
type nativeLimitedListener struct {
|
type nativeTrackingListener struct {
|
||||||
net.Listener
|
net.Listener
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l nativeLimitedListener) Accept() (net.Conn, error) {
|
func (l nativeTrackingListener) Accept() (net.Conn, error) {
|
||||||
for {
|
for {
|
||||||
// Reserve before accepting. When the transport is at capacity, connections
|
|
||||||
// remain queued by the kernel rather than being accepted and reset, which is
|
|
||||||
// the behavior CDNs commonly report as an origin 502.
|
|
||||||
release, ok := acquireNativeTransportConnection()
|
release, ok := acquireNativeTransportConnection()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
// Unlimited admission can only fail if this implementation changes. Avoid
|
||||||
|
// accepting and resetting a socket if that ever happens.
|
||||||
time.Sleep(nativeOverloadBackoff)
|
time.Sleep(nativeOverloadBackoff)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -286,9 +268,9 @@ func (l nativeLimitedListener) Accept() (net.Conn, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func limitNativeListener(ln net.Listener) net.Listener {
|
func trackNativeListener(ln net.Listener) net.Listener {
|
||||||
if ln == nil {
|
if ln == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return nativeLimitedListener{Listener: ln}
|
return nativeTrackingListener{Listener: ln}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-56
@@ -18,10 +18,11 @@ type XrayNativeTuning struct {
|
|||||||
const (
|
const (
|
||||||
defaultNativeRuntimeGOMAXPROCS = 0
|
defaultNativeRuntimeGOMAXPROCS = 0
|
||||||
defaultNativeMuxGlobalSessions = 32768
|
defaultNativeMuxGlobalSessions = 32768
|
||||||
defaultNativeMaxConnections = 32768
|
// Transport sockets, XHTTP requests, and XHTTP sessions are VPN traffic, not
|
||||||
// XHTTP packet handlers are governed by HTTP/2 flow control and bounded byte
|
// website requests. Keep the legacy JSON fields for config compatibility, but
|
||||||
// queues, not a website-style request ceiling. A negative configured value is
|
// always normalize them to unlimited. Actual resource protection is provided by
|
||||||
// normalized to the internal unlimited representation.
|
// socket/HTTP flow control and the bounded byte queues in xray_xhttp.go.
|
||||||
|
defaultNativeMaxConnections = -1
|
||||||
defaultNativeMaxXHTTPRequests = -1
|
defaultNativeMaxXHTTPRequests = -1
|
||||||
|
|
||||||
fixedNativeMuxMaxSessions = 64
|
fixedNativeMuxMaxSessions = 64
|
||||||
@@ -29,28 +30,47 @@ const (
|
|||||||
fixedNativeMuxUDPReadBuffer = 256 * 1024
|
fixedNativeMuxUDPReadBuffer = 256 * 1024
|
||||||
fixedNativeMuxUDPWriteBuffer = 256 * 1024
|
fixedNativeMuxUDPWriteBuffer = 256 * 1024
|
||||||
|
|
||||||
defaultNativeXHTTPMaxSessions = 32768
|
defaultNativeXHTTPMaxSessions = -1
|
||||||
defaultNativeHTTP2MaxStreams = 1024
|
|
||||||
// Packet-up posts are also protected by byte budgets in xray_xhttp.go. Keep
|
// Packet-up posts are also protected by byte budgets in xray_xhttp.go. Keep
|
||||||
// the default reorder queue modest so thousands of unauthenticated sessions
|
// the default reorder queue modest so thousands of unauthenticated sessions
|
||||||
// cannot consume large amounts of memory merely by allocating empty channel
|
// cannot consume large amounts of memory merely by allocating empty channel
|
||||||
// buffers. Operators may request more, up to the hard cap enforced there.
|
// buffers. Operators may request more, up to the hard cap enforced there.
|
||||||
defaultNativeXHTTPBufferedPosts = 64
|
defaultNativeXHTTPBufferedPosts = 64
|
||||||
|
|
||||||
// Do not impose an application-level lifetime on a connected XHTTP VPN
|
// These are simultaneous resource-safety windows, not request-rate limits.
|
||||||
// session. The official Xray server keeps a connected session for the
|
// They are deliberately far above the expected 6-8K connected-user load, but
|
||||||
// lifetime of its stream-down GET; request cancellation and I/O errors own
|
// finite so a reconnect storm, broken CDN, or hostile client cannot retain an
|
||||||
// cleanup. A fixed five-minute sweeper incorrectly killed healthy but idle
|
// unbounded number of sockets, HTTP handlers, sessions, and goroutine stacks.
|
||||||
// VPNs. Zero disables the connected-session sweeper.
|
// Transport Accept waits at capacity (kernel backpressure); XHTTP overloads
|
||||||
fixedNativeXHTTPIdleMS = 0
|
// receive 503 rather than the web-rate-limit semantics of 429.
|
||||||
|
fixedNativeMaxTransportConnections = 65536
|
||||||
|
fixedNativeMaxXHTTPRequests = 65536
|
||||||
|
fixedNativeMaxXHTTPSessions = 65536
|
||||||
|
fixedNativeHTTP2ConcurrentStreams = 4096
|
||||||
|
fixedNativeXHTTPWriteTimeoutMS = 60 * 1000
|
||||||
|
|
||||||
|
// Backstop reaper for connected XHTTP VPN sessions. The stream-down GET's
|
||||||
|
// request context is the primary lifetime owner, but behind a CDN that context
|
||||||
|
// frequently never fires when a client silently drops (mobile networks, CDN
|
||||||
|
// connection pooling, half-open TCP). When it doesn't, an idle SSH backend
|
||||||
|
// never errors either, so the session, its goroutines, socket/fd, and SSH
|
||||||
|
// connection leak until the whole process restarts. That accumulation is what
|
||||||
|
// drove the recurring XHTTP 502s that only a reboot cleared: the origin slowly
|
||||||
|
// ran out of fds/memory and could no longer serve new stream-down GETs.
|
||||||
|
//
|
||||||
|
// This sweeper only ever reaps sessions with genuinely stale lastSeen. lastSeen
|
||||||
|
// is refreshed on every successful read OR write via nativeXHTTPConn.onActivity,
|
||||||
|
// so any tunnel still passing data or keepalives is never touched -- only a
|
||||||
|
// session with zero bytes in BOTH directions for the full window (i.e. one that
|
||||||
|
// looks dead) is closed. 20 minutes is generous enough not to disturb a
|
||||||
|
// genuinely idle-but-alive tunnel while still bounding resource growth under
|
||||||
|
// heavy 6-8K-user churn. Zero disables the connected-session sweeper.
|
||||||
|
fixedNativeXHTTPIdleMS = 20 * 60 * 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
nativeTuneRuntimeGOMAXPROCS atomic.Int64
|
nativeTuneRuntimeGOMAXPROCS atomic.Int64
|
||||||
nativeTuneMuxGlobalSessions atomic.Int64
|
nativeTuneMuxGlobalSessions atomic.Int64
|
||||||
nativeTuneMaxConnections atomic.Int64
|
|
||||||
nativeTuneMaxXHTTPRequests atomic.Int64
|
|
||||||
nativeTuneXHTTPMaxSessions atomic.Int64
|
|
||||||
nativeTuneTracePackets atomic.Bool
|
nativeTuneTracePackets atomic.Bool
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,33 +83,18 @@ func normalizeNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
|
|||||||
t = &XrayNativeTuning{}
|
t = &XrayNativeTuning{}
|
||||||
}
|
}
|
||||||
out := *t
|
out := *t
|
||||||
// Migrate the two profiles written by older panel builds. Those defaults were
|
|
||||||
// sized like a web service (4K/8K sessions and a global request cap) and cause
|
|
||||||
// valid high-volume XHTTP VPN traffic to be rejected after an upgrade unless
|
|
||||||
// the persisted values are translated here.
|
|
||||||
legacySafe := out.MaxConcurrentConnections == 4096 && out.MaxConcurrentXHTTPRequests == 8192 && out.XHTTPMaxSessions == 4096
|
|
||||||
legacy2K := out.MaxConcurrentConnections == 8192 && out.MaxConcurrentXHTTPRequests == 16384 && out.XHTTPMaxSessions == 8192
|
|
||||||
if legacySafe || legacy2K {
|
|
||||||
out.MuxGlobalSessions = defaultNativeMuxGlobalSessions
|
|
||||||
out.MaxConcurrentConnections = defaultNativeMaxConnections
|
|
||||||
out.MaxConcurrentXHTTPRequests = defaultNativeMaxXHTTPRequests
|
|
||||||
out.XHTTPMaxSessions = defaultNativeXHTTPMaxSessions
|
|
||||||
}
|
|
||||||
if out.RuntimeGOMAXPROCS < 0 {
|
if out.RuntimeGOMAXPROCS < 0 {
|
||||||
out.RuntimeGOMAXPROCS = defaultNativeRuntimeGOMAXPROCS
|
out.RuntimeGOMAXPROCS = defaultNativeRuntimeGOMAXPROCS
|
||||||
}
|
}
|
||||||
if out.MuxGlobalSessions <= 0 {
|
if out.MuxGlobalSessions <= 0 {
|
||||||
out.MuxGlobalSessions = defaultNativeMuxGlobalSessions
|
out.MuxGlobalSessions = defaultNativeMuxGlobalSessions
|
||||||
}
|
}
|
||||||
if out.MaxConcurrentConnections == 0 {
|
// Ignore every old positive/zero admission ceiling. This migration is
|
||||||
out.MaxConcurrentConnections = defaultNativeMaxConnections
|
// deliberately unconditional so upgrading an existing server immediately
|
||||||
}
|
// removes the old 4K/8K/32K web-style caps without requiring a panel save.
|
||||||
if out.MaxConcurrentXHTTPRequests == 0 {
|
out.MaxConcurrentConnections = defaultNativeMaxConnections
|
||||||
out.MaxConcurrentXHTTPRequests = defaultNativeMaxXHTTPRequests
|
out.MaxConcurrentXHTTPRequests = defaultNativeMaxXHTTPRequests
|
||||||
}
|
out.XHTTPMaxSessions = defaultNativeXHTTPMaxSessions
|
||||||
if out.XHTTPMaxSessions == 0 {
|
|
||||||
out.XHTTPMaxSessions = defaultNativeXHTTPMaxSessions
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,27 +110,12 @@ func applyNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
|
|||||||
runtime.GOMAXPROCS(gomax)
|
runtime.GOMAXPROCS(gomax)
|
||||||
nativeTuneRuntimeGOMAXPROCS.Store(int64(gomax))
|
nativeTuneRuntimeGOMAXPROCS.Store(int64(gomax))
|
||||||
nativeTuneMuxGlobalSessions.Store(int64(out.MuxGlobalSessions))
|
nativeTuneMuxGlobalSessions.Store(int64(out.MuxGlobalSessions))
|
||||||
nativeTuneMaxConnections.Store(nativeLimitValue(out.MaxConcurrentConnections))
|
|
||||||
nativeTuneMaxXHTTPRequests.Store(nativeLimitValue(out.MaxConcurrentXHTTPRequests))
|
|
||||||
nativeTuneXHTTPMaxSessions.Store(nativeLimitValue(out.XHTTPMaxSessions))
|
|
||||||
nativeTuneTracePackets.Store(out.TracePackets)
|
nativeTuneTracePackets.Store(out.TracePackets)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native tuning limits use zero internally for unlimited. In configuration,
|
|
||||||
// zero means "use the safe default" and any negative value disables the cap.
|
|
||||||
func nativeLimitValue(v int) int64 {
|
|
||||||
if v < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int64(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) }
|
func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) }
|
||||||
func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) }
|
func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) }
|
||||||
func nativeMaxConnectionLimit() int { return int(nativeTuneMaxConnections.Load()) }
|
|
||||||
func nativeMaxXHTTPRequestLimit() int { return int(nativeTuneMaxXHTTPRequests.Load()) }
|
|
||||||
func nativeXHTTPMaxSessionLimit() int { return int(nativeTuneXHTTPMaxSessions.Load()) }
|
|
||||||
func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() }
|
func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() }
|
||||||
|
|
||||||
func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions }
|
func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions }
|
||||||
@@ -133,11 +123,13 @@ func nativeMuxUDPReadBufferSize() int { return fixedNativeMuxUDPReadBuffer }
|
|||||||
func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer }
|
func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer }
|
||||||
func nativeXHTTPBufferedPostLimit() int { return defaultNativeXHTTPBufferedPosts }
|
func nativeXHTTPBufferedPostLimit() int { return defaultNativeXHTTPBufferedPosts }
|
||||||
func nativeHTTP2MaxConcurrentStreams() uint32 {
|
func nativeHTTP2MaxConcurrentStreams() uint32 {
|
||||||
limit := nativeMaxXHTTPRequestLimit()
|
return fixedNativeHTTP2ConcurrentStreams
|
||||||
if limit <= 0 || limit > defaultNativeHTTP2MaxStreams {
|
}
|
||||||
return defaultNativeHTTP2MaxStreams
|
func nativeTransportConnectionLimit() int { return fixedNativeMaxTransportConnections }
|
||||||
}
|
func nativeXHTTPRequestLimit() int { return fixedNativeMaxXHTTPRequests }
|
||||||
return uint32(limit)
|
func nativeXHTTPSessionLimit() int { return fixedNativeMaxXHTTPSessions }
|
||||||
|
func nativeXHTTPWriteTimeout() time.Duration {
|
||||||
|
return fixedNativeXHTTPWriteTimeoutMS * time.Millisecond
|
||||||
}
|
}
|
||||||
func nativeMuxUDPIdleTimeout() time.Duration {
|
func nativeMuxUDPIdleTimeout() time.Duration {
|
||||||
return fixedNativeMuxUDPIdleMS * time.Millisecond
|
return fixedNativeMuxUDPIdleMS * time.Millisecond
|
||||||
|
|||||||
+253
-127
@@ -21,8 +21,6 @@ import (
|
|||||||
"golang.org/x/net/http2/h2c"
|
"golang.org/x/net/http2/h2c"
|
||||||
)
|
)
|
||||||
|
|
||||||
const nativeXHTTPServerIdleTimeout = 90 * time.Second
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
nativeXHTTPMaxSessionIDBytes = 256
|
nativeXHTTPMaxSessionIDBytes = 256
|
||||||
nativeXHTTPMaxSequenceBytes = 20
|
nativeXHTTPMaxSequenceBytes = 20
|
||||||
@@ -31,6 +29,10 @@ const (
|
|||||||
nativeXHTTPMaxBufferedPosts = 512
|
nativeXHTTPMaxBufferedPosts = 512
|
||||||
nativeXHTTPMaxBufferedSessionBytes = 16 * 1024 * 1024
|
nativeXHTTPMaxBufferedSessionBytes = 16 * 1024 * 1024
|
||||||
nativeXHTTPMaxBufferedGlobalBytes = 128 * 1024 * 1024
|
nativeXHTTPMaxBufferedGlobalBytes = 128 * 1024 * 1024
|
||||||
|
// Tiny/empty packet-up requests still retain queue metadata. Charge a minimum
|
||||||
|
// amount against the byte budgets so the reassembly queue can be count-unlimited
|
||||||
|
// without allowing zero-byte packets to grow the heap without bound.
|
||||||
|
nativeXHTTPMinPacketAccountingBytes int64 = 256
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -39,10 +41,18 @@ var (
|
|||||||
errNativeXHTTPUploadBufferFull = errors.New("xhttp upload buffer limit reached")
|
errNativeXHTTPUploadBufferFull = errors.New("xhttp upload buffer limit reached")
|
||||||
nativeXHTTPMemoryWait = struct {
|
nativeXHTTPMemoryWait = struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
changed chan struct{}
|
waiters []*nativeXHTTPMemoryWaiter
|
||||||
}{changed: make(chan struct{})}
|
head int
|
||||||
|
queued int
|
||||||
|
}{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type nativeXHTTPMemoryWaiter struct {
|
||||||
|
bytes int64
|
||||||
|
ready chan struct{}
|
||||||
|
granted bool
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
xhttpPlacementPath = "path"
|
xhttpPlacementPath = "path"
|
||||||
xhttpPlacementQuery = "query"
|
xhttpPlacementQuery = "query"
|
||||||
@@ -181,7 +191,6 @@ func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
|
|||||||
func (g *nativeXHTTPListener) serve(ln net.Listener) {
|
func (g *nativeXHTTPListener) serve(ln net.Listener) {
|
||||||
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
|
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
|
||||||
h2s := &http2.Server{
|
h2s := &http2.Server{
|
||||||
IdleTimeout: nativeXHTTPServerIdleTimeout,
|
|
||||||
MaxConcurrentStreams: nativeHTTP2MaxConcurrentStreams(),
|
MaxConcurrentStreams: nativeHTTP2MaxConcurrentStreams(),
|
||||||
}
|
}
|
||||||
handler := http.Handler(g)
|
handler := http.Handler(g)
|
||||||
@@ -195,7 +204,6 @@ func (g *nativeXHTTPListener) serve(ln net.Listener) {
|
|||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 4 * time.Second,
|
ReadHeaderTimeout: 4 * time.Second,
|
||||||
IdleTimeout: nativeXHTTPServerIdleTimeout,
|
|
||||||
MaxHeaderBytes: g.headerSize,
|
MaxHeaderBytes: g.headerSize,
|
||||||
}
|
}
|
||||||
if g.security == "tls" && g.tlsConfig != nil {
|
if g.security == "tls" && g.tlsConfig != nil {
|
||||||
@@ -328,6 +336,15 @@ func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
releaseRequest, ok := acquireNativeXHTTPRequest()
|
||||||
|
if !ok {
|
||||||
|
logNativeLimitRejection("simultaneous XHTTP handlers", &nativeXHTTPRejected, nativeXHTTPRequestLimit())
|
||||||
|
w.Header().Set("Retry-After", "1")
|
||||||
|
http.Error(w, "xhttp transport temporarily busy", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer releaseRequest()
|
||||||
|
|
||||||
sessionID, seqStr := ib.extractXHTTPMeta(r, base)
|
sessionID, seqStr := ib.extractXHTTPMeta(r, base)
|
||||||
if len(sessionID) > nativeXHTTPMaxSessionIDBytes || len(seqStr) > nativeXHTTPMaxSequenceBytes {
|
if len(sessionID) > nativeXHTTPMaxSessionIDBytes || len(seqStr) > nativeXHTTPMaxSequenceBytes {
|
||||||
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=metadata-size remote=%s", ib.tag, r.RemoteAddr)
|
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=metadata-size remote=%s", ib.tag, r.RemoteAddr)
|
||||||
@@ -560,14 +577,11 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
|
|||||||
s.touch()
|
s.touch()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max {
|
|
||||||
http.Error(w, "native XHTTP session capacity reached", http.StatusServiceUnavailable)
|
|
||||||
logNativePreAuthRejection("native xray: xhttp session rejected inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
releaseSlot, ok := acquireNativeXHTTPSession()
|
releaseSlot, ok := acquireNativeXHTTPSession()
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "native XHTTP global session capacity reached", http.StatusServiceUnavailable)
|
logNativeLimitRejection("simultaneous XHTTP sessions", &nativeXHTTPRejected, nativeXHTTPSessionLimit())
|
||||||
|
w.Header().Set("Retry-After", "1")
|
||||||
|
http.Error(w, "xhttp session capacity temporarily busy", http.StatusServiceUnavailable)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s := &nativeXHTTPSession{
|
s := &nativeXHTTPSession{
|
||||||
@@ -585,9 +599,7 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ib *nativeInbound) xhttpMaxActiveSessions() int {
|
func (ib *nativeInbound) xhttpMaxActiveSessions() int {
|
||||||
// normalizeNativeXrayTuning already installs the safe default. A zero value
|
return nativeXHTTPSessionLimit()
|
||||||
// here therefore intentionally means the operator configured -1 (unlimited).
|
|
||||||
return nativeXHTTPMaxSessionLimit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
|
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
|
||||||
@@ -679,7 +691,7 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
|
|||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
memory.shrink(int64(len(payload)))
|
memory.shrink(nativeXHTTPAccountedPacketBytes(int64(len(payload))))
|
||||||
xrayTracef("native xray: xhttp packet-up inbound=%q session=%q seq=%d payload=%d remote=%s", ib.tag, sess.id, seq, len(payload), r.RemoteAddr)
|
xrayTracef("native xray: xhttp packet-up inbound=%q session=%q seq=%d payload=%d remote=%s", ib.tag, sess.id, seq, len(payload), r.RemoteAddr)
|
||||||
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}, memory); err != nil {
|
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}, memory); err != nil {
|
||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
@@ -816,11 +828,18 @@ func (ib *nativeInbound) xhttpUploadReservationBytes(r *http.Request) int64 {
|
|||||||
placement := firstNonEmpty(ib.xhttpUplinkDataPlacement, xhttpPlacementBody)
|
placement := firstNonEmpty(ib.xhttpUplinkDataPlacement, xhttpPlacementBody)
|
||||||
if placement == xhttpPlacementBody && r.ContentLength >= 0 {
|
if placement == xhttpPlacementBody && r.ContentLength >= 0 {
|
||||||
if r.ContentLength > maxBytes {
|
if r.ContentLength > maxBytes {
|
||||||
return maxBytes
|
return nativeXHTTPAccountedPacketBytes(maxBytes)
|
||||||
}
|
}
|
||||||
return r.ContentLength
|
return nativeXHTTPAccountedPacketBytes(r.ContentLength)
|
||||||
}
|
}
|
||||||
return maxBytes
|
return nativeXHTTPAccountedPacketBytes(maxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeXHTTPAccountedPacketBytes(payloadBytes int64) int64 {
|
||||||
|
if payloadBytes < nativeXHTTPMinPacketAccountingBytes {
|
||||||
|
return nativeXHTTPMinPacketAccountingBytes
|
||||||
|
}
|
||||||
|
return payloadBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) {
|
func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1052,12 +1071,19 @@ func (c *nativeXHTTPConn) SetReadDeadline(t time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *nativeXHTTPConn) SetWriteDeadline(time.Time) error { return nil }
|
func (c *nativeXHTTPConn) SetWriteDeadline(t time.Time) error {
|
||||||
|
if dw, ok := c.writer.(interface{ SetWriteDeadline(time.Time) error }); ok {
|
||||||
|
return dw.SetWriteDeadline(t)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type nativeXHTTPResponseWriter struct {
|
type nativeXHTTPResponseWriter struct {
|
||||||
mu sync.Mutex
|
writeMu sync.Mutex
|
||||||
w http.ResponseWriter
|
stateMu sync.Mutex
|
||||||
closed bool
|
w http.ResponseWriter
|
||||||
|
closed bool
|
||||||
|
deadline time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newNativeXHTTPResponseWriter(w http.ResponseWriter) *nativeXHTTPResponseWriter {
|
func newNativeXHTTPResponseWriter(w http.ResponseWriter) *nativeXHTTPResponseWriter {
|
||||||
@@ -1065,22 +1091,53 @@ func newNativeXHTTPResponseWriter(w http.ResponseWriter) *nativeXHTTPResponseWri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *nativeXHTTPResponseWriter) Write(p []byte) (int, error) {
|
func (w *nativeXHTTPResponseWriter) Write(p []byte) (int, error) {
|
||||||
w.mu.Lock()
|
w.writeMu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.writeMu.Unlock()
|
||||||
if w.closed {
|
|
||||||
|
w.stateMu.Lock()
|
||||||
|
closed := w.closed
|
||||||
|
deadline := w.deadline
|
||||||
|
w.stateMu.Unlock()
|
||||||
|
if closed {
|
||||||
return 0, io.ErrClosedPipe
|
return 0, io.ErrClosedPipe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safetyDeadline := time.Now().Add(nativeXHTTPWriteTimeout())
|
||||||
|
if deadline.IsZero() || deadline.After(safetyDeadline) {
|
||||||
|
deadline = safetyDeadline
|
||||||
|
}
|
||||||
|
controller := http.NewResponseController(w.w)
|
||||||
|
if err := controller.SetWriteDeadline(deadline); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
n, err := w.w.Write(p)
|
n, err := w.w.Write(p)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
flushHTTP(w.w)
|
if flushErr := controller.Flush(); flushErr != nil && !errors.Is(flushErr, http.ErrNotSupported) {
|
||||||
|
err = flushErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *nativeXHTTPResponseWriter) close() {
|
func (w *nativeXHTTPResponseWriter) close() {
|
||||||
w.mu.Lock()
|
// Do not wait for writeMu: Close is commonly called by the request-context
|
||||||
|
// watcher specifically because a CDN write is stalled. Mark the writer closed
|
||||||
|
// and force the active net/http write deadline to expire so Write returns.
|
||||||
|
w.stateMu.Lock()
|
||||||
w.closed = true
|
w.closed = true
|
||||||
w.mu.Unlock()
|
w.stateMu.Unlock()
|
||||||
|
_ = http.NewResponseController(w.w).SetWriteDeadline(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *nativeXHTTPResponseWriter) SetWriteDeadline(t time.Time) error {
|
||||||
|
w.stateMu.Lock()
|
||||||
|
w.deadline = t
|
||||||
|
w.stateMu.Unlock()
|
||||||
|
err := http.NewResponseController(w.w).SetWriteDeadline(t)
|
||||||
|
if errors.Is(err, http.ErrNotSupported) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// nativeXHTTPMemoryLease reserves from a process-wide byte budget before a
|
// nativeXHTTPMemoryLease reserves from a process-wide byte budget before a
|
||||||
@@ -1095,16 +1152,15 @@ func acquireNativeXHTTPMemory(n int64) (*nativeXHTTPMemoryLease, bool) {
|
|||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return &nativeXHTTPMemoryLease{}, true
|
return &nativeXHTTPMemoryLease{}, true
|
||||||
}
|
}
|
||||||
for {
|
nativeXHTTPMemoryWait.Lock()
|
||||||
current := nativeXHTTPBufferedBytes.Load()
|
defer nativeXHTTPMemoryWait.Unlock()
|
||||||
if current > nativeXHTTPMaxBufferedGlobalBytes-n {
|
current := nativeXHTTPBufferedBytes.Load()
|
||||||
logNativeLimitRejection("XHTTP buffered upload bytes", &nativeXHTTPBufferRejected, nativeXHTTPMaxBufferedGlobalBytes)
|
if nativeXHTTPMemoryWait.queued != 0 || current > nativeXHTTPMaxBufferedGlobalBytes-n {
|
||||||
return nil, false
|
logNativeLimitRejection("XHTTP buffered upload bytes", &nativeXHTTPBufferRejected, nativeXHTTPMaxBufferedGlobalBytes)
|
||||||
}
|
return nil, false
|
||||||
if nativeXHTTPBufferedBytes.CompareAndSwap(current, current+n) {
|
|
||||||
return &nativeXHTTPMemoryLease{bytes: n}, true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
nativeXHTTPBufferedBytes.Store(current + n)
|
||||||
|
return &nativeXHTTPMemoryLease{bytes: n}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// acquireNativeXHTTPMemoryContext applies process-wide memory backpressure.
|
// acquireNativeXHTTPMemoryContext applies process-wide memory backpressure.
|
||||||
@@ -1117,27 +1173,42 @@ func acquireNativeXHTTPMemoryContext(ctx context.Context, n int64) (*nativeXHTTP
|
|||||||
if n > nativeXHTTPMaxBufferedGlobalBytes {
|
if n > nativeXHTTPMaxBufferedGlobalBytes {
|
||||||
return nil, errNativeXHTTPUploadBufferFull
|
return nil, errNativeXHTTPUploadBufferFull
|
||||||
}
|
}
|
||||||
for {
|
waiter := &nativeXHTTPMemoryWaiter{bytes: n, ready: make(chan struct{})}
|
||||||
current := nativeXHTTPBufferedBytes.Load()
|
nativeXHTTPMemoryWait.Lock()
|
||||||
if current <= nativeXHTTPMaxBufferedGlobalBytes-n && nativeXHTTPBufferedBytes.CompareAndSwap(current, current+n) {
|
current := nativeXHTTPBufferedBytes.Load()
|
||||||
return &nativeXHTTPMemoryLease{bytes: n}, nil
|
if nativeXHTTPMemoryWait.queued == 0 && current <= nativeXHTTPMaxBufferedGlobalBytes-n {
|
||||||
}
|
nativeXHTTPBufferedBytes.Store(current + n)
|
||||||
|
|
||||||
nativeXHTTPMemoryWait.Lock()
|
|
||||||
// Recheck while holding the generation lock so a release cannot happen
|
|
||||||
// between the failed check and subscribing to the notification channel.
|
|
||||||
current = nativeXHTTPBufferedBytes.Load()
|
|
||||||
if current <= nativeXHTTPMaxBufferedGlobalBytes-n {
|
|
||||||
nativeXHTTPMemoryWait.Unlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
changed := nativeXHTTPMemoryWait.changed
|
|
||||||
nativeXHTTPMemoryWait.Unlock()
|
nativeXHTTPMemoryWait.Unlock()
|
||||||
select {
|
return &nativeXHTTPMemoryLease{bytes: n}, nil
|
||||||
case <-changed:
|
}
|
||||||
case <-ctx.Done():
|
nativeXHTTPMemoryWait.waiters = append(nativeXHTTPMemoryWait.waiters, waiter)
|
||||||
return nil, ctx.Err()
|
nativeXHTTPMemoryWait.queued++
|
||||||
|
nativeXHTTPMemoryWait.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-waiter.ready:
|
||||||
|
return &nativeXHTTPMemoryLease{bytes: n}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
nativeXHTTPMemoryWait.Lock()
|
||||||
|
if waiter.granted {
|
||||||
|
current := nativeXHTTPBufferedBytes.Load() - n
|
||||||
|
if current < 0 {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
nativeXHTTPBufferedBytes.Store(current)
|
||||||
|
} else {
|
||||||
|
for i := nativeXHTTPMemoryWait.head; i < len(nativeXHTTPMemoryWait.waiters); i++ {
|
||||||
|
candidate := nativeXHTTPMemoryWait.waiters[i]
|
||||||
|
if candidate == waiter {
|
||||||
|
nativeXHTTPMemoryWait.waiters[i] = nil
|
||||||
|
nativeXHTTPMemoryWait.queued--
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
grantNativeXHTTPMemoryWaitersLocked()
|
||||||
|
nativeXHTTPMemoryWait.Unlock()
|
||||||
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,20 +1216,69 @@ func releaseNativeXHTTPMemory(n int64) {
|
|||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for {
|
nativeXHTTPMemoryWait.Lock()
|
||||||
current := nativeXHTTPBufferedBytes.Load()
|
current := nativeXHTTPBufferedBytes.Load()
|
||||||
next := current - n
|
next := current - n
|
||||||
if next < 0 {
|
if next < 0 {
|
||||||
next = 0
|
next = 0
|
||||||
|
}
|
||||||
|
nativeXHTTPBufferedBytes.Store(next)
|
||||||
|
grantNativeXHTTPMemoryWaitersLocked()
|
||||||
|
nativeXHTTPMemoryWait.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantNativeXHTTPMemoryWaitersLocked wakes only the FIFO waiters whose exact
|
||||||
|
// reservations now fit. The former broadcast channel woke every blocked HTTP
|
||||||
|
// handler after every tiny release, creating a thundering herd and sustained
|
||||||
|
// multi-core CPU usage while the 128 MB budget was full.
|
||||||
|
func grantNativeXHTTPMemoryWaitersLocked() {
|
||||||
|
for nativeXHTTPMemoryWait.queued > 0 {
|
||||||
|
for nativeXHTTPMemoryWait.head < len(nativeXHTTPMemoryWait.waiters) &&
|
||||||
|
nativeXHTTPMemoryWait.waiters[nativeXHTTPMemoryWait.head] == nil {
|
||||||
|
nativeXHTTPMemoryWait.head++
|
||||||
}
|
}
|
||||||
if nativeXHTTPBufferedBytes.CompareAndSwap(current, next) {
|
if nativeXHTTPMemoryWait.head >= len(nativeXHTTPMemoryWait.waiters) {
|
||||||
nativeXHTTPMemoryWait.Lock()
|
nativeXHTTPMemoryWait.waiters = nil
|
||||||
close(nativeXHTTPMemoryWait.changed)
|
nativeXHTTPMemoryWait.head = 0
|
||||||
nativeXHTTPMemoryWait.changed = make(chan struct{})
|
nativeXHTTPMemoryWait.queued = 0
|
||||||
nativeXHTTPMemoryWait.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waiter := nativeXHTTPMemoryWait.waiters[nativeXHTTPMemoryWait.head]
|
||||||
|
current := nativeXHTTPBufferedBytes.Load()
|
||||||
|
if current > nativeXHTTPMaxBufferedGlobalBytes-waiter.bytes {
|
||||||
|
compactNativeXHTTPMemoryWaitersLocked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nativeXHTTPMemoryWait.waiters[nativeXHTTPMemoryWait.head] = nil
|
||||||
|
nativeXHTTPMemoryWait.head++
|
||||||
|
nativeXHTTPMemoryWait.queued--
|
||||||
|
nativeXHTTPBufferedBytes.Store(current + waiter.bytes)
|
||||||
|
waiter.granted = true
|
||||||
|
close(waiter.ready)
|
||||||
}
|
}
|
||||||
|
compactNativeXHTTPMemoryWaitersLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactNativeXHTTPMemoryWaitersLocked() {
|
||||||
|
head := nativeXHTTPMemoryWait.head
|
||||||
|
if head == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if nativeXHTTPMemoryWait.queued == 0 {
|
||||||
|
nativeXHTTPMemoryWait.waiters = nil
|
||||||
|
nativeXHTTPMemoryWait.head = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if head < 1024 && head*2 < len(nativeXHTTPMemoryWait.waiters) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining := copy(nativeXHTTPMemoryWait.waiters, nativeXHTTPMemoryWait.waiters[head:])
|
||||||
|
for i := remaining; i < len(nativeXHTTPMemoryWait.waiters); i++ {
|
||||||
|
nativeXHTTPMemoryWait.waiters[i] = nil
|
||||||
|
}
|
||||||
|
nativeXHTTPMemoryWait.waiters = nativeXHTTPMemoryWait.waiters[:remaining]
|
||||||
|
nativeXHTTPMemoryWait.head = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *nativeXHTTPMemoryLease) shrink(n int64) {
|
func (l *nativeXHTTPMemoryLease) shrink(n int64) {
|
||||||
@@ -1186,14 +1306,14 @@ func (l *nativeXHTTPMemoryLease) release() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type nativeXHTTPPacket struct {
|
type nativeXHTTPPacket struct {
|
||||||
Reader io.ReadCloser
|
Reader io.ReadCloser
|
||||||
Payload []byte
|
Payload []byte
|
||||||
Seq uint64
|
Seq uint64
|
||||||
|
accountedBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type nativeXHTTPUploadQueue struct {
|
type nativeXHTTPUploadQueue struct {
|
||||||
pushedPackets chan nativeXHTTPPacket
|
pushedPackets chan nativeXHTTPPacket
|
||||||
maxPackets int
|
|
||||||
maxBytes int64
|
maxBytes int64
|
||||||
|
|
||||||
// readMu serializes the single decoded stream reader with close-time queue
|
// readMu serializes the single decoded stream reader with close-time queue
|
||||||
@@ -1227,7 +1347,6 @@ func newNativeXHTTPUploadQueue(maxPackets int, maxBytes int64) *nativeXHTTPUploa
|
|||||||
}
|
}
|
||||||
return &nativeXHTTPUploadQueue{
|
return &nativeXHTTPUploadQueue{
|
||||||
pushedPackets: make(chan nativeXHTTPPacket, maxPackets),
|
pushedPackets: make(chan nativeXHTTPPacket, maxPackets),
|
||||||
maxPackets: maxPackets,
|
|
||||||
maxBytes: maxBytes,
|
maxBytes: maxBytes,
|
||||||
closed: make(chan struct{}),
|
closed: make(chan struct{}),
|
||||||
spaceChanged: make(chan struct{}),
|
spaceChanged: make(chan struct{}),
|
||||||
@@ -1318,15 +1437,18 @@ func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket,
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
payloadBytes := int64(len(p.Payload))
|
accountedBytes := int64(0)
|
||||||
if err := q.adoptPayloadMemory(ctx, memory, payloadBytes); err != nil {
|
if p.Reader == nil {
|
||||||
|
accountedBytes = nativeXHTTPAccountedPacketBytes(int64(len(p.Payload)))
|
||||||
|
}
|
||||||
|
if err := q.adoptPayloadMemory(ctx, memory, accountedBytes); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
transferred := payloadBytes > 0
|
p.accountedBytes = accountedBytes
|
||||||
if transferred {
|
if accountedBytes > 0 {
|
||||||
defer func() {
|
defer func() {
|
||||||
if payloadBytes > 0 {
|
if accountedBytes > 0 {
|
||||||
q.releasePayloadMemory(payloadBytes)
|
q.releasePayloadMemory(accountedBytes)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -1334,7 +1456,7 @@ func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket,
|
|||||||
case q.pushedPackets <- p:
|
case q.pushedPackets <- p:
|
||||||
// Ownership has moved to the queue. close() waits for this producer and
|
// Ownership has moved to the queue. close() waits for this producer and
|
||||||
// then drains/releases anything not consumed by the stream reader.
|
// then drains/releases anything not consumed by the stream reader.
|
||||||
payloadBytes = 0
|
accountedBytes = 0
|
||||||
readerReserved = false
|
readerReserved = false
|
||||||
return nil
|
return nil
|
||||||
case <-q.closed:
|
case <-q.closed:
|
||||||
@@ -1429,70 +1551,74 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
|
|||||||
return reader.Read(b)
|
return reader.Read(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
for {
|
||||||
case <-q.closed:
|
|
||||||
return 0, io.EOF
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(q.heap) == 0 {
|
|
||||||
p, err := q.recv()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if p.Reader != nil {
|
|
||||||
if !q.setReader(p.Reader) {
|
|
||||||
_ = p.Reader.Close()
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
return p.Reader.Read(b)
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case <-q.closed:
|
case <-q.closed:
|
||||||
q.releasePayloadMemory(int64(len(p.Payload)))
|
|
||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
heap.Push(&q.heap, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
for len(q.heap) > 0 {
|
if len(q.heap) == 0 {
|
||||||
packet := heap.Pop(&q.heap).(nativeXHTTPPacket)
|
|
||||||
|
|
||||||
if packet.Seq == q.nextSeq {
|
|
||||||
n := copy(b, packet.Payload)
|
|
||||||
q.releasePayloadMemory(int64(n))
|
|
||||||
if n < len(packet.Payload) {
|
|
||||||
packet.Payload = packet.Payload[n:]
|
|
||||||
heap.Push(&q.heap, packet)
|
|
||||||
} else {
|
|
||||||
q.nextSeq = packet.Seq + 1
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if packet.Seq > q.nextSeq {
|
|
||||||
if len(q.heap) > q.maxPackets {
|
|
||||||
return 0, errors.New("xhttp upload reassembly buffer too large")
|
|
||||||
}
|
|
||||||
heap.Push(&q.heap, packet)
|
|
||||||
p, err := q.recv()
|
p, err := q.recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if p.Reader != nil {
|
if p.Reader != nil {
|
||||||
_ = p.Reader.Close()
|
if !q.setReader(p.Reader) {
|
||||||
return 0, errors.New("xhttp mixed stream-up and packet-up upload")
|
_ = p.Reader.Close()
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
return p.Reader.Read(b)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-q.closed:
|
||||||
|
q.releasePayloadMemory(p.accountedBytes)
|
||||||
|
return 0, io.EOF
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
heap.Push(&q.heap, p)
|
heap.Push(&q.heap, p)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A duplicate/late packet is discarded; release the bytes it owned.
|
for len(q.heap) > 0 {
|
||||||
q.releasePayloadMemory(int64(len(packet.Payload)))
|
packet := heap.Pop(&q.heap).(nativeXHTTPPacket)
|
||||||
}
|
|
||||||
|
|
||||||
return 0, nil
|
if packet.Seq == q.nextSeq {
|
||||||
|
if len(packet.Payload) == 0 {
|
||||||
|
q.releasePayloadMemory(packet.accountedBytes)
|
||||||
|
q.nextSeq = packet.Seq + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := copy(b, packet.Payload)
|
||||||
|
if n < len(packet.Payload) {
|
||||||
|
q.releasePayloadMemory(int64(n))
|
||||||
|
packet.accountedBytes -= int64(n)
|
||||||
|
packet.Payload = packet.Payload[n:]
|
||||||
|
heap.Push(&q.heap, packet)
|
||||||
|
} else {
|
||||||
|
q.releasePayloadMemory(packet.accountedBytes)
|
||||||
|
q.nextSeq = packet.Seq + 1
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if packet.Seq > q.nextSeq {
|
||||||
|
heap.Push(&q.heap, packet)
|
||||||
|
p, err := q.recv()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if p.Reader != nil {
|
||||||
|
_ = p.Reader.Close()
|
||||||
|
return 0, errors.New("xhttp mixed stream-up and packet-up upload")
|
||||||
|
}
|
||||||
|
heap.Push(&q.heap, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A duplicate/late packet is discarded; release the bytes it owned.
|
||||||
|
q.releasePayloadMemory(packet.accountedBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *nativeXHTTPUploadQueue) loadReader() io.ReadCloser {
|
func (q *nativeXHTTPUploadQueue) loadReader() io.ReadCloser {
|
||||||
|
|||||||
Reference in New Issue
Block a user