Compare commits
2
Commits
44b2313299
...
2f4cb008ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4cb008ae | ||
|
|
9bbd950b66 |
@@ -68,7 +68,7 @@ A confirmação dessa migração é exibida dentro do próprio painel. Se a grav
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -609,7 +609,7 @@ The migration confirmation is rendered inside the panel. If saving fails, the te
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -1144,14 +1144,17 @@ curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN"
|
||||
|
||||
#### `GET /api/users` — session
|
||||
- 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
|
||||
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` (0–10000); `expires_at` (RFC3339 string); `limit_mbps_up` and `limit_mbps_down` (0–1000000); `data_quota_bytes` (non-negative int64); `quota_action` (`block` or `throttle`); `quota_throttle_mbps` (0–1000000; 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.
|
||||
- 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
|
||||
- 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`.
|
||||
@@ -1261,16 +1264,19 @@ Read/write a managed server's `config.json`. Query: `server_id`. Local delegates
|
||||
#### `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.
|
||||
- `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
|
||||
- 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` (0–10000), `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`.
|
||||
|
||||
#### `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` (0–10000), `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`.
|
||||
|
||||
#### `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
|
||||
- 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`.
|
||||
|
||||
@@ -750,3 +750,34 @@ function patchRenderedInbounds(inbounds) {
|
||||
}
|
||||
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."
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@ cancelUserBtn.addEventListener("click", () => {
|
||||
function prepareNewSSHUser() {
|
||||
userForm.reset();
|
||||
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;
|
||||
fUsageDisplay.value = "0 B";
|
||||
fResetUsage.checked = false;
|
||||
|
||||
@@ -35,18 +35,18 @@ function toggleUdpgwFields(on) {
|
||||
const XRAY_NATIVE_TUNING_DEFAULTS = {
|
||||
safe: {
|
||||
runtime_gomaxprocs: 0,
|
||||
mux_global_sessions: 8192,
|
||||
max_concurrent_connections: 4096,
|
||||
max_concurrent_xhttp_requests: 8192,
|
||||
xhttp_max_sessions: 4096,
|
||||
mux_global_sessions: 32768,
|
||||
max_concurrent_connections: 32768,
|
||||
max_concurrent_xhttp_requests: -1,
|
||||
xhttp_max_sessions: 32768,
|
||||
trace_packets: false,
|
||||
},
|
||||
"2k": {
|
||||
"high": {
|
||||
runtime_gomaxprocs: 0,
|
||||
mux_global_sessions: 32768,
|
||||
max_concurrent_connections: 8192,
|
||||
max_concurrent_xhttp_requests: 16384,
|
||||
xhttp_max_sessions: 8192,
|
||||
mux_global_sessions: 65536,
|
||||
max_concurrent_connections: 65536,
|
||||
max_concurrent_xhttp_requests: -1,
|
||||
xhttp_max_sessions: 65536,
|
||||
trace_packets: false,
|
||||
},
|
||||
};
|
||||
@@ -59,7 +59,7 @@ const XRAY_NATIVE_TUNING_FIELDS = {
|
||||
xhttp_max_sessions: "cfgXrayMaxXHTTPSessions",
|
||||
};
|
||||
|
||||
function setXrayNativeTuningDefaults(profile = "2k") {
|
||||
function setXrayNativeTuningDefaults(profile = "high") {
|
||||
const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe;
|
||||
writeXrayNativeTuning(t);
|
||||
}
|
||||
|
||||
+10
-10
@@ -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 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>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>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>
|
||||
@@ -1509,16 +1509,16 @@
|
||||
<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="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 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>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>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>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"><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>
|
||||
<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>
|
||||
</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>
|
||||
</details>
|
||||
</div>
|
||||
@@ -1558,14 +1558,14 @@
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution order,
|
||||
so behavior is identical to the old single file. Keep this load order. -->
|
||||
<script defer src="assets/js/01-core.js?v=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/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/05-resellers.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/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/11-update-status.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script>
|
||||
|
||||
@@ -586,7 +586,7 @@ func startResellerExpiryChecker(store *Store) {
|
||||
u.IsActive = false
|
||||
adminUsers.set(u)
|
||||
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)
|
||||
@@ -602,6 +602,7 @@ func startResellerExpiryChecker(store *Store) {
|
||||
}
|
||||
u.IsActive = true
|
||||
adminUsers.set(u)
|
||||
restoreOwnerXrayClients(ctx, store, u.Username)
|
||||
}
|
||||
|
||||
sessions.cleanup()
|
||||
@@ -862,7 +863,9 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
if u.Role == RoleReseller {
|
||||
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
disconnectOwnerUsers(u.Username)
|
||||
removeOwnerXrayClients(ctx, store, u.Username)
|
||||
suspendOwnerXrayClients(ctx, store, u.Username)
|
||||
} else {
|
||||
restoreOwnerXrayClients(ctx, store, u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1907,10 +1907,25 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -106,9 +106,9 @@ func (s *Store) ResetSSHUserTraffic(ctx context.Context, username string) error
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ssh_users
|
||||
SET total_uplink_bytes = 0, total_downlink_bytes = 0
|
||||
WHERE username = $1`, username)
|
||||
UPDATE ssh_users
|
||||
SET total_uplink_bytes = 0, total_downlink_bytes = 0
|
||||
WHERE username = $1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -353,9 +353,15 @@ func validateQuotaConfig(quotaBytes int64, action string, throttleMbps int) erro
|
||||
if quotaBytes < 0 {
|
||||
return fmt.Errorf("data_quota_bytes must be non-negative")
|
||||
}
|
||||
action = normalizeQuotaAction(action)
|
||||
if quotaBytes > 0 && action == quotaActionThrottle && throttleMbps < 0 {
|
||||
rawAction := strings.ToLower(strings.TrimSpace(action))
|
||||
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")
|
||||
}
|
||||
if throttleMbps > 1000000 {
|
||||
return fmt.Errorf("quota_throttle_mbps must not exceed 1000000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,6 +71,67 @@ func TestNativeClientMaxConnectionsAndBatchedActiveDelta(t *testing.T) {
|
||||
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) {
|
||||
m := &XrayManager{
|
||||
nativeQuotaByUUID: map[string]*xrayNativeQuotaState{
|
||||
|
||||
+92
-29
@@ -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
|
||||
// Xray clients from both the config file and the database every 5 minutes.
|
||||
func startXrayClientExpiryChecker(store *Store) {
|
||||
@@ -320,39 +380,42 @@ func startXrayClientExpiryChecker(store *Store) {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx := context.Background()
|
||||
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()
|
||||
}
|
||||
expireXrayClientsOnce(store)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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
|
||||
// the account or changing its expiry/quota policy.
|
||||
func (s *Store) ResetXrayClientTraffic(ctx context.Context, uuid string) error {
|
||||
|
||||
+102
-38
@@ -309,6 +309,10 @@ func initXrayManager(cfg *XrayConfig) {
|
||||
}
|
||||
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()
|
||||
|
||||
// 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 {
|
||||
m.statsByEmail = make(map[string]xrayRuntimeStat)
|
||||
}
|
||||
st := m.statsByEmail[email]
|
||||
key := firstNonEmpty(uuid, email)
|
||||
st := m.statsByEmail[key]
|
||||
st.Email = email
|
||||
st.LastActive = now
|
||||
st.ActiveConnections++
|
||||
m.statsByEmail[email] = st
|
||||
m.statsByEmail[key] = st
|
||||
m.statsMu.Unlock()
|
||||
|
||||
m.queueNativeActiveDelta(uuid, email, 1, true, state)
|
||||
@@ -499,11 +504,12 @@ func (m *XrayManager) recordNativeDisconnect(uuid, email string, state *xrayNati
|
||||
}
|
||||
m.statsMu.Lock()
|
||||
if m.statsByEmail != nil {
|
||||
st := m.statsByEmail[email]
|
||||
key := firstNonEmpty(uuid, email)
|
||||
st := m.statsByEmail[key]
|
||||
if st.ActiveConnections > 0 {
|
||||
st.ActiveConnections--
|
||||
}
|
||||
m.statsByEmail[email] = st
|
||||
m.statsByEmail[key] = st
|
||||
}
|
||||
m.statsMu.Unlock()
|
||||
|
||||
@@ -595,12 +601,13 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64, ge
|
||||
if m.statsByEmail == nil {
|
||||
m.statsByEmail = make(map[string]xrayRuntimeStat)
|
||||
}
|
||||
st := m.statsByEmail[email]
|
||||
key := firstNonEmpty(uuid, email)
|
||||
st := m.statsByEmail[key]
|
||||
st.Email = email
|
||||
st.Uplink += up
|
||||
st.Downlink += down
|
||||
st.LastActive = now
|
||||
m.statsByEmail[email] = st
|
||||
m.statsByEmail[key] = st
|
||||
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.
|
||||
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()
|
||||
defer m.mu.Unlock()
|
||||
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)
|
||||
}
|
||||
if id == uuid {
|
||||
if allowExisting {
|
||||
cm["email"] = email
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -2645,6 +2676,11 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Email == "" {
|
||||
req.Email = req.UUID
|
||||
}
|
||||
expiresAt, err := parseOptionalXrayExpiry(req.ExpiresAt)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ownerUsername := ""
|
||||
@@ -2681,10 +2717,7 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var savedMeta *XrayClientMeta
|
||||
if statsStore != nil {
|
||||
meta := XrayClientMeta{
|
||||
UUID: req.UUID,
|
||||
@@ -2697,24 +2730,22 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
QuotaAction: normalizeQuotaAction(req.QuotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(req.QuotaThrottleMbps),
|
||||
}
|
||||
if req.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
|
||||
}
|
||||
}
|
||||
meta.ExpiresAt = expiresAt
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
xrayLogf("xray: save meta for %s: %v", req.UUID, err)
|
||||
} else {
|
||||
xrayMgr.setNativeQuotaPolicy(&meta)
|
||||
http.Error(w, "save client metadata failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
@@ -2747,6 +2778,15 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "uuid required", http.StatusBadRequest)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -2784,6 +2824,15 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
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{
|
||||
UUID: req.UUID,
|
||||
@@ -2798,15 +2847,18 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
TotalUplinkBytes: existing.TotalUplinkBytes,
|
||||
TotalDownlinkBytes: existing.TotalDownlinkBytes,
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, req.ExpiresAt); err == nil {
|
||||
meta.ExpiresAt = &t
|
||||
break
|
||||
}
|
||||
meta.ExpiresAt = expiresAt
|
||||
emailChanged := req.Email != existing.Email
|
||||
if emailChanged {
|
||||
if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil {
|
||||
http.Error(w, "update config email failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -2819,16 +2871,28 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
meta.TotalDownlinkBytes = 0
|
||||
}
|
||||
xrayMgr.setNativeQuotaPolicy(&meta)
|
||||
if req.Email != "" {
|
||||
if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil {
|
||||
xrayLogf("xray: update config email for %s: %v", req.UUID, err)
|
||||
} else {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if meta.ExpiresAt != nil && !meta.ExpiresAt.After(time.Now()) {
|
||||
xrayMgr.disconnectNativeClient(req.UUID)
|
||||
}
|
||||
if emailChanged {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
|
||||
+3
-3
@@ -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)
|
||||
return
|
||||
}
|
||||
if xrayMgr.nativeQuotaBlocked(client.uuid) {
|
||||
xrayLogf("native xray: inbound %q rejected VLESS user %s after data quota", ib.tag, client.email)
|
||||
if reason := xrayMgr.nativeClientAccessDenied(client.uuid); reason != "" {
|
||||
xrayLogf("native xray: inbound %q rejected VLESS user %s: %s", ib.tag, client.email, reason)
|
||||
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])
|
||||
return
|
||||
}
|
||||
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email)
|
||||
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email, stream)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
+84
-1
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -25,6 +26,10 @@ type xrayNativeQuotaState struct {
|
||||
generation uint64
|
||||
maxConns int
|
||||
activeConns int
|
||||
owner string
|
||||
expiresAt time.Time
|
||||
hasExpiry bool
|
||||
connections map[io.Closer]struct{}
|
||||
}
|
||||
|
||||
func (m *XrayManager) reloadNativeQuotaPolicies() {
|
||||
@@ -60,9 +65,19 @@ func newXrayNativeQuotaState(meta *XrayClientMeta) *xrayNativeQuotaState {
|
||||
throttleMbps: quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps),
|
||||
generation: 1,
|
||||
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 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
@@ -92,6 +107,9 @@ func (m *XrayManager) setNativeQuotaPolicy(meta *XrayClientMeta) {
|
||||
existing.action = normalizeQuotaAction(meta.QuotaAction)
|
||||
existing.throttleMbps = quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps)
|
||||
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.mu.Unlock()
|
||||
}
|
||||
@@ -102,8 +120,10 @@ func (m *XrayManager) removeNativeQuotaPolicy(uuid string) {
|
||||
return
|
||||
}
|
||||
m.nativeQuotaMu.Lock()
|
||||
state := m.nativeQuotaByUUID[uuid]
|
||||
delete(m.nativeQuotaByUUID, uuid)
|
||||
m.nativeQuotaMu.Unlock()
|
||||
closeNativeClientConnections(state)
|
||||
|
||||
// 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.
|
||||
@@ -216,10 +236,19 @@ func (m *XrayManager) nativeQuotaState(uuid string) *xrayNativeQuotaState {
|
||||
// acquireNativeClientConnection enforces the DB-backed max_conns policy across
|
||||
// every native inbound and transport. The returned release function is safe to
|
||||
// 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)
|
||||
var closer io.Closer
|
||||
if len(closers) > 0 {
|
||||
closer = closers[0]
|
||||
}
|
||||
if state != nil {
|
||||
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 {
|
||||
limit := state.maxConns
|
||||
state.mu.Unlock()
|
||||
@@ -227,6 +256,12 @@ func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(),
|
||||
return nil, state, false
|
||||
}
|
||||
state.activeConns++
|
||||
if closer != nil {
|
||||
if state.connections == nil {
|
||||
state.connections = make(map[io.Closer]struct{})
|
||||
}
|
||||
state.connections[closer] = struct{}{}
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -236,6 +271,9 @@ func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(),
|
||||
once.Do(func() {
|
||||
if state != nil {
|
||||
state.mu.Lock()
|
||||
if closer != nil {
|
||||
delete(state.connections, closer)
|
||||
}
|
||||
if state.activeConns > 0 {
|
||||
state.activeConns--
|
||||
}
|
||||
@@ -246,6 +284,51 @@ func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(),
|
||||
}, 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 {
|
||||
return nativeQuotaStateBlocked(m.nativeQuotaState(uuid))
|
||||
}
|
||||
|
||||
+3
-3
@@ -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)
|
||||
return
|
||||
}
|
||||
if xrayMgr.nativeQuotaBlocked(client.uuid) {
|
||||
log.Printf("native xray: inbound %q rejected VMess user %s after data quota", ib.tag, client.email)
|
||||
if reason := xrayMgr.nativeClientAccessDenied(client.uuid); reason != "" {
|
||||
log.Printf("native xray: inbound %q rejected VMess user %s: %s", ib.tag, client.email, reason)
|
||||
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)
|
||||
return
|
||||
}
|
||||
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email)
|
||||
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email, stream)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user