Fix quota

This commit is contained in:
2026-07-20 00:00:39 -03:00
parent 5f43698e2b
commit 9bbd950b66
17 changed files with 729 additions and 157 deletions
+21 -15
View File
@@ -68,15 +68,15 @@ A confirmação dessa migração é exibida dentro do próprio painel. Se a grav
### Cota de tráfego e proteção de recursos ### Cota de tráfego e proteção de recursos
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. 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: O runtime nativo também possui limites globais para impedir crescimento sem controle de sockets, goroutines e sessões HTTP:
- `max_concurrent_connections`: conexões de transporte TCP/TLS/WebSocket/XHTTP; padrão `4096`; - `max_concurrent_connections`: conexões de transporte TCP/TLS/WebSocket/XHTTP; padrão `32768`;
- `max_concurrent_xhttp_requests`: handlers XHTTP simultâneos; padrão `8192`; - `max_concurrent_xhttp_requests`: mantido apenas para compatibilidade de configuração; o limite de requisições web fica desativado (`-1`) no XHTTP;
- `xhttp_max_sessions`: sessões XHTTP ativas; padrão `4096`. - `xhttp_max_sessions`: sessões XHTTP ativas; padrão `32768`.
Esses campos ficam em **Configurações → Xray → Native Xray scale tuning**. `0` seleciona o padrão seguro e `-1` desativa o respectivo contador de admissão, o que não é recomendado em listeners públicos; conexões HTTP/2 continuam com um limite de 256 streams simultâneos por conexão. Cada transporte Mux aceita no máximo 64 sessões filhas, com limite global padrão de 8192. 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. 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.
### Requisitos ### Requisitos
@@ -609,15 +609,15 @@ The migration confirmation is rendered inside the panel. If saving fails, the te
### Traffic quotas and resource protection ### Traffic quotas and resource protection
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. `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 native runtime also has global ceilings that prevent unbounded socket, goroutine, and HTTP-session growth:
- `max_concurrent_connections`: TCP/TLS/WebSocket/XHTTP transport connections; default `4096`; - `max_concurrent_connections`: TCP/TLS/WebSocket/XHTTP transport connections; default `32768`;
- `max_concurrent_xhttp_requests`: simultaneous XHTTP handlers; default `8192`; - `max_concurrent_xhttp_requests`: retained for configuration compatibility; the web-request cap is disabled (`-1`) for XHTTP;
- `xhttp_max_sessions`: active XHTTP sessions; default `4096`. - `xhttp_max_sessions`: active XHTTP sessions; default `32768`.
These fields are available under **Settings → Xray → Native Xray scale tuning**. `0` selects the safe default and `-1` disables the corresponding admission counter, which is not recommended on public listeners; HTTP/2 connections still retain a 256-stream concurrent guard. Each Mux transport accepts at most 64 child sessions, with a default global ceiling of 8192. 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. 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.
### Requirements ### Requirements
@@ -1144,14 +1144,17 @@ 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), `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`, `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.
- Body: `username` (string, required); `password` (string, optional — empty keeps the existing password on an existing user; for a new user either `password` or `totp_secret` is required); `max_connections` (int); `expires_at` (string); `limit_mbps_up` (int); `limit_mbps_down` (int); `totp_secret` (string); `totp_period` (int); `totp_window` (int); `totp_digits` (int); `allow_static_password` (bool); `owner_username` (string, optional — honored only for superadmin; resellers are forced to themselves); `server_id` (string, optional). - Body: `username` (string, required); `password` (string, optional — empty keeps the existing password on an existing user; for a new user either `password` or `totp_secret` is required); `max_connections` (010000); `expires_at` (RFC3339 string); `limit_mbps_up` and `limit_mbps_down` (01000000); `data_quota_bytes` (non-negative int64); `quota_action` (`block` or `throttle`); `quota_throttle_mbps` (01000000; zero defaults to 1); `reset_usage` (bool); `totp_secret` (string); `totp_period` (int); `totp_window` (int); `totp_digits` (int); `allow_static_password` (bool); `owner_username` (string, optional — honored only for superadmin; resellers are forced to themselves); `server_id` (string, optional).
- `201 Created` (empty body). A proxied create returns the remote node's status/body. - `201 Created` (empty body). A proxied create returns the remote node's status/body.
- Errors: `400 username required`, `400 password or totp_secret required for new user`; `403 user limit reached (N)`; `403 SSH creation is disabled for this server`; `503 database not configured`. - Errors: `400 username required`, `400 password or totp_secret required for new user`; `403 user limit reached (N)`; `403 SSH creation is disabled for this server`; `503 database not configured`.
#### `POST /api/users/reset-traffic` — session
- Body: `username` (required), `server_id` (optional). Resets only byte counters. Resellers may reset only their own users.
#### `DELETE /api/users/delete` — session #### `DELETE /api/users/delete` — session
- Query: `username` (string, required); optional `server_id`. Resellers may delete only their own users. - Query: `username` (string, required); optional `server_id`. Resellers may delete only their own users.
- `204 No Content`. Errors: `400 username required`; `403 forbidden`; `503 database not configured`. - `204 No Content`. Errors: `400 username required`; `403 forbidden`; `503 database not configured`.
@@ -1261,16 +1264,19 @@ 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`, `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), `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` (string, required), `email` (string, optional — defaults to name then uuid), `name` (string, optional), `expires_at` (string, optional), `max_connections` (int), `owner_username` (string, optional — superadmin only), `server_id` (string, optional). - Body: `inbound_tag` (string, required), `uuid` (valid UUID, required), `email` (string, optional — defaults to name then uuid), `name` (string, optional), `expires_at` (RFC3339, `YYYY-MM-DDThh:mm`, or `YYYY-MM-DD`), `max_connections` (010000), `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `owner_username` (string, optional — superadmin only), `server_id` (string, optional).
- `201 Created` (empty). Errors: `400 inbound_tag and uuid required` / `UUID already exists in database`; `403 reseller account suspended or expired` / `user limit reached (N)` / `Xray creation is disabled for this server`; `500`. - `201 Created` (empty). Errors: `400 inbound_tag and uuid required` / `UUID already exists in database`; `403 reseller account suspended or expired` / `user limit reached (N)` / `Xray creation is disabled for this server`; `500`.
#### `POST /api/xray/clients/update` — session #### `POST /api/xray/clients/update` — session
- Body: `uuid` (string, required), `name` (string), `email` (string), `expires_at` (string), `max_connections` (int), `server_id` (string, optional). Inbound tag and owner are preserved from existing metadata. Resellers may update only their own clients. - Body: `uuid` (valid UUID, required), `name` (string), `email` (string), `expires_at` (string), `max_connections` (010000), `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `reset_usage` (bool), `server_id` (string, optional). Inbound tag and owner are preserved from existing metadata. Resellers may update only their own clients.
- `200`. Errors: `400 uuid required`; `403 forbidden`; `404 client metadata not found`; `500`. - `200`. Errors: `400 uuid required`; `403 forbidden`; `404 client metadata not found`; `500`.
#### `POST /api/xray/clients/reset-traffic` — session
- Body: `uuid` (required), `server_id` (optional). Resets only byte counters. Resellers may reset only their own clients.
#### `DELETE /api/xray/clients/remove` — session #### `DELETE /api/xray/clients/remove` — session
- Query: `inbound_tag` (string, required), `uuid` (string, required); optional `server_id`. Resellers may remove only their own clients. - Query: `inbound_tag` (string, required), `uuid` (string, required); optional `server_id`. Resellers may remove only their own clients.
- `204 No Content`. Errors: `400 inbound_tag and uuid required`; `403 forbidden`; `500`. - `204 No Content`. Errors: `400 inbound_tag and uuid required`; `403 forbidden`; `500`.
+31
View File
@@ -750,3 +750,34 @@ function patchRenderedInbounds(inbounds) {
} }
return true; return true;
} }
// 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.
Object.assign(I18N_TEXT["en-US"], {
"Native Xray scale tuning":"Native Xray scale tuning",
"Go CPU threads (GOMAXPROCS)":"Go CPU threads (GOMAXPROCS)",
"Global mux backend sessions":"Global mux backend sessions",
"Global transport connections":"Global transport connections",
"XHTTP web request cap":"XHTTP web request 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",
"debug only, slows QUIC":"debug only, slows QUIC",
"Apply high-traffic VPN defaults":"Apply high-traffic VPN 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."
});
Object.assign(I18N_TEXT["pt-BR"], {
"Native Xray scale tuning":"Ajustes de escala do Xray nativo",
"Go CPU threads (GOMAXPROCS)":"Threads de CPU do Go (GOMAXPROCS)",
"Global mux backend sessions":"Sessões globais de backend Mux",
"Global transport connections":"Conexões globais de transporte",
"XHTTP web request cap":"Limite web de requisiçõ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",
"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 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."
});
+4 -1
View File
@@ -9,7 +9,10 @@ cancelUserBtn.addEventListener("click", () => {
function prepareNewSSHUser() { function prepareNewSSHUser() {
userForm.reset(); userForm.reset();
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6; fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
fQuotaAction.value = "block"; // SSH and SSH-over-XHTTP plans normally remain connected after quota and
// fall back to the configured post-quota speed. Existing users keep their
// saved action when edited.
fQuotaAction.value = "throttle";
fQuotaThrottle.value = 1; fQuotaThrottle.value = 1;
fUsageDisplay.value = "0 B"; fUsageDisplay.value = "0 B";
fResetUsage.checked = false; fResetUsage.checked = false;
+10 -10
View File
@@ -35,18 +35,18 @@ function toggleUdpgwFields(on) {
const XRAY_NATIVE_TUNING_DEFAULTS = { const XRAY_NATIVE_TUNING_DEFAULTS = {
safe: { safe: {
runtime_gomaxprocs: 0, runtime_gomaxprocs: 0,
mux_global_sessions: 8192, mux_global_sessions: 32768,
max_concurrent_connections: 4096, max_concurrent_connections: 32768,
max_concurrent_xhttp_requests: 8192, max_concurrent_xhttp_requests: -1,
xhttp_max_sessions: 4096, xhttp_max_sessions: 32768,
trace_packets: false, trace_packets: false,
}, },
"2k": { "high": {
runtime_gomaxprocs: 0, runtime_gomaxprocs: 0,
mux_global_sessions: 32768, mux_global_sessions: 65536,
max_concurrent_connections: 8192, max_concurrent_connections: 65536,
max_concurrent_xhttp_requests: 16384, max_concurrent_xhttp_requests: -1,
xhttp_max_sessions: 8192, xhttp_max_sessions: 65536,
trace_packets: false, trace_packets: false,
}, },
}; };
@@ -59,7 +59,7 @@ const XRAY_NATIVE_TUNING_FIELDS = {
xhttp_max_sessions: "cfgXrayMaxXHTTPSessions", xhttp_max_sessions: "cfgXrayMaxXHTTPSessions",
}; };
function setXrayNativeTuningDefaults(profile = "2k") { function setXrayNativeTuningDefaults(profile = "high") {
const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe; const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe;
writeXrayNativeTuning(t); writeXrayNativeTuning(t);
} }
+10 -10
View File
@@ -311,7 +311,7 @@
<div class="field"><label>Max Upload (Mb/s)</label><input id="fUp" type="number" min="0" placeholder="0 = default"/></div> <div class="field"><label>Max Upload (Mb/s)</label><input id="fUp" type="number" min="0" placeholder="0 = default"/></div>
<div class="field"><label>Max Download (Mb/s)</label><input id="fDown" type="number" min="0" placeholder="0 = default"/></div> <div class="field"><label>Max Download (Mb/s)</label><input id="fDown" type="number" min="0" placeholder="0 = default"/></div>
<div class="field"><label>Data quota (GB) <span class="hint">0 = unlimited · 1024 = 1 TB</span></label><input id="fQuotaGB" type="number" min="0" step="0.01" placeholder="0"/></div> <div class="field"><label>Data quota (GB) <span class="hint">0 = unlimited · 1024 = 1 TB</span></label><input id="fQuotaGB" type="number" min="0" step="0.01" placeholder="0"/></div>
<div class="field"><label>When quota is reached</label><select id="fQuotaAction"><option value="block">Block user</option><option value="throttle">Reduce speed</option></select></div> <div class="field"><label>When quota is reached</label><select id="fQuotaAction"><option value="block">Block user</option><option value="throttle" selected>Reduce speed</option></select></div>
<div class="field"><label>Post-quota speed (Mb/s)</label><input id="fQuotaThrottle" type="number" min="1" value="1"/></div> <div class="field"><label>Post-quota speed (Mb/s)</label><input id="fQuotaThrottle" type="number" min="1" value="1"/></div>
<div class="field"><label>Current usage</label><input id="fUsageDisplay" readonly value="0 B"/></div> <div class="field"><label>Current usage</label><input id="fUsageDisplay" readonly value="0 B"/></div>
<div class="field"><label>Reset traffic counter</label><input id="fResetUsage" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div> <div class="field"><label>Reset traffic counter</label><input id="fResetUsage" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
@@ -1509,16 +1509,16 @@
<summary style="cursor:pointer;font-size:.76rem;font-weight:700;color:var(--text);">Native Xray scale tuning</summary> <summary style="cursor:pointer;font-size:.76rem;font-weight:700;color:var(--text);">Native Xray scale tuning</summary>
<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="8192"/></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=4096, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxConnections" placeholder="4096"/></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"><label>Concurrent XHTTP requests <span class="hint">0=8192, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxXHTTPRequests" placeholder="8192"/></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=4096, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxXHTTPSessions" placeholder="4096"/></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('2k')">Apply 2K 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;">The transport ceiling rejects sockets before native protocol/TLS work starts; the XHTTP ceilings bound concurrent handlers and session state. Keep the safe defaults unless load testing proves the VPS can sustain more; -1 disables an application ceiling and is not recommended on public listeners. HTTP/2 still keeps a 256-stream guard per connection. Transport buffers remain fixed to safe defaults. 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 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> </div>
</details> </details>
</div> </div>
@@ -1558,14 +1558,14 @@
<!-- 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=20260715quotareset1"></script> <script defer src="assets/js/01-core.js?v=20260719xhttp502fix1"></script>
<script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script> <script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260715sshtraffic1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260719quotaaudit1"></script>
<script defer src="assets/js/04-xray.js?v=20260715quotareset1"></script> <script defer src="assets/js/04-xray.js?v=20260715quotareset1"></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=20260715hardening1"></script> <script defer src="assets/js/08-server-config.js?v=20260719xhttp502fix1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260714quota1"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260714quota1"></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>
+5 -2
View File
@@ -586,7 +586,7 @@ func startResellerExpiryChecker(store *Store) {
u.IsActive = false u.IsActive = false
adminUsers.set(u) adminUsers.set(u)
disconnectOwnerUsers(u.Username) disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username) suspendOwnerXrayClients(ctx, store, u.Username)
} }
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil) // Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
@@ -602,6 +602,7 @@ func startResellerExpiryChecker(store *Store) {
} }
u.IsActive = true u.IsActive = true
adminUsers.set(u) adminUsers.set(u)
restoreOwnerXrayClients(ctx, store, u.Username)
} }
sessions.cleanup() sessions.cleanup()
@@ -862,7 +863,9 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
if u.Role == RoleReseller { if u.Role == RoleReseller {
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) { if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
disconnectOwnerUsers(u.Username) disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username) suspendOwnerXrayClients(ctx, store, u.Username)
} else {
restoreOwnerXrayClients(ctx, store, u.Username)
} }
} }
+15
View File
@@ -1907,10 +1907,25 @@ func handleCreateUser(store *Store) http.HandlerFunc {
http.Error(w, "invalid json", http.StatusBadRequest) http.Error(w, "invalid json", http.StatusBadRequest)
return return
} }
p.Username = strings.TrimSpace(p.Username)
if p.Username == "" { if p.Username == "" {
http.Error(w, "username required", http.StatusBadRequest) http.Error(w, "username required", http.StatusBadRequest)
return return
} }
if p.MaxConnections < 0 || p.MaxConnections > 10000 {
http.Error(w, "max_connections must be between 0 and 10000", http.StatusBadRequest)
return
}
if p.LimitUpMbps < 0 || p.LimitUpMbps > 1000000 || p.LimitDownMbps < 0 || p.LimitDownMbps > 1000000 {
http.Error(w, "bandwidth limits must be between 0 and 1000000 Mbps", http.StatusBadRequest)
return
}
if p.ExpiresAt != "" {
if _, err := time.Parse(time.RFC3339, p.ExpiresAt); err != nil {
http.Error(w, "invalid expires_at (RFC3339 required)", http.StatusBadRequest)
return
}
}
if err := validateQuotaConfig(p.DataQuotaBytes, p.QuotaAction, p.QuotaThrottleMbps); err != nil { if err := validateQuotaConfig(p.DataQuotaBytes, p.QuotaAction, p.QuotaThrottleMbps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
+11 -5
View File
@@ -106,9 +106,9 @@ func (s *Store) ResetSSHUserTraffic(ctx context.Context, username string) error
return nil return nil
} }
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
UPDATE ssh_users UPDATE ssh_users
SET total_uplink_bytes = 0, total_downlink_bytes = 0 SET total_uplink_bytes = 0, total_downlink_bytes = 0
WHERE username = $1`, username) WHERE username = $1`, username)
return err return err
} }
@@ -353,9 +353,15 @@ func validateQuotaConfig(quotaBytes int64, action string, throttleMbps int) erro
if quotaBytes < 0 { if quotaBytes < 0 {
return fmt.Errorf("data_quota_bytes must be non-negative") return fmt.Errorf("data_quota_bytes must be non-negative")
} }
action = normalizeQuotaAction(action) rawAction := strings.ToLower(strings.TrimSpace(action))
if quotaBytes > 0 && action == quotaActionThrottle && throttleMbps < 0 { if rawAction != "" && rawAction != quotaActionBlock && rawAction != quotaActionThrottle {
return fmt.Errorf("quota_action must be block or throttle")
}
if throttleMbps < 0 {
return fmt.Errorf("quota_throttle_mbps must be non-negative") return fmt.Errorf("quota_throttle_mbps must be non-negative")
} }
if throttleMbps > 1000000 {
return fmt.Errorf("quota_throttle_mbps must not exceed 1000000")
}
return nil return nil
} }
+162
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"io" "io"
"net" "net"
"net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync" "sync"
@@ -70,6 +71,67 @@ func TestNativeClientMaxConnectionsAndBatchedActiveDelta(t *testing.T) {
release2() release2()
} }
func TestNativeOnlineUsersAreKeyedByUUID(t *testing.T) {
oldStore := statsStore
statsStore = nil
defer func() { statsStore = oldStore }()
m := &XrayManager{}
m.recordNativeConnect("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "shared@example", nil)
m.recordNativeConnect("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "shared@example", nil)
if got := m.CountOnlineUsers(); got != 2 {
t.Fatalf("online UUID count = %d, want 2 for two UUIDs sharing one email", got)
}
m.statsMu.RLock()
_, first := m.statsByEmail["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"]
_, second := m.statsByEmail["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"]
m.statsMu.RUnlock()
if !first || !second {
t.Fatal("native runtime stats were not stored under canonical UUID keys")
}
}
func TestNativeExpiryRejectsAndDisconnectsClient(t *testing.T) {
const uuid = "cccccccc-cccc-cccc-cccc-cccccccccccc"
state := &xrayNativeQuotaState{hasExpiry: true, expiresAt: time.Now().Add(time.Hour), generation: 1}
m := &XrayManager{nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: state}}
closer := &closeTrackingReader{}
release, _, ok := m.acquireNativeClientConnection(uuid, "expiry@example", closer)
if !ok {
t.Fatal("unexpired client was rejected")
}
m.disconnectNativeClient(uuid)
if !closer.closed.Load() {
t.Fatal("active native client was not closed during revocation")
}
release()
state.mu.Lock()
state.expiresAt = time.Now().Add(-time.Second)
state.mu.Unlock()
if reason := m.nativeClientAccessDenied(uuid); reason != "expired" {
t.Fatalf("expired client denial = %q, want expired", reason)
}
if _, _, ok := m.acquireNativeClientConnection(uuid, "expiry@example"); ok {
t.Fatal("expired client acquired a new connection")
}
}
func TestQuotaAndExpiryValidationRejectsUnsafeInput(t *testing.T) {
if err := validateQuotaConfig(1, "typo", 1); err == nil {
t.Fatal("unknown quota action was accepted")
}
if err := validateQuotaConfig(1, quotaActionBlock, -1); err == nil {
t.Fatal("negative throttle setting was accepted")
}
if _, err := parseOptionalXrayExpiry("not-a-date"); err == nil {
t.Fatal("invalid Xray expiry was accepted")
}
if exp, err := parseOptionalXrayExpiry(""); err != nil || exp != nil {
t.Fatalf("empty Xray expiry = (%v, %v), want nil, nil", exp, err)
}
}
func TestRemoveNativeQuotaPolicyPrunesPendingMaps(t *testing.T) { func TestRemoveNativeQuotaPolicyPrunesPendingMaps(t *testing.T) {
m := &XrayManager{ m := &XrayManager{
nativeQuotaByUUID: map[string]*xrayNativeQuotaState{ nativeQuotaByUUID: map[string]*xrayNativeQuotaState{
@@ -211,6 +273,40 @@ func TestNativeProtocolGuardsRemainFinite(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: "/"}
req := httptest.NewRequest(http.MethodOptions, "/", nil)
rec := httptest.NewRecorder()
ib.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("XHTTP OPTIONS at legacy request ceiling = %d, want 200", rec.Code)
}
}
func TestLegacyXHTTPTuningMigratesToVPNDefaults(t *testing.T) {
got := normalizeNativeXrayTuning(&XrayNativeTuning{
MuxGlobalSessions: 8192,
MaxConcurrentConnections: 4096,
MaxConcurrentXHTTPRequests: 8192,
XHTTPMaxSessions: 4096,
})
if got.MuxGlobalSessions != defaultNativeMuxGlobalSessions ||
got.MaxConcurrentConnections != defaultNativeMaxConnections ||
got.MaxConcurrentXHTTPRequests != defaultNativeMaxXHTTPRequests ||
got.XHTTPMaxSessions != defaultNativeXHTTPMaxSessions {
t.Fatalf("legacy tuning was not migrated: %+v", got)
}
}
func TestXHTTPMetadataLengthIsBoundedBeforeSessionAllocation(t *testing.T) { func TestXHTTPMetadataLengthIsBoundedBeforeSessionAllocation(t *testing.T) {
ib := &nativeInbound{transport: "xhttp", path: "/"} ib := &nativeInbound{transport: "xhttp", path: "/"}
req := httptest.NewRequest("GET", "/"+strings.Repeat("a", nativeXHTTPMaxSessionIDBytes+1), nil) req := httptest.NewRequest("GET", "/"+strings.Repeat("a", nativeXHTTPMaxSessionIDBytes+1), nil)
@@ -288,6 +384,72 @@ func TestXHTTPUploadQueueEnforcesPerSessionByteBudget(t *testing.T) {
} }
} }
func TestXHTTPUploadQueueBackpressuresInsteadOfRejectingBurst(t *testing.T) {
before := nativeXHTTPBufferedBytes.Load()
q := newNativeXHTTPUploadQueue(2, 4)
defer q.close()
first, ok := acquireNativeXHTTPMemory(4)
if !ok {
t.Fatal("failed to reserve first XHTTP payload")
}
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("one!"), Seq: 0}, first); err != nil {
first.release()
t.Fatalf("first queue push failed: %v", err)
}
first.release()
second, ok := acquireNativeXHTTPMemory(4)
if !ok {
t.Fatal("failed to reserve second XHTTP payload")
}
done := make(chan error, 1)
go func() {
done <- q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("two!"), Seq: 1}, second)
}()
select {
case err := <-done:
second.release()
t.Fatalf("second burst packet did not backpressure: %v", err)
case <-time.After(25 * time.Millisecond):
}
buf := make([]byte, 4)
if n, err := q.Read(buf); err != nil || n != 4 || string(buf) != "one!" {
second.release()
t.Fatalf("first queue read = (%d, %v, %q)", n, err, string(buf))
}
select {
case err := <-done:
if err != nil {
second.release()
t.Fatalf("backpressured packet failed after space released: %v", err)
}
second.release()
case <-time.After(time.Second):
second.release()
t.Fatal("backpressured packet did not resume")
}
q.close()
if got := nativeXHTTPBufferedBytes.Load(); got != before {
t.Fatalf("backpressure test leaked %d buffered bytes (baseline %d)", got, before)
}
}
func TestXHTTPBodyReservationUsesActualContentLength(t *testing.T) {
ib := &nativeInbound{xhttpMaxEachPostBytes: 1_000_000}
req := httptest.NewRequest(http.MethodPost, "/session/0", strings.NewReader("small"))
if got := ib.xhttpUploadReservationBytes(req); got != 5 {
t.Fatalf("body reservation = %d, want actual payload length 5", got)
}
req.ContentLength = -1
if got := ib.xhttpUploadReservationBytes(req); got != 1_000_000 {
t.Fatalf("chunked body reservation = %d, want configured maximum", got)
}
}
func TestNativeQuotaResetWaitsForInFlightTraffic(t *testing.T) { func TestNativeQuotaResetWaitsForInFlightTraffic(t *testing.T) {
const uuid = "22222222-2222-2222-2222-222222222222" const uuid = "22222222-2222-2222-2222-222222222222"
state := &xrayNativeQuotaState{usedBytes: 123, generation: 1} state := &xrayNativeQuotaState{usedBytes: 123, generation: 1}
+92 -29
View File
@@ -310,6 +310,66 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str
} }
} }
// suspendOwnerXrayClients revokes transport access while preserving metadata,
// expiry, traffic and quota. This makes reseller suspension/renewal reversible.
func suspendOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
if store == nil || ownerUsername == "" {
return
}
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
if err != nil {
log.Printf("xray owner suspension: list %s: %v", ownerUsername, err)
return
}
changed := false
for _, m := range clients {
xrayMgr.disconnectNativeClient(m.UUID)
if m.InboundTag == "" {
continue
}
if err := xrayMgr.RemoveXrayClient(m.InboundTag, m.UUID); err != nil {
log.Printf("xray owner suspension: remove %s from %s: %v", m.UUID, m.InboundTag, err)
continue
}
changed = true
}
if changed {
xrayMgr.restartIfExternalRunning()
}
}
// restoreOwnerXrayClients reactivates non-expired clients after reseller renewal.
func restoreOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
if store == nil || ownerUsername == "" {
return
}
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
if err != nil {
log.Printf("xray owner restore: list %s: %v", ownerUsername, err)
return
}
now := time.Now()
changed := false
for _, m := range clients {
if m.InboundTag == "" || (m.ExpiresAt != nil && !m.ExpiresAt.After(now)) {
continue
}
email := m.Email
if email == "" {
email = m.UUID
}
if err := xrayMgr.EnsureXrayClient(m.InboundTag, m.UUID, email); err != nil {
log.Printf("xray owner restore: add %s to %s: %v", m.UUID, m.InboundTag, err)
continue
}
xrayMgr.setNativeQuotaPolicy(m)
changed = true
}
if changed {
xrayMgr.restartIfExternalRunning()
}
}
// startXrayClientExpiryChecker runs a background goroutine that removes expired // startXrayClientExpiryChecker runs a background goroutine that removes expired
// Xray clients from both the config file and the database every 5 minutes. // Xray clients from both the config file and the database every 5 minutes.
func startXrayClientExpiryChecker(store *Store) { func startXrayClientExpiryChecker(store *Store) {
@@ -320,39 +380,42 @@ func startXrayClientExpiryChecker(store *Store) {
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
ctx := context.Background() expireXrayClientsOnce(store)
expired, err := store.ListExpiredXrayClients(ctx)
if err != nil {
log.Printf("xray expiry checker: list error: %v", err)
continue
}
if len(expired) == 0 {
continue
}
needRestart := false
for _, m := range expired {
tag := m.InboundTag
if tag == "" {
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
continue
}
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
} else {
needRestart = true
}
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
}
log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
}
if needRestart {
xrayMgr.restartIfExternalRunning()
}
} }
}() }()
} }
func expireXrayClientsOnce(store *Store) {
if store == nil {
return
}
ctx := context.Background()
expired, err := store.ListExpiredXrayClients(ctx)
if err != nil {
log.Printf("xray expiry checker: list error: %v", err)
return
}
needRestart := false
for _, m := range expired {
tag := m.InboundTag
xrayMgr.disconnectNativeClient(m.UUID)
if tag != "" {
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
} else {
needRestart = true
}
}
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
}
log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
}
if needRestart {
xrayMgr.restartIfExternalRunning()
}
}
// ResetXrayClientTraffic clears a client's persistent usage without removing // ResetXrayClientTraffic clears a client's persistent usage without removing
// the account or changing its expiry/quota policy. // the account or changing its expiry/quota policy.
func (s *Store) ResetXrayClientTraffic(ctx context.Context, uuid string) error { func (s *Store) ResetXrayClientTraffic(ctx context.Context, uuid string) error {
+102 -38
View File
@@ -309,6 +309,10 @@ func initXrayManager(cfg *XrayConfig) {
} }
xrayMgr.mu.Unlock() xrayMgr.mu.Unlock()
// Reconcile already-expired rows before the runtime loads DB-backed clients.
// The periodic checker intentionally sleeps between passes, so doing one pass
// here closes the startup window in which an expired UUID could reconnect.
expireXrayClientsOnce(statsStore)
xrayMgr.reloadNativeQuotaPolicies() xrayMgr.reloadNativeQuotaPolicies()
// In native mode the in-process emulator records traffic directly, so the // In native mode the in-process emulator records traffic directly, so the
@@ -478,11 +482,12 @@ func (m *XrayManager) recordNativeConnect(uuid, email string, state *xrayNativeQ
if m.statsByEmail == nil { if m.statsByEmail == nil {
m.statsByEmail = make(map[string]xrayRuntimeStat) m.statsByEmail = make(map[string]xrayRuntimeStat)
} }
st := m.statsByEmail[email] key := firstNonEmpty(uuid, email)
st := m.statsByEmail[key]
st.Email = email st.Email = email
st.LastActive = now st.LastActive = now
st.ActiveConnections++ st.ActiveConnections++
m.statsByEmail[email] = st m.statsByEmail[key] = st
m.statsMu.Unlock() m.statsMu.Unlock()
m.queueNativeActiveDelta(uuid, email, 1, true, state) m.queueNativeActiveDelta(uuid, email, 1, true, state)
@@ -499,11 +504,12 @@ func (m *XrayManager) recordNativeDisconnect(uuid, email string, state *xrayNati
} }
m.statsMu.Lock() m.statsMu.Lock()
if m.statsByEmail != nil { if m.statsByEmail != nil {
st := m.statsByEmail[email] key := firstNonEmpty(uuid, email)
st := m.statsByEmail[key]
if st.ActiveConnections > 0 { if st.ActiveConnections > 0 {
st.ActiveConnections-- st.ActiveConnections--
} }
m.statsByEmail[email] = st m.statsByEmail[key] = st
} }
m.statsMu.Unlock() m.statsMu.Unlock()
@@ -595,12 +601,13 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64, ge
if m.statsByEmail == nil { if m.statsByEmail == nil {
m.statsByEmail = make(map[string]xrayRuntimeStat) m.statsByEmail = make(map[string]xrayRuntimeStat)
} }
st := m.statsByEmail[email] key := firstNonEmpty(uuid, email)
st := m.statsByEmail[key]
st.Email = email st.Email = email
st.Uplink += up st.Uplink += up
st.Downlink += down st.Downlink += down
st.LastActive = now st.LastActive = now
m.statsByEmail[email] = st m.statsByEmail[key] = st
m.statsMu.Unlock() m.statsMu.Unlock()
} }
@@ -2261,6 +2268,16 @@ func (m *XrayManager) modifyRawConfig(fn func(cfg map[string]interface{}) error)
// AddXrayClient adds a client to the named inbound and saves the config. // AddXrayClient adds a client to the named inbound and saves the config.
func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error { func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error {
return m.addXrayClient(inboundTag, uuid, email, false)
}
// EnsureXrayClient restores a previously suspended DB-backed client without
// failing if it is already present in the active config.
func (m *XrayManager) EnsureXrayClient(inboundTag, uuid, email string) error {
return m.addXrayClient(inboundTag, uuid, email, true)
}
func (m *XrayManager) addXrayClient(inboundTag, uuid, email string, allowExisting bool) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
err := m.modifyRawConfig(func(raw map[string]interface{}) error { err := m.modifyRawConfig(func(raw map[string]interface{}) error {
@@ -2287,6 +2304,10 @@ func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error {
id, _ = cm["password"].(string) id, _ = cm["password"].(string)
} }
if id == uuid { if id == uuid {
if allowExisting {
cm["email"] = email
return nil
}
return fmt.Errorf("UUID %s already exists in inbound %s", uuid, inboundTag) return fmt.Errorf("UUID %s already exists in inbound %s", uuid, inboundTag)
} }
} }
@@ -2594,6 +2615,16 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
return return
} }
req.InboundTag = strings.TrimSpace(req.InboundTag)
req.UUID = strings.TrimSpace(req.UUID)
if _, err := parseUUID(req.UUID); err != nil {
http.Error(w, "invalid uuid: "+err.Error(), http.StatusBadRequest)
return
}
if req.MaxConnections < 0 || req.MaxConnections > 10000 {
http.Error(w, "max_connections must be between 0 and 10000", http.StatusBadRequest)
return
}
if err := validateQuotaConfig(req.DataQuotaBytes, req.QuotaAction, req.QuotaThrottleMbps); err != nil { if err := validateQuotaConfig(req.DataQuotaBytes, req.QuotaAction, req.QuotaThrottleMbps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -2645,6 +2676,11 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
if req.Email == "" { if req.Email == "" {
req.Email = req.UUID req.Email = req.UUID
} }
expiresAt, err := parseOptionalXrayExpiry(req.ExpiresAt)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sess := sessionFromCtx(r.Context()) sess := sessionFromCtx(r.Context())
ownerUsername := "" ownerUsername := ""
@@ -2681,10 +2717,7 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil { var savedMeta *XrayClientMeta
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if statsStore != nil { if statsStore != nil {
meta := XrayClientMeta{ meta := XrayClientMeta{
UUID: req.UUID, UUID: req.UUID,
@@ -2697,24 +2730,22 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
QuotaAction: normalizeQuotaAction(req.QuotaAction), QuotaAction: normalizeQuotaAction(req.QuotaAction),
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(req.QuotaThrottleMbps), QuotaThrottleMbps: quotaThrottleMbpsOrDefault(req.QuotaThrottleMbps),
} }
if req.ExpiresAt != "" { meta.ExpiresAt = expiresAt
var t time.Time
var err error
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
t, err = time.Parse(layout, req.ExpiresAt)
if err == nil {
break
}
}
if err == nil {
meta.ExpiresAt = &t
}
}
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil { if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
xrayLogf("xray: save meta for %s: %v", req.UUID, err) http.Error(w, "save client metadata failed: "+err.Error(), http.StatusInternalServerError)
} else { return
xrayMgr.setNativeQuotaPolicy(&meta)
} }
xrayMgr.setNativeQuotaPolicy(&meta)
savedMeta = &meta
}
// Publish the credential only after its quota/owner/expiry policy exists, so
// a fast native client can never enter an unmetered window during creation.
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
if savedMeta != nil {
_ = statsStore.DeleteXrayClientMeta(r.Context(), req.UUID)
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
} }
xrayMgr.restartIfExternalRunning() xrayMgr.restartIfExternalRunning()
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
@@ -2747,6 +2778,15 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "uuid required", http.StatusBadRequest) http.Error(w, "uuid required", http.StatusBadRequest)
return return
} }
req.UUID = strings.TrimSpace(req.UUID)
if _, err := parseUUID(req.UUID); err != nil {
http.Error(w, "invalid uuid: "+err.Error(), http.StatusBadRequest)
return
}
if req.MaxConnections < 0 || req.MaxConnections > 10000 {
http.Error(w, "max_connections must be between 0 and 10000", http.StatusBadRequest)
return
}
if err := validateQuotaConfig(req.DataQuotaBytes, req.QuotaAction, req.QuotaThrottleMbps); err != nil { if err := validateQuotaConfig(req.DataQuotaBytes, req.QuotaAction, req.QuotaThrottleMbps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -2784,6 +2824,15 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
req.Email = strings.TrimSpace(req.Email)
if req.Email == "" {
req.Email = firstNonEmpty(strings.TrimSpace(req.Name), existing.Email, req.UUID)
}
expiresAt, err := parseOptionalXrayExpiry(req.ExpiresAt)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
meta := XrayClientMeta{ meta := XrayClientMeta{
UUID: req.UUID, UUID: req.UUID,
@@ -2798,15 +2847,18 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
TotalUplinkBytes: existing.TotalUplinkBytes, TotalUplinkBytes: existing.TotalUplinkBytes,
TotalDownlinkBytes: existing.TotalDownlinkBytes, TotalDownlinkBytes: existing.TotalDownlinkBytes,
} }
if req.ExpiresAt != "" { meta.ExpiresAt = expiresAt
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { emailChanged := req.Email != existing.Email
if t, err := time.Parse(layout, req.ExpiresAt); err == nil { if emailChanged {
meta.ExpiresAt = &t if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil {
break http.Error(w, "update config email failed: "+err.Error(), http.StatusInternalServerError)
} return
} }
} }
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil { if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
if emailChanged {
_ = xrayMgr.UpdateXrayClientEmail(req.UUID, existing.Email)
}
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
@@ -2819,16 +2871,28 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
meta.TotalDownlinkBytes = 0 meta.TotalDownlinkBytes = 0
} }
xrayMgr.setNativeQuotaPolicy(&meta) xrayMgr.setNativeQuotaPolicy(&meta)
if req.Email != "" { if meta.ExpiresAt != nil && !meta.ExpiresAt.After(time.Now()) {
if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil { xrayMgr.disconnectNativeClient(req.UUID)
xrayLogf("xray: update config email for %s: %v", req.UUID, err) }
} else { if emailChanged {
xrayMgr.restartIfExternalRunning() xrayMgr.restartIfExternalRunning()
}
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func parseOptionalXrayExpiry(raw string) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return &t, nil
}
}
return nil, fmt.Errorf("invalid expires_at (RFC3339, YYYY-MM-DDThh:mm, or YYYY-MM-DD required)")
}
func handleXrayClientResetTraffic(w http.ResponseWriter, r *http.Request) { func handleXrayClientResetTraffic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
+4 -4
View File
@@ -264,7 +264,7 @@ func (ib *nativeInbound) acceptLoop(ln net.Listener) {
xrayLogf("native xray: accept error on %s: %v", ln.Addr(), err) xrayLogf("native xray: accept error on %s: %v", ln.Addr(), err)
continue continue
} }
counted, ok := wrapTrackedNativeTransportConn(c) counted, ok := waitWrapTrackedNativeTransportConn(c)
if !ok { if !ok {
time.Sleep(nativeOverloadBackoff) time.Sleep(nativeOverloadBackoff)
continue continue
@@ -379,8 +379,8 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
logNativePreAuthRejection("native xray: inbound %q rejected unknown VLESS uuid from %s", ib.tag, remote) logNativePreAuthRejection("native xray: inbound %q rejected unknown VLESS uuid from %s", ib.tag, remote)
return return
} }
if xrayMgr.nativeQuotaBlocked(client.uuid) { if reason := xrayMgr.nativeClientAccessDenied(client.uuid); reason != "" {
xrayLogf("native xray: inbound %q rejected VLESS user %s after data quota", ib.tag, client.email) xrayLogf("native xray: inbound %q rejected VLESS user %s: %s", ib.tag, client.email, reason)
return return
} }
@@ -433,7 +433,7 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
xrayLogf("native xray: inbound %q VLESS command %d not supported yet", ib.tag, cmd[0]) xrayLogf("native xray: inbound %q VLESS command %d not supported yet", ib.tag, cmd[0])
return return
} }
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email) releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email, stream)
if !ok { if !ok {
return return
} }
+41 -5
View File
@@ -189,7 +189,14 @@ func wrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
_ = c.Close() _ = c.Close()
return nil, false return nil, false
} }
return registerTrackedNativeTransportConn(c, release)
}
// registerTrackedNativeTransportConn finishes registration when the caller has
// already reserved a transport slot. Keeping reservation and Accept separate is
// what lets the production listener apply kernel/socket backpressure instead of
// accepting and immediately resetting connections at capacity.
func registerTrackedNativeTransportConn(c net.Conn, release func()) (net.Conn, bool) {
counted := &nativeCountedConn{Conn: c, release: release} counted := &nativeCountedConn{Conn: c, release: release}
counted.onClose = func() { counted.onClose = func() {
nativeTransportRegistry.Lock() nativeTransportRegistry.Lock()
@@ -208,6 +215,25 @@ func wrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
return counted, true return counted, true
} }
// waitWrapTrackedNativeTransportConn is used by raw native accept loops. It
// holds at most one already-accepted socket while capacity is busy, leaving the
// rest in the kernel backlog instead of creating origin-side resets/502s.
func waitWrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
if c == nil {
return nil, false
}
configureNativeTransportSocket(c)
for nativeTransportAccepting.Load() {
release, ok := acquireNativeTransportConnection()
if ok {
return registerTrackedNativeTransportConn(c, release)
}
time.Sleep(nativeOverloadBackoff)
}
_ = c.Close()
return nil, false
}
func beginNativeTransportAccepting() { func beginNativeTransportAccepting() {
nativeTransportAccepting.Store(true) nativeTransportAccepting.Store(true)
} }
@@ -229,23 +255,33 @@ func closeAllNativeTransportConnections() {
} }
// nativeLimitedListener applies the same pre-authentication ceiling to XHTTP // nativeLimitedListener applies the same pre-authentication ceiling to XHTTP
// listeners. net/http receives only sockets that own a slot; rejected sockets // listeners. net/http receives only sockets that own a slot; when capacity is
// are closed before it can allocate a per-connection goroutine or perform TLS. // busy, new sockets remain in the kernel backlog until a slot becomes available.
type nativeLimitedListener struct { type nativeLimitedListener struct {
net.Listener net.Listener
} }
func (l nativeLimitedListener) Accept() (net.Conn, error) { func (l nativeLimitedListener) 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()
if !ok {
time.Sleep(nativeOverloadBackoff)
continue
}
c, err := l.Listener.Accept() c, err := l.Listener.Accept()
if err != nil { if err != nil {
release()
return nil, err return nil, err
} }
if counted, ok := wrapTrackedNativeTransportConn(c); ok { configureNativeTransportSocket(c)
if counted, ok := registerTrackedNativeTransportConn(c, release); ok {
return counted, nil return counted, nil
} }
// At the ceiling, a hot accept/close loop can itself consume a CPU core. // Shutdown may race Accept. Registration closes the socket and releases the
// A short fixed backoff also lets the kernel backlog absorb brief spikes. // slot; the next Accept observes the listener close.
time.Sleep(nativeOverloadBackoff) time.Sleep(nativeOverloadBackoff)
} }
} }
+20 -5
View File
@@ -17,17 +17,20 @@ type XrayNativeTuning struct {
const ( const (
defaultNativeRuntimeGOMAXPROCS = 0 defaultNativeRuntimeGOMAXPROCS = 0
defaultNativeMuxGlobalSessions = 8192 defaultNativeMuxGlobalSessions = 32768
defaultNativeMaxConnections = 4096 defaultNativeMaxConnections = 32768
defaultNativeMaxXHTTPRequests = 8192 // XHTTP packet handlers are governed by HTTP/2 flow control and bounded byte
// queues, not a website-style request ceiling. A negative configured value is
// normalized to the internal unlimited representation.
defaultNativeMaxXHTTPRequests = -1
fixedNativeMuxMaxSessions = 64 fixedNativeMuxMaxSessions = 64
fixedNativeMuxUDPIdleMS = 120000 fixedNativeMuxUDPIdleMS = 120000
fixedNativeMuxUDPReadBuffer = 256 * 1024 fixedNativeMuxUDPReadBuffer = 256 * 1024
fixedNativeMuxUDPWriteBuffer = 256 * 1024 fixedNativeMuxUDPWriteBuffer = 256 * 1024
defaultNativeXHTTPMaxSessions = 4096 defaultNativeXHTTPMaxSessions = 32768
defaultNativeHTTP2MaxStreams = 256 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
@@ -60,6 +63,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
} }
+84 -1
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"strings" "strings"
"sync" "sync"
"time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -25,6 +26,10 @@ type xrayNativeQuotaState struct {
generation uint64 generation uint64
maxConns int maxConns int
activeConns int activeConns int
owner string
expiresAt time.Time
hasExpiry bool
connections map[io.Closer]struct{}
} }
func (m *XrayManager) reloadNativeQuotaPolicies() { func (m *XrayManager) reloadNativeQuotaPolicies() {
@@ -60,9 +65,19 @@ func newXrayNativeQuotaState(meta *XrayClientMeta) *xrayNativeQuotaState {
throttleMbps: quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps), throttleMbps: quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps),
generation: 1, generation: 1,
maxConns: normalizeXrayMaxConns(meta.MaxConns), maxConns: normalizeXrayMaxConns(meta.MaxConns),
owner: strings.TrimSpace(meta.OwnerUsername),
hasExpiry: meta.ExpiresAt != nil,
expiresAt: xrayExpiryValue(meta.ExpiresAt),
} }
} }
func xrayExpiryValue(expiry *time.Time) time.Time {
if expiry == nil {
return time.Time{}
}
return *expiry
}
func normalizeXrayMaxConns(v int) int { func normalizeXrayMaxConns(v int) int {
if v < 0 { if v < 0 {
return 0 return 0
@@ -92,6 +107,9 @@ func (m *XrayManager) setNativeQuotaPolicy(meta *XrayClientMeta) {
existing.action = normalizeQuotaAction(meta.QuotaAction) existing.action = normalizeQuotaAction(meta.QuotaAction)
existing.throttleMbps = quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps) existing.throttleMbps = quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps)
existing.maxConns = normalizeXrayMaxConns(meta.MaxConns) existing.maxConns = normalizeXrayMaxConns(meta.MaxConns)
existing.owner = strings.TrimSpace(meta.OwnerUsername)
existing.hasExpiry = meta.ExpiresAt != nil
existing.expiresAt = xrayExpiryValue(meta.ExpiresAt)
existing.limiter = nil existing.limiter = nil
existing.mu.Unlock() existing.mu.Unlock()
} }
@@ -102,8 +120,10 @@ func (m *XrayManager) removeNativeQuotaPolicy(uuid string) {
return return
} }
m.nativeQuotaMu.Lock() m.nativeQuotaMu.Lock()
state := m.nativeQuotaByUUID[uuid]
delete(m.nativeQuotaByUUID, uuid) delete(m.nativeQuotaByUUID, uuid)
m.nativeQuotaMu.Unlock() m.nativeQuotaMu.Unlock()
closeNativeClientConnections(state)
// Do not retain failed traffic/active deltas for a client that no longer // Do not retain failed traffic/active deltas for a client that no longer
// exists. This also bounds the pending maps during a prolonged DB outage. // exists. This also bounds the pending maps during a prolonged DB outage.
@@ -216,10 +236,19 @@ func (m *XrayManager) nativeQuotaState(uuid string) *xrayNativeQuotaState {
// acquireNativeClientConnection enforces the DB-backed max_conns policy across // acquireNativeClientConnection enforces the DB-backed max_conns policy across
// every native inbound and transport. The returned release function is safe to // every native inbound and transport. The returned release function is safe to
// call more than once and keeps runtime/DB online counters in sync. // call more than once and keeps runtime/DB online counters in sync.
func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(), *xrayNativeQuotaState, bool) { func (m *XrayManager) acquireNativeClientConnection(uuid, email string, closers ...io.Closer) (func(), *xrayNativeQuotaState, bool) {
state := m.nativeQuotaState(uuid) state := m.nativeQuotaState(uuid)
var closer io.Closer
if len(closers) > 0 {
closer = closers[0]
}
if state != nil { if state != nil {
state.mu.Lock() state.mu.Lock()
if reason := nativeClientAccessDeniedLocked(state); reason != "" {
state.mu.Unlock()
xrayLogf("native xray: rejected user %s: %s", email, reason)
return nil, state, false
}
if state.maxConns > 0 && state.activeConns >= state.maxConns { if state.maxConns > 0 && state.activeConns >= state.maxConns {
limit := state.maxConns limit := state.maxConns
state.mu.Unlock() state.mu.Unlock()
@@ -227,6 +256,12 @@ func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(),
return nil, state, false return nil, state, false
} }
state.activeConns++ state.activeConns++
if closer != nil {
if state.connections == nil {
state.connections = make(map[io.Closer]struct{})
}
state.connections[closer] = struct{}{}
}
state.mu.Unlock() state.mu.Unlock()
} }
@@ -236,6 +271,9 @@ func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(),
once.Do(func() { once.Do(func() {
if state != nil { if state != nil {
state.mu.Lock() state.mu.Lock()
if closer != nil {
delete(state.connections, closer)
}
if state.activeConns > 0 { if state.activeConns > 0 {
state.activeConns-- state.activeConns--
} }
@@ -246,6 +284,51 @@ func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(),
}, state, true }, state, true
} }
func closeNativeClientConnections(state *xrayNativeQuotaState) {
if state == nil {
return
}
state.mu.Lock()
closers := make([]io.Closer, 0, len(state.connections))
for closer := range state.connections {
closers = append(closers, closer)
}
state.mu.Unlock()
for _, closer := range closers {
_ = closer.Close()
}
}
func (m *XrayManager) disconnectNativeClient(uuid string) {
closeNativeClientConnections(m.nativeQuotaState(uuid))
}
func (m *XrayManager) nativeClientAccessDenied(uuid string) string {
state := m.nativeQuotaState(uuid)
if state == nil {
return ""
}
state.mu.Lock()
reason := nativeClientAccessDeniedLocked(state)
state.mu.Unlock()
return reason
}
func nativeClientAccessDeniedLocked(state *xrayNativeQuotaState) string {
if state.hasExpiry && !state.expiresAt.After(time.Now()) {
return "expired"
}
if state.owner != "" {
if err := ownerIsActive(state.owner); err != nil {
return "owner suspended or expired"
}
}
if state.quotaBytes > 0 && normalizeQuotaAction(state.action) == quotaActionBlock && state.usedBytes >= state.quotaBytes {
return "data quota exceeded"
}
return ""
}
func (m *XrayManager) nativeQuotaBlocked(uuid string) bool { func (m *XrayManager) nativeQuotaBlocked(uuid string) bool {
return nativeQuotaStateBlocked(m.nativeQuotaState(uuid)) return nativeQuotaStateBlocked(m.nativeQuotaState(uuid))
} }
+3 -3
View File
@@ -673,8 +673,8 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
logNativePreAuthRejection("native xray: inbound %q rejected unknown/expired VMess auth id from %s", ib.tag, remote) logNativePreAuthRejection("native xray: inbound %q rejected unknown/expired VMess auth id from %s", ib.tag, remote)
return return
} }
if xrayMgr.nativeQuotaBlocked(client.uuid) { if reason := xrayMgr.nativeClientAccessDenied(client.uuid); reason != "" {
log.Printf("native xray: inbound %q rejected VMess user %s after data quota", ib.tag, client.email) log.Printf("native xray: inbound %q rejected VMess user %s: %s", ib.tag, client.email, reason)
return return
} }
@@ -694,7 +694,7 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
log.Printf("native xray: inbound %q VMess command %d not supported yet", ib.tag, req.command) log.Printf("native xray: inbound %q VMess command %d not supported yet", ib.tag, req.command)
return return
} }
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email) releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email, stream)
if !ok { if !ok {
return return
} }
+114 -29
View File
@@ -37,6 +37,10 @@ var (
nativeXHTTPBufferedBytes atomic.Int64 nativeXHTTPBufferedBytes atomic.Int64
nativeXHTTPBufferRejected atomic.Int64 nativeXHTTPBufferRejected atomic.Int64
errNativeXHTTPUploadBufferFull = errors.New("xhttp upload buffer limit reached") errNativeXHTTPUploadBufferFull = errors.New("xhttp upload buffer limit reached")
nativeXHTTPMemoryWait = struct {
sync.Mutex
changed chan struct{}
}{changed: make(chan struct{})}
) )
const ( const (
@@ -295,13 +299,12 @@ func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
// byte stream to the VLESS/VMess handlers as a net.Conn. // byte stream to the VLESS/VMess handlers as a net.Conn.
func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer xrayRecover(fmt.Sprintf("native xray XHTTP request inbound=%q method=%s path=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.RemoteAddr)) defer xrayRecover(fmt.Sprintf("native xray XHTTP request inbound=%q method=%s path=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.RemoteAddr))
releaseRequest, ok := acquireNativeXHTTPRequest() // XHTTP is a VPN transport, not a web API. A single connected user keeps a
if !ok { // long-lived download handler and can generate many short packet-up handlers.
w.Header().Set("Retry-After", "1") // Rejecting handlers at an application request ceiling turns normal tunnel
http.Error(w, "native XHTTP request limit reached", http.StatusTooManyRequests) // bursts into 429s and, through CDNs/reverse proxies, intermittent 502s.
return // HTTP/2 flow control plus the bounded, cancelable upload queues below provide
} // backpressure without applying website rate-limit semantics to tunnel traffic.
defer releaseRequest()
if !ib.isXHTTP() { if !ib.isXHTTP() {
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=not-xhttp method=%s path=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, r.RemoteAddr) logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=not-xhttp method=%s path=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, r.RemoteAddr)
xhttpBadRequest(w) xhttpBadRequest(w)
@@ -558,15 +561,13 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
return s return s
} }
if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max { if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max {
w.Header().Set("Retry-After", "1") http.Error(w, "native XHTTP session capacity reached", http.StatusServiceUnavailable)
http.Error(w, "native XHTTP session limit reached", http.StatusTooManyRequests)
logNativePreAuthRejection("native xray: xhttp session rejected inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max) logNativePreAuthRejection("native xray: xhttp session rejected inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max)
return nil return nil
} }
releaseSlot, ok := acquireNativeXHTTPSession() releaseSlot, ok := acquireNativeXHTTPSession()
if !ok { if !ok {
w.Header().Set("Retry-After", "1") http.Error(w, "native XHTTP global session capacity reached", http.StatusServiceUnavailable)
http.Error(w, "native XHTTP global session limit reached", http.StatusTooManyRequests)
return nil return nil
} }
s := &nativeXHTTPSession{ s := &nativeXHTTPSession{
@@ -662,10 +663,14 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
http.Error(w, "bad xhttp sequence", http.StatusBadRequest) http.Error(w, "bad xhttp sequence", http.StatusBadRequest)
return return
} }
memory, ok := acquireNativeXHTTPMemory(ib.xhttpMaxPostBytes()) // Reserve the expected payload rather than the configured maximum. Normal
if !ok { // XHTTP body uploads have a Content-Length, so small packets no longer each
w.Header().Set("Retry-After", "1") // consume a full 1 MB reservation. Unknown/chunked or metadata-carried uploads
http.Error(w, errNativeXHTTPUploadBufferFull.Error(), http.StatusTooManyRequests) // still reserve the maximum before decoding to preserve the hard memory bound.
memory, err := acquireNativeXHTTPMemoryContext(r.Context(), ib.xhttpUploadReservationBytes(r))
if err != nil {
// If the client/CDN canceled while waiting for backpressure, there is no
// useful HTTP error to send. Returning also releases every reservation.
return return
} }
defer memory.release() defer memory.release()
@@ -680,9 +685,14 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return return
} }
if errors.Is(err, io.ErrClosedPipe) {
// A packet can race the stream-down request closing. Acknowledge the late
// upload instead of leaking an origin 500/502 into the reconnect path.
w.WriteHeader(http.StatusOK)
return
}
if errors.Is(err, errNativeXHTTPUploadBufferFull) { if errors.Is(err, errNativeXHTTPUploadBufferFull) {
w.Header().Set("Retry-After", "1") http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
http.Error(w, err.Error(), http.StatusTooManyRequests)
return return
} }
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err) xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
@@ -797,6 +807,22 @@ func (ib *nativeInbound) xhttpMaxPostBytes() int64 {
return 1_000_000 return 1_000_000
} }
// xhttpUploadReservationBytes returns a safe pre-read reservation. Body-mode
// clients normally send Content-Length, which lets thousands of small packets
// share the global budget. Header/cookie/auto and chunked bodies reserve the
// configured maximum because their decoded size is not known until parsed.
func (ib *nativeInbound) xhttpUploadReservationBytes(r *http.Request) int64 {
maxBytes := ib.xhttpMaxPostBytes()
placement := firstNonEmpty(ib.xhttpUplinkDataPlacement, xhttpPlacementBody)
if placement == xhttpPlacementBody && r.ContentLength >= 0 {
if r.ContentLength > maxBytes {
return maxBytes
}
return r.ContentLength
}
return maxBytes
}
func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) { func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) {
defer xrayRecover(fmt.Sprintf("native xray XHTTP stream-one inbound=%q remote=%s", ib.tag, r.RemoteAddr)) defer xrayRecover(fmt.Sprintf("native xray XHTTP stream-one inbound=%q remote=%s", ib.tag, r.RemoteAddr))
xrayTracef("native xray: xhttp stream-one inbound=%q len=%d remote=%s", ib.tag, r.ContentLength, r.RemoteAddr) xrayTracef("native xray: xhttp stream-one inbound=%q len=%d remote=%s", ib.tag, r.ContentLength, r.RemoteAddr)
@@ -1081,6 +1107,40 @@ func acquireNativeXHTTPMemory(n int64) (*nativeXHTTPMemoryLease, bool) {
} }
} }
// acquireNativeXHTTPMemoryContext applies process-wide memory backpressure.
// Unlike the old fail-fast admission path, a legitimate tunnel burst waits for
// queued bytes to be consumed and remains cancelable if its HTTP request ends.
func acquireNativeXHTTPMemoryContext(ctx context.Context, n int64) (*nativeXHTTPMemoryLease, error) {
if n <= 0 {
return &nativeXHTTPMemoryLease{}, nil
}
if n > nativeXHTTPMaxBufferedGlobalBytes {
return nil, errNativeXHTTPUploadBufferFull
}
for {
current := nativeXHTTPBufferedBytes.Load()
if current <= nativeXHTTPMaxBufferedGlobalBytes-n && nativeXHTTPBufferedBytes.CompareAndSwap(current, current+n) {
return &nativeXHTTPMemoryLease{bytes: n}, nil
}
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()
select {
case <-changed:
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func releaseNativeXHTTPMemory(n int64) { func releaseNativeXHTTPMemory(n int64) {
if n <= 0 { if n <= 0 {
return return
@@ -1092,6 +1152,10 @@ func releaseNativeXHTTPMemory(n int64) {
next = 0 next = 0
} }
if nativeXHTTPBufferedBytes.CompareAndSwap(current, next) { if nativeXHTTPBufferedBytes.CompareAndSwap(current, next) {
nativeXHTTPMemoryWait.Lock()
close(nativeXHTTPMemoryWait.changed)
nativeXHTTPMemoryWait.changed = make(chan struct{})
nativeXHTTPMemoryWait.Unlock()
return return
} }
} }
@@ -1145,6 +1209,7 @@ type nativeXHTTPUploadQueue struct {
readDeadline time.Time readDeadline time.Time
bufferedBytes int64 bufferedBytes int64
closedFlag bool closedFlag bool
spaceChanged chan struct{}
closed chan struct{} closed chan struct{}
closeOnce sync.Once closeOnce sync.Once
@@ -1165,6 +1230,7 @@ func newNativeXHTTPUploadQueue(maxPackets int, maxBytes int64) *nativeXHTTPUploa
maxPackets: maxPackets, maxPackets: maxPackets,
maxBytes: maxBytes, maxBytes: maxBytes,
closed: make(chan struct{}), closed: make(chan struct{}),
spaceChanged: make(chan struct{}),
} }
} }
@@ -1178,21 +1244,38 @@ func (q *nativeXHTTPUploadQueue) beginPush() bool {
return true return true
} }
func (q *nativeXHTTPUploadQueue) adoptPayloadMemory(memory *nativeXHTTPMemoryLease, n int64) bool { func (q *nativeXHTTPUploadQueue) adoptPayloadMemory(ctx context.Context, memory *nativeXHTTPMemoryLease, n int64) error {
if n <= 0 { if n <= 0 {
return true return nil
} }
if memory == nil || memory.bytes != n { if memory == nil || memory.bytes != n {
return false return errNativeXHTTPUploadBufferFull
} }
q.mu.Lock() if n > q.maxBytes {
defer q.mu.Unlock() return errNativeXHTTPUploadBufferFull
if q.closedFlag || q.bufferedBytes > q.maxBytes-n { }
return false for {
q.mu.Lock()
if q.closedFlag {
q.mu.Unlock()
return io.ErrClosedPipe
}
if q.bufferedBytes <= q.maxBytes-n {
q.bufferedBytes += n
memory.bytes = 0
q.mu.Unlock()
return nil
}
changed := q.spaceChanged
q.mu.Unlock()
select {
case <-changed:
case <-q.closed:
return io.ErrClosedPipe
case <-ctx.Done():
return ctx.Err()
}
} }
q.bufferedBytes += n
memory.bytes = 0
return true
} }
func (q *nativeXHTTPUploadQueue) releasePayloadMemory(n int64) { func (q *nativeXHTTPUploadQueue) releasePayloadMemory(n int64) {
@@ -1205,6 +1288,8 @@ func (q *nativeXHTTPUploadQueue) releasePayloadMemory(n int64) {
release = q.bufferedBytes release = q.bufferedBytes
} }
q.bufferedBytes -= release q.bufferedBytes -= release
close(q.spaceChanged)
q.spaceChanged = make(chan struct{})
q.mu.Unlock() q.mu.Unlock()
releaseNativeXHTTPMemory(release) releaseNativeXHTTPMemory(release)
} }
@@ -1234,8 +1319,8 @@ func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket,
}() }()
} }
payloadBytes := int64(len(p.Payload)) payloadBytes := int64(len(p.Payload))
if !q.adoptPayloadMemory(memory, payloadBytes) { if err := q.adoptPayloadMemory(ctx, memory, payloadBytes); err != nil {
return errNativeXHTTPUploadBufferFull return err
} }
transferred := payloadBytes > 0 transferred := payloadBytes > 0
if transferred { if transferred {