Compare commits
26
Commits
beta
..
44f5b2b09c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f5b2b09c | ||
|
|
1576ce9038 | ||
|
|
decf48992e | ||
|
|
7c51ea3f86 | ||
|
|
3d64d6394b | ||
|
|
b903775fb7 | ||
|
|
9c5bbaf55d | ||
|
|
2f4cb008ae | ||
|
|
9bbd950b66 | ||
|
|
44b2313299 | ||
|
|
37861cda22 | ||
|
|
5f43698e2b | ||
|
|
ab6f1e1329 | ||
|
|
ff175174e4 | ||
|
|
ed0e240241 | ||
|
|
c6bfefe2fb | ||
|
|
fa990c2094 | ||
|
|
1797c50ea3 | ||
|
|
492a7f2002 | ||
|
|
5ddca88147 | ||
|
|
8117f6ed11 | ||
|
|
11cfd3f092 | ||
|
|
628878e055 | ||
|
|
7e0ec393a8 | ||
|
|
8a141ae86d | ||
|
|
2ff7976768 |
@@ -1,3 +1,4 @@
|
||||
/shell2.exe
|
||||
/BOT_PLAN.md
|
||||
/SECURITY_REVIEW.md
|
||||
/DragonCoreSSH-NewWEB.zip
|
||||
|
||||
@@ -15,8 +15,9 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
|
||||
- Endpoint XHTTP compartilhado no modo nativo: VLESS **ou** VMess em `/` e SSH em `/ssh`, usando o mesmo domínio/porta/TLS
|
||||
- Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual
|
||||
- Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel
|
||||
- Listas de usuários SSH e Xray com botões de ordenação e filtros por status, conexão, uso, validade e cota, além de cabeçalhos clicáveis
|
||||
- Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray
|
||||
- Revendedores hierárquicos com sub-revendas, planos por validade/slots ou créditos, auditoria e escopo próprio
|
||||
- Contas de revendedor (reseller) com cota de usuários e escopo próprio
|
||||
- Gerenciamento multi-servidor (master/slave) direto pelo painel
|
||||
- API HTTP completa para bots/automações (ver **HTTP API Reference**)
|
||||
- API pública `/check` para consultar usuário ou UUID
|
||||
@@ -66,22 +67,17 @@ Para configurações XHTTP antigas, carregue a configuração visual e clique em
|
||||
|
||||
A confirmação dessa migração é exibida dentro do próprio painel. Se a gravação falhar, o inbound SSH temporário é removido do rascunho e o inbound antigo permanece intacto, permitindo tentar novamente após corrigir o erro exibido.
|
||||
|
||||
### Revendedores compatíveis com o painel PHP antigo
|
||||
### Cota de tráfego e proteção de recursos
|
||||
|
||||
A área **Revendedores** mantém o fluxo mais importante do DraconCore PHP, com autorização refeita no servidor:
|
||||
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).
|
||||
|
||||
- cada revendedor gerencia apenas suas contas e seus sub-revendedores diretos;
|
||||
- o superadmin enxerga toda a hierarquia;
|
||||
- plano **Validade / slots** compartilha a cota entre a capacidade `max_connections` das contas SSH/Xray e os slots reservados aos sub-revendedores;
|
||||
- plano **Créditos** debita no cadastro e na renovação; o custo acompanha `max_connections`, com mínimo de uma conexão, e esse limite fica congelado após a criação;
|
||||
- contas de plano por crédito recebem 31 dias por cadastro ou renovação;
|
||||
- criação, edição, renovação, suspensão, reativação e exclusão de revendedores ficam registradas em auditoria;
|
||||
- suspensão bloqueia a árvore e remove os clientes Xray do runtime sem apagar seus metadados; a reativação restaura os clientes ainda válidos;
|
||||
- exclusão remove a árvore, suas contas SSH/Xray e devolve ao pai os créditos ainda não usados;
|
||||
- a cota considera todos os nós gerenciados, mesmo temporariamente desativados, para impedir liberação artificial de limite;
|
||||
- o estado de acesso é sincronizado com nós gerenciados sem copiar senha ou hash de login.
|
||||
As antigas chaves globais de admissão continuam no JSON somente para compatibilidade, mas são sempre normalizadas para `-1` (ilimitado), inclusive quando um `config.json` antigo ainda contém `4096`, `8192`, `32768` ou outro valor positivo:
|
||||
|
||||
Revendedores existentes são migrados automaticamente como contas principais no modo **Validade / slots**. Não é necessário recriá-los. Por segurança, as funções antigas de revelar senha em texto puro e de alterar a sessão para “entrar como revendedor” não foram copiadas.
|
||||
- `max_concurrent_connections`: sem limite global de conexões de transporte;
|
||||
- `max_concurrent_xhttp_requests`: sem limite global de requisições XHTTP;
|
||||
- `xhttp_max_sessions`: sem limite global de sessões XHTTP.
|
||||
|
||||
O painel não expõe mais esses três controles como limites ajustáveis. Xray XHTTP e XHTTP SSH usam o mesmo listener VPN sem teto por quantidade de requisições, streams HTTP/2, conexões de transporte ou sessões XHTTP. Rajadas de `packet-up` e a remontagem fora de ordem usam backpressure cancelável contabilizado em bytes; até pacotes vazios consomem um custo mínimo de memória contabilizada, portanto remover o limite por quantidade não cria uma fila de metadados sem limite. Não existem respostas `429` nem rejeições `503` por capacidade global. As políticas reais por usuário (`max_conns`, cota e banda) continuam ativas. Cada transporte Mux aceita no máximo 64 sessões filhas, com limite global padrão de 32768. Parar/reiniciar o Xray nativo fecha conexões e sessões existentes. Atualizações de tráfego e de conexões ativas são agregadas e persistidas em lote a cada cinco segundos, sem criar uma goroutine ou consulta PostgreSQL por conexão. Entradas pendentes de usuários removidos são descartadas para manter os mapas de retry limitados ao conjunto atual de contas.
|
||||
|
||||
### Requisitos
|
||||
|
||||
@@ -90,8 +86,6 @@ Revendedores existentes são migrados automaticamente como contas principais no
|
||||
- Gerenciador de pacotes `apt`, `yum` ou `dnf`
|
||||
- Portas liberadas no firewall/security group conforme a configuração usada
|
||||
|
||||
O instalador usa Go 1.25.12 e baixa as dependências fixadas no `go.mod`. As bibliotecas oficiais `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text` e `x/time` estão fixadas nas versões de segurança revisadas em 13/07/2026.
|
||||
|
||||
Distribuições alvo:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -111,7 +105,6 @@ sudo bash install.sh
|
||||
Durante a instalação, o script instala/configura:
|
||||
|
||||
- Go
|
||||
- verificação SHA-256 dos arquivos oficiais de Go e Xray antes da extração
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- Binário do DragonCoreSSH V40
|
||||
@@ -206,7 +199,7 @@ Também é possível editar diretamente o `config.json`:
|
||||
```json
|
||||
"dnstt": {
|
||||
"domain": "t.example.com",
|
||||
"udp_listen": "[::]:5300",
|
||||
"udp_listen": "0.0.0.0:5300",
|
||||
"privkey_file": "/opt/sshpanel/dnstt.key",
|
||||
"auto_restart_interval": "6h",
|
||||
"auto_restart_grace": "2s"
|
||||
@@ -226,7 +219,7 @@ Exemplo:
|
||||
"t.example.com",
|
||||
"t.local.lan"
|
||||
],
|
||||
"udp_listen": "[::]:5300",
|
||||
"udp_listen": "0.0.0.0:5300",
|
||||
"privkey_file": "/opt/sshpanel/dnstt.key"
|
||||
}
|
||||
```
|
||||
@@ -565,8 +558,7 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
|
||||
- Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher
|
||||
- Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations
|
||||
- Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray
|
||||
- Full reseller workflow compatible with the useful parts of the legacy PHP panel: direct-child hierarchy, validity/slot and credit plans, weighted SSH/Xray connection quotas, renew, suspend/reactivate, delete, and audit history
|
||||
- Existing reseller-owned SSH/Xray accounts with a legacy zero connection limit are migrated to one slot automatically; they do not need to be recreated
|
||||
- Reseller accounts with a user quota and self-scoped access
|
||||
- Multi-server (master/slave) management directly from the panel
|
||||
- Full HTTP API for bots/automations (see **HTTP API Reference**)
|
||||
- Public `/check` API for checking username or UUID
|
||||
@@ -616,6 +608,18 @@ For older XHTTP configurations, load the visual configuration and click **Enable
|
||||
|
||||
The migration confirmation is rendered inside the panel. If saving fails, the temporary SSH inbound is removed from the draft and the old inbound remains intact, so the operation can be retried after fixing the displayed error.
|
||||
|
||||
### 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. 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 old global admission keys remain in JSON for compatibility, but they are always normalized to `-1` (unlimited), including when an old `config.json` still contains `4096`, `8192`, `32768`, or any other positive value:
|
||||
|
||||
- `max_concurrent_connections`: no global transport-connection count cap;
|
||||
- `max_concurrent_xhttp_requests`: no global XHTTP-request count cap;
|
||||
- `xhttp_max_sessions`: no global XHTTP-session count cap.
|
||||
|
||||
The panel no longer exposes those three controls as adjustable ceilings. Xray XHTTP and XHTTP SSH share the same VPN listener with no count ceiling for requests, HTTP/2 streams, transport connections, or XHTTP sessions. Packet-up bursts and out-of-order reassembly use cancelable byte-accounted backpressure; even empty packets are charged a minimum accounted-memory cost, so removing the request-count limit does not create an unbounded metadata queue. There are no `429` responses or global-capacity `503` rejections. Real per-user policies (`max_conns`, quota, and bandwidth) remain active. Each Mux transport accepts at most 64 child sessions, with a default global ceiling of 32768. Stopping/restarting native Xray closes existing transports and XHTTP sessions. Traffic and active-connection changes are aggregated and written in five-second batches rather than creating a PostgreSQL query or goroutine for every connection. Pending retry entries for deleted clients are removed so retry maps stay bounded by the current account set.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Linux server with `systemd`
|
||||
@@ -623,8 +627,6 @@ The migration confirmation is rendered inside the panel. If saving fails, the te
|
||||
- `apt`, `yum`, or `dnf` package manager
|
||||
- Required ports opened in the firewall/security group
|
||||
|
||||
The installer uses Go 1.25.12 and downloads the versions pinned in `go.mod`. The official `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text`, and `x/time` modules are pinned to the security-reviewed versions current on 2026-07-13.
|
||||
|
||||
Target distributions:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -644,7 +646,6 @@ sudo bash install.sh
|
||||
During installation, the script installs/configures:
|
||||
|
||||
- Go
|
||||
- SHA-256 verification of the official Go and Xray archives before extraction
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- DragonCoreSSH V40 binary
|
||||
@@ -737,7 +738,7 @@ You can also edit `config.json` directly:
|
||||
```json
|
||||
"dnstt": {
|
||||
"domain": "t.example.com",
|
||||
"udp_listen": "[::]:5300",
|
||||
"udp_listen": "0.0.0.0:5300",
|
||||
"privkey_file": "/opt/sshpanel/dnstt.key",
|
||||
"auto_restart_interval": "6h",
|
||||
"auto_restart_grace": "2s"
|
||||
@@ -757,7 +758,7 @@ Example:
|
||||
"t.example.com",
|
||||
"t.local.lan"
|
||||
],
|
||||
"udp_listen": "[::]:5300",
|
||||
"udp_listen": "0.0.0.0:5300",
|
||||
"privkey_file": "/opt/sshpanel/dnstt.key"
|
||||
}
|
||||
```
|
||||
@@ -1136,7 +1137,7 @@ curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN"
|
||||
- No body. Deletes the session for the supplied `X-Session-Token`. Returns `200` (empty).
|
||||
|
||||
#### `GET /api/auth/me` — session
|
||||
- `200`: `{ "username": string, "role": string }`. Reseller responses also include `max_users`, weighted `used_users`, SSH/Xray account counts across managed nodes, `parent_username`, `quota_mode`, `credit_balance`, child allocation/count, expiry, and direct/effective active status.
|
||||
- `200`: `{ "username": string, "role": string }`. If the role is `reseller`, it also includes `max_users` (int), `used_users` (int, combined SSH+Xray), `used_ssh_users` (int), `used_xray_users` (int), `expires_at` (string RFC3339 or null), `is_active` (bool).
|
||||
|
||||
---
|
||||
|
||||
@@ -1144,14 +1145,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`.
|
||||
@@ -1204,35 +1208,19 @@ Creates or updates (upsert) an SSH user.
|
||||
|
||||
---
|
||||
|
||||
### Resellers (authenticated; hierarchy scoped)
|
||||
### Resellers (superadmin only)
|
||||
|
||||
Superadmins manage every reseller. A reseller sees and manages only its direct children; it cannot skip a hierarchy level. Child plans inherit the parent's `slots` or `credits` mode.
|
||||
#### `GET /api/resellers` — superadmin
|
||||
- `200`: array of `{ "id": int, "username": string, "role": string, "max_users": int, "used_users": int, "used_ssh_users": int, "used_xray_users": int, "expires_at": string/null, "is_active": bool, "created_at": string }`.
|
||||
|
||||
#### `GET /api/resellers`
|
||||
- `200`: direct-child array with hierarchy, plan, weighted quota, account-count breakdown, contact, price, expiry, and effective status fields: `{ "id", "username", "parent_username", "quota_mode", "max_users", "credit_balance", "used_users", "used_ssh_users", "used_xray_users", "child_allocation", "child_count", "available", "usage_incomplete", "whatsapp", "monthly_price_cents", "expires_at", "is_active", "effective_active", "created_at" }`.
|
||||
- `used_users` is weighted by each SSH/Xray account's connection limit and includes every configured managed node. If a node cannot be verified, `usage_incomplete` is true and provisioning remains fail-closed.
|
||||
#### `POST /api/resellers/create` — superadmin
|
||||
Creates or updates a reseller (upsert by username).
|
||||
- Body: `username` (string, required); `password` (string, optional — required only when creating; if given on an existing account it is changed); `max_users` (int); `expires_at` (string, optional RFC3339; empty clears expiry); `is_active` (bool).
|
||||
- `201 Created` (empty). Errors: `400 username required`, `400 password required for new account`, `400 invalid expires_at (RFC3339 required)`; `500 db error`.
|
||||
|
||||
#### `POST /api/resellers/create`
|
||||
Creates or edits a reseller.
|
||||
- Body: `username` (required); `password` (required only on create); `parent_username`; `quota_mode` (`slots` or `credits`); `max_users`; `credits`; `expires_at` (RFC3339 for slot plans); `is_active`; `whatsapp`; `monthly_price_cents`.
|
||||
- For reseller callers, `parent_username` and `quota_mode` are forced to the signed-in parent. Parent and plan mode are immutable after creation.
|
||||
- Credit transfers are atomic and audited. Slot limits cannot be reduced below direct account use plus reserved child allocation.
|
||||
- `201`: `{ "username": string, "created": bool }`.
|
||||
|
||||
#### `POST /api/resellers/action`
|
||||
- Body: `username`, `action` (`renew`, `suspend`, `reactivate`, or superadmin-only `pull`), and optional `days`.
|
||||
- `renew` extends a validity reseller from the later of now/current expiry. `suspend` and `reactivate` apply to the full descendant tree and owned SSH/Xray access on managed nodes. `pull` safely attaches a nested reseller directly to the main panel without duplicating transferred credits.
|
||||
- `200`: `{ "ok": true, "runtime_warning": string }`.
|
||||
|
||||
#### `DELETE /api/resellers/delete`
|
||||
- Query: `username` (required). Suspends the subtree first, then removes all descendant reseller records and their owned SSH/Xray accounts locally and from every configured managed node. Unused descendant credit balances are returned once to the direct credit parent.
|
||||
- `204 No Content`.
|
||||
|
||||
#### `GET /api/resellers/audit`
|
||||
- Returns the latest 200 lifecycle/credit events. Resellers receive only their own and direct-child activity.
|
||||
|
||||
#### `POST /api/resellers/runtime` — superadmin/internal node synchronization
|
||||
- Password-free master-to-node hierarchy/status synchronization used for managed-server suspension, expiry, reactivation, and cleanup. Login passwords and password hashes are never replicated.
|
||||
#### `DELETE /api/resellers/delete` — superadmin
|
||||
- Query: `username` (string, required). Also disconnects/removes the reseller's owned SSH users and Xray clients.
|
||||
- `204 No Content`. Errors: `400 username required`; `500 db error`.
|
||||
|
||||
---
|
||||
|
||||
@@ -1277,16 +1265,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`.
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type accountRenewPayload struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Days int `json:"days,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
func renewalDays(owner string, requested int) int {
|
||||
if u, ok := adminUsers.get(owner); ok && normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
||||
return 31
|
||||
}
|
||||
if requested == 0 {
|
||||
return 30
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func renewalExpiry(existing *time.Time, days int) time.Time {
|
||||
base := time.Now()
|
||||
if existing != nil && existing.After(base) {
|
||||
base = *existing
|
||||
}
|
||||
return base.AddDate(0, 0, days)
|
||||
}
|
||||
|
||||
func jsonInt(value interface{}) int {
|
||||
switch value := value.(type) {
|
||||
case int:
|
||||
return value
|
||||
case int64:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
case json.Number:
|
||||
result, _ := strconv.Atoi(value.String())
|
||||
return result
|
||||
default:
|
||||
result, _ := strconv.ParseFloat(fmt.Sprint(value), 64)
|
||||
return int(result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewSSHUser(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_connections"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew SSH account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := userMgr.Get(p.Username)
|
||||
if !ok {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
cfg := state.Cfg
|
||||
existingExpiry := state.ExpiresAt
|
||||
state.mu.Unlock()
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if cfg.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(cfg.MaxConnections)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-ssh:"+p.Username)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(existingExpiry, p.Days)
|
||||
cfg.ExpiresAt = next.UTC().Format(time.RFC3339)
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, cfg.OwnerUsername, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
userMgr.DisconnectUser(p.Username)
|
||||
reloadUsersFromDB(ctx, store)
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, cfg.OwnerUsername, "renewed SSH account",
|
||||
fmt.Sprintf("account=%s days=%d", p.Username, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewXrayClient(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.UUID = strings.TrimSpace(p.UUID)
|
||||
if _, err := parseUUID(p.UUID); err != nil {
|
||||
http.Error(w, "invalid UUID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteXrayClientInfo(ctx, ms, p.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_conns"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew Xray account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := store.GetXrayClientMeta(ctx, p.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if meta.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(meta.MaxConns)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-xray:"+p.UUID)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(meta.ExpiresAt, p.Days)
|
||||
meta.ExpiresAt = &next
|
||||
if err := store.UpsertXrayClientMeta(ctx, *meta); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, meta.OwnerUsername, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runtimeWarning := ""
|
||||
if meta.OwnerUsername != "" {
|
||||
if runtimeErr := restoreOwnerXrayClients(ctx, store, meta.OwnerUsername); runtimeErr != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, runtimeErr)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
} else if err := ensureXrayClientPresent(*meta); err != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, err)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, meta.OwnerUsername, "renewed Xray account",
|
||||
fmt.Sprintf("uuid=%s days=%d", p.UUID, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next, "runtime_warning": runtimeWarning})
|
||||
}
|
||||
}
|
||||
|
||||
func ensureXrayClientPresent(meta XrayClientMeta) error {
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Tag != meta.InboundTag {
|
||||
continue
|
||||
}
|
||||
for _, client := range inbound.Clients {
|
||||
if client.UUID == meta.UUID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
email := strings.TrimSpace(meta.Email)
|
||||
if email == "" {
|
||||
email = meta.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(meta.InboundTag, meta.UUID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inbound %s no longer exists", meta.InboundTag)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var accountUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$`)
|
||||
|
||||
func validateAccountUsername(username string) error {
|
||||
if !accountUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, @, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAccountControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0
|
||||
}
|
||||
|
||||
func validateOptionalAccountExpiry(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, raw); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid expiration date")
|
||||
}
|
||||
|
||||
func validateSSHUserPayload(p *UserPayload) error {
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
p.OwnerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
p.TOTPSecret = normalizeBase32Secret(p.TOTPSecret)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Password != nil && len(*p.Password) > 4096 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
if p.MaxConnections < 0 || p.MaxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
if p.LimitUpMbps < 0 || p.LimitUpMbps > 100000 || p.LimitDownMbps < 0 || p.LimitDownMbps > 100000 {
|
||||
return fmt.Errorf("speed limits must be between 0 and 100000 Mbps")
|
||||
}
|
||||
if err := validateOptionalAccountExpiry(p.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(p.OwnerUsername); err != nil {
|
||||
return fmt.Errorf("invalid owner username")
|
||||
}
|
||||
}
|
||||
if len(p.ServerID) > 32 || hasAccountControlCharacters(p.ServerID) {
|
||||
return fmt.Errorf("invalid server id")
|
||||
}
|
||||
if p.TOTPSecret != "" {
|
||||
if len(p.TOTPSecret) > 256 {
|
||||
return fmt.Errorf("TOTP secret is too long")
|
||||
}
|
||||
if _, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(p.TOTPSecret); err != nil {
|
||||
return fmt.Errorf("invalid TOTP secret")
|
||||
}
|
||||
}
|
||||
if p.TOTPPeriod != 0 && (p.TOTPPeriod < 15 || p.TOTPPeriod > 300) {
|
||||
return fmt.Errorf("TOTP period must be between 15 and 300 seconds")
|
||||
}
|
||||
if p.TOTPWindow < 0 || p.TOTPWindow > 10 {
|
||||
return fmt.Errorf("TOTP window must be between 0 and 10")
|
||||
}
|
||||
if p.TOTPDigits != 0 && (p.TOTPDigits < 6 || p.TOTPDigits > 8) {
|
||||
return fmt.Errorf("TOTP digits must be between 6 and 8")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateXrayClientFields(uuid, inboundTag, email, name, expiresAt string, maxConnections int, requireInbound bool) error {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
if _, err := parseUUID(uuid); err != nil {
|
||||
return fmt.Errorf("invalid UUID")
|
||||
}
|
||||
if requireInbound && strings.TrimSpace(inboundTag) == "" {
|
||||
return fmt.Errorf("inbound_tag required")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"inbound_tag": inboundTag,
|
||||
"email": email,
|
||||
"name": name,
|
||||
} {
|
||||
limit := 256
|
||||
if field == "inbound_tag" {
|
||||
limit = 128
|
||||
}
|
||||
if len(value) > limit || hasAccountControlCharacters(value) {
|
||||
return fmt.Errorf("invalid %s", field)
|
||||
}
|
||||
}
|
||||
if maxConnections < 0 || maxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
return validateOptionalAccountExpiry(expiresAt)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateSSHUserPayloadBounds(t *testing.T) {
|
||||
valid := &UserPayload{
|
||||
Username: "client-01",
|
||||
MaxConnections: 2,
|
||||
TOTPPeriod: 60,
|
||||
TOTPWindow: 1,
|
||||
TOTPDigits: 6,
|
||||
}
|
||||
if err := validateSSHUserPayload(valid); err != nil {
|
||||
t.Fatalf("valid SSH payload rejected: %v", err)
|
||||
}
|
||||
|
||||
invalid := *valid
|
||||
invalid.MaxConnections = -1
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("negative max_connections was accepted")
|
||||
}
|
||||
|
||||
invalid = *valid
|
||||
invalid.TOTPSecret = "not base32!"
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("invalid TOTP secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateXrayClientFields(t *testing.T) {
|
||||
const id = "d9428888-122b-11e1-b85c-61cd3cbb3210"
|
||||
if err := validateXrayClientFields(id, "vless-in", "client@example.test", "Client", "", 2, true); err != nil {
|
||||
t.Fatalf("valid Xray client rejected: %v", err)
|
||||
}
|
||||
if err := validateXrayClientFields("not-a-uuid", "vless-in", "", "", "", 1, true); err == nil {
|
||||
t.Fatal("invalid Xray UUID was accepted")
|
||||
}
|
||||
if err := validateXrayClientFields(id, "vless-in", "", "", "", 1001, true); err == nil {
|
||||
t.Fatal("excessive Xray connection limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditAccountConnectionLimitIsImmutable(t *testing.T) {
|
||||
username := "credit-limit-test"
|
||||
adminUsers.set(&AdminUser{Username: username, Role: RoleReseller, QuotaMode: QuotaModeCredit, IsActive: true})
|
||||
defer adminUsers.delete(username)
|
||||
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 3); err != errCreditLimitImmutable {
|
||||
t.Fatalf("credit limit change error = %v, want %v", err, errCreditLimitImmutable)
|
||||
}
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 2); err != nil {
|
||||
t.Fatalf("unchanged credit limit rejected: %v", err)
|
||||
}
|
||||
if _, _, err := authorizeResellerProvision(nil, nil, username, "ssh:test", 0); err != errResellerConnLimit {
|
||||
t.Fatalf("zero-connection credit account error = %v, want %v", err, errResellerConnLimit)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -743,7 +743,6 @@ select:disabled {
|
||||
.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;}
|
||||
|
||||
.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);}
|
||||
.reseller-row-actions{min-width:265px;flex-wrap:wrap}.reseller-audit-table{min-width:820px}.reseller-audit-table td:nth-child(1){white-space:nowrap}.reseller-audit-table td:nth-child(4){color:#ffd36d;font-weight:800}.reseller-audit-table td:last-child{max-width:360px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.68rem;white-space:normal}
|
||||
.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);}
|
||||
|
||||
.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;}
|
||||
@@ -751,3 +750,15 @@ select:disabled {
|
||||
@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}}
|
||||
@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}}
|
||||
@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}}
|
||||
|
||||
/* Sortable SSH user table headers */
|
||||
th[data-sort-key]{cursor:pointer;user-select:none;white-space:nowrap;transition:color .12s ease;}
|
||||
th[data-sort-key]:hover{color:var(--accent);}
|
||||
th[data-sort-key]::after{content:"";display:inline-block;width:.9em;font-size:.72em;opacity:.85;}
|
||||
th[data-sort-key].sort-asc::after{content:" \25B2";}
|
||||
th[data-sort-key].sort-desc::after{content:" \25BC";}
|
||||
|
||||
/* Shared SSH/Xray user list sorting and filtering */
|
||||
.user-list-controls{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:12px;border:1px solid rgba(var(--section-accent,139,92,246),.18);border-radius:16px;background:rgba(var(--section-accent,139,92,246),.055)}
|
||||
.user-list-control-group{display:flex;flex-direction:column;gap:6px;min-width:0}.user-list-control-label{color:var(--muted);font-size:.63rem;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.user-list-buttons{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.user-list-filter-btn{min-height:30px;padding:5px 9px;border:1px solid rgba(148,163,184,.16);border-radius:10px;background:rgba(255,255,255,.025);color:var(--muted);font-size:.68rem;font-weight:850;cursor:pointer;transition:.15s ease}.user-list-filter-btn:hover{color:var(--text);border-color:rgba(var(--section-accent,139,92,246),.38);background:rgba(var(--section-accent,139,92,246),.09)}.user-list-filter-btn.active{color:#fff;border-color:rgba(var(--section-accent,139,92,246),.44);background:linear-gradient(135deg,rgba(var(--section-accent,139,92,246),.3),rgba(34,211,238,.1));box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}.user-list-filter-btn[data-direction]::after{margin-left:4px;font-size:.7em}.user-list-filter-btn[data-direction="asc"]::after{content:"\25B2"}.user-list-filter-btn[data-direction="desc"]::after{content:"\25BC"}.user-list-count{margin-left:auto;white-space:nowrap}
|
||||
@media(max-width:760px){.user-list-controls{align-items:stretch}.user-list-control-group{width:100%}.user-list-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.user-list-filter-btn{width:100%}.user-list-count{margin-left:0;align-self:flex-start}}
|
||||
|
||||
+58
-11
@@ -5,8 +5,6 @@ if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
let currentRole = "";
|
||||
let currentUser = "";
|
||||
let currentQuotaMode = "slots";
|
||||
let currentCreditBalance = 0;
|
||||
let statsTimer = null, usersTimer = null, xrayTimer = null;
|
||||
let tlsForwardersState = [];
|
||||
let managedTlsForwardersState = [];
|
||||
@@ -174,6 +172,24 @@ Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.",
|
||||
"Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros."
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Reset":"Reset","Reset traffic":"Reset traffic","Reset SSH traffic":"Reset SSH traffic","Reset Xray traffic":"Reset Xray traffic",
|
||||
"Reset traffic for user \"{name}\"?":"Reset traffic for user \"{name}\"?","Reset traffic for client {id}…?":"Reset traffic for client {id}…?",
|
||||
"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.",
|
||||
"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.",
|
||||
"Resetting traffic for {name}…":"Resetting traffic for {name}…","Resetting traffic for client {id}…":"Resetting traffic for client {id}…",
|
||||
"Traffic reset successfully.":"Traffic reset successfully.","Xray traffic reset successfully.":"Xray traffic reset successfully.",
|
||||
"Could not reset traffic: {error}":"Could not reset traffic: {error}","Reset traffic counter":"Reset traffic counter"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Reset":"Zerar","Reset traffic":"Zerar tráfego","Reset SSH traffic":"Zerar tráfego SSH","Reset Xray traffic":"Zerar tráfego Xray",
|
||||
"Reset traffic for user \"{name}\"?":"Zerar o tráfego do usuário \"{name}\"?","Reset traffic for client {id}…?":"Zerar o tráfego do cliente {id}…?",
|
||||
"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, senha, validade e cota não serão alteradas.",
|
||||
"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, validade e cota não serão alteradas.",
|
||||
"Resetting traffic for {name}…":"Zerando o tráfego de {name}…","Resetting traffic for client {id}…":"Zerando o tráfego do cliente {id}…",
|
||||
"Traffic reset successfully.":"Tráfego zerado com sucesso.","Xray traffic reset successfully.":"Tráfego Xray zerado com sucesso.",
|
||||
"Could not reset traffic: {error}":"Não foi possível zerar o tráfego: {error}","Reset traffic counter":"Zerar contador de tráfego"
|
||||
});
|
||||
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
|
||||
let currentLang = detectInitialLanguage();
|
||||
let i18nTranslating = false;
|
||||
@@ -261,6 +277,8 @@ function applyLanguage(lang, options = {}) {
|
||||
if (languageSelect) languageSelect.value = currentLang;
|
||||
updatePageHeading();
|
||||
translateStatic(document.body);
|
||||
if (typeof window.renderSSHListControls === "function") window.renderSSHListControls();
|
||||
if (typeof window.renderXrayListControls === "function") window.renderXrayListControls();
|
||||
document.documentElement.classList.remove("i18n-pending");
|
||||
}
|
||||
|
||||
@@ -371,13 +389,8 @@ const resellerFormTitle = document.getElementById("resellerFormTitle");
|
||||
const resellerForm = document.getElementById("resellerForm");
|
||||
const rUsername = document.getElementById("rUsername");
|
||||
const rPassword = document.getElementById("rPassword");
|
||||
const rParent = document.getElementById("rParent");
|
||||
const rQuotaMode = document.getElementById("rQuotaMode");
|
||||
const rMaxUsers = document.getElementById("rMaxUsers");
|
||||
const rCredits = document.getElementById("rCredits");
|
||||
const rExpires = document.getElementById("rExpires");
|
||||
const rWhatsApp = document.getElementById("rWhatsApp");
|
||||
const rMonthlyPrice = document.getElementById("rMonthlyPrice");
|
||||
const rActive = document.getElementById("rActive");
|
||||
|
||||
// Managed servers
|
||||
@@ -398,6 +411,7 @@ const serverFormStatus = document.getElementById("serverFormStatus");
|
||||
const serversListView = document.getElementById("serversListView");
|
||||
const serverConfigSubpage = document.getElementById("serverConfigSubpage");
|
||||
const cfgServerName = document.getElementById("cfgServerName");
|
||||
const managedConfigEditor = document.getElementById("managedConfigEditor");
|
||||
const managedConfigStatus = document.getElementById("managedConfigStatus");
|
||||
const serversStatusGrid = document.getElementById("serversStatusGrid");
|
||||
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
|
||||
@@ -540,9 +554,7 @@ async function api(path, opts = {}) {
|
||||
"X-Session-Token": sessionToken,
|
||||
});
|
||||
const res = await fetch(path, o);
|
||||
// A 403 is an in-session permission or quota error; only 401 means the
|
||||
// session is no longer valid and should return to the login screen.
|
||||
if (res.status === 401) throw new Error("auth");
|
||||
if (res.status === 401 || res.status === 403) throw new Error("auth");
|
||||
return res;
|
||||
}
|
||||
function withServerParam(path, serverID) {
|
||||
@@ -691,7 +703,12 @@ function clientTrafficHTML(c) {
|
||||
const up = Number(c.uplink_bytes || 0);
|
||||
const down = Number(c.downlink_bytes || 0);
|
||||
const total = Number(c.total_bytes || (up + down) || 0);
|
||||
return `${escapeHTML(formatBytes(total))}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
|
||||
const quota = Number(c.data_quota_bytes || 0);
|
||||
const quotaLabel = quota > 0 ? formatBytes(quota) : "∞";
|
||||
const state = c.quota_exceeded
|
||||
? (c.quota_action === "throttle" ? ` · ${t("throttled")}` : ` · ${t("blocked")}`)
|
||||
: "";
|
||||
return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
|
||||
}
|
||||
|
||||
function updateCell(row, name, html) {
|
||||
@@ -729,9 +746,39 @@ function patchRenderedInbounds(inbounds) {
|
||||
updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
|
||||
updateCell(row, "status", clientStatusHTML(c));
|
||||
updateCell(row, "online", clientOnlineHTML(c));
|
||||
updateCell(row, "connections", escapeHTML(c.active_connections || 0));
|
||||
updateCell(row, "traffic", clientTrafficHTML(c));
|
||||
updateCell(row, "max", escapeHTML(c.max_conns || "∞"));
|
||||
}
|
||||
}
|
||||
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"], {
|
||||
"Sort by":"Sort by","Show":"Show","All":"All","Offline":"Offline","Connections":"Connections","Usage":"Usage","Quota reached":"Quota reached","{visible} of {total} users":"{visible} of {total} users","No Xray users match this filter.":"No Xray users match this filter.","No SSH users match this filter.":"No SSH users match this filter.",
|
||||
"Native Xray scale tuning":"Native Xray scale tuning",
|
||||
"Go CPU threads (GOMAXPROCS)":"Go CPU threads (GOMAXPROCS)",
|
||||
"Global mux backend sessions":"Global mux backend sessions",
|
||||
"Transport and XHTTP admission":"Transport and XHTTP admission",
|
||||
"Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.":"Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.",
|
||||
"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 and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally.":"XHTTP is handled as VPN tunnel traffic: packet requests and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally."
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Sort by":"Ordenar por","Show":"Mostrar","All":"Todos","Offline":"Offline","Connections":"Conexões","Usage":"Uso","Quota reached":"Cota atingida","{visible} of {total} users":"{visible} de {total} usuários","No Xray users match this filter.":"Nenhum usuário Xray corresponde a este filtro.","No SSH users match this filter.":"Nenhum usuário SSH corresponde a este filtro.",
|
||||
"Native Xray scale tuning":"Ajustes de escala do Xray nativo",
|
||||
"Go CPU threads (GOMAXPROCS)":"Threads de CPU do Go (GOMAXPROCS)",
|
||||
"Global mux backend sessions":"Sessões globais de backend Mux",
|
||||
"Transport and XHTTP admission":"Admissão de transporte e XHTTP",
|
||||
"Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.":"Ilimitado para tráfego VPN. Não existe limite global por quantidade de requisições HTTP, streams HTTP/2, conexões de transporte ou sessões XHTTP.",
|
||||
"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 and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally.":"O XHTTP é tratado como tráfego de túnel VPN: requisições de pacotes e remontagem usam somente backpressure com limite de bytes, nunca limite por quantidade de requisições. Limites web antigos já salvos são ignorados automaticamente após a atualização. As regras por usuário de max_conns, cota e banda continuam funcionando normalmente."
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ function selectTab(tab) {
|
||||
if (tab === "stats" && currentRole === "superadmin") loadStats();
|
||||
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
|
||||
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
|
||||
if (tab === "resellers") loadResellers();
|
||||
if (tab === "resellers" && currentRole === "superadmin") loadResellers();
|
||||
if (tab === "servers" && currentRole === "superadmin") loadServers();
|
||||
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
|
||||
}
|
||||
@@ -218,13 +218,6 @@ function clearTimers() {
|
||||
}
|
||||
|
||||
function initAfterLogin() {
|
||||
if (currentRole === "superadmin") {
|
||||
currentQuotaMode = "slots";
|
||||
currentCreditBalance = 0;
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (input) { input.disabled = false; input.title = ""; }
|
||||
});
|
||||
}
|
||||
meUsername.textContent = currentUser;
|
||||
mainApp.classList.remove("role-superadmin", "role-reseller");
|
||||
mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller");
|
||||
@@ -283,28 +276,13 @@ async function loadMe() {
|
||||
const res = await api("/api/auth/me");
|
||||
const d = await res.json();
|
||||
dashboardCache.me = d;
|
||||
currentQuotaMode = d.quota_mode || "slots";
|
||||
currentCreditBalance = d.credit_balance || 0;
|
||||
const creditPlan = currentQuotaMode === "credits";
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (!input) return;
|
||||
input.disabled = creditPlan;
|
||||
input.title = creditPlan ? "Planos por crédito usam 31 dias e são renovados pelo botão +30d." : "";
|
||||
});
|
||||
const used = d.used_users ?? 0;
|
||||
const max = d.max_users || 0;
|
||||
rUsedMax.textContent = currentQuotaMode === "credits"
|
||||
? `${currentCreditBalance} créditos`
|
||||
: `${used + (d.child_allocation || 0)} / ${max || "∞"}`;
|
||||
rUsedMax.textContent = used + " / " + (max || "∞");
|
||||
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration");
|
||||
const effectiveActive = d.effective_active ?? d.is_active;
|
||||
rStatus.textContent = effectiveActive ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = effectiveActive ? "var(--success)" : "var(--danger)";
|
||||
if (currentQuotaMode === "credits") {
|
||||
updateCreditQuotaCard(currentCreditBalance, d.used_ssh_users || 0, d.used_xray_users || 0, d.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard(used + (d.child_allocation || 0), max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
}
|
||||
rStatus.textContent = d.is_active ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
|
||||
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
renderDashboardCounters();
|
||||
} catch {}
|
||||
}
|
||||
@@ -355,24 +333,6 @@ function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) {
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed});
|
||||
}
|
||||
|
||||
function updateCreditQuotaCard(balance, sshUsed = 0, xrayUsed = 0, childCount = 0) {
|
||||
if (!dashQuotaText) return;
|
||||
dashQuotaChip.textContent = `${balance} Cr`;
|
||||
dashQuotaChip.className = `chip ${balance <= 0 ? "red" : balance <= 5 ? "warn" : "green"}`;
|
||||
dashQuotaText.textContent = `${balance} créditos disponíveis`;
|
||||
dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed} · ${childCount} sub-revendas`;
|
||||
dashQuotaBar.style.width = "100%";
|
||||
if (dashQuotaRemaining) {
|
||||
dashQuotaRemaining.textContent = String(balance);
|
||||
setQuotaTone(dashQuotaRemaining, balance <= 0 ? "quota-danger" : balance <= 5 ? "quota-warn" : "quota-good");
|
||||
}
|
||||
if (dashQuotaSummaryText) dashQuotaSummaryText.textContent = `${balance} créditos no saldo`;
|
||||
if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = balance > 0 ? "100%" : "0%";
|
||||
if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${balance} Cr`;
|
||||
if (xrayResellerQuotaRemaining) xrayResellerQuotaRemaining.textContent = String(balance);
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
|
||||
}
|
||||
|
||||
function flattenXrayClients(inbounds = []) {
|
||||
return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c)));
|
||||
}
|
||||
@@ -431,11 +391,7 @@ function renderDashboardCounters() {
|
||||
|
||||
const me = dashboardCache.me;
|
||||
if (currentRole === "reseller" && me) {
|
||||
if ((me.quota_mode || "slots") === "credits") {
|
||||
updateCreditQuotaCard(me.credit_balance || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length, me.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard((me.used_users ?? total) + (me.child_allocation || 0), me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
}
|
||||
updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+231
-58
@@ -1,5 +1,4 @@
|
||||
// ─── SSH Users ────────────────────────────────────────────────────────────────
|
||||
let editingSSHUser = "";
|
||||
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
|
||||
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
|
||||
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
|
||||
@@ -8,13 +7,15 @@ cancelUserBtn.addEventListener("click", () => {
|
||||
setWorkspaceSection("ssh", "users");
|
||||
});
|
||||
function prepareNewSSHUser() {
|
||||
editingSSHUser = "";
|
||||
userForm.reset();
|
||||
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
|
||||
fMaxConn.disabled = false;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.value = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = "";
|
||||
// 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;
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Create user");
|
||||
@@ -63,57 +64,220 @@ async function loadUsersSilent() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Column sorting (click a header to sort) ----
|
||||
// Value extractor per sortable column. Numbers sort numerically, strings
|
||||
// alphabetically; online counts as 1 so "status" groups online users together.
|
||||
const USER_SORT_EXTRACT = {
|
||||
username: u => String(u.username || "").toLowerCase(),
|
||||
status: u => (u.active_conns || 0) > 0 ? 1 : 0,
|
||||
auth: u => u.use_pam ? "pam" : (u.totp_enabled ? (u.allow_static_password ? "totp+pw" : "totp") : "password"),
|
||||
conn: u => u.active_conns || 0,
|
||||
max: u => u.max_connections || 0,
|
||||
up: u => u.limit_mbps_up || 0,
|
||||
down: u => u.limit_mbps_down || 0,
|
||||
usage: u => Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0),
|
||||
expires: u => u.expires_at ? new Date(u.expires_at).getTime() : Infinity,
|
||||
owner: u => String(u.owner_username || "").toLowerCase(),
|
||||
};
|
||||
// Columns that default to descending on first click (most/online first).
|
||||
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "usage"]);
|
||||
const USER_SORT_OPTIONS = [
|
||||
["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"],
|
||||
["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"], ["up", "Up"], ["down", "Dn"],
|
||||
["owner", "Owner"],
|
||||
];
|
||||
const USER_FILTER_OPTIONS = [
|
||||
["all", "All"], ["online", "Online"], ["offline", "Offline"],
|
||||
["active", "Active"], ["expired", "Expired"], ["quota", "Quota reached"],
|
||||
];
|
||||
|
||||
let userSort = { key: "username", dir: "asc" };
|
||||
let userFilter = "all";
|
||||
let lastUsersData = [];
|
||||
|
||||
function userMatchesFilter(user) {
|
||||
const online = Number(user.active_conns || 0) > 0;
|
||||
const expired = isExpiredDate(user.expires_at);
|
||||
switch (userFilter) {
|
||||
case "online": return online;
|
||||
case "offline": return !online;
|
||||
case "active": return !expired;
|
||||
case "expired": return expired;
|
||||
case "quota": return !!user.quota_exceeded;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sortUsers(list) {
|
||||
const ext = USER_SORT_EXTRACT[userSort.key] || USER_SORT_EXTRACT.username;
|
||||
const dir = userSort.dir === "desc" ? -1 : 1;
|
||||
return list.slice().sort((a, b) => {
|
||||
const va = ext(a), vb = ext(b);
|
||||
let cmp;
|
||||
if (typeof va === "number" && typeof vb === "number") cmp = va - vb;
|
||||
else cmp = String(va).localeCompare(String(vb));
|
||||
// Stable tie-break by username so equal rows never shuffle between polls.
|
||||
if (cmp === 0) cmp = String(a.username || "").localeCompare(String(b.username || ""));
|
||||
return cmp * dir;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
const table = usersBody && usersBody.closest("table");
|
||||
if (!table) return;
|
||||
table.querySelectorAll("th[data-sort-key]").forEach(th => {
|
||||
th.classList.remove("sort-asc", "sort-desc");
|
||||
if (th.getAttribute("data-sort-key") === userSort.key) {
|
||||
th.classList.add(userSort.dir === "asc" ? "sort-asc" : "sort-desc");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderSSHListControls() {
|
||||
const sortLabel = document.getElementById("sshSortLabel");
|
||||
const filterLabel = document.getElementById("sshFilterLabel");
|
||||
const sortButtons = document.getElementById("sshSortButtons");
|
||||
const filterButtons = document.getElementById("sshFilterButtons");
|
||||
const count = document.getElementById("sshListCount");
|
||||
if (sortLabel) sortLabel.textContent = t("Sort by");
|
||||
if (filterLabel) filterLabel.textContent = t("Show");
|
||||
if (sortButtons) {
|
||||
const options = USER_SORT_OPTIONS.filter(([key]) => key !== "owner" || currentRole === "superadmin");
|
||||
sortButtons.replaceChildren(...options.map(([key, label]) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "user-list-filter-btn" + (userSort.key === key ? " active" : "");
|
||||
button.textContent = t(label);
|
||||
button.setAttribute("aria-pressed", userSort.key === key ? "true" : "false");
|
||||
if (userSort.key === key) button.dataset.direction = userSort.dir;
|
||||
button.addEventListener("click", () => setUserSort(key));
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
if (filterButtons) {
|
||||
filterButtons.replaceChildren(...USER_FILTER_OPTIONS.map(([key, label]) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "user-list-filter-btn" + (userFilter === key ? " active" : "");
|
||||
button.textContent = t(label);
|
||||
button.setAttribute("aria-pressed", userFilter === key ? "true" : "false");
|
||||
button.addEventListener("click", () => setUserFilter(key));
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
if (count) {
|
||||
const visible = lastUsersData.filter(userMatchesFilter).length;
|
||||
count.textContent = t("{visible} of {total} users", {visible, total:lastUsersData.length});
|
||||
}
|
||||
}
|
||||
|
||||
function setUserSort(key) {
|
||||
if (!USER_SORT_EXTRACT[key]) return;
|
||||
if (userSort.key === key) {
|
||||
userSort.dir = userSort.dir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
userSort.key = key;
|
||||
userSort.dir = USER_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc";
|
||||
}
|
||||
renderUsers(lastUsersData);
|
||||
}
|
||||
|
||||
function setUserFilter(filter) {
|
||||
if (!USER_FILTER_OPTIONS.some(([key]) => key === filter)) return;
|
||||
userFilter = filter;
|
||||
renderUsers(lastUsersData);
|
||||
}
|
||||
|
||||
(function initUserSortHeaders() {
|
||||
const table = usersBody && usersBody.closest("table");
|
||||
if (!table) return;
|
||||
table.querySelectorAll("th[data-sort-key]").forEach(th => {
|
||||
th.addEventListener("click", () => setUserSort(th.getAttribute("data-sort-key")));
|
||||
});
|
||||
updateSortIndicators();
|
||||
})();
|
||||
|
||||
renderSSHListControls();
|
||||
|
||||
function sshTrafficHTML(u) {
|
||||
const up = Number(u.total_uplink_bytes || 0);
|
||||
const down = Number(u.total_downlink_bytes || 0);
|
||||
const total = Number(u.total_bytes || (up + down) || 0);
|
||||
const quota = Number(u.data_quota_bytes || 0);
|
||||
const quotaLabel = quota > 0 ? formatBytes(quota) : "∞";
|
||||
const state = u.quota_exceeded
|
||||
? (u.quota_action === "throttle" ? ` · ${t("throttled")}` : ` · ${t("blocked")}`)
|
||||
: "";
|
||||
return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
updateDashboardFromUsers(users);
|
||||
// Cache the raw list so a header click can re-sort without refetching, and
|
||||
// order by the active column so rows don't shuffle on each live poll.
|
||||
lastUsersData = Array.isArray(users) ? users : [];
|
||||
users = sortUsers(lastUsersData.filter(userMatchesFilter));
|
||||
updateDashboardFromUsers(lastUsersData);
|
||||
renderSSHListControls();
|
||||
updateSortIndicators();
|
||||
const isSA = currentRole === "superadmin";
|
||||
userCountChip.textContent = users.length;
|
||||
if (isSA) ownerColHead.classList.remove("hidden");
|
||||
ownerColHead.classList.toggle("hidden", !isSA);
|
||||
usersBody.innerHTML = "";
|
||||
let online = 0;
|
||||
let expiredCount = 0;
|
||||
const online = lastUsersData.filter(u => Number(u.active_conns || 0) > 0).length;
|
||||
const expiredCount = lastUsersData.filter(u => isExpiredDate(u.expires_at)).length;
|
||||
users.forEach(u => {
|
||||
const on = (u.active_conns || 0) > 0;
|
||||
if (on) online++;
|
||||
if (isExpiredDate(u.expires_at)) expiredCount++;
|
||||
const tr = document.createElement("tr");
|
||||
const cells = [
|
||||
u.username,
|
||||
on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`,
|
||||
u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password",
|
||||
u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password"),
|
||||
u.active_conns ?? 0,
|
||||
u.max_connections || 0,
|
||||
u.limit_mbps_up || 0,
|
||||
u.limit_mbps_down || 0,
|
||||
sshTrafficHTML(u),
|
||||
u.expires_at ? fmtDate(u.expires_at) : "—",
|
||||
];
|
||||
if (isSA) cells.push(u.owner_username || "—");
|
||||
cells.forEach((c, i) => {
|
||||
const td = document.createElement("td");
|
||||
if (i === 1) td.innerHTML = c; else td.textContent = c;
|
||||
if (i === 1 || i === 7) td.innerHTML = c; else td.textContent = c;
|
||||
if (i === 7) td.style.fontSize = ".7rem";
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const tdA = document.createElement("td");
|
||||
const renewBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:"+30d",
|
||||
onclick: () => renewSSHUser(u),
|
||||
});
|
||||
const editBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillUserForm(u),
|
||||
});
|
||||
const resetBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-warn btn-sm", textContent:t("Reset"),
|
||||
style: "margin-left:4px;",
|
||||
title: t("Reset traffic"),
|
||||
onclick: () => resetUserTraffic(u.username, resetBtn),
|
||||
});
|
||||
const delBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteUser(u.username),
|
||||
});
|
||||
tdA.className = "bot-row-actions";
|
||||
tdA.append(renewBtn, editBtn, delBtn);
|
||||
tdA.append(editBtn, resetBtn, delBtn);
|
||||
tr.appendChild(tdA);
|
||||
usersBody.appendChild(tr);
|
||||
});
|
||||
const activeCount = Math.max(0, users.length - expiredCount);
|
||||
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
|
||||
if (sshMetricTotal) sshMetricTotal.textContent = String(users.length);
|
||||
if (!users.length) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = isSA ? 11 : 10;
|
||||
cell.className = "hint";
|
||||
cell.style.cssText = "padding:24px;text-align:center;";
|
||||
cell.textContent = t("No SSH users match this filter.");
|
||||
row.appendChild(cell);
|
||||
usersBody.appendChild(row);
|
||||
}
|
||||
const activeCount = Math.max(0, lastUsersData.length - expiredCount);
|
||||
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count:lastUsersData.length, active:activeCount, online});
|
||||
if (sshMetricTotal) sshMetricTotal.textContent = String(lastUsersData.length);
|
||||
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
|
||||
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
|
||||
if (sshMetricState) sshMetricState.textContent = t("Online");
|
||||
@@ -124,7 +288,6 @@ function renderUsers(users) {
|
||||
}
|
||||
|
||||
function fillUserForm(u) {
|
||||
editingSSHUser = u.username || "";
|
||||
setWorkspaceSection("ssh", "create");
|
||||
fUsername.value = u.username || "";
|
||||
fPassword.value = "";
|
||||
@@ -134,12 +297,14 @@ function fillUserForm(u) {
|
||||
fTotpDigits.value = u.totp_digits || 6;
|
||||
fAllowStatic.checked = !!u.allow_static_password;
|
||||
fMaxConn.value = u.max_connections || "";
|
||||
const creditLocked = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
fMaxConn.disabled = creditLocked;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = creditLocked ? "Em planos por crédito, altere o limite criando uma nova conta." : "";
|
||||
fUp.value = u.limit_mbps_up || "";
|
||||
fDown.value = u.limit_mbps_down || "";
|
||||
fQuotaGB.value = u.data_quota_bytes ? (Number(u.data_quota_bytes) / (1024 ** 3)).toFixed(2).replace(/\.00$/, "") : "0";
|
||||
fQuotaAction.value = u.quota_action === "throttle" ? "throttle" : "block";
|
||||
fQuotaThrottle.value = u.quota_throttle_mbps || 1;
|
||||
const totalBytes = Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0);
|
||||
fUsageDisplay.value = `${formatBytes(totalBytes)} (↑ ${formatBytes(u.total_uplink_bytes || 0)} · ↓ ${formatBytes(u.total_downlink_bytes || 0)})`;
|
||||
fResetUsage.checked = false;
|
||||
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
@@ -161,11 +326,13 @@ userForm.addEventListener("submit", async e => {
|
||||
totp_digits: parseInt(fTotpDigits.value||"6",10),
|
||||
allow_static_password: !!fAllowStatic.checked,
|
||||
max_connections: parseInt(fMaxConn.value||"0",10),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits" && editingSSHUser
|
||||
? ""
|
||||
: isoFromLocal(fExpires.value),
|
||||
expires_at: isoFromLocal(fExpires.value),
|
||||
limit_mbps_up: parseInt(fUp.value||"0",10),
|
||||
limit_mbps_down: parseInt(fDown.value||"0",10),
|
||||
data_quota_bytes: Math.round((parseFloat(fQuotaGB.value || "0") || 0) * (1024 ** 3)),
|
||||
quota_action: fQuotaAction.value === "throttle" ? "throttle" : "block",
|
||||
quota_throttle_mbps: parseInt(fQuotaThrottle.value || "1", 10) || 1,
|
||||
reset_usage: !!fResetUsage.checked,
|
||||
server_id: selectedSSHServer(),
|
||||
};
|
||||
try {
|
||||
@@ -173,6 +340,7 @@ userForm.addEventListener("submit", async e => {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
userStatus.textContent = t("Saved.");
|
||||
fPassword.value = "";
|
||||
fResetUsage.checked = false;
|
||||
loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
|
||||
@@ -185,6 +353,37 @@ userForm.addEventListener("submit", async e => {
|
||||
}
|
||||
});
|
||||
|
||||
async function resetUserTraffic(username, button) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"warning", icon:"↺", title:t("Reset SSH traffic"),
|
||||
message:t("Reset traffic for user \"{name}\"?", {name: username}),
|
||||
detail:t("Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged."),
|
||||
confirmLabel:t("Reset traffic"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
const previousDisabled = !!button?.disabled;
|
||||
if (button) button.disabled = true;
|
||||
userStatus.textContent = t("Resetting traffic for {name}…", {name: username});
|
||||
try {
|
||||
const res = await api("/api/users/reset-traffic", {
|
||||
method:"POST",
|
||||
body: JSON.stringify({ username, server_id:selectedSSHServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()) || "reset failed");
|
||||
userStatus.textContent = t("Traffic reset successfully.");
|
||||
showPanelToast(t("Traffic reset successfully."), "success", t("SSH / SlowDNS"));
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
userStatus.textContent = t("Could not reset traffic: {error}", {error:e.message});
|
||||
showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("SSH / SlowDNS"));
|
||||
}
|
||||
} finally {
|
||||
if (button) button.disabled = previousDisabled;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(username) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Delete SSH account"),
|
||||
@@ -205,29 +404,3 @@ async function deleteUser(username) {
|
||||
else userStatus.textContent = t("Error deleting.");
|
||||
}
|
||||
}
|
||||
|
||||
async function renewSSHUser(user) {
|
||||
const creditCost = Math.max(1, Number(user.max_connections || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar SSH", message:`Renovar “${user.username}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
userStatus.textContent = `Renovando ${user.username}…`;
|
||||
try {
|
||||
const res = await api("/api/users/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ username:user.username, days:30, server_id:selectedSSHServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${user.username} renovado.`, "success", "SSH / SlowDNS");
|
||||
await loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar SSH");
|
||||
}
|
||||
}
|
||||
|
||||
+200
-46
@@ -18,6 +18,133 @@ document.getElementById("xCreateUUIDBtn")?.addEventListener("click", () => {
|
||||
document.getElementById("xCreateInbound")?.addEventListener("change", updateXrayCreatorInboundLabel);
|
||||
document.getElementById("xCreateClientForm")?.addEventListener("submit", submitXrayClientCreator);
|
||||
|
||||
// Keep Xray list controls consistent with the sortable SSH user table while
|
||||
// preserving the inbound grouping. Sorting is applied inside each inbound;
|
||||
// filters apply across all inbound groups.
|
||||
const XRAY_CLIENT_SORT_EXTRACT = {
|
||||
name: c => String(c.name || c.email || c.id || "").toLowerCase(),
|
||||
status: c => c.expired ? 0 : 1,
|
||||
online: c => c.online ? 1 : 0,
|
||||
connections: c => Number(c.active_connections || 0),
|
||||
usage: c => Number(c.total_bytes || ((c.uplink_bytes || 0) + (c.downlink_bytes || 0)) || 0),
|
||||
expiry: c => c.expires_at ? new Date(c.expires_at).getTime() : Infinity,
|
||||
max: c => Number(c.max_conns || 0),
|
||||
};
|
||||
const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "usage", "max"]);
|
||||
const XRAY_CLIENT_SORT_OPTIONS = [
|
||||
["name", "Name"], ["status", "Status"], ["online", "Online"],
|
||||
["connections", "Connections"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"],
|
||||
];
|
||||
const XRAY_CLIENT_FILTER_OPTIONS = [
|
||||
["all", "All"], ["online", "Online"], ["offline", "Offline"],
|
||||
["active", "Active"], ["expired", "Expired"], ["quota", "Quota reached"],
|
||||
];
|
||||
let xrayClientSort = { key: "name", dir: "asc" };
|
||||
let xrayClientFilter = "all";
|
||||
let lastXrayInboundsData = [];
|
||||
|
||||
function xrayClientMatchesFilter(client) {
|
||||
switch (xrayClientFilter) {
|
||||
case "online": return !!client.online;
|
||||
case "offline": return !client.online;
|
||||
case "active": return !client.expired;
|
||||
case "expired": return !!client.expired;
|
||||
case "quota": return !!client.quota_exceeded;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sortXrayClients(clients = []) {
|
||||
const extract = XRAY_CLIENT_SORT_EXTRACT[xrayClientSort.key] || XRAY_CLIENT_SORT_EXTRACT.name;
|
||||
const direction = xrayClientSort.dir === "desc" ? -1 : 1;
|
||||
return clients.slice().sort((a, b) => {
|
||||
const left = extract(a), right = extract(b);
|
||||
let comparison;
|
||||
if (typeof left === "number" && typeof right === "number") comparison = left - right;
|
||||
else comparison = String(left).localeCompare(String(right));
|
||||
if (comparison === 0) comparison = String(a.name || a.email || a.id || "").localeCompare(String(b.name || b.email || b.id || ""));
|
||||
return comparison * direction;
|
||||
});
|
||||
}
|
||||
|
||||
function prepareXrayInboundsForList(inbounds = []) {
|
||||
return (inbounds || []).map(inbound => ({
|
||||
...inbound,
|
||||
clients: sortXrayClients((inbound.clients || []).filter(xrayClientMatchesFilter)),
|
||||
})).filter(inbound => xrayClientFilter === "all" || inbound.clients.length > 0);
|
||||
}
|
||||
|
||||
function xrayClientListCounts(inbounds = []) {
|
||||
const clients = (inbounds || []).flatMap(inbound => inbound.clients || []);
|
||||
return { total: clients.length, visible: clients.filter(xrayClientMatchesFilter).length };
|
||||
}
|
||||
|
||||
function renderXrayListControls() {
|
||||
const sortLabel = document.getElementById("xraySortLabel");
|
||||
const filterLabel = document.getElementById("xrayFilterLabel");
|
||||
const sortButtons = document.getElementById("xraySortButtons");
|
||||
const filterButtons = document.getElementById("xrayFilterButtons");
|
||||
const count = document.getElementById("xrayListCount");
|
||||
if (sortLabel) sortLabel.textContent = t("Sort by");
|
||||
if (filterLabel) filterLabel.textContent = t("Show");
|
||||
if (sortButtons) {
|
||||
sortButtons.replaceChildren(...XRAY_CLIENT_SORT_OPTIONS.map(([key, label]) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "user-list-filter-btn" + (xrayClientSort.key === key ? " active" : "");
|
||||
button.textContent = t(label);
|
||||
button.setAttribute("aria-pressed", xrayClientSort.key === key ? "true" : "false");
|
||||
if (xrayClientSort.key === key) button.dataset.direction = xrayClientSort.dir;
|
||||
button.addEventListener("click", () => setXrayClientSort(key));
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
if (filterButtons) {
|
||||
filterButtons.replaceChildren(...XRAY_CLIENT_FILTER_OPTIONS.map(([key, label]) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "user-list-filter-btn" + (xrayClientFilter === key ? " active" : "");
|
||||
button.textContent = t(label);
|
||||
button.setAttribute("aria-pressed", xrayClientFilter === key ? "true" : "false");
|
||||
button.addEventListener("click", () => setXrayClientFilter(key));
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
if (count) {
|
||||
const counts = xrayClientListCounts(lastXrayInboundsData);
|
||||
count.textContent = t("{visible} of {total} users", counts);
|
||||
}
|
||||
}
|
||||
|
||||
function setXrayClientSort(key) {
|
||||
if (!XRAY_CLIENT_SORT_EXTRACT[key]) return;
|
||||
if (xrayClientSort.key === key) xrayClientSort.dir = xrayClientSort.dir === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
xrayClientSort.key = key;
|
||||
xrayClientSort.dir = XRAY_CLIENT_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc";
|
||||
}
|
||||
renderInbounds(lastXrayInboundsData, { force:true, fromControls:true });
|
||||
}
|
||||
|
||||
function setXrayClientFilter(filter) {
|
||||
if (!XRAY_CLIENT_FILTER_OPTIONS.some(([key]) => key === filter)) return;
|
||||
xrayClientFilter = filter;
|
||||
renderInbounds(lastXrayInboundsData, { force:true, fromControls:true });
|
||||
}
|
||||
|
||||
function bindXrayTableSortHeaders(table) {
|
||||
table.querySelectorAll("th[data-sort-key]").forEach(header => {
|
||||
const key = header.dataset.sortKey;
|
||||
const selected = key === xrayClientSort.key;
|
||||
header.classList.toggle("sort-asc", selected && xrayClientSort.dir === "asc");
|
||||
header.classList.toggle("sort-desc", selected && xrayClientSort.dir === "desc");
|
||||
header.setAttribute("aria-sort", selected ? (xrayClientSort.dir === "asc" ? "ascending" : "descending") : "none");
|
||||
header.addEventListener("click", () => setXrayClientSort(key));
|
||||
});
|
||||
}
|
||||
|
||||
renderXrayListControls();
|
||||
|
||||
|
||||
async function loadXrayStatus() {
|
||||
if (xrayChip) {
|
||||
@@ -167,25 +294,33 @@ async function copyText(text) {
|
||||
|
||||
function renderInbounds(inbounds, options = {}) {
|
||||
const { silent = false, force = false } = options || {};
|
||||
updateDashboardXray(inbounds);
|
||||
syncXrayCreatorInbounds(inbounds);
|
||||
const nextStructure = inboundStructure(inbounds);
|
||||
lastXrayInboundsData = Array.isArray(inbounds) ? inbounds : [];
|
||||
updateDashboardXray(lastXrayInboundsData);
|
||||
syncXrayCreatorInbounds(lastXrayInboundsData);
|
||||
const listInbounds = prepareXrayInboundsForList(lastXrayInboundsData);
|
||||
const nextStructure = inboundStructure(listInbounds);
|
||||
renderXrayListControls();
|
||||
|
||||
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return;
|
||||
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(listInbounds)) return;
|
||||
if (silent && !force && isXrayClientEditorActive()) {
|
||||
patchRenderedInbounds(inbounds);
|
||||
patchRenderedInbounds(listInbounds);
|
||||
if (xStatus) xStatus.textContent = t("New client data is available; editing was preserved.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inbounds.length) {
|
||||
if (!lastXrayInboundsData.length) {
|
||||
inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("No VLESS/VMess/Trojan inbounds found.")}</div>`;
|
||||
lastInboundsStructure = nextStructure;
|
||||
return;
|
||||
}
|
||||
if (!listInbounds.length) {
|
||||
inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("No Xray users match this filter.")}</div>`;
|
||||
lastInboundsStructure = nextStructure;
|
||||
return;
|
||||
}
|
||||
inboundsContainer.innerHTML = "";
|
||||
lastInboundsStructure = nextStructure;
|
||||
inbounds.forEach(ib => {
|
||||
listInbounds.forEach(ib => {
|
||||
const section = document.createElement("div");
|
||||
section.dataset.inboundTag = String(ib.tag || "");
|
||||
section.dataset.inboundProtocol = String(ib.protocol || "");
|
||||
@@ -219,7 +354,7 @@ function renderInbounds(inbounds, options = {}) {
|
||||
tblWrap.innerHTML = `<div class="hint" style="padding:4px 0;">${t("No clients.")}</div>`;
|
||||
} else {
|
||||
const tbl = document.createElement("table");
|
||||
tbl.innerHTML = `<thead><tr><th>${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th>${t("Expiry")}</th><th>${t("Status")}</th><th>${t("Online")}</th><th>${t("Traffic")}</th><th>${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
|
||||
tbl.innerHTML = `<thead><tr><th data-sort-key="name">${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th data-sort-key="expiry">${t("Expiry")}</th><th data-sort-key="status">${t("Status")}</th><th data-sort-key="online">${t("Online")}</th><th data-sort-key="connections">${t("Conn")}</th><th data-sort-key="usage">${t("Traffic")}</th><th data-sort-key="max">${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
|
||||
const tbody = document.createElement("tbody");
|
||||
clients.forEach(c => {
|
||||
const tr = document.createElement("tr");
|
||||
@@ -231,6 +366,7 @@ function renderInbounds(inbounds, options = {}) {
|
||||
<td data-cell="expiry" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
|
||||
<td data-cell="status">${clientStatusHTML(c)}</td>
|
||||
<td data-cell="online">${clientOnlineHTML(c)}</td>
|
||||
<td data-cell="connections" style="font-size:.7rem;">${escapeHTML(c.active_connections || 0)}</td>
|
||||
<td data-cell="traffic" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
|
||||
<td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
|
||||
const actTd = document.createElement("td");
|
||||
@@ -244,21 +380,23 @@ function renderInbounds(inbounds, options = {}) {
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = t("Edit");
|
||||
editBtn.onclick = () => openEditXrayClient(ib.tag, c);
|
||||
const renewBtn = document.createElement("button");
|
||||
renewBtn.className = "btn btn-ghost btn-sm";
|
||||
renewBtn.style.marginLeft = "4px";
|
||||
renewBtn.textContent = "+30d";
|
||||
renewBtn.onclick = () => renewXrayClient(c);
|
||||
const resetBtn = document.createElement("button");
|
||||
resetBtn.className = "btn btn-warn btn-sm";
|
||||
resetBtn.style.marginLeft = "4px";
|
||||
resetBtn.textContent = t("Reset");
|
||||
resetBtn.title = t("Reset traffic");
|
||||
resetBtn.onclick = () => resetXrayClientTraffic(c.id, resetBtn);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = t("Del");
|
||||
delBtn.onclick = () => removeClient(ib.tag, c.id);
|
||||
actTd.append(copyBtn, renewBtn, editBtn, delBtn);
|
||||
actTd.append(copyBtn, editBtn, resetBtn, delBtn);
|
||||
tr.appendChild(actTd);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbl.appendChild(tbody);
|
||||
bindXrayTableSortHeaders(tbl);
|
||||
tblWrap.appendChild(tbl);
|
||||
}
|
||||
section.appendChild(tblWrap);
|
||||
@@ -329,10 +467,13 @@ function prepareXrayClientCreator(preferredTag = "") {
|
||||
const uuid = document.getElementById("xCreateUUID");
|
||||
if (uuid) uuid.value = genUUID();
|
||||
const maxConns = document.getElementById("xCreateMaxConns");
|
||||
if (maxConns) {
|
||||
maxConns.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxConns.value = currentRole === "reseller" ? "1" : "0";
|
||||
}
|
||||
if (maxConns) maxConns.value = "0";
|
||||
const quotaGB = document.getElementById("xCreateQuotaGB");
|
||||
if (quotaGB) quotaGB.value = "0";
|
||||
const quotaAction = document.getElementById("xCreateQuotaAction");
|
||||
if (quotaAction) quotaAction.value = "block";
|
||||
const quotaThrottle = document.getElementById("xCreateQuotaThrottle");
|
||||
if (quotaThrottle) quotaThrottle.value = "1";
|
||||
const status = document.getElementById("xCreateClientStatus");
|
||||
if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
|
||||
updateXrayCreatorInboundLabel();
|
||||
@@ -359,6 +500,9 @@ async function submitXrayClientCreator(event) {
|
||||
name: (document.getElementById("xCreateName")?.value || "").trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("xCreateExpiry")?.value || ""),
|
||||
max_connections: parseInt(document.getElementById("xCreateMaxConns")?.value || "0", 10) || 0,
|
||||
data_quota_bytes: Math.round((parseFloat(document.getElementById("xCreateQuotaGB")?.value || "0") || 0) * (1024 ** 3)),
|
||||
quota_action: document.getElementById("xCreateQuotaAction")?.value === "throttle" ? "throttle" : "block",
|
||||
quota_throttle_mbps: parseInt(document.getElementById("xCreateQuotaThrottle")?.value || "1", 10) || 1,
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
if (button) button.disabled = true;
|
||||
@@ -393,6 +537,44 @@ async function submitXrayClientCreator(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetXrayClientTraffic(uuid, button) {
|
||||
const shortID = String(uuid || "").slice(0, 8);
|
||||
const accepted = await panelConfirm({
|
||||
tone:"warning", icon:"↺", title:t("Reset Xray traffic"),
|
||||
message:t("Reset traffic for client {id}…?", {id:shortID}),
|
||||
detail:t("Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged."),
|
||||
confirmLabel:t("Reset traffic"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
const previousDisabled = !!button?.disabled;
|
||||
if (button) button.disabled = true;
|
||||
xStatus.textContent = t("Resetting traffic for client {id}…", {id:shortID});
|
||||
try {
|
||||
const res = await api("/api/xray/clients/reset-traffic", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ uuid, server_id:selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()) || "reset failed");
|
||||
xStatus.textContent = t("Xray traffic reset successfully.");
|
||||
showPanelToast(t("Xray traffic reset successfully."), "success", t("Xray user"));
|
||||
if (editingXrayClientId === uuid) {
|
||||
const usage = document.getElementById("editXrayUsage");
|
||||
if (usage) usage.value = "0 B (↑ 0 B · ↓ 0 B)";
|
||||
const resetUsage = document.getElementById("editXrayResetUsage");
|
||||
if (resetUsage) resetUsage.checked = false;
|
||||
}
|
||||
await loadInbounds({ force:true });
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
xStatus.textContent = t("Could not reset traffic: {error}", {error:e.message});
|
||||
showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("Xray user"));
|
||||
}
|
||||
} finally {
|
||||
if (button) button.disabled = previousDisabled;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClient(tag, uuid) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Remove Xray client"),
|
||||
@@ -413,34 +595,6 @@ async function removeClient(tag, uuid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renewXrayClient(client) {
|
||||
const creditCost = Math.max(1, Number(client.max_conns || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar Xray", message:`Renovar “${client.name || client.email || client.id.slice(0, 8)}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
xStatus.textContent = "Renovando cliente Xray…";
|
||||
try {
|
||||
const res = await api("/api/xray/clients/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", "Renovar Xray");
|
||||
else showPanelToast("Cliente Xray renovado.", "success", "Xray");
|
||||
await loadInbounds({ force:true });
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar Xray");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadXrayCfg() {
|
||||
if (!xCfgEditor) return;
|
||||
const target = selectedXrayServerLabel();
|
||||
|
||||
+71
-241
@@ -1,297 +1,127 @@
|
||||
// ─── Hierarchical resellers ───────────────────────────────────────────────────
|
||||
let resellersCache = [];
|
||||
let editingReseller = "";
|
||||
|
||||
document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
navigateWorkspaceSection("resellers", "create");
|
||||
});
|
||||
document.getElementById("cancelResellerBtn")?.addEventListener("click", () => {
|
||||
// ─── Resellers ────────────────────────────────────────────────────────────────
|
||||
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn").addEventListener("click", () => navigateWorkspaceSection("resellers", "create"));
|
||||
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
});
|
||||
document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit);
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
document.querySelectorAll("[data-workspace='resellers'][data-workspace-section='audit']").forEach(el => {
|
||||
el.addEventListener("click", loadResellerAudit);
|
||||
});
|
||||
document.querySelector("[data-workspace-select='resellers']")?.addEventListener("change", e => {
|
||||
if (e.target.value === "audit") loadResellerAudit();
|
||||
});
|
||||
rQuotaMode?.addEventListener("change", toggleResellerPlanFields);
|
||||
|
||||
function toggleResellerPlanFields() {
|
||||
const credit = rQuotaMode.value === "credits";
|
||||
document.getElementById("rSlotsField")?.classList.toggle("hidden", credit);
|
||||
document.getElementById("rCreditsField")?.classList.toggle("hidden", !credit);
|
||||
document.getElementById("rExpiresField")?.classList.toggle("hidden", credit);
|
||||
if (credit) rExpires.value = "";
|
||||
}
|
||||
|
||||
function prepareNewReseller() {
|
||||
editingReseller = "";
|
||||
resellerFormTitle.textContent = t("Create Reseller");
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Create reseller");
|
||||
resellerForm.reset();
|
||||
rUsername.disabled = false;
|
||||
rParent.disabled = false;
|
||||
rQuotaMode.disabled = currentRole === "reseller";
|
||||
rQuotaMode.value = currentRole === "reseller" ? currentQuotaMode : "slots";
|
||||
rMaxUsers.min = currentRole === "reseller" ? "1" : "0";
|
||||
rMaxUsers.value = currentRole === "reseller" ? "1" : "30";
|
||||
rCredits.value = "1";
|
||||
rActive.checked = true;
|
||||
populateResellerParents();
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("New reseller.");
|
||||
resellerStatus.textContent = "New reseller.";
|
||||
requestAnimationFrame(() => rUsername.focus());
|
||||
}
|
||||
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
|
||||
async function loadResellers() {
|
||||
resellerStatus.textContent = t("Loading…");
|
||||
setResellerLiveStatus("Carregando revendedores…", "is-loading");
|
||||
resellerStatus.textContent = "Loading…";
|
||||
try {
|
||||
const res = await api("/api/resellers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellersCache = await res.json() || [];
|
||||
renderResellers(resellersCache);
|
||||
populateResellerParents();
|
||||
resellerStatus.textContent = t("Loaded.");
|
||||
setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok");
|
||||
const res = await api("/api/resellers");
|
||||
const data = await res.json();
|
||||
renderResellers(data || []);
|
||||
resellerStatus.textContent = "Loaded.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim();
|
||||
setResellerLiveStatus("Falha ao carregar revendedores", "is-error");
|
||||
}
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error loading.";
|
||||
}
|
||||
}
|
||||
|
||||
function setResellerLiveStatus(message, tone) {
|
||||
const el = document.getElementById("resellerLiveStatus");
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.className = `workspace-live-status ${tone || ""}`.trim();
|
||||
}
|
||||
|
||||
function renderResellerMetrics(list) {
|
||||
const active = list.filter(r => r.effective_active).length;
|
||||
const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0);
|
||||
const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0);
|
||||
document.getElementById("resellerMetricTotal").textContent = String(list.length);
|
||||
document.getElementById("resellerMetricActive").textContent = String(active);
|
||||
document.getElementById("resellerMetricAllocated").textContent = String(allocated);
|
||||
document.getElementById("resellerMetricCredits").textContent = String(credits);
|
||||
}
|
||||
|
||||
function renderResellers(list) {
|
||||
resellerCountChip.textContent = list.length;
|
||||
renderResellerMetrics(list);
|
||||
resellersBody.innerHTML = "";
|
||||
if (!list.length) {
|
||||
resellersBody.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhum revendedor direto cadastrado.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
list.forEach(r => {
|
||||
const expired = !!r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const effective = !!r.effective_active && !expired;
|
||||
const maxUsers = Number(r.max_users || 0);
|
||||
const directUsed = Number(r.used_users || 0);
|
||||
const childAllocation = Number(r.child_allocation || 0);
|
||||
const committed = directUsed + childAllocation;
|
||||
const remaining = maxUsers ? Math.max(0, maxUsers - committed) : "∞";
|
||||
const pct = maxUsers ? Math.min(100, Math.round((committed / maxUsers) * 100)) : 0;
|
||||
const isCredit = r.quota_mode === "credits";
|
||||
const expired = r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const max = r.max_users || 0;
|
||||
const used = r.used_users || 0;
|
||||
const remaining = max ? Math.max(0, max - used) : "∞";
|
||||
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHTML(r.username)}</td>
|
||||
<td>
|
||||
<div class="bot-primary-cell"><strong>${escapeHTML(r.username)}</strong>
|
||||
<small>${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""}</small>
|
||||
${r.whatsapp ? `<small>${escapeHTML(r.whatsapp)}</small>` : ""}
|
||||
</div>
|
||||
<strong>${used} / ${max || "∞"}</strong>
|
||||
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div>
|
||||
<div class="table-meter"><span style="width:${pct}%"></span></div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`}</strong>
|
||||
<div class="hint">${isCredit ? "31 dias por renovação" : `Disponível ${remaining} · capacidade usada ${directUsed} · reservado ${childAllocation}`} · SSH ${r.used_ssh_users || 0} contas · Xray ${r.used_xray_users || 0} contas</div>
|
||||
${isCredit ? "" : `<div class="table-meter"><span style="width:${pct}%"></span></div>`}
|
||||
</td>
|
||||
<td>${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
|
||||
<td><span class="${effective ? "badge-on" : "badge-off"}">${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"}</span></td>
|
||||
<td>${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
|
||||
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
|
||||
<td></td>`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "bot-row-actions reseller-row-actions";
|
||||
actions.appendChild(resellerActionButton(t("Edit"), "btn btn-ghost btn-sm", () => fillResellerForm(r)));
|
||||
if (!isCredit) actions.appendChild(resellerActionButton("+30d", "btn btn-ghost btn-sm", () => runResellerAction(r, "renew")));
|
||||
if (currentRole === "superadmin" && r.parent_username) actions.appendChild(resellerActionButton("Puxar", "btn btn-ghost btn-sm", () => runResellerAction(r, "pull")));
|
||||
actions.appendChild(resellerActionButton(r.is_active ? "Suspender" : "Reativar", r.is_active ? "btn btn-warn btn-sm" : "btn btn-ghost btn-sm", () => runResellerAction(r, r.is_active ? "suspend" : "reactivate")));
|
||||
actions.appendChild(resellerActionButton(t("Del"), "btn btn-danger btn-sm", () => deleteReseller(r)));
|
||||
tr.lastElementChild.appendChild(actions);
|
||||
const tdA = tr.lastElementChild;
|
||||
const editBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillResellerForm(r),
|
||||
});
|
||||
const delBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteReseller(r.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
resellersBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function resellerActionButton(label, className, onclick) {
|
||||
return Object.assign(document.createElement("button"), { type: "button", className, textContent: label, onclick });
|
||||
}
|
||||
|
||||
function populateResellerParents() {
|
||||
if (!rParent) return;
|
||||
const selected = rParent.value;
|
||||
rParent.innerHTML = `<option value="">Principal / sem pai</option>`;
|
||||
resellersCache
|
||||
.filter(r => r.username !== editingReseller && r.effective_active)
|
||||
.forEach(r => {
|
||||
const option = document.createElement("option");
|
||||
option.value = r.username;
|
||||
option.textContent = `${r.username} · ${r.quota_mode === "credits" ? `${r.credit_balance || 0} Cr` : `${r.available < 0 ? "∞" : r.available} slots`}`;
|
||||
rParent.appendChild(option);
|
||||
});
|
||||
if ([...rParent.options].some(o => o.value === selected)) rParent.value = selected;
|
||||
}
|
||||
|
||||
function fillResellerForm(r) {
|
||||
editingReseller = r.username;
|
||||
setWorkspaceSection("resellers", "create");
|
||||
resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
|
||||
resellerFormTitle.textContent = `Edit: ${r.username}`;
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Edit reseller");
|
||||
rUsername.value = r.username;
|
||||
rUsername.disabled = true;
|
||||
rPassword.value = "";
|
||||
populateResellerParents();
|
||||
rParent.value = r.parent_username || "";
|
||||
rParent.disabled = true;
|
||||
rQuotaMode.value = r.quota_mode || "slots";
|
||||
rQuotaMode.disabled = true;
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rCredits.value = r.credit_balance || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rWhatsApp.value = r.whatsapp || "";
|
||||
rMonthlyPrice.value = ((r.monthly_price_cents || 0) / 100).toFixed(2);
|
||||
rActive.checked = !!r.is_active;
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("Editing {name}.", {name: r.username});
|
||||
rUsername.value = r.username;
|
||||
rPassword.value = "";
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rActive.checked = r.is_active;
|
||||
resellerStatus.textContent = `Editing ${r.username}.`;
|
||||
}
|
||||
|
||||
resellerForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("saveResellerBtn");
|
||||
btn.disabled = true;
|
||||
resellerStatus.textContent = t("Saving…");
|
||||
const mode = rQuotaMode.value || "slots";
|
||||
resellerStatus.textContent = "Saving…";
|
||||
const payload = {
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
parent_username: currentRole === "superadmin" ? rParent.value : undefined,
|
||||
quota_mode: mode,
|
||||
max_users: parseInt(rMaxUsers.value || "0", 10),
|
||||
credits: parseInt(rCredits.value || "0", 10),
|
||||
expires_at: mode === "slots" ? isoFromLocal(rExpires.value) : "",
|
||||
whatsapp: rWhatsApp.value.trim(),
|
||||
monthly_price_cents: Math.round(Math.max(0, parseFloat(rMonthlyPrice.value || "0")) * 100),
|
||||
is_active: rActive.checked,
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
max_users: parseInt(rMaxUsers.value||"0",10),
|
||||
expires_at: isoFromLocal(rExpires.value),
|
||||
is_active: rActive.checked,
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellerStatus.textContent = "Saved.";
|
||||
resellerForm.reset(); rActive.checked = true;
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
loadResellers();
|
||||
showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
|
||||
prepareNewReseller();
|
||||
await loadResellers();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error")}: ${e.message}`;
|
||||
showPanelToast(e.message, "error", t("Resellers"));
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error: "+e.message;
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
async function runResellerAction(reseller, action) {
|
||||
const labels = { renew: "Renovar por 30 dias", suspend: "Suspender revendedor", reactivate: "Reativar revendedor", pull: "Puxar para o painel principal" };
|
||||
const descriptions = {
|
||||
renew: "A validade será estendida a partir da data atual ou da validade existente.",
|
||||
suspend: "A conta, seus descendentes e os acessos SSH/Xray ficarão bloqueados sem apagar os cadastros.",
|
||||
reactivate: "Os acessos preservados serão restaurados nos servidores disponíveis.",
|
||||
pull: "O revendedor deixará a revenda atual e passará a ser administrado diretamente pelo superadmin. Os créditos já transferidos não serão duplicados.",
|
||||
};
|
||||
async function deleteReseller(username) {
|
||||
const accepted = await panelConfirm({
|
||||
tone: action === "suspend" ? "danger" : "default",
|
||||
icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
|
||||
title: labels[action],
|
||||
message: `${labels[action]} “${reseller.username}”?`,
|
||||
detail: descriptions[action],
|
||||
confirmLabel: labels[action],
|
||||
tone:"danger", icon:"×", title:t("Delete reseller"),
|
||||
message:t("Delete reseller \"{name}\"?", {name:username}),
|
||||
detail:t("Their owned access will be removed and active SSH sessions will be disconnected."),
|
||||
confirmLabel:t("Delete reseller"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = `${labels[action]}…`;
|
||||
resellerStatus.textContent = `Deleting ${username}…`;
|
||||
try {
|
||||
const res = await api("/api/resellers/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: reseller.username, action, days: action === "renew" ? 30 : undefined }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", labels[action]);
|
||||
else showPanelToast(`${reseller.username}: operação concluída.`, "success", labels[action]);
|
||||
await loadResellers();
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error("failed");
|
||||
resellerStatus.textContent = "Deleted.";
|
||||
loadResellers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", labels[action]);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReseller(reseller) {
|
||||
const accepted = await panelConfirm({
|
||||
tone: "danger", icon: "×", title: t("Delete reseller"),
|
||||
message: t("Delete reseller \"{name}\"?", {name: reseller.username}),
|
||||
detail: `Serão removidos ${reseller.child_count || 0} sub-revendedores e todos os acessos SSH/Xray pertencentes à árvore. Esta ação não pode ser desfeita.`,
|
||||
confirmLabel: t("Delete reseller"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = t("Deleting {name}…", {name: reseller.username});
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(reseller.username)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${reseller.username} removido.`, "success", t("Resellers"));
|
||||
await loadResellers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message || "Falha ao excluir.", "error", t("Delete reseller"));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResellerAudit() {
|
||||
const body = document.getElementById("resellerAuditBody");
|
||||
if (!body) return;
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Carregando atividade…</td></tr>`;
|
||||
try {
|
||||
const res = await api("/api/resellers/audit");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const rows = await res.json() || [];
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhuma atividade registrada.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
rows.forEach(item => {
|
||||
const tr = document.createElement("tr");
|
||||
[fmtDate(item.created_at), item.actor_username, item.target_username, item.action, item.details || "—"].forEach(value => {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Falha ao carregar a atividade.</td></tr>`;
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error deleting.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,7 @@ async function loadManagedServerConfig(id) {
|
||||
document.getElementById("managedCfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
|
||||
document.getElementById("managedCfgQuiet").checked = !!c.quiet;
|
||||
document.getElementById("managedCfgUserCount").checked = !!c.user_count;
|
||||
document.getElementById("managedCfgPamAuth").checked = !!c.pam_auth_enabled;
|
||||
document.getElementById("managedCfgBanner").value = c.banner || "";
|
||||
|
||||
const hasDnstt = !!c.dnstt;
|
||||
@@ -425,7 +426,7 @@ async function loadManagedServerConfig(id) {
|
||||
toggleManagedDnsttFields(hasDnstt);
|
||||
const d = c.dnstt || {};
|
||||
document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d);
|
||||
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "";
|
||||
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "0.0.0.0:5300";
|
||||
document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||
document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||
document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||
@@ -487,12 +488,13 @@ function managedConfigFromForm() {
|
||||
ssh_idle_timeout: document.getElementById("managedCfgSSHIdleTimeout").value.trim() || "0s",
|
||||
quiet: document.getElementById("managedCfgQuiet").checked,
|
||||
user_count: document.getElementById("managedCfgUserCount").checked,
|
||||
pam_auth_enabled: document.getElementById("managedCfgPamAuth").checked,
|
||||
banner: document.getElementById("managedCfgBanner").value,
|
||||
banner_file: "/opt/sshpanel/banner.txt",
|
||||
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
|
||||
domain: dnsttDomains[0] || "",
|
||||
domains: dnsttDomains,
|
||||
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(),
|
||||
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim() || "0.0.0.0:5300",
|
||||
fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked,
|
||||
fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(),
|
||||
fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(),
|
||||
|
||||
@@ -29,18 +29,24 @@ function toggleUdpgwFields(on) {
|
||||
}
|
||||
|
||||
|
||||
// Only operator-safe knobs remain. Transport buffers (HTTP/2 flow control, XHTTP
|
||||
// reorder buffer, mux/UDP buffers) are fixed to xray-core defaults in the backend
|
||||
// and are no longer exposed here, so they cannot be misconfigured.
|
||||
// Only operator-safe knobs remain. Global transport/XHTTP count admission is
|
||||
// fixed to unlimited; byte backpressure and protocol buffers stay internal so a
|
||||
// panel value cannot turn normal VPN traffic into HTTP overload responses.
|
||||
const XRAY_NATIVE_TUNING_DEFAULTS = {
|
||||
safe: {
|
||||
runtime_gomaxprocs: 0,
|
||||
mux_global_sessions: 8192,
|
||||
mux_global_sessions: 32768,
|
||||
max_concurrent_connections: -1,
|
||||
max_concurrent_xhttp_requests: -1,
|
||||
xhttp_max_sessions: -1,
|
||||
trace_packets: false,
|
||||
},
|
||||
"2k": {
|
||||
"high": {
|
||||
runtime_gomaxprocs: 0,
|
||||
mux_global_sessions: 32768,
|
||||
mux_global_sessions: 65536,
|
||||
max_concurrent_connections: -1,
|
||||
max_concurrent_xhttp_requests: -1,
|
||||
xhttp_max_sessions: -1,
|
||||
trace_packets: false,
|
||||
},
|
||||
};
|
||||
@@ -50,7 +56,7 @@ const XRAY_NATIVE_TUNING_FIELDS = {
|
||||
mux_global_sessions: "cfgXrayMuxGlobalSessions",
|
||||
};
|
||||
|
||||
function setXrayNativeTuningDefaults(profile = "2k") {
|
||||
function setXrayNativeTuningDefaults(profile = "high") {
|
||||
const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe;
|
||||
writeXrayNativeTuning(t);
|
||||
}
|
||||
@@ -71,6 +77,12 @@ function readXrayNativeTuning() {
|
||||
const el = document.getElementById(id);
|
||||
out[key] = parseInt(el?.value || "0", 10) || 0;
|
||||
});
|
||||
// These legacy JSON keys are intentionally fixed at unlimited. Keeping them
|
||||
// in saved configs makes upgrades/downgrades explicit without exposing web
|
||||
// request ceilings that do not belong on a VPN transport.
|
||||
out.max_concurrent_connections = -1;
|
||||
out.max_concurrent_xhttp_requests = -1;
|
||||
out.xhttp_max_sessions = -1;
|
||||
out.trace_packets = !!document.getElementById("cfgXrayTracePackets")?.checked;
|
||||
return out;
|
||||
}
|
||||
@@ -99,6 +111,7 @@ async function loadServerConfig() {
|
||||
document.getElementById("cfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
|
||||
document.getElementById("cfgQuiet").checked = !!c.quiet;
|
||||
document.getElementById("cfgUserCount").checked = !!c.user_count;
|
||||
document.getElementById("cfgPamAuth").checked = !!c.pam_auth_enabled;
|
||||
|
||||
// Banner
|
||||
document.getElementById("cfgBanner").value = c.banner || "";
|
||||
@@ -109,7 +122,7 @@ async function loadServerConfig() {
|
||||
toggleDnsttFields(hasDnstt);
|
||||
const d = c.dnstt || {};
|
||||
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
|
||||
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
|
||||
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "0.0.0.0:5300";
|
||||
document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||
document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||
document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||
@@ -182,12 +195,13 @@ async function saveServerConfig() {
|
||||
ssh_idle_timeout: document.getElementById("cfgSSHIdleTimeout").value.trim() || "0s",
|
||||
quiet: document.getElementById("cfgQuiet").checked,
|
||||
user_count: document.getElementById("cfgUserCount").checked,
|
||||
pam_auth_enabled: document.getElementById("cfgPamAuth").checked,
|
||||
banner: document.getElementById("cfgBanner").value,
|
||||
banner_file: "/opt/sshpanel/banner.txt",
|
||||
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
|
||||
domain: dnsttDomains[0] || "",
|
||||
domains: dnsttDomains,
|
||||
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
|
||||
udp_listen: document.getElementById("cfgDnsttUDP").value.trim() || "0.0.0.0:5300",
|
||||
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked,
|
||||
fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(),
|
||||
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(),
|
||||
|
||||
@@ -5,11 +5,12 @@ function openEditXrayClient(tag, client) {
|
||||
document.getElementById("editXrayName").value = client.name || "";
|
||||
document.getElementById("editXrayEmail").value = client.email || "";
|
||||
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
||||
const maxInput = document.getElementById("editXrayMaxConns");
|
||||
maxInput.value = client.max_conns || 0;
|
||||
maxInput.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxInput.disabled = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
maxInput.title = maxInput.disabled ? "Em planos por crédito, o limite de conexões fica fixo." : "";
|
||||
document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
|
||||
document.getElementById("editXrayQuotaGB").value = client.data_quota_bytes ? (Number(client.data_quota_bytes) / (1024 ** 3)).toFixed(2).replace(/\.00$/, "") : "0";
|
||||
document.getElementById("editXrayQuotaAction").value = client.quota_action === "throttle" ? "throttle" : "block";
|
||||
document.getElementById("editXrayQuotaThrottle").value = client.quota_throttle_mbps || 1;
|
||||
document.getElementById("editXrayUsage").value = `${formatBytes(client.total_bytes || 0)} (↑ ${formatBytes(client.uplink_bytes || 0)} · ↓ ${formatBytes(client.downlink_bytes || 0)})`;
|
||||
document.getElementById("editXrayResetUsage").checked = false;
|
||||
document.getElementById("editXrayClientStatus").textContent = "";
|
||||
document.getElementById("editXrayClientPanel").classList.remove("hidden");
|
||||
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
@@ -28,10 +29,12 @@ async function saveEditXrayClient() {
|
||||
uuid: editingXrayClientId,
|
||||
name: document.getElementById("editXrayName").value.trim(),
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? ""
|
||||
: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
||||
data_quota_bytes: Math.round((parseFloat(document.getElementById("editXrayQuotaGB").value || "0") || 0) * (1024 ** 3)),
|
||||
quota_action: document.getElementById("editXrayQuotaAction").value === "throttle" ? "throttle" : "block",
|
||||
quota_throttle_mbps: parseInt(document.getElementById("editXrayQuotaThrottle").value || "1", 10) || 1,
|
||||
reset_usage: !!document.getElementById("editXrayResetUsage").checked,
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
try {
|
||||
|
||||
+69
-51
@@ -16,7 +16,7 @@
|
||||
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713reseller10"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260720sshfilters1"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -51,7 +51,7 @@
|
||||
<button class="tab-btn" data-tab="ssh"><span class="nav-icon">👥</span><span>SSH / SlowDNS</span></button>
|
||||
<button class="tab-btn" data-tab="xray"><span class="nav-icon">◇</span><span>Xray Users</span></button>
|
||||
<div class="nav-group-label superadmin-only hidden">Administração</div>
|
||||
<button class="tab-btn" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon">▣</span><span>Infraestrutura</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon">☰</span><span>Logs</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="bot"><span class="nav-icon">🤖</span><span>Bot / Vendas</span></button>
|
||||
@@ -269,12 +269,23 @@
|
||||
<button class="btn btn-ghost btn-sm" id="reloadUsersBtn">Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-list-controls" id="sshListControls" aria-label="SSH user list controls">
|
||||
<div class="user-list-control-group">
|
||||
<span class="user-list-control-label" id="sshSortLabel"></span>
|
||||
<div class="user-list-buttons" id="sshSortButtons"></div>
|
||||
</div>
|
||||
<div class="user-list-control-group">
|
||||
<span class="user-list-control-label" id="sshFilterLabel"></span>
|
||||
<div class="user-list-buttons" id="sshFilterButtons"></div>
|
||||
</div>
|
||||
<span class="chip user-list-count" id="sshListCount"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>User</th><th>Status</th><th>Auth</th>
|
||||
<th>Conn</th><th>Max</th><th>Up</th><th>Dn</th><th>Expires</th>
|
||||
<th id="ownerColHead" class="superadmin-only hidden">Owner</th>
|
||||
<th data-sort-key="username">User</th><th data-sort-key="status">Status</th><th data-sort-key="auth">Auth</th>
|
||||
<th data-sort-key="conn">Conn</th><th data-sort-key="max">Max</th><th data-sort-key="up">Up</th><th data-sort-key="down">Dn</th><th data-sort-key="usage">Traffic</th><th data-sort-key="expires">Expires</th>
|
||||
<th id="ownerColHead" data-sort-key="owner" class="superadmin-only hidden">Owner</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
@@ -310,6 +321,11 @@
|
||||
<div class="field"><label>Expires at</label><input id="fExpires" type="datetime-local"/></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>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" 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>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit" id="saveUserBtn">Save user</button>
|
||||
@@ -372,6 +388,11 @@
|
||||
<div class="field"><label>Email / Label</label><input id="editXrayEmail" autocomplete="off"/></div>
|
||||
<div class="field"><label>Expiry Date</label><input type="datetime-local" id="editXrayExpiry" style="color-scheme:dark;"/></div>
|
||||
<div class="field"><label>Max Connections <span class="hint">(0 = unlimited)</span></label><input type="number" min="0" id="editXrayMaxConns"/></div>
|
||||
<div class="field"><label>Data quota (GB) <span class="hint">0 = unlimited · 1024 = 1 TB</span></label><input type="number" min="0" step="0.01" id="editXrayQuotaGB"/></div>
|
||||
<div class="field"><label>When quota is reached</label><select id="editXrayQuotaAction"><option value="block">Block user</option><option value="throttle">Reduce speed</option></select></div>
|
||||
<div class="field"><label>Post-quota speed (Mb/s)</label><input type="number" min="1" id="editXrayQuotaThrottle" value="1"/></div>
|
||||
<div class="field"><label>Current usage</label><input id="editXrayUsage" readonly value="0 B"/></div>
|
||||
<div class="field"><label>Reset traffic counter</label><input id="editXrayResetUsage" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;">
|
||||
<button class="btn btn-sm" onclick="saveEditXrayClient()">Save Changes</button>
|
||||
@@ -410,6 +431,17 @@
|
||||
<div class="card-title">Inbounds & Clients</div>
|
||||
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div>
|
||||
</div>
|
||||
<div class="user-list-controls" id="xrayListControls" aria-label="Xray user list controls">
|
||||
<div class="user-list-control-group">
|
||||
<span class="user-list-control-label" id="xraySortLabel"></span>
|
||||
<div class="user-list-buttons" id="xraySortButtons"></div>
|
||||
</div>
|
||||
<div class="user-list-control-group">
|
||||
<span class="user-list-control-label" id="xrayFilterLabel"></span>
|
||||
<div class="user-list-buttons" id="xrayFilterButtons"></div>
|
||||
</div>
|
||||
<span class="chip user-list-count" id="xrayListCount"></span>
|
||||
</div>
|
||||
<div id="inboundsContainer">
|
||||
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
||||
</div>
|
||||
@@ -428,6 +460,9 @@
|
||||
<div class="field"><label>Email / identificação</label><input id="xCreateEmail" autocomplete="off" placeholder="cliente@example"/></div>
|
||||
<div class="field"><label>Expira em</label><input id="xCreateExpiry" type="datetime-local"/></div>
|
||||
<div class="field"><label>Máximo de conexões <span class="hint">0 = ilimitado</span></label><input id="xCreateMaxConns" type="number" min="0" value="0"/></div>
|
||||
<div class="field"><label>Cota de dados (GB) <span class="hint">0 = ilimitado · 1024 = 1 TB</span></label><input id="xCreateQuotaGB" type="number" min="0" step="0.01" value="0"/></div>
|
||||
<div class="field"><label>Ao atingir a cota</label><select id="xCreateQuotaAction"><option value="block">Bloquear usuário</option><option value="throttle">Reduzir velocidade</option></select></div>
|
||||
<div class="field"><label>Velocidade após a cota (Mb/s)</label><input id="xCreateQuotaThrottle" type="number" min="1" value="1"/></div>
|
||||
</div>
|
||||
<div class="form-actions"><button class="btn" id="xCreateClientBtn" type="submit">Criar usuário</button><button class="btn btn-ghost" id="xCreateCancelBtn" type="button">Voltar aos usuários</button></div>
|
||||
<div class="statusbar"><span id="xCreateClientStatus">Preencha os dados do novo cliente.</span></div>
|
||||
@@ -656,26 +691,16 @@
|
||||
</section>
|
||||
</div><!-- /tab-xray -->
|
||||
|
||||
<!-- ═══════════ Hierarchical reseller management ═══════════ -->
|
||||
<!-- ═══════════ Resellers Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-resellers">
|
||||
<section class="page-hero status-hero" data-tone="amber">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle hierarquia, créditos, cotas, validade e acesso dos parceiros em um só lugar.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status is-loading" id="resellerLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="resellerHeroReloadBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">◎</span><div><small>Revendedores</small><strong id="resellerMetricTotal">--</strong><span class="workspace-card-note">parceiros diretos</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">●</span><div><small>Ativos</small><strong id="resellerMetricActive">--</strong><span class="workspace-card-note">acesso liberado</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">#</span><div><small>Alocação</small><strong id="resellerMetricAllocated">--</strong><span class="workspace-card-note">slots reservados</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">Cr</span><div><small>Créditos</small><strong id="resellerMetricCredits">--</strong><span class="workspace-card-note">saldo nos parceiros</span></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<section class="page-hero" data-tone="amber"><div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle cotas, validade e acesso dos parceiros em um só lugar.</p></div></section>
|
||||
<div class="workspace-nav-shell" data-tone="amber">
|
||||
<nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:3">
|
||||
<nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:2">
|
||||
<button class="active" type="button" data-workspace="resellers" data-workspace-section="users"><span>◎</span> Revendedores</button>
|
||||
<button type="button" data-workspace="resellers" data-workspace-section="create"><span>+</span> Criar revendedor</button>
|
||||
<button type="button" data-workspace="resellers" data-workspace-section="audit"><span>☰</span> Atividade</button>
|
||||
</nav>
|
||||
<select id="resellerSection" class="workspace-section-select" data-workspace-select="resellers" aria-label="Área de revendedores">
|
||||
<option value="users">Revendedores</option><option value="create">Criar revendedor</option><option value="audit">Atividade</option>
|
||||
<option value="users">Revendedores</option><option value="create">Criar revendedor</option>
|
||||
</select>
|
||||
</div>
|
||||
<section class="workspace-section active" data-workspace-panel="resellers" data-workspace-section-panel="users">
|
||||
@@ -692,7 +717,7 @@
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Conta</th><th>Plano e uso</th><th>Validade</th><th>Status</th><th>Ações</th>
|
||||
<th>Username</th><th>Users (used/max)</th><th>Expires</th><th>Status</th><th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="resellersBody"></tbody>
|
||||
</table>
|
||||
@@ -712,14 +737,9 @@
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Username</label><input id="rUsername" required autocomplete="off"/></div>
|
||||
<div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="rPassword" type="password" autocomplete="new-password"/></div>
|
||||
<div class="field superadmin-only" id="rParentField"><label>Revendedor pai <span class="hint">(vazio = principal)</span></label><select id="rParent"><option value="">Principal / sem pai</option></select></div>
|
||||
<div class="field"><label>Modo do plano</label><select id="rQuotaMode"><option value="slots">Validade / slots</option><option value="credits">Créditos</option></select><span class="hint" id="rQuotaModeHint">Sub-revendedores herdam o modo da conta pai.</span></div>
|
||||
<div class="field" id="rSlotsField"><label>Limite compartilhado <span class="hint">(0 = ilimitado só para principal)</span></label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field hidden" id="rCreditsField"><label>Saldo de créditos</label><input id="rCredits" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field" id="rExpiresField"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>WhatsApp</label><input id="rWhatsApp" autocomplete="tel" placeholder="+5511999999999"/></div>
|
||||
<div class="field"><label>Valor mensal</label><input id="rMonthlyPrice" type="number" min="0" step="0.01" placeholder="0,00"/></div>
|
||||
<label class="bot-check-field"><input id="rActive" type="checkbox" checked/> Acesso ativo</label>
|
||||
<div class="field"><label>Max SSH users (0 = unlimited)</label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>Active</label><input id="rActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit" id="saveResellerBtn">Save reseller</button>
|
||||
@@ -727,13 +747,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="resellers" data-workspace-section-panel="audit">
|
||||
<div class="workspace-section-heading"><div><span>03 · Auditoria</span><h3>Atividade das revendas</h3><p>Veja quem criou, alterou, renovou, suspendeu ou removeu cada conta.</p></div><button class="btn btn-ghost btn-sm" id="reloadResellerAuditBtn" type="button">Atualizar</button></div>
|
||||
<div class="card">
|
||||
<div class="tbl-wrap"><table class="reseller-audit-table"><thead><tr><th>Quando</th><th>Responsável</th><th>Revendedor</th><th>Ação</th><th>Detalhes</th></tr></thead><tbody id="resellerAuditBody"></tbody></table></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="statusbar workspace-section-status"><span id="resellerStatus">Ready.</span></div>
|
||||
</div><!-- /tab-resellers -->
|
||||
|
||||
@@ -829,6 +842,7 @@
|
||||
<div class="field"><label>SSH Idle Timeout <span class="hint">0s/off = disabled</span></label><input type="text" id="managedCfgSSHIdleTimeout" placeholder="0s" title="Keep disabled for VPN/XHTTP connections."/></div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgQuiet"/> Quiet Logs</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgUserCount"/> User Count Display</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;" title="Verify SSH logins against the Linux system password (/etc/shadow); regular accounts (UID ≥ 1000) are auto-imported."><input type="checkbox" id="managedCfgPamAuth"/> Linux PAM Login (auto-import)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -853,7 +867,7 @@
|
||||
</div>
|
||||
<div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
|
||||
<div class="field" style="grid-column:1/-1"><label>NS / Root Domains <span class="hint">one per line</span></label><textarea id="managedCfgDnsttDomains" rows="3" placeholder="t.example.com t.local.lan"></textarea></div>
|
||||
<div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="[::]:5300"/></div>
|
||||
<div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="0.0.0.0:5300"/></div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS</label>
|
||||
<div class="field"><label>Local DNS Listen <span class="hint">IPv6 ok</span></label><input type="text" id="managedCfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/></div>
|
||||
<div class="field"><label>Local DNS Domain</label><input type="text" id="managedCfgDnsttFakeDomain" placeholder="t.local.lan"/></div>
|
||||
@@ -1290,6 +1304,9 @@
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;">
|
||||
<input type="checkbox" id="cfgUserCount"/> User Count Display
|
||||
</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;" title="Verify SSH logins against the Linux system password (/etc/shadow). Regular accounts (UID ≥ 1000) that log in successfully are auto-imported into the panel.">
|
||||
<input type="checkbox" id="cfgPamAuth"/> Linux PAM Login (auto-import)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1327,7 +1344,7 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>UDP Listen</label>
|
||||
<input type="text" id="cfgDnsttUDP" placeholder="[::]:5300"/>
|
||||
<input type="text" id="cfgDnsttUDP" placeholder="0.0.0.0:5300"/>
|
||||
</div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
||||
<input type="checkbox" id="cfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS
|
||||
@@ -1515,12 +1532,13 @@
|
||||
<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="32768"/></div>
|
||||
<div class="field" style="grid-column:1/-1;"><label>Transport and XHTTP admission</label><div class="hint">Unlimited for VPN traffic. There is no global HTTP request, HTTP/2 stream, transport-connection, or XHTTP-session count cap.</div></div>
|
||||
<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;">Transport buffers (HTTP/2 flow control, XHTTP reorder buffer, mux/UDP buffers) are fixed to xray-core defaults and no longer tunable, so they can't be misconfigured. Go CPU threads = 0 means all detected cores. Saved in the panel config and applied live on restart/reload.</div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">XHTTP is handled as VPN tunnel traffic: packet requests and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -1560,17 +1578,17 @@
|
||||
<!-- 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=20260713reseller10"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/01-core.js?v=20260722xhttpunlimited2"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260720sshfilters1"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260720sshfilters1"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/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=20260722xhttpunlimited2"></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>
|
||||
<script defer src="assets/js/10-boot.js?v=20260714pamfix1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -24,8 +24,6 @@ import (
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
QuotaModeSlots = "slots"
|
||||
QuotaModeCredit = "credits"
|
||||
sessionTTL = 12 * time.Hour
|
||||
adminBcryptCost = 12
|
||||
)
|
||||
@@ -35,19 +33,14 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$
|
||||
// ---------- AdminUser ----------
|
||||
|
||||
type AdminUser struct {
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ParentUsername string
|
||||
QuotaMode string
|
||||
CreditBalance int
|
||||
WhatsApp string
|
||||
MonthlyPriceCents int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- Session store (in-memory) ----------
|
||||
@@ -202,7 +195,8 @@ func sessionMiddleware(next http.Handler) http.Handler {
|
||||
// Re-check the account on every request. This immediately revokes sessions
|
||||
// after an account is suspended, expired, deleted, or has its role changed.
|
||||
u, ok := adminUsers.get(s.Username)
|
||||
if !ok || u.ID != s.UserID || u.Role != s.Role || adminAccountChainActive(s.Username) != nil {
|
||||
if !ok || u.ID != s.UserID || !u.IsActive || u.Role != s.Role ||
|
||||
(u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
sessions.Delete(token)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -351,47 +345,7 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS parent_username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS quota_mode TEXT NOT NULL DEFAULT 'slots'`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS credit_balance INT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS whatsapp TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS monthly_price_cents INT NOT NULL DEFAULT 0`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_users_parent ON admin_users(parent_username)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_username TEXT NOT NULL,
|
||||
target_username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_created ON reseller_audit_log(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_actor ON reseller_audit_log(actor_username, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_target ON reseller_audit_log(target_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_credit_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reseller_username TEXT NOT NULL,
|
||||
actor_username TEXT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_credit_ledger_owner ON reseller_credit_ledger(reseller_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_runtime_state (
|
||||
owner_username TEXT PRIMARY KEY,
|
||||
parent_username TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
// Older reseller-owned accounts used zero to mean "unlimited". The
|
||||
// reseller quota model charges at least one slot per account, so normalize
|
||||
// those rows once during schema setup instead of leaving a quota bypass.
|
||||
`UPDATE ssh_users SET max_connections = 1
|
||||
WHERE owner_username <> '' AND max_connections < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -401,51 +355,45 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const adminUserSelectColumns = `id, username, password_hash, role, max_users,
|
||||
COALESCE(parent_username, ''), COALESCE(quota_mode, 'slots'), COALESCE(credit_balance, 0),
|
||||
COALESCE(whatsapp, ''), COALESCE(monthly_price_cents, 0), expires_at, is_active, created_at`
|
||||
|
||||
func scanAdminUser(scanner interface{ Scan(...interface{}) error }) (*AdminUser, error) {
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
&u.ParentUsername, &u.QuotaMode, &u.CreditBalance, &u.WhatsApp, &u.MonthlyPriceCents,
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users WHERE username = $1`, username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
&expiresAt, &u.IsActive, &u.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
u.QuotaMode = normalizeQuotaMode(u.QuotaMode)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
u, err := scanAdminUser(s.db.QueryRowContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users WHERE username = $1`, username))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAdminUsers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users ORDER BY role, username`)
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users ORDER BY role, username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -458,21 +406,15 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
|
||||
}
|
||||
if u.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, parent_username,
|
||||
quota_mode, credit_balance, whatsapp, monthly_price_cents, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive,
|
||||
).Scan(&u.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4, parent_username=$5,
|
||||
quota_mode=$6, credit_balance=$7, whatsapp=$8, monthly_price_cents=$9,
|
||||
expires_at=$10, is_active=$11 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive)
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4,
|
||||
expires_at=$5, is_active=$6 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -493,7 +435,8 @@ func (s *Store) SetAdminUserActive(ctx context.Context, username string, active
|
||||
|
||||
func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -505,7 +448,8 @@ func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error)
|
||||
|
||||
func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -518,10 +462,15 @@ func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUs
|
||||
func scanAdminUsers(rows *sql.Rows) ([]*AdminUser, error) {
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -567,12 +516,7 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states, err := store.ListResellerRuntimeStates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUsers.replaceAll(users)
|
||||
resellerRuntimeStates.replaceAll(states)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -580,10 +524,20 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
|
||||
// ownerIsActive returns nil if an SSH user's reseller owner is active, or an error if suspended/expired.
|
||||
func ownerIsActive(ownerUsername string) error {
|
||||
if _, replicated := resellerRuntimeStates.get(ownerUsername); replicated {
|
||||
return resellerRuntimeChainActive(ownerUsername)
|
||||
if ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
return adminAccountChainActive(ownerUsername)
|
||||
u, ok := adminUsers.get(ownerUsername)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
if !u.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner.
|
||||
@@ -625,38 +579,30 @@ func startResellerExpiryChecker(store *Store) {
|
||||
}
|
||||
for _, u := range expired {
|
||||
log.Printf("reseller %s expired — suspending", u.Username)
|
||||
resellerLifecycleMu.Lock()
|
||||
all, listErr := store.ListAdminUsers(ctx)
|
||||
if listErr != nil {
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry hierarchy for %s: %v", u.Username, listErr)
|
||||
continue
|
||||
}
|
||||
quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(listResellerSubtree(all, u.Username)))
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, false); err != nil {
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = false
|
||||
adminUsers.set(u)
|
||||
sessions.DeleteUser(u.ID)
|
||||
if err := applyResellerSubtreeRuntime(ctx, store, u.Username, false); err != nil {
|
||||
log.Printf("reseller expiry runtime for %s: %v", u.Username, err)
|
||||
}
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
disconnectOwnerUsers(u.Username)
|
||||
suspendOwnerXrayClients(ctx, store, u.Username)
|
||||
}
|
||||
|
||||
// Replicated owner records on managed nodes also enforce expiration and
|
||||
// inherited parent suspension without contacting the master on each login.
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) != nil {
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, "suspend"); err != nil {
|
||||
log.Printf("replicated reseller expiry runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
|
||||
renewed, err := store.ListInactiveButRenewedResellers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller renewal check: %v", err)
|
||||
}
|
||||
for _, u := range renewed {
|
||||
log.Printf("reseller %s renewed — reactivating", u.Username)
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, true); err != nil {
|
||||
log.Printf("reseller renewal: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = true
|
||||
adminUsers.set(u)
|
||||
restoreOwnerXrayClients(ctx, store, u.Username)
|
||||
}
|
||||
|
||||
sessions.cleanup()
|
||||
@@ -719,8 +665,12 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if adminAccountChainActive(u.Username) != nil {
|
||||
http.Error(w, "account suspended or expired", http.StatusForbidden)
|
||||
if !u.IsActive {
|
||||
http.Error(w, "account suspended", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
http.Error(w, "account expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -771,35 +721,185 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.Role == RoleReseller {
|
||||
if u, ok := adminUsers.get(s.Username); ok {
|
||||
childAllocation, childCount := 0, 0
|
||||
if statsStore != nil {
|
||||
childAllocation, _ = statsStore.directChildAllocation(r.Context(), s.Username, "")
|
||||
childCount = statsStore.directChildCount(r.Context(), s.Username)
|
||||
}
|
||||
resp["max_users"] = u.MaxUsers
|
||||
usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), statsStore, s.Username)
|
||||
if usageErr != nil {
|
||||
usage = resellerQuotaUsage{
|
||||
Weighted: countOwnedQuota(r.Context(), statsStore, s.Username),
|
||||
SSHAccounts: countOwnedUsers(s.Username),
|
||||
XrayAccounts: countOwnedXrayClients(r.Context(), statsStore, s.Username),
|
||||
}
|
||||
}
|
||||
resp["used_users"] = usage.Weighted
|
||||
resp["used_ssh_users"] = usage.SSHAccounts
|
||||
resp["used_xray_users"] = usage.XrayAccounts
|
||||
resp["parent_username"] = u.ParentUsername
|
||||
resp["quota_mode"] = normalizeQuotaMode(u.QuotaMode)
|
||||
resp["credit_balance"] = u.CreditBalance
|
||||
resp["child_allocation"] = childAllocation
|
||||
resp["child_count"] = childCount
|
||||
resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username)
|
||||
resp["used_ssh_users"] = countOwnedUsers(s.Username)
|
||||
resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username)
|
||||
resp["expires_at"] = u.ExpiresAt
|
||||
resp["is_active"] = u.IsActive
|
||||
resp["effective_active"] = adminAccountChainActive(u.Username) == nil
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// Reseller management handlers live in reseller_management.go.
|
||||
// ---------- Reseller management (superadmin only) ----------
|
||||
|
||||
type ResellerDTO struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
UsedUsers int `json:"used_users"`
|
||||
UsedSSH int `json:"used_ssh_users"`
|
||||
UsedXray int `json:"used_xray_users"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func handleListResellers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
users, err := store.ListAdminUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]ResellerDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, ResellerDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
MaxUsers: u.MaxUsers,
|
||||
UsedUsers: countOwnedQuota(r.Context(), store, u.Username),
|
||||
UsedSSH: countOwnedUsers(u.Username),
|
||||
UsedXray: countOwnedXrayClients(r.Context(), store, u.Username),
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
IsActive: u.IsActive,
|
||||
CreatedAt: u.CreatedAt,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
}
|
||||
|
||||
type ResellerPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p ResellerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAdminUsername(p.Username); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.MaxUsers < 0 || p.MaxUsers > 1000000 {
|
||||
http.Error(w, "max_users must be between 0 and 1000000", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
existing, err := store.GetAdminUserByUsername(ctx, p.Username)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var u *AdminUser
|
||||
if existing != nil {
|
||||
u = existing
|
||||
} else {
|
||||
if p.Password == "" {
|
||||
http.Error(w, "password required for new account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u = &AdminUser{Username: p.Username, Role: RoleReseller}
|
||||
}
|
||||
|
||||
if p.Password != "" {
|
||||
if err := validateAdminPassword(p.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
passwordHash, err := hashAdminPassword(p.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
}
|
||||
u.MaxUsers = p.MaxUsers
|
||||
u.IsActive = p.IsActive
|
||||
u.ExpiresAt = nil
|
||||
if p.ExpiresAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, p.ExpiresAt)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid expires_at (RFC3339 required)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u.ExpiresAt = &t
|
||||
}
|
||||
|
||||
if err := store.UpsertAdminUser(ctx, u); err != nil {
|
||||
log.Printf("upsert reseller: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
adminUsers.set(u)
|
||||
if p.Password != "" && existing != nil {
|
||||
sessions.DeleteUser(u.ID)
|
||||
}
|
||||
|
||||
if u.Role == RoleReseller {
|
||||
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
disconnectOwnerUsers(u.Username)
|
||||
suspendOwnerXrayClients(ctx, store, u.Username)
|
||||
} else {
|
||||
restoreOwnerXrayClients(ctx, store, u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if err := validateAdminUsername(username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
u, _ := store.GetAdminUserByUsername(ctx, username)
|
||||
if u != nil && u.Role == RoleSuperAdmin {
|
||||
http.Error(w, "superadmin accounts cannot be deleted from the reseller endpoint", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := store.DeleteAdminUser(ctx, username); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
disconnectOwnerUsers(username)
|
||||
removeOwnerXrayClients(ctx, store, username)
|
||||
adminUsers.delete(username)
|
||||
if u != nil {
|
||||
sessions.DeleteUser(u.ID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -119,7 +119,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
case http.MethodGet:
|
||||
cfg, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
writeInternalError(w, "load bot configuration", err)
|
||||
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, botConfigDTO{
|
||||
@@ -224,7 +224,7 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
|
||||
}
|
||||
if err := SaveBotConfig(ctx, store, cfg); err != nil {
|
||||
writeInternalError(w, "save bot configuration", err)
|
||||
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reloadBotService(store)
|
||||
@@ -265,7 +265,7 @@ func handleBotPlans(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if err := store.UpsertPlan(ctx, &p); err != nil {
|
||||
writeInternalError(w, "save bot plan", err)
|
||||
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, p)
|
||||
|
||||
+28
-4
@@ -11,7 +11,7 @@ import (
|
||||
const (
|
||||
defaultMainListen = "0.0.0.0:80"
|
||||
defaultExtraListen = "0.0.0.0:8080"
|
||||
defaultDNSTTListen = "[::]:5300"
|
||||
defaultDNSTTListen = "0.0.0.0:5300"
|
||||
defaultUDPGWListen = "0.0.0.0:7400"
|
||||
)
|
||||
|
||||
@@ -109,9 +109,10 @@ func normalizeRuntimePorts(cfg *Config) []string {
|
||||
}
|
||||
}
|
||||
|
||||
cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen)
|
||||
if cfg.DNSTT.UDPListen == "" {
|
||||
cfg.DNSTT.UDPListen = defaultDNSTTListen
|
||||
var migratedLegacyDNSTTWildcard bool
|
||||
cfg.DNSTT.UDPListen, migratedLegacyDNSTTWildcard = normalizeDNSTTListenDefault(cfg.DNSTT.UDPListen)
|
||||
if migratedLegacyDNSTTWildcard {
|
||||
warn("DNSTT legacy default [::]:5300 is IPv6-only; using IPv4 default %s", cfg.DNSTT.UDPListen)
|
||||
}
|
||||
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err != nil {
|
||||
old := cfg.DNSTT.UDPListen
|
||||
@@ -295,6 +296,29 @@ func normalizeDNSTTDomainList(primary string, domains []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeDNSTTListenDefault keeps explicit IPv4 and concrete IPv6 listeners,
|
||||
// but migrates the old wildcard IPv6 default. listenDNSTTPacket deliberately
|
||||
// opens IPv6 addresses with udp6, so [::]:5300 never receives IPv4 queries.
|
||||
// Existing installations commonly inherited that value from the old default;
|
||||
// moving only that wildcard/default-port combination makes them work after an
|
||||
// update without changing intentionally selected IPv6 interface addresses.
|
||||
func normalizeDNSTTListenDefault(addr string) (string, bool) {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return defaultDNSTTListen, false
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil || port != "5300" {
|
||||
return addr, false
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip != nil && ip.To4() == nil && ip.IsUnspecified() {
|
||||
return defaultDNSTTListen, true
|
||||
}
|
||||
return addr, false
|
||||
}
|
||||
|
||||
func udpAddrAvailableForDNSTT(addr string) error {
|
||||
if addr == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDNSTTListenDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
migrated bool
|
||||
}{
|
||||
{name: "empty uses IPv4 default", input: "", want: "0.0.0.0:5300"},
|
||||
{name: "whitespace uses IPv4 default", input: " ", want: "0.0.0.0:5300"},
|
||||
{name: "legacy IPv6 wildcard migrates", input: "[::]:5300", want: "0.0.0.0:5300", migrated: true},
|
||||
{name: "expanded legacy wildcard migrates", input: "[0:0:0:0:0:0:0:0]:5300", want: "0.0.0.0:5300", migrated: true},
|
||||
{name: "explicit IPv4 remains", input: "192.0.2.10:53", want: "192.0.2.10:53"},
|
||||
{name: "IPv4 wildcard remains", input: "0.0.0.0:5300", want: "0.0.0.0:5300"},
|
||||
{name: "concrete IPv6 remains", input: "[2001:db8::10]:53", want: "[2001:db8::10]:53"},
|
||||
{name: "IPv6 wildcard on custom port remains", input: "[::]:5301", want: "[::]:5301"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, migrated := normalizeDNSTTListenDefault(tt.input)
|
||||
if got != tt.want || migrated != tt.migrated {
|
||||
t.Fatalf("normalizeDNSTTListenDefault(%q) = (%q, %v), want (%q, %v)", tt.input, got, migrated, tt.want, tt.migrated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,17 @@ func TestSSHIdleTimeoutExplicitValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeXHTTPConnectedIdleSweepDisabled(t *testing.T) {
|
||||
if got := nativeXHTTPIdleTimeout(); got != 0 {
|
||||
t.Fatalf("native XHTTP idle timeout = %s, want disabled", got)
|
||||
// The connected-session sweeper is the backstop that reaps XHTTP->SSH sessions
|
||||
// whose stream-down GET context never fires (silent client drop behind a CDN).
|
||||
// Without it those sessions leak fds/goroutines until a process restart, which
|
||||
// is what produced the recurring reboot-only XHTTP 502s. It must stay enabled;
|
||||
// the window is generous so only zero-traffic (dead) sessions are reaped.
|
||||
func TestNativeXHTTPConnectedIdleSweepEnabled(t *testing.T) {
|
||||
got := nativeXHTTPIdleTimeout()
|
||||
if got <= 0 {
|
||||
t.Fatalf("native XHTTP idle timeout = %s, want a positive backstop window", got)
|
||||
}
|
||||
if got != 20*time.Minute {
|
||||
t.Fatalf("native XHTTP idle timeout = %s, want 20m backstop", got)
|
||||
}
|
||||
}
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Traditional DES-based crypt(3) — the 13-character, no-"$"-prefix hash used by
|
||||
// old Linux/UNIX systems (e.g. accounts created with perl's crypt() or legacy
|
||||
// SSH-account scripts). Pure Go; no dependency on libcrypt.
|
||||
//
|
||||
// Verified against the canonical vector crypt("rasmuslerdorf","rl") ==
|
||||
// "rl.3StKT.4T8M" (see descrypt_test.go).
|
||||
|
||||
const cryptAlphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
func crypt64Decode(c byte) int {
|
||||
return strings.IndexByte(cryptAlphabet, c)
|
||||
}
|
||||
|
||||
// ---- Standard DES permutation tables (1-indexed, MSB-first) ----
|
||||
|
||||
var ipTable = []int{
|
||||
58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
|
||||
62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
|
||||
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
|
||||
61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
|
||||
}
|
||||
|
||||
var fpTable = []int{
|
||||
40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
|
||||
38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
|
||||
36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
|
||||
34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
|
||||
}
|
||||
|
||||
var eTable = []int{
|
||||
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13,
|
||||
12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25,
|
||||
24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
|
||||
}
|
||||
|
||||
var pTable = []int{
|
||||
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
|
||||
2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
|
||||
}
|
||||
|
||||
var pc1Table = []int{
|
||||
57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18,
|
||||
10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
|
||||
63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22,
|
||||
14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
|
||||
}
|
||||
|
||||
var pc2Table = []int{
|
||||
14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10,
|
||||
23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
|
||||
41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48,
|
||||
44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
|
||||
}
|
||||
|
||||
var shiftTable = []int{1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1}
|
||||
|
||||
var sBoxes = [8][64]int{
|
||||
{14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7,
|
||||
0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
|
||||
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0,
|
||||
15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13},
|
||||
{15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10,
|
||||
3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5,
|
||||
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15,
|
||||
13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9},
|
||||
{10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8,
|
||||
13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
|
||||
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7,
|
||||
1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12},
|
||||
{7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15,
|
||||
13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
|
||||
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4,
|
||||
3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14},
|
||||
{2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9,
|
||||
14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
|
||||
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14,
|
||||
11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3},
|
||||
{12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11,
|
||||
10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
|
||||
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6,
|
||||
4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13},
|
||||
{4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1,
|
||||
13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
|
||||
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2,
|
||||
6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12},
|
||||
{13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7,
|
||||
1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
|
||||
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8,
|
||||
2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11},
|
||||
}
|
||||
|
||||
// permute selects bits from in (each element 0/1, MSB-first) per a 1-indexed table.
|
||||
func permute(in []byte, table []int) []byte {
|
||||
out := make([]byte, len(table))
|
||||
for i, pos := range table {
|
||||
out[i] = in[pos-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keySchedule(key64 []byte) [][]byte {
|
||||
cd := permute(key64, pc1Table) // 56 bits
|
||||
c := cd[:28]
|
||||
d := cd[28:]
|
||||
subkeys := make([][]byte, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
c = rotl(c, shiftTable[i])
|
||||
d = rotl(d, shiftTable[i])
|
||||
combined := append(append([]byte{}, c...), d...)
|
||||
subkeys[i] = permute(combined, pc2Table) // 48 bits
|
||||
}
|
||||
return subkeys
|
||||
}
|
||||
|
||||
func rotl(b []byte, n int) []byte {
|
||||
out := make([]byte, len(b))
|
||||
for i := range b {
|
||||
out[i] = b[(i+n)%len(b)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// feistel computes f(R, K) with the salt-perturbed E expansion.
|
||||
func feistel(r []byte, k []byte, saltMask [24]bool) []byte {
|
||||
e := permute(r, eTable) // 48 bits
|
||||
// Salt: for i in 0..23, if saltMask[i] swap E-output bits i and i+24.
|
||||
for i := 0; i < 24; i++ {
|
||||
if saltMask[i] {
|
||||
e[i], e[i+24] = e[i+24], e[i]
|
||||
}
|
||||
}
|
||||
x := make([]byte, 48)
|
||||
for i := range x {
|
||||
x[i] = e[i] ^ k[i]
|
||||
}
|
||||
out := make([]byte, 32)
|
||||
for box := 0; box < 8; box++ {
|
||||
off := box * 6
|
||||
row := int(x[off])<<1 | int(x[off+5])
|
||||
col := int(x[off+1])<<3 | int(x[off+2])<<2 | int(x[off+3])<<1 | int(x[off+4])
|
||||
val := sBoxes[box][row*16+col]
|
||||
for bit := 0; bit < 4; bit++ {
|
||||
out[box*4+bit] = byte((val >> (3 - bit)) & 1)
|
||||
}
|
||||
}
|
||||
return permute(out, pTable)
|
||||
}
|
||||
|
||||
func desEncryptBlock(block []byte, subkeys [][]byte, saltMask [24]bool) []byte {
|
||||
ip := permute(block, ipTable)
|
||||
l := ip[:32]
|
||||
r := ip[32:]
|
||||
for i := 0; i < 16; i++ {
|
||||
f := feistel(r, subkeys[i], saltMask)
|
||||
newR := make([]byte, 32)
|
||||
for j := 0; j < 32; j++ {
|
||||
newR[j] = l[j] ^ f[j]
|
||||
}
|
||||
l = r
|
||||
r = newR
|
||||
}
|
||||
pre := append(append([]byte{}, r...), l...) // R16 L16
|
||||
return permute(pre, fpTable)
|
||||
}
|
||||
|
||||
// desCrypt implements the traditional 13-char DES crypt. setting supplies the
|
||||
// 2-char salt (its first two characters).
|
||||
func desCrypt(password, setting string) (string, error) {
|
||||
if len(setting) < 2 {
|
||||
return "", errors.New("descrypt: salt too short")
|
||||
}
|
||||
s0 := crypt64Decode(setting[0])
|
||||
s1 := crypt64Decode(setting[1])
|
||||
if s0 < 0 || s1 < 0 {
|
||||
return "", errors.New("descrypt: bad salt characters")
|
||||
}
|
||||
salt := s0 | (s1 << 6)
|
||||
var saltMask [24]bool
|
||||
for i := 0; i < 24; i++ {
|
||||
if (salt>>i)&1 == 1 {
|
||||
saltMask[i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Key: first 8 bytes of the password, each char<<1 forms a key byte.
|
||||
key64 := make([]byte, 64)
|
||||
for i := 0; i < 8; i++ {
|
||||
var c byte
|
||||
if i < len(password) {
|
||||
c = password[i]
|
||||
}
|
||||
kb := c << 1
|
||||
for bit := 0; bit < 8; bit++ {
|
||||
key64[i*8+bit] = (kb >> (7 - bit)) & 1
|
||||
}
|
||||
}
|
||||
subkeys := keySchedule(key64)
|
||||
|
||||
block := make([]byte, 64) // all zeros
|
||||
for iter := 0; iter < 25; iter++ {
|
||||
block = desEncryptBlock(block, subkeys, saltMask)
|
||||
}
|
||||
|
||||
return string(setting[0]) + string(setting[1]) + encodeDESOutput(block), nil
|
||||
}
|
||||
|
||||
// encodeDESOutput packs the 64-bit result (MSB-first bit array) into 11
|
||||
// crypt-base64 characters: eleven 6-bit groups read most-significant-bit first,
|
||||
// the last group zero-padded to 6 bits.
|
||||
func encodeDESOutput(block []byte) string {
|
||||
out := make([]byte, 0, 11)
|
||||
for j := 0; j < 11; j++ {
|
||||
v := 0
|
||||
for k := 0; k < 6; k++ {
|
||||
idx := j*6 + k
|
||||
bit := 0
|
||||
if idx < len(block) {
|
||||
bit = int(block[idx])
|
||||
}
|
||||
v = (v << 1) | bit
|
||||
}
|
||||
out = append(out, cryptAlphabet[v])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDESCryptCanonical(t *testing.T) {
|
||||
got, err := desCrypt("rasmuslerdorf", "rl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "rl.3StKT.4T8M"
|
||||
t.Logf("got=%q want=%q", got, want)
|
||||
if got != want {
|
||||
t.Errorf("desCrypt mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
+12
-13
@@ -595,11 +595,8 @@ func startDNSTTInstance(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
udpListen := cfg.UDPListen
|
||||
if udpListen == "" {
|
||||
udpListen = defaultDNSTTListen
|
||||
cfg.UDPListen = udpListen
|
||||
}
|
||||
udpListen, _ := normalizeDNSTTListenDefault(cfg.UDPListen)
|
||||
cfg.UDPListen = udpListen
|
||||
|
||||
fakeDomains := domains
|
||||
if cfg.FakeDNSEnabled {
|
||||
@@ -1843,18 +1840,20 @@ func handleDnsttGenKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
privkey, err := noise.GeneratePrivkey()
|
||||
if err != nil {
|
||||
writeInternalError(w, "generate DNSTT key", err)
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := noise.WriteKey(&encoded, privkey); err != nil {
|
||||
writeInternalError(w, "encode DNSTT key", err)
|
||||
f, err := os.OpenFile(dnsttKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(dnsttKeyFile, encoded.Bytes(), 0o600); err != nil {
|
||||
writeInternalError(w, "write DNSTT key", err)
|
||||
if err := noise.WriteKey(f, privkey); err != nil {
|
||||
f.Close()
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -1877,13 +1876,13 @@ func handleDnsttGetPubKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
f, err := os.Open(keyPath)
|
||||
if err != nil {
|
||||
writeInternalError(w, "open DNSTT key", err)
|
||||
http.Error(w, "open key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
privkey, err := noise.ReadKey(f)
|
||||
if err != nil {
|
||||
writeInternalError(w, "read DNSTT key", err)
|
||||
http.Error(w, "read key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Trusted Go archives used by install.sh and update.sh.
|
||||
# Format: version architecture sha256
|
||||
1.25.12 amd64 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
1.25.12 arm64 8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2
|
||||
1.25.12 armv6l 6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1
|
||||
@@ -1,14 +1,16 @@
|
||||
module shell2
|
||||
|
||||
go 1.25.12
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/openwall/yescrypt-go v1.0.0
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61
|
||||
github.com/xtaci/smux v1.5.50
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/time v0.14.0
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0
|
||||
)
|
||||
|
||||
@@ -18,6 +20,6 @@ require (
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
@@ -36,13 +38,15 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/openwall/yescrypt-go v1.0.0 h1:jsGk48zkFvtUjGVOhYPGh+CS595JmTRcKnpggK2AON4=
|
||||
github.com/openwall/yescrypt-go v1.0.0/go.mod h1:e6CWtFizUEOUttaOjeVMiv1lJaJie3mfOtLJ9CCD6sA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61 h1:ajm12pGuWO+GWQNusPyPESC7Rq0yTC2rEXVYkM8ExOg=
|
||||
@@ -55,8 +59,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -68,8 +72,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -80,17 +84,17 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
|
||||
@@ -335,6 +335,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
|
||||
setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown)
|
||||
setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout)
|
||||
setMaxTotalConnsFromConfig(newCfg.MaxTotalConnections)
|
||||
setPAMAuthEnabled(newCfg.PAMAuthEnabled)
|
||||
|
||||
// Quiet logging / user count display
|
||||
if newCfg.Quiet {
|
||||
|
||||
+7
-71
@@ -15,7 +15,6 @@ LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
|
||||
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
|
||||
GO_SHA256="${GO_SHA256:-}"
|
||||
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
|
||||
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
|
||||
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
|
||||
@@ -34,38 +33,6 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "$GO_SHA256" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -268,21 +235,16 @@ if command -v go &>/dev/null; then
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
GO_EXPECTED_SHA256=""
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
esac
|
||||
GO_EXPECTED_SHA256="$(trusted_go_sha256 "$GO_VERSION" "$GOARCH" "$SCRIPT_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$GO_EXPECTED_SHA256" ]] || error "No trusted Go checksum for ${GO_VERSION}/${GOARCH}; set GO_SHA256 explicitly"
|
||||
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||
info " Downloading $GO_URL"
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
|
||||
verify_sha256_file "$GO_EXPECTED_SHA256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
@@ -337,51 +299,25 @@ fi
|
||||
|
||||
# ── 6. Xray binary ──────────────────────────────────────────────────────────
|
||||
info "[6/10] Downloading Xray-core…"
|
||||
XRAY_VER=$(curl -sf "https://api.github.com/repos/XTLS/Xray-core/releases/latest" \
|
||||
| grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30")
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) XRAY_ARCH="64" ;;
|
||||
aarch64) XRAY_ARCH="arm64-v8a" ;;
|
||||
armv7l) XRAY_ARCH="arm32-v7a" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
*) XRAY_ARCH="64" ;;
|
||||
esac
|
||||
PYTHON_BIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
|
||||
[[ -n "$PYTHON_BIN" ]] || error "Python is required to validate Xray release metadata"
|
||||
XRAY_RELEASE_JSON=/tmp/xray-release.json
|
||||
curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \
|
||||
-o "$XRAY_RELEASE_JSON" https://api.github.com/repos/XTLS/Xray-core/releases/latest
|
||||
readarray -t XRAY_META < <("$PYTHON_BIN" -c '
|
||||
import json, re, sys
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
||||
release = json.load(handle)
|
||||
tag = release.get("tag_name", "")
|
||||
name = sys.argv[2]
|
||||
asset = next((item for item in release.get("assets", []) if item.get("name") == name), None)
|
||||
if not tag or not asset:
|
||||
raise SystemExit(2)
|
||||
url = asset.get("browser_download_url", "")
|
||||
digest = asset.get("digest", "")
|
||||
prefix = "https://github.com/XTLS/Xray-core/releases/download/" + tag + "/"
|
||||
if not url.startswith(prefix) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise SystemExit(3)
|
||||
print(tag)
|
||||
print(url)
|
||||
print(digest.split(":", 1)[1])
|
||||
' "$XRAY_RELEASE_JSON" "Xray-linux-${XRAY_ARCH}.zip")
|
||||
[[ ${#XRAY_META[@]} -eq 3 ]] || error "Xray release metadata is missing a trusted asset digest"
|
||||
XRAY_VER="${XRAY_META[0]}"
|
||||
XRAY_URL="${XRAY_META[1]}"
|
||||
XRAY_SHA256="${XRAY_META[2]}"
|
||||
XRAY_URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VER}/Xray-linux-${XRAY_ARCH}.zip"
|
||||
info " Xray ${XRAY_VER} (${XRAY_ARCH})"
|
||||
wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL"
|
||||
verify_sha256_file "$XRAY_SHA256" /tmp/xray.zip
|
||||
info " Xray archive checksum verified"
|
||||
unzip -o /tmp/xray.zip xray -d "$INSTALL_DIR" > /dev/null 2>&1 || {
|
||||
mkdir -p /tmp/xray_extract
|
||||
unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1
|
||||
mv /tmp/xray_extract/xray "$INSTALL_DIR/xray"
|
||||
}
|
||||
chmod +x "$INSTALL_DIR/xray"
|
||||
rm -f /tmp/xray.zip "$XRAY_RELEASE_JSON"
|
||||
rm -f /tmp/xray.zip
|
||||
"$INSTALL_DIR/xray" version
|
||||
|
||||
# ── 7. PostgreSQL ────────────────────────────────────────────────────────────
|
||||
@@ -738,7 +674,7 @@ ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
LimitNOFILE=65536
|
||||
LimitNOFILE=1048576
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
|
||||
@@ -97,6 +97,13 @@ type Config struct {
|
||||
|
||||
UserCount bool `json:"user_count"`
|
||||
|
||||
// PAMAuthEnabled turns on Linux system-password login for this server. When
|
||||
// true, an SSH login with a username not present in the panel is verified
|
||||
// against /etc/shadow; on success the account (regular users, UID >= 1000)
|
||||
// is auto-imported into the panel. When false, only panel-managed accounts
|
||||
// can log in and previously-imported PAM accounts are refused.
|
||||
PAMAuthEnabled bool `json:"pam_auth_enabled"`
|
||||
|
||||
// SSHIdleTimeout controls how long an authenticated SSH connection may
|
||||
// remain with no bytes moving in either direction before it is closed and
|
||||
// released from the active user count. Empty, "0", or "0s" disables it.
|
||||
@@ -351,6 +358,12 @@ type UserConfig struct {
|
||||
// When false and totp_secret is set, only the TOTP code is accepted.
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
|
||||
// UsePAM is a legacy opt-in: when true, the supplied SSH password is
|
||||
// verified against the Linux PAM auth stack (auth phase only) for the
|
||||
// system account matching this username, instead of the panel-managed
|
||||
// Password/TOTP. New users leave this false and keep the script's own auth.
|
||||
UsePAM bool `json:"use_pam"`
|
||||
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ExpiresAt string `json:"expires_at"` // RFC3339 or empty
|
||||
|
||||
@@ -358,6 +371,13 @@ type UserConfig struct {
|
||||
LimitMbpsUp int `json:"limit_mbps_up"` // Mbps upstream
|
||||
LimitMbpsDown int `json:"limit_mbps_down"` // Mbps downstream
|
||||
|
||||
// Persistent data quota. Zero means unlimited. When the total uploaded +
|
||||
// downloaded bytes reaches the quota, QuotaAction either blocks traffic or
|
||||
// throttles the account to QuotaThrottleMbps.
|
||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||
QuotaAction string `json:"quota_action"`
|
||||
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
||||
|
||||
// OwnerUsername is the reseller who created this SSH user. Empty = superadmin-owned.
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
}
|
||||
@@ -370,6 +390,18 @@ type UserState struct {
|
||||
mu sync.Mutex
|
||||
ActiveConns int
|
||||
conns map[*ssh.ServerConn]struct{} // active SSH connections for this user
|
||||
|
||||
// Persistent per-user tunnel traffic. totalBytes includes reservations made
|
||||
// by concurrent copy loops, while directional totals only include bytes that
|
||||
// were actually written. The pending counters are flushed to PostgreSQL.
|
||||
TotalUplinkBytes int64
|
||||
TotalDownlinkBytes int64
|
||||
totalBytes int64
|
||||
pendingUplinkBytes int64
|
||||
pendingDownlinkBytes int64
|
||||
trafficMu sync.RWMutex
|
||||
quotaLimiter *rate.Limiter
|
||||
quotaLimiterMbps int
|
||||
}
|
||||
|
||||
type UserManager struct {
|
||||
@@ -384,6 +416,19 @@ func (m *UserManager) Get(username string) (*UserState, bool) {
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// AddIfAbsent inserts u only if no user with the same username exists yet, and
|
||||
// reports whether it was added. Used by PAM auto-import to register a freshly
|
||||
// authenticated system account without clobbering an existing runtime state.
|
||||
func (m *UserManager) AddIfAbsent(u *UserState) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.users[u.Cfg.Username]; exists {
|
||||
return false
|
||||
}
|
||||
m.users[u.Cfg.Username] = u
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *UserManager) List() []*UserState {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -533,12 +578,15 @@ var copyBufPool = sync.Pool{
|
||||
// io.Copy, which allocates a fresh 32 KiB buffer per direction per channel and
|
||||
// never pools it — at thousands of channels that churn dominated GC pressure.
|
||||
func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
|
||||
return copyWithRateLimitContext(context.Background(), dst, src, lim)
|
||||
}
|
||||
|
||||
func copyWithRateLimitContext(ctx context.Context, dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
|
||||
bufp := copyBufPool.Get().(*[]byte)
|
||||
buf := *bufp
|
||||
defer copyBufPool.Put(bufp)
|
||||
|
||||
var ctx context.Context
|
||||
if lim != nil {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
@@ -1347,17 +1395,29 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
|
||||
expires_at TEXT,
|
||||
limit_mbps_up INT NOT NULL DEFAULT 0,
|
||||
limit_mbps_down INT NOT NULL DEFAULT 0,
|
||||
data_quota_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
quota_action TEXT NOT NULL DEFAULT 'block',
|
||||
quota_throttle_mbps INT NOT NULL DEFAULT 1,
|
||||
total_uplink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
total_downlink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_period INT NOT NULL DEFAULT 60,
|
||||
totp_window INT NOT NULL DEFAULT 1,
|
||||
totp_digits INT NOT NULL DEFAULT 6,
|
||||
allow_static_password BOOLEAN NOT NULL DEFAULT FALSE
|
||||
allow_static_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
use_pam BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_period INT NOT NULL DEFAULT 60`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_window INT NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_digits INT NOT NULL DEFAULT 6`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS allow_static_password BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS use_pam BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS data_quota_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS quota_action TEXT NOT NULL DEFAULT 'block'`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS quota_throttle_mbps INT NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS total_uplink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE ssh_users ALTER COLUMN password SET DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
@@ -1405,9 +1465,11 @@ func (s *Store) migrateSSHPasswords(ctx context.Context) error {
|
||||
func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
|
||||
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1),
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0),
|
||||
COALESCE(totp_secret, ''), COALESCE(totp_period, 60), COALESCE(totp_window, 1),
|
||||
COALESCE(totp_digits, 6), COALESCE(allow_static_password, FALSE),
|
||||
COALESCE(owner_username, '')
|
||||
COALESCE(use_pam, FALSE), COALESCE(owner_username, '')
|
||||
FROM ssh_users`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1423,15 +1485,22 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
expiresAt sql.NullString
|
||||
limitUp int
|
||||
limitDown int
|
||||
dataQuotaBytes int64
|
||||
quotaAction string
|
||||
quotaThrottleMbps int
|
||||
totalUplinkBytes int64
|
||||
totalDownlinkBytes int64
|
||||
totpSecret string
|
||||
totpPeriod int
|
||||
totpWindow int
|
||||
totpDigits int
|
||||
allowStaticPassword bool
|
||||
usePAM bool
|
||||
ownerUsername string
|
||||
)
|
||||
if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown,
|
||||
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil {
|
||||
&dataQuotaBytes, "aAction, "aThrottleMbps, &totalUplinkBytes, &totalDownlinkBytes,
|
||||
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &usePAM, &ownerUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password, err = openSSHPassword(password)
|
||||
@@ -1445,15 +1514,20 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
MaxConnections: maxConnections,
|
||||
LimitMbpsUp: limitUp,
|
||||
LimitMbpsDown: limitDown,
|
||||
DataQuotaBytes: dataQuotaBytes,
|
||||
QuotaAction: normalizeQuotaAction(quotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbps,
|
||||
TOTPSecret: totpSecret,
|
||||
TOTPPeriod: totpPeriod,
|
||||
TOTPWindow: totpWindow,
|
||||
TOTPDigits: totpDigits,
|
||||
AllowStaticPassword: allowStaticPassword,
|
||||
UsePAM: usePAM,
|
||||
OwnerUsername: ownerUsername,
|
||||
}
|
||||
|
||||
st := &UserState{Cfg: cfg}
|
||||
initSSHRuntimeUsage(st, totalUplinkBytes, totalDownlinkBytes)
|
||||
if expiresAt.Valid && expiresAt.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, expiresAt.String)
|
||||
if err != nil {
|
||||
@@ -1480,23 +1554,29 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO ssh_users (
|
||||
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
|
||||
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username
|
||||
data_quota_bytes, quota_action, quota_throttle_mbps,
|
||||
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, use_pam, owner_username
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET password = EXCLUDED.password,
|
||||
max_connections = EXCLUDED.max_connections,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
limit_mbps_up = EXCLUDED.limit_mbps_up,
|
||||
limit_mbps_down = EXCLUDED.limit_mbps_down,
|
||||
data_quota_bytes = EXCLUDED.data_quota_bytes,
|
||||
quota_action = EXCLUDED.quota_action,
|
||||
quota_throttle_mbps = EXCLUDED.quota_throttle_mbps,
|
||||
totp_secret = EXCLUDED.totp_secret,
|
||||
totp_period = EXCLUDED.totp_period,
|
||||
totp_window = EXCLUDED.totp_window,
|
||||
totp_digits = EXCLUDED.totp_digits,
|
||||
allow_static_password = EXCLUDED.allow_static_password`,
|
||||
allow_static_password = EXCLUDED.allow_static_password,
|
||||
use_pam = EXCLUDED.use_pam`,
|
||||
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only.
|
||||
u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
|
||||
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername)
|
||||
u.DataQuotaBytes, normalizeQuotaAction(u.QuotaAction), quotaThrottleMbpsOrDefault(u.QuotaThrottleMbps),
|
||||
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.UsePAM, u.OwnerUsername)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1612,7 +1692,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
// SSH user management (session required; role-filtered inside handlers)
|
||||
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
|
||||
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
|
||||
mux.Handle("/api/users/renew", sessionMiddleware(http.HandlerFunc(handleRenewSSHUser(store))))
|
||||
mux.Handle("/api/users/reset-traffic", sessionMiddleware(http.HandlerFunc(handleResetUserTraffic(store))))
|
||||
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
|
||||
|
||||
// Server stats: visible to authenticated sessions; reset remains superadmin-only.
|
||||
@@ -1626,15 +1706,10 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats)))
|
||||
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
|
||||
|
||||
// Hierarchical reseller management. Scope checks inside each handler limit a
|
||||
// reseller to its direct children; superadmins retain global management.
|
||||
mux.Handle("/api/resellers", sessionMiddleware(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", sessionMiddleware(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/action", sessionMiddleware(http.HandlerFunc(handleResellerAction(store))))
|
||||
mux.Handle("/api/resellers/delete", sessionMiddleware(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
mux.Handle("/api/resellers/audit", sessionMiddleware(http.HandlerFunc(handleResellerAudit(store))))
|
||||
// Called master-to-node with the managed server's superadmin session.
|
||||
mux.Handle("/api/resellers/runtime", saSession(http.HandlerFunc(handleResellerRuntime(store))))
|
||||
// Superadmin-only: reseller management
|
||||
mux.Handle("/api/resellers", saSession(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
|
||||
// Master/slave server management. Superadmins can add slave nodes; all authenticated
|
||||
// users can read the enabled server list to pick where accounts are created.
|
||||
@@ -1654,7 +1729,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
|
||||
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
|
||||
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
|
||||
mux.Handle("/api/xray/clients/renew", sessionMiddleware(http.HandlerFunc(handleRenewXrayClient(store))))
|
||||
mux.Handle("/api/xray/clients/reset-traffic", sessionMiddleware(http.HandlerFunc(handleXrayClientResetTraffic)))
|
||||
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
|
||||
|
||||
// Superadmin-only: TLS certificate generation
|
||||
@@ -1715,11 +1790,19 @@ type UserDTO struct {
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LimitUpMbps int `json:"limit_mbps_up"`
|
||||
LimitDownMbps int `json:"limit_mbps_down"`
|
||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||
QuotaAction string `json:"quota_action"`
|
||||
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
||||
TotalUplinkBytes int64 `json:"total_uplink_bytes"`
|
||||
TotalDownlinkBytes int64 `json:"total_downlink_bytes"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
QuotaExceeded bool `json:"quota_exceeded"`
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPPeriod int `json:"totp_period"`
|
||||
TOTPWindow int `json:"totp_window"`
|
||||
TOTPDigits int `json:"totp_digits"`
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
UsePAM bool `json:"use_pam"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
@@ -1748,6 +1831,9 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := u.Cfg
|
||||
expires := u.ExpiresAt
|
||||
u.mu.Unlock()
|
||||
totalUp := atomic.LoadInt64(&u.TotalUplinkBytes)
|
||||
totalDown := atomic.LoadInt64(&u.TotalDownlinkBytes)
|
||||
totalBytes := atomic.LoadInt64(&u.totalBytes)
|
||||
|
||||
// Resellers only see their own users
|
||||
if sess != nil && sess.Role == RoleReseller && cfg.OwnerUsername != sess.Username {
|
||||
@@ -1761,11 +1847,19 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
ExpiresAt: expires,
|
||||
LimitUpMbps: cfg.LimitMbpsUp,
|
||||
LimitDownMbps: cfg.LimitMbpsDown,
|
||||
DataQuotaBytes: cfg.DataQuotaBytes,
|
||||
QuotaAction: normalizeQuotaAction(cfg.QuotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(cfg.QuotaThrottleMbps),
|
||||
TotalUplinkBytes: totalUp,
|
||||
TotalDownlinkBytes: totalDown,
|
||||
TotalBytes: totalBytes,
|
||||
QuotaExceeded: cfg.DataQuotaBytes > 0 && totalBytes >= cfg.DataQuotaBytes,
|
||||
TOTPSecret: cfg.TOTPSecret,
|
||||
TOTPPeriod: cfg.TOTPPeriod,
|
||||
TOTPWindow: cfg.TOTPWindow,
|
||||
TOTPDigits: cfg.TOTPDigits,
|
||||
AllowStaticPassword: cfg.AllowStaticPassword,
|
||||
UsePAM: cfg.UsePAM,
|
||||
TOTPEnabled: strings.TrimSpace(cfg.TOTPSecret) != "",
|
||||
OwnerUsername: cfg.OwnerUsername,
|
||||
})
|
||||
@@ -1783,14 +1877,18 @@ type UserPayload struct {
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
LimitUpMbps int `json:"limit_mbps_up"`
|
||||
LimitDownMbps int `json:"limit_mbps_down"`
|
||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||
QuotaAction string `json:"quota_action"`
|
||||
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
||||
ResetUsage bool `json:"reset_usage,omitempty"`
|
||||
TOTPSecret string `json:"totp_secret"`
|
||||
TOTPPeriod int `json:"totp_period"`
|
||||
TOTPWindow int `json:"totp_window"`
|
||||
TOTPDigits int `json:"totp_digits"`
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
UsePAM bool `json:"use_pam"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
PreserveExpires bool `json:"preserve_expires,omitempty"`
|
||||
}
|
||||
|
||||
func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
@@ -1804,113 +1902,83 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
var p UserPayload
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateSSHUserPayload(&p); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableSSH {
|
||||
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
chargedCredits, creditCost, creditOwner := false, 0, ""
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
row, exists, ownerErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
currentOwner, exists, ownerErr := remoteSSHUserOwner(ctx, ms, p.Username)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
currentOwner := ""
|
||||
if exists {
|
||||
currentOwner = strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
}
|
||||
if exists && currentOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.PreserveExpires = true
|
||||
p.MaxConnections = jsonInt(row["max_connections"])
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, jsonInt(row["max_connections"]), p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
if ownerErr != nil {
|
||||
writeResellerProvisionError(w, ownerErr)
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
creditOwner = sess.Username
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.OwnerUsername = sess.Username
|
||||
}
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, sess.Username); syncErr != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
|
||||
if err != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
writeBadGatewayError(w, "create SSH account on managed server", err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(ctx)
|
||||
var existingLocalExpiry string
|
||||
var existingLocalUser bool
|
||||
var existingLocalMax int
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
var existingOwner string
|
||||
var expiresAt sql.NullTime
|
||||
err := store.db.QueryRowContext(ctx,
|
||||
`SELECT owner_username, expires_at, max_connections FROM ssh_users WHERE username=$1`,
|
||||
p.Username).Scan(&existingOwner, &expiresAt, &existingLocalMax)
|
||||
err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, p.Username).Scan(&existingOwner)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1919,31 +1987,6 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
existingLocalUser = err == nil
|
||||
if expiresAt.Valid {
|
||||
existingLocalExpiry = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if existingLocalUser {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" && resellerExpiryExtended(existingLocalExpiry, p.ExpiresAt) {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.ExpiresAt = existingLocalExpiry
|
||||
p.MaxConnections = existingLocalMax
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, existingLocalMax, p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.PreserveExpires && !existingLocalUser {
|
||||
var expiresAt sql.NullTime
|
||||
if err := store.db.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM ssh_users WHERE username=$1`, p.Username).Scan(&expiresAt); err == nil && expiresAt.Valid {
|
||||
p.ExpiresAt = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
// Decide what password to use:
|
||||
@@ -1964,7 +2007,9 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
).Scan(&existing)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
if strings.TrimSpace(p.TOTPSecret) == "" {
|
||||
// PAM users authenticate against the system account, so they
|
||||
// need neither a panel password nor a TOTP secret.
|
||||
if strings.TrimSpace(p.TOTPSecret) == "" && !p.UsePAM {
|
||||
http.Error(w, "password or totp_secret required for new user", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -1974,32 +2019,29 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
password, err = openSSHPassword(existing)
|
||||
if err != nil {
|
||||
log.Printf("failed to decrypt existing password for %s: %v", p.Username, err)
|
||||
http.Error(w, "stored credential is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
password = existing
|
||||
}
|
||||
}
|
||||
|
||||
// Determine owner and enforce reseller quota. Credit accounts spend one
|
||||
// credit per allowed connection (minimum one) and receive 31 days.
|
||||
// Determine owner and enforce reseller quota
|
||||
ownerUsername := ""
|
||||
chargedCredits, creditCost := false, 0
|
||||
isNewUser := false
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
if !existingLocalUser {
|
||||
isNewUser = true
|
||||
var quotaErr error
|
||||
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
// Enforce user limit — only count on new user creation
|
||||
var existsInDB bool
|
||||
_ = store.db.QueryRowContext(ctx,
|
||||
`SELECT TRUE FROM ssh_users WHERE username=$1`, p.Username,
|
||||
).Scan(&existsInDB)
|
||||
if !existsInDB {
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
|
||||
if quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
|
||||
@@ -2013,31 +2055,106 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
LimitMbpsUp: p.LimitUpMbps,
|
||||
LimitMbpsDown: p.LimitDownMbps,
|
||||
DataQuotaBytes: p.DataQuotaBytes,
|
||||
QuotaAction: normalizeQuotaAction(p.QuotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(p.QuotaThrottleMbps),
|
||||
TOTPSecret: strings.TrimSpace(p.TOTPSecret),
|
||||
TOTPPeriod: p.TOTPPeriod,
|
||||
TOTPWindow: p.TOTPWindow,
|
||||
TOTPDigits: p.TOTPDigits,
|
||||
AllowStaticPassword: p.AllowStaticPassword,
|
||||
UsePAM: p.UsePAM,
|
||||
OwnerUsername: ownerUsername,
|
||||
}
|
||||
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if isNewUser && chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, ownerUsername, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("failed to upsert user: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Force-disconnect all active sessions for this user so new config applies.
|
||||
userMgr.DisconnectUser(p.Username)
|
||||
if p.ResetUsage {
|
||||
if err := resetSSHUserTrafficAccounting(ctx, store, p.Username); err != nil {
|
||||
http.Error(w, "could not reset usage", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reloadUsersFromDB(ctx, store)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func handleResetUserTraffic(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, req.Username, sess.Username) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/reset-traffic", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
var owner string
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, req.Username).Scan(&owner); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && strings.TrimSpace(owner) != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := resetSSHUserTrafficAccounting(ctx, store, req.Username); err != nil {
|
||||
log.Printf("failed to reset SSH traffic for %s: %v", req.Username, err)
|
||||
http.Error(w, "could not reset traffic", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "username": req.Username})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
@@ -2049,15 +2166,15 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if err := validateAccountUsername(username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, username, sess.Username) {
|
||||
@@ -2067,7 +2184,7 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "delete SSH account from managed server", err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2231,19 +2348,52 @@ func matchTOTPPassword(u *UserState, supplied string, now time.Time) bool {
|
||||
// ---------- Auth callbacks ----------
|
||||
|
||||
func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
||||
supplied := string(pass)
|
||||
u, ok := userMgr.Get(meta.User())
|
||||
now := time.Now()
|
||||
|
||||
// Enforce panel policy (expiry / reseller owner) for known users up front,
|
||||
// so neither PAM nor the static password can bypass it.
|
||||
if ok {
|
||||
if u.ExpiresAt != nil && now.After(*u.ExpiresAt) {
|
||||
log.Printf("user %s tried to connect but account is expired", meta.User())
|
||||
return nil, fmt.Errorf("account expired")
|
||||
}
|
||||
if sshUserQuotaBlocked(u) {
|
||||
log.Printf("user %s tried to connect after reaching the data quota", meta.User())
|
||||
return nil, errDataQuotaExceeded
|
||||
}
|
||||
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// System (PAM) login. When enabled server-wide, the Linux system password
|
||||
// (/etc/shadow) is accepted for any regular account (UID >= 1000) — whether
|
||||
// or not it is already a panel user. Unknown accounts are auto-imported on
|
||||
// success. Falls through to panel credentials if PAM does not accept.
|
||||
if isPAMAuthEnabled() {
|
||||
if isRegularLoginUser(meta.User()) {
|
||||
if err := authenticatePAM(meta.User(), supplied); err == nil {
|
||||
if !ok {
|
||||
importPAMUser(meta.User())
|
||||
}
|
||||
pamLogf("PAM: %q authenticated against /etc/shadow", meta.User())
|
||||
return nil, nil
|
||||
} else {
|
||||
pamLogf("PAM: %q rejected by /etc/shadow: %v", meta.User(), err)
|
||||
}
|
||||
} else if !ok {
|
||||
pamLogf("PAM: %q is not a regular login account (needs an /etc/passwd entry with UID >= %d)", meta.User(), minLoginUID)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
pamLogf("auth: user %q rejected (no panel account and PAM did not accept it)", meta.User())
|
||||
return nil, fmt.Errorf("authentication failed")
|
||||
}
|
||||
now := time.Now()
|
||||
if u.ExpiresAt != nil && now.After(*u.ExpiresAt) {
|
||||
log.Printf("user %s tried to connect but account is expired", meta.User())
|
||||
return nil, fmt.Errorf("account expired")
|
||||
}
|
||||
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
supplied := string(pass)
|
||||
|
||||
// Fall back to panel-managed credentials (TOTP and/or static password).
|
||||
if strings.TrimSpace(u.Cfg.TOTPSecret) != "" {
|
||||
if matchTOTPPassword(u, supplied, now) {
|
||||
return nil, nil
|
||||
@@ -2268,6 +2418,9 @@ func publicKeyCallback(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissio
|
||||
log.Printf("user %s tried to connect but account is expired", meta.User())
|
||||
return nil, fmt.Errorf("account expired")
|
||||
}
|
||||
if sshUserQuotaBlocked(u) {
|
||||
return nil, errDataQuotaExceeded
|
||||
}
|
||||
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
@@ -2399,6 +2552,10 @@ type directTCPIPReq struct {
|
||||
}
|
||||
|
||||
func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimiter *rate.Limiter) {
|
||||
if sshUserQuotaBlocked(u) {
|
||||
newChan.Reject(ssh.Prohibited, "data quota exceeded")
|
||||
return
|
||||
}
|
||||
var req directTCPIPReq
|
||||
if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil {
|
||||
newChan.Reject(ssh.Prohibited, "bad direct-tcpip request")
|
||||
@@ -2427,9 +2584,14 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
// half-close that never completes), both sides are force-closed so the
|
||||
// other direction unblocks. Close is idempotent, so calling it from both
|
||||
// directions is safe and no separate waiter goroutine is needed.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var closeOnce sync.Once
|
||||
closeAll := func() {
|
||||
_ = backend.Close()
|
||||
_ = ch.Close()
|
||||
closeOnce.Do(func() {
|
||||
cancel()
|
||||
_ = backend.Close()
|
||||
_ = ch.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// Drain channel requests concurrently so the peer isn't left waiting.
|
||||
@@ -2443,7 +2605,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
|
||||
// upstream: SSH channel -> backend, in its own goroutine.
|
||||
go func() {
|
||||
_, _ = copyWithRateLimit(backend, ch, upLimiter)
|
||||
_, _ = copyWithRateLimitContext(ctx, sshQuotaWriter{w: backend, user: u, uplink: true, ctx: ctx}, ch, upLimiter)
|
||||
// Signal to the backend that we are done writing.
|
||||
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
@@ -2454,7 +2616,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
// downstream: backend -> SSH channel, run in this goroutine.
|
||||
// handleDirectTCPIP already runs as its own goroutine (see handleConn),
|
||||
// so reusing it here avoids spawning a third goroutine per channel.
|
||||
_, _ = copyWithRateLimit(ch, backend, downLimiter)
|
||||
_, _ = copyWithRateLimitContext(ctx, sshQuotaWriter{w: ch, user: u, uplink: false, ctx: ctx}, backend, downLimiter)
|
||||
closeAll()
|
||||
}
|
||||
|
||||
@@ -3096,6 +3258,7 @@ func main() {
|
||||
// Optional: initialize interface totals persistence (best-effort).
|
||||
if store != nil {
|
||||
statsStore = store
|
||||
startSSHUserTrafficFlusher(store)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureXrayClientsSchema(ctx); err != nil {
|
||||
log.Printf("xray clients table: %v", err)
|
||||
@@ -3129,7 +3292,6 @@ func main() {
|
||||
} else {
|
||||
log.Printf("iface totals persistence disabled: %v", err)
|
||||
}
|
||||
startManagedResellerStateSync(store)
|
||||
}
|
||||
|
||||
// start background collector for CPU + interface stats
|
||||
@@ -3150,9 +3312,6 @@ func main() {
|
||||
|
||||
// Start the integrated Xray-core subprocess if configured.
|
||||
initXrayManager(cfg.Xray)
|
||||
if store != nil {
|
||||
reconcileLocalResellerRuntimeStates(store)
|
||||
}
|
||||
|
||||
// Global banner text (from config or file) — stored in a global so the
|
||||
// admin API can update it on the fly without a restart.
|
||||
@@ -3297,6 +3456,7 @@ func main() {
|
||||
setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown)
|
||||
setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout)
|
||||
setMaxTotalConnsFromConfig(cfg.MaxTotalConnections)
|
||||
setPAMAuthEnabled(cfg.PAMAuthEnabled)
|
||||
|
||||
// Initialise listener pools (used for initial startup and hot-reload alike).
|
||||
publicPool = newListenerPool(serveHTTP80)
|
||||
|
||||
+64
-150
@@ -35,25 +35,6 @@ func managedServerHTTPClient(timeout time.Duration) *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
func remoteErrorSnippet(data []byte) string {
|
||||
const limit = 4096
|
||||
truncated := len(data) > limit
|
||||
if truncated {
|
||||
data = data[:limit]
|
||||
}
|
||||
value := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, string(data))
|
||||
value = strings.TrimSpace(value)
|
||||
if truncated {
|
||||
value += "…"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
@@ -363,36 +344,6 @@ func managedServerFromID(ctx context.Context, store *Store, id string) (*Managed
|
||||
return ms, true, nil
|
||||
}
|
||||
|
||||
func writeManagedServerSelectionError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
switch err.Error() {
|
||||
case "invalid server id", "server not found", "server is disabled":
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case "database not configured":
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
default:
|
||||
writeInternalError(w, "select managed server", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeManagedServerSaveError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
message := err.Error()
|
||||
safe := message == "server name required" || message == "invalid server name" ||
|
||||
message == "invalid admin username" || message == "invalid admin credential" ||
|
||||
message == "invalid server id" || message == "admin key/password required" ||
|
||||
strings.HasPrefix(message, "base url") || message == "invalid base url"
|
||||
if safe {
|
||||
http.Error(w, message, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "save managed server", err)
|
||||
}
|
||||
|
||||
func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ms.BaseURL+"/api/auth/login", bytes.NewReader(body))
|
||||
@@ -408,7 +359,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("remote login failed with HTTP %d: %q", resp.StatusCode, remoteErrorSnippet(data))
|
||||
return "", fmt.Errorf("remote login failed: %s", strings.TrimSpace(string(data)))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
@@ -456,13 +407,6 @@ func handleManagedProxyOrLocal(store *Store, local http.HandlerFunc) http.Handle
|
||||
}
|
||||
|
||||
func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentType string) {
|
||||
if status >= http.StatusInternalServerError {
|
||||
if len(body) > 0 {
|
||||
log.Printf("managed server returned HTTP %d: %q", status, remoteErrorSnippet(body))
|
||||
}
|
||||
body = []byte("managed server request failed\n")
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
@@ -478,7 +422,7 @@ func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentT
|
||||
func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store *Store, remotePath string, body []byte, filterOwner string) bool {
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
|
||||
if err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
if !remote {
|
||||
@@ -500,7 +444,7 @@ func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "proxy managed server request", err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") {
|
||||
@@ -591,7 +535,7 @@ func handleServers(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, err := store.UpsertManagedServer(r.Context(), p)
|
||||
if err != nil {
|
||||
writeManagedServerSaveError(w, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -660,17 +604,17 @@ func handleServerTest(store *Store) http.HandlerFunc {
|
||||
}
|
||||
token, err := remoteLoginToken(r.Context(), ms)
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "test managed server login", err)
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
_ = token
|
||||
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "test managed server session", err)
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
writeBadGatewayError(w, "test managed server session", fmt.Errorf("HTTP %d: %q", status, remoteErrorSnippet(data)))
|
||||
http.Error(w, strings.TrimSpace(string(data)), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -700,7 +644,7 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, id)
|
||||
if err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !remote {
|
||||
@@ -709,42 +653,35 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "proxy managed server configuration", err)
|
||||
log.Printf("managed server config proxy %s: %v", ms.BaseURL, err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserInfo(ctx context.Context, ms *ManagedServer, username string) (map[string]interface{}, bool, error) {
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
if username == "" {
|
||||
return nil, false, nil
|
||||
return "", false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
return "", false, err
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return nil, false, err
|
||||
return "", false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username {
|
||||
return row, true, nil
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteSSHUserInfo(ctx, ms, username)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
@@ -752,40 +689,31 @@ func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func remoteXrayClientInfo(ctx context.Context, ms *ManagedServer, uuid string) (map[string]interface{}, bool, error) {
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
if uuid == "" {
|
||||
return nil, false, nil
|
||||
return "", false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
return "", false, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return nil, false, err
|
||||
return "", false, err
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid {
|
||||
m["inbound_tag"] = fmt.Sprint(ib["tag"])
|
||||
return m, true, nil
|
||||
return strings.TrimSpace(fmt.Sprint(m["owner_username"])), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteXrayClientInfo(ctx, ms, uuid)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
@@ -793,73 +721,59 @@ func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner s
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
type resellerQuotaUsage struct {
|
||||
Weighted int
|
||||
SSHAccounts int
|
||||
XrayAccounts int
|
||||
}
|
||||
|
||||
func ownedQuotaUsageAcrossManagedServers(ctx context.Context, store *Store, owner string) (resellerQuotaUsage, error) {
|
||||
usage := resellerQuotaUsage{}
|
||||
if owner == "" {
|
||||
return usage, nil
|
||||
}
|
||||
usage.Weighted = countOwnedQuota(ctx, store, owner)
|
||||
usage.SSHAccounts = countOwnedUsers(owner)
|
||||
usage.XrayAccounts = countOwnedXrayClients(ctx, store, owner)
|
||||
if store == nil {
|
||||
return usage, nil
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
if store == nil || owner == "" {
|
||||
return 0, nil
|
||||
}
|
||||
total := countOwnedQuota(ctx, store, owner)
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
return 0, err
|
||||
}
|
||||
for _, ms := range servers {
|
||||
// Count every configured node and both account types. Temporarily disabling
|
||||
// a node or a protocol must not release its committed reseller quota.
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
if !ms.IsActive {
|
||||
continue
|
||||
}
|
||||
if ms.EnableSSH {
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(user["max_connections"]))
|
||||
usage.SSHAccounts++
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
status, data, _, err = proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
if ms.EnableXray {
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(item["max_conns"]))
|
||||
usage.XrayAccounts++
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
usage, err := ownedQuotaUsageAcrossManagedServers(ctx, store, owner)
|
||||
return usage.Weighted, err
|
||||
return total, nil
|
||||
}
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GehirnInc/crypt"
|
||||
_ "github.com/GehirnInc/crypt/apr1_crypt"
|
||||
_ "github.com/GehirnInc/crypt/md5_crypt"
|
||||
_ "github.com/GehirnInc/crypt/sha256_crypt"
|
||||
_ "github.com/GehirnInc/crypt/sha512_crypt"
|
||||
"github.com/openwall/yescrypt-go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
shadowFile = "/etc/shadow"
|
||||
passwdFile = "/etc/passwd"
|
||||
|
||||
// minLoginUID / nobodyUID bound the accounts eligible for auto-import.
|
||||
// Regular human login accounts start at UID 1000 on Debian/Ubuntu; system
|
||||
// and service accounts (and "nobody") are excluded.
|
||||
minLoginUID = 1000
|
||||
nobodyUID = 65534
|
||||
)
|
||||
|
||||
var errNoSystemPassword = errors.New("account has no usable password")
|
||||
|
||||
// pamLogger writes PAM auth diagnostics straight to stderr (captured by
|
||||
// journald) so they remain visible even when "Quiet Logs" redirects the default
|
||||
// logger to io.Discard. Use pamLogf for anything an operator needs to see when
|
||||
// debugging why a system login was accepted or refused.
|
||||
var pamLogger = log.New(os.Stderr, "", log.LstdFlags)
|
||||
|
||||
func pamLogf(format string, args ...interface{}) { pamLogger.Printf(format, args...) }
|
||||
|
||||
// pamAuthEnabled mirrors Config.PAMAuthEnabled and is toggled live on config
|
||||
// reload. Guarded atomically so passwordCallback can read it lock-free.
|
||||
var pamAuthEnabled atomic.Bool
|
||||
|
||||
func setPAMAuthEnabled(v bool) {
|
||||
pamAuthEnabled.Store(v)
|
||||
state := "disabled"
|
||||
if v {
|
||||
state = "ENABLED"
|
||||
}
|
||||
pamLogf("PAM: system (Linux /etc/shadow) login is now %s", state)
|
||||
}
|
||||
func isPAMAuthEnabled() bool { return pamAuthEnabled.Load() }
|
||||
|
||||
// importPAMUser registers a freshly PAM-authenticated account in the running
|
||||
// user manager and persists it (marked use_pam) so it shows up in the panel and
|
||||
// later logins are re-verified against the system password. Idempotent: a
|
||||
// second concurrent/subsequent login for the same user is a no-op.
|
||||
func importPAMUser(username string) {
|
||||
cfg := UserConfig{Username: username, UsePAM: true}
|
||||
// Carry over the Linux account expiry (/etc/shadow field 8) so the panel's
|
||||
// "Vence em" shows the real expiration instead of "—".
|
||||
var expPtr *time.Time
|
||||
if exp := shadowAccountExpiry(username); exp != nil {
|
||||
cfg.ExpiresAt = exp.Format(time.RFC3339)
|
||||
expPtr = exp
|
||||
}
|
||||
st := &UserState{Cfg: cfg, ExpiresAt: expPtr}
|
||||
if !userMgr.AddIfAbsent(st) {
|
||||
return // already present in memory
|
||||
}
|
||||
pamLogf("PAM: auto-imported system user %s into the panel", username)
|
||||
if statsStore != nil {
|
||||
if err := statsStore.UpsertUser(context.Background(), cfg); err != nil {
|
||||
pamLogf("PAM: failed to persist auto-imported user %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isRegularLoginUser reports whether username is a regular human login account
|
||||
// (UID >= 1000 and not "nobody"), by parsing /etc/passwd. System/service
|
||||
// accounts and root are excluded from auto-import.
|
||||
func isRegularLoginUser(username string) bool {
|
||||
data, err := os.ReadFile(passwdFile)
|
||||
if err != nil {
|
||||
pamLogf("PAM: cannot read %s: %v", passwdFile, err)
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) < 3 || fields[0] != username {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(fields[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return uid >= minLoginUID && uid != nobodyUID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shadowAccountExpiry returns the account expiration date from /etc/shadow
|
||||
// field 8 (days since 1970-01-01), or nil if the account never expires (empty
|
||||
// field) or the value is unusable. This is the `chage -E` / `useradd -e` date,
|
||||
// which maps to the panel's per-user expiry.
|
||||
func shadowAccountExpiry(username string) *time.Time {
|
||||
data, err := os.ReadFile(shadowFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Split(strings.TrimRight(line, "\r"), ":")
|
||||
if len(fields) < 8 || fields[0] != username {
|
||||
continue
|
||||
}
|
||||
expStr := strings.TrimSpace(fields[7])
|
||||
if expStr == "" {
|
||||
return nil // no account expiry set
|
||||
}
|
||||
days, err := strconv.Atoi(expStr)
|
||||
if err != nil || days <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(int64(days)*86400, 0).UTC()
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authenticatePAM verifies password against the Linux system account matching
|
||||
// username. It reads the account's hash from /etc/shadow (the panel runs as
|
||||
// root) and recomputes it with the same algorithm — this is the "just the auth"
|
||||
// behaviour: the supplied password is checked exactly as the system would,
|
||||
// with no account/session management and nothing to do with the SSH daemon.
|
||||
//
|
||||
// It is called "PAM" for continuity with the user-facing flag, but it does not
|
||||
// link libpam; it verifies the crypt(3) hash directly. Supported hash formats:
|
||||
// yescrypt ($y$), sha512-crypt ($6$), sha256-crypt ($5$), md5-crypt ($1$),
|
||||
// apr1 ($apr1$) and bcrypt ($2a$/$2b$/$2y$). Returns nil on success.
|
||||
func authenticatePAM(username, password string) error {
|
||||
if username == "" {
|
||||
return errors.New("shadow: empty username")
|
||||
}
|
||||
hash, err := lookupShadowHash(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return verifyCryptHash(hash, password)
|
||||
}
|
||||
|
||||
// lookupShadowHash returns the password hash field for username from /etc/shadow.
|
||||
func lookupShadowHash(username string) (string, error) {
|
||||
data, err := os.ReadFile(shadowFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", shadowFile, err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) < 2 || fields[0] != username {
|
||||
continue
|
||||
}
|
||||
hash := fields[1]
|
||||
// Empty, or locked/disabled accounts (! or * in the hash field) have no
|
||||
// password that any input can match — reject rather than risk a match.
|
||||
if hash == "" || strings.HasPrefix(hash, "!") || strings.HasPrefix(hash, "*") {
|
||||
return "", errNoSystemPassword
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
return "", fmt.Errorf("shadow: user %q not found", username)
|
||||
}
|
||||
|
||||
// verifyCryptHash checks password against a crypt(3)-style hash string,
|
||||
// dispatching on the hash prefix. Returns nil only on an exact match.
|
||||
func verifyCryptHash(hash, password string) error {
|
||||
switch {
|
||||
case strings.HasPrefix(hash, "$y$"):
|
||||
computed, err := yescrypt.Hash([]byte(password), []byte(hash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("yescrypt: %w", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare(computed, []byte(hash)) == 1 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("password mismatch")
|
||||
case strings.HasPrefix(hash, "$2a$"), strings.HasPrefix(hash, "$2b$"), strings.HasPrefix(hash, "$2y$"):
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
case crypt.IsHashSupported(hash):
|
||||
return crypt.NewFromHash(hash).Verify(hash, []byte(password))
|
||||
case isTraditionalDES(hash):
|
||||
computed, err := desCrypt(password, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("descrypt: %w", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(computed), []byte(hash)) == 1 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("password mismatch")
|
||||
default:
|
||||
return fmt.Errorf("shadow: unsupported hash format")
|
||||
}
|
||||
}
|
||||
|
||||
// isTraditionalDES reports whether hash looks like a classic 13-character
|
||||
// DES crypt(3) hash (2 salt chars + 11 hash chars, all from the crypt alphabet,
|
||||
// no "$" scheme prefix). Used by old Linux/UNIX accounts.
|
||||
func isTraditionalDES(hash string) bool {
|
||||
if len(hash) != 13 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(hash); i++ {
|
||||
if crypt64Decode(hash[i]) < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Known crypt(3) test vectors covering the formats found in /etc/shadow across
|
||||
// old and new Linux. verifyCryptHash must accept the right password and reject
|
||||
// the wrong one for each.
|
||||
func TestVerifyCryptHashVectors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hash string
|
||||
pw string
|
||||
}{
|
||||
{
|
||||
name: "sha512crypt $6$ (glibc, older Linux)",
|
||||
// openssl passwd -6 -salt saltstring "Hello world!"
|
||||
hash: "$6$saltstring$svn8UoSVapNtMuq1ukKS4tPQd8iKwSMHWjl/O817G3uBnIFNjnQJuesI68u4OTLiBFdcbYEdFCoEOfaS35inz1",
|
||||
pw: "Hello world!",
|
||||
},
|
||||
{
|
||||
name: "yescrypt $y$ (Debian 11+/Ubuntu 22.04+)",
|
||||
hash: "$y$j9T$e8R9q85ZuzUkArEUurdtS.$esON.7y6H.u3UCPVCpbRFueRpAut2n2cMf1EhpjbuiC",
|
||||
pw: "pleaseletmein",
|
||||
},
|
||||
{
|
||||
// Real DES-crypt account from the production server's /etc/shadow.
|
||||
name: "traditional DES (old Linux) — testedragon",
|
||||
hash: "pae9A3UKpfaU6",
|
||||
pw: "testedragon",
|
||||
},
|
||||
{
|
||||
name: "traditional DES (old Linux) — ipv6dragon",
|
||||
hash: "pa9eao3LI6u.6",
|
||||
pw: "0tMGUL9chq8D",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if err := verifyCryptHash(c.hash, c.pw); err != nil {
|
||||
t.Errorf("correct password REJECTED: %v", err)
|
||||
}
|
||||
// Build a wrong password that differs in the FIRST character, so the
|
||||
// check is meaningful even for traditional DES (which only considers
|
||||
// the first 8 bytes of the password).
|
||||
wrong := "Z" + c.pw
|
||||
if err := verifyCryptHash(c.hash, wrong); err == nil {
|
||||
t.Errorf("wrong password ACCEPTED")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) {
|
||||
path := panelLogFilePath()
|
||||
maxBytes := panelLogMaxBytes()
|
||||
if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil {
|
||||
writeInternalError(w, "clear panel log", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
quotaActionBlock = "block"
|
||||
quotaActionThrottle = "throttle"
|
||||
)
|
||||
|
||||
var errDataQuotaExceeded = errors.New("data quota exceeded")
|
||||
|
||||
func normalizeQuotaAction(v string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(v), quotaActionThrottle) {
|
||||
return quotaActionThrottle
|
||||
}
|
||||
return quotaActionBlock
|
||||
}
|
||||
|
||||
func quotaThrottleMbpsOrDefault(v int) int {
|
||||
if v <= 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type sshTrafficDelta struct {
|
||||
Uplink int64
|
||||
Downlink int64
|
||||
}
|
||||
|
||||
var (
|
||||
sshTrafficPersistenceMu sync.Mutex
|
||||
sshTrafficDirtyMu sync.Mutex
|
||||
sshTrafficDirty = make(map[string]*UserState)
|
||||
)
|
||||
|
||||
func markSSHUserTrafficDirty(u *UserState) {
|
||||
if u == nil || strings.TrimSpace(u.Cfg.Username) == "" {
|
||||
return
|
||||
}
|
||||
sshTrafficDirtyMu.Lock()
|
||||
sshTrafficDirty[u.Cfg.Username] = u
|
||||
sshTrafficDirtyMu.Unlock()
|
||||
}
|
||||
|
||||
func takeSSHUserTrafficDirty() map[string]*UserState {
|
||||
sshTrafficDirtyMu.Lock()
|
||||
dirty := sshTrafficDirty
|
||||
sshTrafficDirty = make(map[string]*UserState)
|
||||
sshTrafficDirtyMu.Unlock()
|
||||
return dirty
|
||||
}
|
||||
|
||||
func clearSSHUserTrafficDirty(username string, u *UserState) {
|
||||
sshTrafficDirtyMu.Lock()
|
||||
if current := sshTrafficDirty[username]; u == nil || current == u {
|
||||
delete(sshTrafficDirty, username)
|
||||
}
|
||||
sshTrafficDirtyMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) AddSSHUserTrafficBatch(ctx context.Context, deltas map[string]sshTrafficDelta) error {
|
||||
if s == nil || len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
UPDATE ssh_users SET
|
||||
total_uplink_bytes = GREATEST(total_uplink_bytes + GREATEST($2::BIGINT, 0), 0),
|
||||
total_downlink_bytes = GREATEST(total_downlink_bytes + GREATEST($3::BIGINT, 0), 0)
|
||||
WHERE username = $1`)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for username, d := range deltas {
|
||||
if strings.TrimSpace(username) == "" || (d.Uplink == 0 && d.Downlink == 0) {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, username, d.Uplink, d.Downlink); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ResetSSHUserTraffic(ctx context.Context, username string) error {
|
||||
if s == nil || strings.TrimSpace(username) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE ssh_users
|
||||
SET total_uplink_bytes = 0, total_downlink_bytes = 0
|
||||
WHERE username = $1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func initSSHRuntimeUsage(u *UserState, uplink, downlink int64) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
if uplink < 0 {
|
||||
uplink = 0
|
||||
}
|
||||
if downlink < 0 {
|
||||
downlink = 0
|
||||
}
|
||||
atomic.StoreInt64(&u.TotalUplinkBytes, uplink)
|
||||
atomic.StoreInt64(&u.TotalDownlinkBytes, downlink)
|
||||
atomic.StoreInt64(&u.totalBytes, uplink+downlink)
|
||||
atomic.StoreInt64(&u.pendingUplinkBytes, 0)
|
||||
atomic.StoreInt64(&u.pendingDownlinkBytes, 0)
|
||||
}
|
||||
|
||||
func resetSSHRuntimeUsageLocked(u *UserState) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
initSSHRuntimeUsage(u, 0, 0)
|
||||
u.mu.Lock()
|
||||
u.quotaLimiter = nil
|
||||
u.quotaLimiterMbps = 0
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
func resetSSHRuntimeUsage(username string) {
|
||||
u, ok := userMgr.Get(username)
|
||||
if !ok || u == nil {
|
||||
return
|
||||
}
|
||||
u.trafficMu.Lock()
|
||||
resetSSHRuntimeUsageLocked(u)
|
||||
clearSSHUserTrafficDirty(username, u)
|
||||
u.trafficMu.Unlock()
|
||||
}
|
||||
|
||||
func resetSSHUserTrafficAccounting(ctx context.Context, store *Store, username string) error {
|
||||
u, _ := userMgr.Get(username)
|
||||
if u != nil {
|
||||
u.trafficMu.Lock()
|
||||
defer u.trafficMu.Unlock()
|
||||
}
|
||||
sshTrafficPersistenceMu.Lock()
|
||||
defer sshTrafficPersistenceMu.Unlock()
|
||||
if err := store.ResetSSHUserTraffic(ctx, username); err != nil {
|
||||
return err
|
||||
}
|
||||
if u != nil {
|
||||
resetSSHRuntimeUsageLocked(u)
|
||||
clearSSHUserTrafficDirty(username, u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshUserQuotaBlocked(u *UserState) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
u.mu.Lock()
|
||||
quota := u.Cfg.DataQuotaBytes
|
||||
action := normalizeQuotaAction(u.Cfg.QuotaAction)
|
||||
u.mu.Unlock()
|
||||
return quota > 0 && action == quotaActionBlock && atomic.LoadInt64(&u.totalBytes) >= quota
|
||||
}
|
||||
|
||||
func sshQuotaLimiter(u *UserState, mbps int) *rate.Limiter {
|
||||
mbps = quotaThrottleMbpsOrDefault(mbps)
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if u.quotaLimiter == nil || u.quotaLimiterMbps != mbps {
|
||||
bps := mbpsToBytesPerSec(mbps)
|
||||
burst := int(bps)
|
||||
if burst < copyBufSize {
|
||||
burst = copyBufSize
|
||||
}
|
||||
u.quotaLimiter = rate.NewLimiter(rate.Limit(bps), burst)
|
||||
u.quotaLimiterMbps = mbps
|
||||
}
|
||||
return u.quotaLimiter
|
||||
}
|
||||
|
||||
func reserveSSHUserBytes(u *UserState, requested int) (allowed int, throttle *rate.Limiter, stopAfter bool) {
|
||||
if u == nil || requested <= 0 {
|
||||
return 0, nil, false
|
||||
}
|
||||
u.mu.Lock()
|
||||
quota := u.Cfg.DataQuotaBytes
|
||||
action := normalizeQuotaAction(u.Cfg.QuotaAction)
|
||||
throttleMbps := u.Cfg.QuotaThrottleMbps
|
||||
u.mu.Unlock()
|
||||
|
||||
n := int64(requested)
|
||||
if quota <= 0 {
|
||||
atomic.AddInt64(&u.totalBytes, n)
|
||||
return requested, nil, false
|
||||
}
|
||||
|
||||
if action == quotaActionThrottle {
|
||||
previous := atomic.AddInt64(&u.totalBytes, n) - n
|
||||
if previous+n > quota {
|
||||
return requested, sshQuotaLimiter(u, throttleMbps), false
|
||||
}
|
||||
return requested, nil, false
|
||||
}
|
||||
|
||||
for {
|
||||
used := atomic.LoadInt64(&u.totalBytes)
|
||||
remaining := quota - used
|
||||
if remaining <= 0 {
|
||||
return 0, nil, true
|
||||
}
|
||||
take := n
|
||||
if take > remaining {
|
||||
take = remaining
|
||||
}
|
||||
if atomic.CompareAndSwapInt64(&u.totalBytes, used, used+take) {
|
||||
return int(take), nil, take < n || used+take >= quota
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finishSSHUserReservation(u *UserState, uplink bool, reserved, written int) {
|
||||
if u == nil || reserved <= 0 {
|
||||
return
|
||||
}
|
||||
if written < 0 {
|
||||
written = 0
|
||||
}
|
||||
if written > reserved {
|
||||
written = reserved
|
||||
}
|
||||
if written < reserved {
|
||||
atomic.AddInt64(&u.totalBytes, -int64(reserved-written))
|
||||
}
|
||||
if written == 0 {
|
||||
return
|
||||
}
|
||||
if uplink {
|
||||
atomic.AddInt64(&u.TotalUplinkBytes, int64(written))
|
||||
atomic.AddInt64(&u.pendingUplinkBytes, int64(written))
|
||||
} else {
|
||||
atomic.AddInt64(&u.TotalDownlinkBytes, int64(written))
|
||||
atomic.AddInt64(&u.pendingDownlinkBytes, int64(written))
|
||||
}
|
||||
markSSHUserTrafficDirty(u)
|
||||
}
|
||||
|
||||
type sshQuotaWriter struct {
|
||||
w io.Writer
|
||||
user *UserState
|
||||
uplink bool
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (qw sshQuotaWriter) Write(p []byte) (int, error) {
|
||||
if qw.user != nil {
|
||||
qw.user.trafficMu.RLock()
|
||||
defer qw.user.trafficMu.RUnlock()
|
||||
}
|
||||
allowed, quotaLimiter, stopAfter := reserveSSHUserBytes(qw.user, len(p))
|
||||
if allowed <= 0 {
|
||||
return 0, errDataQuotaExceeded
|
||||
}
|
||||
if quotaLimiter != nil {
|
||||
ctx := qw.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := quotaLimiter.WaitN(ctx, allowed); err != nil {
|
||||
finishSSHUserReservation(qw.user, qw.uplink, allowed, 0)
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := qw.w.Write(p[:allowed])
|
||||
finishSSHUserReservation(qw.user, qw.uplink, allowed, n)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if stopAfter || allowed < len(p) {
|
||||
return n, errDataQuotaExceeded
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func startSSHUserTrafficFlusher(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
flushSSHUserTraffic(store)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func flushSSHUserTraffic(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
sshTrafficPersistenceMu.Lock()
|
||||
defer sshTrafficPersistenceMu.Unlock()
|
||||
deltas := make(map[string]sshTrafficDelta)
|
||||
states := make(map[string]*UserState)
|
||||
for username, u := range takeSSHUserTrafficDirty() {
|
||||
if u == nil || strings.TrimSpace(username) == "" {
|
||||
continue
|
||||
}
|
||||
up := atomic.SwapInt64(&u.pendingUplinkBytes, 0)
|
||||
down := atomic.SwapInt64(&u.pendingDownlinkBytes, 0)
|
||||
if up == 0 && down == 0 {
|
||||
continue
|
||||
}
|
||||
deltas[username] = sshTrafficDelta{Uplink: up, Downlink: down}
|
||||
states[username] = u
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := store.AddSSHUserTrafficBatch(ctx, deltas); err != nil {
|
||||
log.Printf("ssh traffic flush failed: %v", err)
|
||||
for username, d := range deltas {
|
||||
if u := states[username]; u != nil {
|
||||
atomic.AddInt64(&u.pendingUplinkBytes, d.Uplink)
|
||||
atomic.AddInt64(&u.pendingDownlinkBytes, d.Downlink)
|
||||
markSSHUserTrafficDirty(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateQuotaConfig(quotaBytes int64, action string, throttleMbps int) error {
|
||||
if quotaBytes < 0 {
|
||||
return fmt.Errorf("data_quota_bytes must be non-negative")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func TestNativeClientMaxConnectionsAndBatchedActiveDelta(t *testing.T) {
|
||||
oldStore := statsStore
|
||||
statsStore = &Store{}
|
||||
defer func() { statsStore = oldStore }()
|
||||
|
||||
const uuid = "11111111-1111-1111-1111-111111111111"
|
||||
m := &XrayManager{
|
||||
nativeQuotaByUUID: map[string]*xrayNativeQuotaState{
|
||||
uuid: {maxConns: 1, generation: 1},
|
||||
},
|
||||
}
|
||||
state := m.nativeQuotaState(uuid)
|
||||
|
||||
release, acquiredState, ok := m.acquireNativeClientConnection(uuid, "user@example")
|
||||
if !ok || release == nil {
|
||||
t.Fatal("first native connection was rejected")
|
||||
}
|
||||
if acquiredState != state {
|
||||
t.Fatal("connection lease did not retain the authenticated policy state")
|
||||
}
|
||||
if _, _, ok := m.acquireNativeClientConnection(uuid, "user@example"); ok {
|
||||
t.Fatal("connection above max_conns was accepted")
|
||||
}
|
||||
|
||||
m.nativeDBMu.Lock()
|
||||
pending := m.nativeActivePending[uuid]
|
||||
m.nativeDBMu.Unlock()
|
||||
if pending.Delta != 1 || !pending.Connected || pending.State != state {
|
||||
t.Fatalf("connect was not queued for batch persistence: %+v", pending)
|
||||
}
|
||||
|
||||
release()
|
||||
release() // idempotent release must not underflow counters.
|
||||
|
||||
m.nativeDBMu.Lock()
|
||||
pending = m.nativeActivePending[uuid]
|
||||
m.nativeDBMu.Unlock()
|
||||
if pending.Delta != 0 || !pending.Connected {
|
||||
t.Fatalf("connect/disconnect batch should net to zero and retain last-active: %+v", pending)
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
active := state.activeConns
|
||||
state.mu.Unlock()
|
||||
if active != 0 {
|
||||
t.Fatalf("active connection count = %d, want 0", active)
|
||||
}
|
||||
|
||||
release2, _, ok := m.acquireNativeClientConnection(uuid, "user@example")
|
||||
if !ok {
|
||||
t.Fatal("slot was not reusable after release")
|
||||
}
|
||||
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{
|
||||
"gone": {generation: 1},
|
||||
},
|
||||
nativeTrafficPending: map[string]xrayPendingTraffic{
|
||||
"gone": {Uplink: 10},
|
||||
},
|
||||
nativeActivePending: map[string]xrayPendingActive{
|
||||
"gone": {Delta: 1},
|
||||
},
|
||||
}
|
||||
m.removeNativeQuotaPolicy("gone")
|
||||
if m.nativeQuotaState("gone") != nil {
|
||||
t.Fatal("quota policy was not removed")
|
||||
}
|
||||
m.nativeDBMu.Lock()
|
||||
_, trafficExists := m.nativeTrafficPending["gone"]
|
||||
_, activeExists := m.nativeActivePending["gone"]
|
||||
m.nativeDBMu.Unlock()
|
||||
if trafficExists || activeExists {
|
||||
t.Fatal("deleted UUID remained in a pending persistence map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCounterIsBoundedAndReleaseIsIdempotent(t *testing.T) {
|
||||
var active atomicInt64ForTest
|
||||
release1, ok := acquireNativeCounter(&active.Int64, 2)
|
||||
if !ok {
|
||||
t.Fatal("first slot rejected")
|
||||
}
|
||||
release2, ok := acquireNativeCounter(&active.Int64, 2)
|
||||
if !ok {
|
||||
t.Fatal("second slot rejected")
|
||||
}
|
||||
if _, ok := acquireNativeCounter(&active.Int64, 2); ok {
|
||||
t.Fatal("slot above limit accepted")
|
||||
}
|
||||
release1()
|
||||
release1()
|
||||
if got := active.Load(); got != 1 {
|
||||
t.Fatalf("active after double release = %d, want 1", got)
|
||||
}
|
||||
release2()
|
||||
if got := active.Load(); got != 0 {
|
||||
t.Fatalf("active after releases = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Embedding keeps the test declaration readable while still passing the exact
|
||||
// atomic.Int64 type required by acquireNativeCounter.
|
||||
type atomicInt64ForTest struct{ Int64 atomic.Int64 }
|
||||
|
||||
func (a *atomicInt64ForTest) Load() int64 { return a.Int64.Load() }
|
||||
|
||||
func TestTrackedNativeConnectionsAreClosedOnShutdown(t *testing.T) {
|
||||
oldAccepting := nativeTransportAccepting.Load()
|
||||
defer nativeTransportAccepting.Store(oldAccepting)
|
||||
|
||||
beginNativeTransportAccepting()
|
||||
before := nativeTransportConnections.Load()
|
||||
serverSide, clientSide := net.Pipe()
|
||||
defer clientSide.Close()
|
||||
wrapped, ok := wrapTrackedNativeTransportConn(serverSide)
|
||||
if !ok {
|
||||
t.Fatal("tracked connection was rejected")
|
||||
}
|
||||
if got := nativeTransportConnections.Load(); got != before+1 {
|
||||
t.Fatalf("transport count = %d, want %d", got, before+1)
|
||||
}
|
||||
|
||||
stopNativeTransportAccepting()
|
||||
closeAllNativeTransportConnections()
|
||||
_ = clientSide.SetReadDeadline(time.Now().Add(time.Second))
|
||||
if _, err := clientSide.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("peer remained open after native shutdown")
|
||||
}
|
||||
if err := wrapped.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("second close returned unexpected error: %v", err)
|
||||
}
|
||||
if got := nativeTransportConnections.Load(); got != before {
|
||||
t.Fatalf("transport count after shutdown = %d, want %d", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPSessionsIgnoreLegacyGlobalCapAndReleaseCounters(t *testing.T) {
|
||||
before := nativeXHTTPSessions.Load()
|
||||
ib := &nativeInbound{xhttpMaxBufferedPosts: 2}
|
||||
for _, id := range []string{"one", "two"} {
|
||||
if sess := ib.upsertXHTTPSession(httptest.NewRecorder(), id); sess == nil {
|
||||
t.Fatalf("session %q was rejected", id)
|
||||
}
|
||||
}
|
||||
if got := nativeXHTTPSessions.Load(); got != before+2 {
|
||||
t.Fatalf("global XHTTP sessions = %d, want %d", got, before+2)
|
||||
}
|
||||
ib.closeAllXHTTPSessions()
|
||||
if got := nativeXHTTPSessions.Load(); got != before {
|
||||
t.Fatalf("global XHTTP sessions after close = %d, want %d", got, before)
|
||||
}
|
||||
ib.xhttpMu.Lock()
|
||||
remaining := len(ib.xhttpSessions)
|
||||
ib.xhttpMu.Unlock()
|
||||
if remaining != 0 {
|
||||
t.Fatalf("inbound retained %d XHTTP sessions", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPSessionSafetyWindowIsAboveProductionScale(t *testing.T) {
|
||||
if got := (&nativeInbound{}).xhttpMaxActiveSessions(); got != fixedNativeMaxXHTTPSessions {
|
||||
t.Fatalf("XHTTP session safety window = %d, want %d", got, fixedNativeMaxXHTTPSessions)
|
||||
}
|
||||
if got := nativeXHTTPSessionLimit(); got < 8_000 {
|
||||
t.Fatalf("XHTTP session safety window = %d, want room for at least 8K users", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeHTTP2StreamsUseFiniteTransportBackpressure(t *testing.T) {
|
||||
if got := nativeHTTP2MaxConcurrentStreams(); got != fixedNativeHTTP2ConcurrentStreams {
|
||||
t.Fatalf("HTTP/2 stream setting = %d, want %d", got, fixedNativeHTTP2ConcurrentStreams)
|
||||
}
|
||||
if got := nativeHTTP2MaxConcurrentStreams(); got < 1024 {
|
||||
t.Fatalf("HTTP/2 stream setting = %d, too small for XHTTP packet bursts", got)
|
||||
}
|
||||
if got := nativeMuxMaxSessionLimit(); got != 64 {
|
||||
t.Fatalf("per-transport Mux session guard = %d, want 64", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPHandlerDoesNotApplyWebRequestCeiling(t *testing.T) {
|
||||
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 TestXHTTPHandlerSafetySlotIsReleased(t *testing.T) {
|
||||
before := nativeXHTTPRequests.Load()
|
||||
ib := &nativeInbound{transport: "xhttp", path: "/"}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ib.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty XHTTP request status = %d, want 400", rec.Code)
|
||||
}
|
||||
if got := nativeXHTTPRequests.Load(); got != before {
|
||||
t.Fatalf("XHTTP handler counter after return = %d, want %d", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedXHTTPAdmissionTuningIsAlwaysUnlimited(t *testing.T) {
|
||||
got := normalizeNativeXrayTuning(&XrayNativeTuning{
|
||||
MuxGlobalSessions: 8192,
|
||||
MaxConcurrentConnections: 4096,
|
||||
MaxConcurrentXHTTPRequests: 8192,
|
||||
XHTTPMaxSessions: 4096,
|
||||
})
|
||||
if got.MuxGlobalSessions != 8192 ||
|
||||
got.MaxConcurrentConnections != -1 ||
|
||||
got.MaxConcurrentXHTTPRequests != defaultNativeMaxXHTTPRequests ||
|
||||
got.XHTTPMaxSessions != -1 {
|
||||
t.Fatalf("persisted admission limits were not removed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPMetadataLengthIsBoundedBeforeSessionAllocation(t *testing.T) {
|
||||
ib := &nativeInbound{transport: "xhttp", path: "/"}
|
||||
req := httptest.NewRequest("GET", "/"+strings.Repeat("a", nativeXHTTPMaxSessionIDBytes+1), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ib.ServeHTTP(rec, req)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("oversized XHTTP session id status = %d, want 400", rec.Code)
|
||||
}
|
||||
ib.xhttpMu.Lock()
|
||||
sessions := len(ib.xhttpSessions)
|
||||
ib.xhttpMu.Unlock()
|
||||
if sessions != 0 {
|
||||
t.Fatalf("oversized metadata allocated %d sessions", sessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPUploadMemoryIsReleasedOnReadAndClose(t *testing.T) {
|
||||
before := nativeXHTTPBufferedBytes.Load()
|
||||
q := newNativeXHTTPUploadQueue(4, 512)
|
||||
|
||||
accounted := nativeXHTTPAccountedPacketBytes(4)
|
||||
lease, ok := acquireNativeXHTTPMemory(accounted)
|
||||
if !ok {
|
||||
t.Fatal("failed to reserve XHTTP test memory")
|
||||
}
|
||||
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("test"), Seq: 0}, lease); err != nil {
|
||||
lease.release()
|
||||
t.Fatalf("queue push failed: %v", err)
|
||||
}
|
||||
lease.release() // transferred leases are a no-op for the producer.
|
||||
if got := nativeXHTTPBufferedBytes.Load(); got != before+accounted {
|
||||
t.Fatalf("buffered bytes after push = %d, want %d", got, before+accounted)
|
||||
}
|
||||
|
||||
buf := make([]byte, 4)
|
||||
if n, err := q.Read(buf); err != nil || n != 4 || string(buf) != "test" {
|
||||
t.Fatalf("queue read = (%d, %v, %q), want (4, nil, test)", n, err, string(buf))
|
||||
}
|
||||
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||
t.Fatalf("buffered bytes after read = %d, want %d", got, before)
|
||||
}
|
||||
|
||||
lease, ok = acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(3))
|
||||
if !ok {
|
||||
t.Fatal("failed to reserve second XHTTP test memory")
|
||||
}
|
||||
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("xyz"), Seq: 2}, lease); err != nil {
|
||||
lease.release()
|
||||
t.Fatalf("second queue push failed: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
q.close()
|
||||
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||
t.Fatalf("buffered bytes after close = %d, want %d", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPUploadQueueEnforcesPerSessionByteBudget(t *testing.T) {
|
||||
before := nativeXHTTPBufferedBytes.Load()
|
||||
q := newNativeXHTTPUploadQueue(4, nativeXHTTPMinPacketAccountingBytes-1)
|
||||
defer q.close()
|
||||
|
||||
lease, ok := acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(5))
|
||||
if !ok {
|
||||
t.Fatal("failed to reserve XHTTP test memory")
|
||||
}
|
||||
defer lease.release()
|
||||
err := q.push(context.Background(), nativeXHTTPPacket{Payload: make([]byte, 5)}, lease)
|
||||
if !errors.Is(err, errNativeXHTTPUploadBufferFull) {
|
||||
t.Fatalf("oversized queue push error = %v, want buffer limit", err)
|
||||
}
|
||||
lease.release()
|
||||
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||
t.Fatalf("rejected payload retained %d bytes, baseline %d", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPUploadQueueBackpressuresInsteadOfRejectingBurst(t *testing.T) {
|
||||
before := nativeXHTTPBufferedBytes.Load()
|
||||
q := newNativeXHTTPUploadQueue(2, nativeXHTTPMinPacketAccountingBytes)
|
||||
defer q.close()
|
||||
|
||||
first, ok := acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(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(nativeXHTTPAccountedPacketBytes(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 TestXHTTPGlobalMemoryBackpressureWakesWaitersFIFO(t *testing.T) {
|
||||
before := nativeXHTTPBufferedBytes.Load()
|
||||
fillBytes := nativeXHTTPMaxBufferedGlobalBytes - before
|
||||
filler, ok := acquireNativeXHTTPMemory(fillBytes)
|
||||
if !ok {
|
||||
t.Fatal("failed to fill XHTTP memory budget for waiter test")
|
||||
}
|
||||
defer filler.release()
|
||||
|
||||
type result struct {
|
||||
lease *nativeXHTTPMemoryLease
|
||||
err error
|
||||
}
|
||||
startWaiter := func() <-chan result {
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
lease, err := acquireNativeXHTTPMemoryContext(context.Background(), nativeXHTTPMinPacketAccountingBytes)
|
||||
done <- result{lease: lease, err: err}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
waitForWaiters := func(want int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
nativeXHTTPMemoryWait.Lock()
|
||||
got := nativeXHTTPMemoryWait.queued
|
||||
nativeXHTTPMemoryWait.Unlock()
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("memory waiters = %d, want %d", got, want)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
firstDone := startWaiter()
|
||||
waitForWaiters(1)
|
||||
secondDone := startWaiter()
|
||||
waitForWaiters(2)
|
||||
|
||||
filler.shrink(fillBytes - nativeXHTTPMinPacketAccountingBytes)
|
||||
first := <-firstDone
|
||||
if first.err != nil || first.lease == nil {
|
||||
t.Fatalf("first memory waiter = (%v, %v)", first.lease, first.err)
|
||||
}
|
||||
select {
|
||||
case second := <-secondDone:
|
||||
if second.lease != nil {
|
||||
second.lease.release()
|
||||
}
|
||||
t.Fatalf("second waiter woke before FIFO capacity was released: %v", second.err)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
first.lease.release()
|
||||
select {
|
||||
case second := <-secondDone:
|
||||
if second.err != nil || second.lease == nil {
|
||||
t.Fatalf("second memory waiter = (%v, %v)", second.lease, second.err)
|
||||
}
|
||||
second.lease.release()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second memory waiter did not wake after first released")
|
||||
}
|
||||
|
||||
filler.release()
|
||||
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||
t.Fatalf("FIFO waiter test leaked %d buffered bytes (baseline %d)", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPReassemblyHasNoPacketRequestCountCeiling(t *testing.T) {
|
||||
before := nativeXHTTPBufferedBytes.Load()
|
||||
q := newNativeXHTTPUploadQueue(1, 4*nativeXHTTPMinPacketAccountingBytes)
|
||||
defer q.close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
for _, seq := range []uint64{3, 2, 1, 0} {
|
||||
lease, ok := acquireNativeXHTTPMemory(nativeXHTTPAccountedPacketBytes(1))
|
||||
if !ok {
|
||||
done <- errors.New("could not reserve packet memory")
|
||||
return
|
||||
}
|
||||
err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte{byte('a' + seq)}, Seq: seq}, lease)
|
||||
lease.release()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for want := byte('a'); want <= byte('d'); want++ {
|
||||
n, err := q.Read(buf)
|
||||
if err != nil || n != 1 || buf[0] != want {
|
||||
t.Fatalf("reassembled packet = (%d, %v, %q), want %q", n, err, buf[:n], []byte{want})
|
||||
}
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("out-of-order burst was rejected: %v", err)
|
||||
}
|
||||
q.close()
|
||||
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||
t.Fatalf("reassembly test leaked %d buffered bytes (baseline %d)", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXHTTPBodyReservationUsesActualContentLength(t *testing.T) {
|
||||
ib := &nativeInbound{xhttpMaxEachPostBytes: 1_000_000}
|
||||
req := httptest.NewRequest(http.MethodPost, "/session/0", strings.NewReader("small"))
|
||||
if got := ib.xhttpUploadReservationBytes(req); got != nativeXHTTPMinPacketAccountingBytes {
|
||||
t.Fatalf("body reservation = %d, want minimum accounted packet size", 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) {
|
||||
const uuid = "22222222-2222-2222-2222-222222222222"
|
||||
state := &xrayNativeQuotaState{usedBytes: 123, generation: 1}
|
||||
m := &XrayManager{nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: state}}
|
||||
|
||||
state.trafficMu.RLock()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
m.resetNativeQuotaUsage(uuid)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
state.trafficMu.RUnlock()
|
||||
t.Fatal("traffic reset crossed an in-flight writer boundary")
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
state.trafficMu.RUnlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("traffic reset did not complete after writer released")
|
||||
}
|
||||
state.mu.Lock()
|
||||
used, generation := state.usedBytes, state.generation
|
||||
state.mu.Unlock()
|
||||
if used != 0 || generation != 2 {
|
||||
t.Fatalf("reset state = used %d generation %d, want 0/2", used, generation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRateWaitCanBeCanceled(t *testing.T) {
|
||||
lim := rate.NewLimiter(1, 1)
|
||||
if !lim.AllowN(time.Now(), 1) {
|
||||
t.Fatal("failed to consume initial limiter token")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitNativeRate(ctx, lim, 1); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("waitNativeRate error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHDirtyQueueDoesNotScanInactiveUsers(t *testing.T) {
|
||||
sshTrafficDirtyMu.Lock()
|
||||
old := sshTrafficDirty
|
||||
sshTrafficDirty = make(map[string]*UserState)
|
||||
sshTrafficDirtyMu.Unlock()
|
||||
defer func() {
|
||||
sshTrafficDirtyMu.Lock()
|
||||
sshTrafficDirty = old
|
||||
sshTrafficDirtyMu.Unlock()
|
||||
}()
|
||||
|
||||
active := &UserState{Cfg: UserConfig{Username: "active"}}
|
||||
inactive := &UserState{Cfg: UserConfig{Username: "inactive"}}
|
||||
markSSHUserTrafficDirty(active)
|
||||
dirty := takeSSHUserTrafficDirty()
|
||||
if len(dirty) != 1 || dirty["active"] != active {
|
||||
t.Fatalf("dirty queue = %#v", dirty)
|
||||
}
|
||||
if _, found := dirty[inactive.Cfg.Username]; found {
|
||||
t.Fatal("inactive user appeared in dirty queue")
|
||||
}
|
||||
if next := takeSSHUserTrafficDirty(); len(next) != 0 {
|
||||
t.Fatalf("dirty queue was not drained: %#v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldNativeConnectionCannotDecrementReplacementAccount(t *testing.T) {
|
||||
oldStore := statsStore
|
||||
statsStore = &Store{}
|
||||
defer func() { statsStore = oldStore }()
|
||||
|
||||
const uuid = "replacement-active-user"
|
||||
oldState := &xrayNativeQuotaState{maxConns: 1, generation: 1}
|
||||
m := &XrayManager{nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: oldState}}
|
||||
|
||||
release, acquiredState, ok := m.acquireNativeClientConnection(uuid, "old@example")
|
||||
if !ok || acquiredState != oldState {
|
||||
t.Fatal("failed to acquire old account connection")
|
||||
}
|
||||
// Discard the old account's successful connect delta so this assertion only
|
||||
// measures what happens when that old connection later disconnects.
|
||||
m.nativeDBMu.Lock()
|
||||
m.nativeActivePending = nil
|
||||
m.nativeDBMu.Unlock()
|
||||
|
||||
newState := &xrayNativeQuotaState{maxConns: 1, generation: 1}
|
||||
m.nativeQuotaMu.Lock()
|
||||
m.nativeQuotaByUUID[uuid] = newState
|
||||
m.nativeQuotaMu.Unlock()
|
||||
|
||||
release()
|
||||
|
||||
m.nativeDBMu.Lock()
|
||||
pending := m.nativeActivePending[uuid]
|
||||
m.nativeDBMu.Unlock()
|
||||
if pending.Delta != 0 || pending.State != nil {
|
||||
t.Fatalf("old disconnect was queued against replacement account: %+v", pending)
|
||||
}
|
||||
newState.mu.Lock()
|
||||
active := newState.activeConns
|
||||
newState.mu.Unlock()
|
||||
if active != 0 {
|
||||
t.Fatalf("replacement account active count changed to %d", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldNativeTrafficCannotAttachToReplacementAccount(t *testing.T) {
|
||||
oldStore := statsStore
|
||||
statsStore = &Store{}
|
||||
defer func() { statsStore = oldStore }()
|
||||
|
||||
const uuid = "replacement-traffic-user"
|
||||
oldState := &xrayNativeQuotaState{generation: 1}
|
||||
newState := &xrayNativeQuotaState{generation: 1}
|
||||
m := &XrayManager{nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: oldState}}
|
||||
|
||||
meter := newTrafficMeter(uuid, "old@example", true, oldState)
|
||||
meter.n = 1234
|
||||
|
||||
m.nativeQuotaMu.Lock()
|
||||
m.nativeQuotaByUUID[uuid] = newState
|
||||
m.nativeQuotaMu.Unlock()
|
||||
|
||||
oldMgr := xrayMgr
|
||||
xrayMgr = m
|
||||
defer func() { xrayMgr = oldMgr }()
|
||||
meter.flush()
|
||||
|
||||
m.nativeDBMu.Lock()
|
||||
pending := m.nativeTrafficPending[uuid]
|
||||
m.nativeDBMu.Unlock()
|
||||
if pending.Uplink != 0 || pending.Downlink != 0 || pending.State != nil {
|
||||
t.Fatalf("old traffic was queued against replacement account: %+v", pending)
|
||||
}
|
||||
m.statsMu.RLock()
|
||||
stat := m.statsByEmail["old@example"]
|
||||
m.statsMu.RUnlock()
|
||||
if stat.Uplink != 0 || stat.Downlink != 0 {
|
||||
t.Fatalf("old traffic resurfaced in runtime stats: %+v", stat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeFlusherDropsMismatchedPolicyIdentity(t *testing.T) {
|
||||
oldStore := statsStore
|
||||
statsStore = &Store{}
|
||||
defer func() { statsStore = oldStore }()
|
||||
|
||||
const uuid = "identity-prune-user"
|
||||
oldState := &xrayNativeQuotaState{generation: 1}
|
||||
newState := &xrayNativeQuotaState{generation: 1}
|
||||
m := &XrayManager{
|
||||
nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: newState},
|
||||
nativeTrafficPending: map[string]xrayPendingTraffic{
|
||||
uuid: {Email: "old@example", Uplink: 99, State: oldState},
|
||||
},
|
||||
nativeActivePending: map[string]xrayPendingActive{
|
||||
uuid: {Email: "old@example", Delta: -1, State: oldState},
|
||||
},
|
||||
}
|
||||
m.flushNativeStatsToDB()
|
||||
|
||||
m.nativeDBMu.Lock()
|
||||
defer m.nativeDBMu.Unlock()
|
||||
if len(m.nativeTrafficPending) != 0 || len(m.nativeActivePending) != 0 {
|
||||
t.Fatalf("mismatched pending deltas survived prune: traffic=%v active=%v", m.nativeTrafficPending, m.nativeActivePending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeMuxFinishRunsOnce(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
s := &nativeMuxSession{
|
||||
closed: make(chan struct{}),
|
||||
uplink: make(chan nativeMuxUplinkItem),
|
||||
onClose: func(*nativeMuxSession) {
|
||||
calls.Add(1)
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.finish()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("mux onClose called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
type closeTrackingReader struct {
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (r *closeTrackingReader) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (r *closeTrackingReader) Close() error {
|
||||
r.closed.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNativeXHTTPQueueCloseClosesQueuedStreamReader(t *testing.T) {
|
||||
q := newNativeXHTTPUploadQueue(1, 1024)
|
||||
r := &closeTrackingReader{}
|
||||
if err := q.push(context.Background(), nativeXHTTPPacket{Reader: r}, nil); err != nil {
|
||||
t.Fatalf("queue stream reader: %v", err)
|
||||
}
|
||||
q.close()
|
||||
if !r.closed.Load() {
|
||||
t.Fatal("queued stream reader was not closed during queue shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeXHTTPQueueSkipsEmptyPacketsWithoutZeroProgressRead(t *testing.T) {
|
||||
before := nativeXHTTPBufferedBytes.Load()
|
||||
q := newNativeXHTTPUploadQueue(2, 2*nativeXHTTPMinPacketAccountingBytes)
|
||||
defer q.close()
|
||||
|
||||
for seq, payload := range [][]byte{nil, []byte("x")} {
|
||||
accounted := nativeXHTTPAccountedPacketBytes(int64(len(payload)))
|
||||
lease, ok := acquireNativeXHTTPMemory(accounted)
|
||||
if !ok {
|
||||
t.Fatal("failed to reserve packet memory")
|
||||
}
|
||||
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: payload, Seq: uint64(seq)}, lease); err != nil {
|
||||
lease.release()
|
||||
t.Fatalf("queue packet %d: %v", seq, err)
|
||||
}
|
||||
lease.release()
|
||||
}
|
||||
|
||||
buf := make([]byte, 1)
|
||||
n, err := q.Read(buf)
|
||||
if err != nil || n != 1 || string(buf[:n]) != "x" {
|
||||
t.Fatalf("queue read after empty packet = (%d, %v, %q), want (1, nil, x)", n, err, buf[:n])
|
||||
}
|
||||
q.close()
|
||||
if got := nativeXHTTPBufferedBytes.Load(); got != before {
|
||||
t.Fatalf("empty-packet test leaked %d buffered bytes (baseline %d)", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
type deadlineUnblockingResponseWriter struct {
|
||||
header http.Header
|
||||
writeStart chan struct{}
|
||||
unblock chan struct{}
|
||||
startOnce sync.Once
|
||||
unblockOnce sync.Once
|
||||
}
|
||||
|
||||
func newDeadlineUnblockingResponseWriter() *deadlineUnblockingResponseWriter {
|
||||
return &deadlineUnblockingResponseWriter{
|
||||
header: make(http.Header),
|
||||
writeStart: make(chan struct{}),
|
||||
unblock: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *deadlineUnblockingResponseWriter) Header() http.Header { return w.header }
|
||||
func (w *deadlineUnblockingResponseWriter) WriteHeader(int) {}
|
||||
func (w *deadlineUnblockingResponseWriter) Flush() {}
|
||||
func (w *deadlineUnblockingResponseWriter) Write([]byte) (int, error) {
|
||||
w.startOnce.Do(func() { close(w.writeStart) })
|
||||
<-w.unblock
|
||||
return 0, os.ErrDeadlineExceeded
|
||||
}
|
||||
func (w *deadlineUnblockingResponseWriter) SetWriteDeadline(deadline time.Time) error {
|
||||
if !deadline.IsZero() && !deadline.After(time.Now().Add(10*time.Millisecond)) {
|
||||
w.unblockOnce.Do(func() { close(w.unblock) })
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNativeXHTTPResponseCloseInterruptsStalledWrite(t *testing.T) {
|
||||
underlying := newDeadlineUnblockingResponseWriter()
|
||||
writer := newNativeXHTTPResponseWriter(underlying)
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := writer.Write([]byte("blocked"))
|
||||
writeDone <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-underlying.writeStart:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("response write did not start")
|
||||
}
|
||||
|
||||
closeDone := make(chan struct{})
|
||||
go func() {
|
||||
writer.close()
|
||||
close(closeDone)
|
||||
}()
|
||||
select {
|
||||
case <-closeDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("response close blocked behind stalled write")
|
||||
}
|
||||
select {
|
||||
case err := <-writeDone:
|
||||
if !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("stalled write error = %v, want deadline exceeded", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stalled response write was not interrupted")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,107 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeQuotaMode(t *testing.T) {
|
||||
for input, expected := range map[string]string{
|
||||
"": QuotaModeSlots,
|
||||
"slots": QuotaModeSlots,
|
||||
"Validade": QuotaModeSlots,
|
||||
"credits": QuotaModeCredit,
|
||||
"Credito": QuotaModeCredit,
|
||||
} {
|
||||
if got := normalizeQuotaMode(input); got != expected {
|
||||
t.Fatalf("normalizeQuotaMode(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerProvisionCost(t *testing.T) {
|
||||
for input, expected := range map[int]int{-10: 1, 0: 1, 1: 1, 3: 3} {
|
||||
if got := resellerProvisionCost(input); got != expected {
|
||||
t.Fatalf("resellerProvisionCost(%d) = %d, want %d", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResellerSubtree(t *testing.T) {
|
||||
all := []*AdminUser{
|
||||
{Username: "root", Role: RoleReseller},
|
||||
{Username: "child-a", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "child-b", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "grandchild", Role: RoleReseller, ParentUsername: "child-a"},
|
||||
{Username: "admin", Role: RoleSuperAdmin},
|
||||
}
|
||||
got := listResellerSubtree(all, "root")
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("subtree size = %d, want 4", len(got))
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, user := range got {
|
||||
seen[user.Username] = true
|
||||
}
|
||||
for _, username := range []string{"root", "child-a", "child-b", "grandchild"} {
|
||||
if !seen[username] {
|
||||
t.Fatalf("subtree does not contain %q", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerCanManageOnlyDirectChildren(t *testing.T) {
|
||||
sess := &AdminSession{Username: "parent", Role: RoleReseller}
|
||||
if !resellerCanManage(sess, &AdminUser{Username: "child", Role: RoleReseller, ParentUsername: "parent"}) {
|
||||
t.Fatal("parent could not manage its direct child")
|
||||
}
|
||||
if resellerCanManage(sess, &AdminUser{Username: "grandchild", Role: RoleReseller, ParentUsername: "child"}) {
|
||||
t.Fatal("parent was allowed to skip a hierarchy level")
|
||||
}
|
||||
admin := &AdminSession{Username: "admin", Role: RoleSuperAdmin}
|
||||
if !resellerCanManage(admin, &AdminUser{Username: "any", Role: RoleReseller}) {
|
||||
t.Fatal("superadmin could not manage a reseller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerExpiryExtensionDetection(t *testing.T) {
|
||||
existing := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
|
||||
if resellerExpiryExtended(existing.Format(time.RFC3339), existing.Format(time.RFC3339)) {
|
||||
t.Fatal("unchanged expiration was treated as an extension")
|
||||
}
|
||||
if !resellerExpiryExtended(existing.Format(time.RFC3339), existing.Add(time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("later expiration was not treated as an extension")
|
||||
}
|
||||
if resellerTimeExtended(&existing, existing.Add(-time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("shorter expiration was treated as an extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalExpiryUsesLaterBase(t *testing.T) {
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
got := renewalExpiry(&future, 30)
|
||||
want := future.AddDate(0, 0, 30)
|
||||
if got.Sub(want) > time.Second || want.Sub(got) > time.Second {
|
||||
t.Fatalf("renewal expiry = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountChainUsesPasswordFreeRuntimeState(t *testing.T) {
|
||||
parent := "runtime-parent-test"
|
||||
child := "runtime-child-test"
|
||||
adminUsers.delete(parent)
|
||||
adminUsers.delete(child)
|
||||
defer resellerRuntimeStates.delete(parent)
|
||||
defer resellerRuntimeStates.delete(child)
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: true})
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: child, ParentUsername: parent, IsActive: true})
|
||||
if err := adminAccountChainActive(child); err != nil {
|
||||
t.Fatalf("active replicated hierarchy was rejected: %v", err)
|
||||
}
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: false})
|
||||
if err := adminAccountChainActive(child); err == nil {
|
||||
t.Fatal("child remained active while its replicated parent was suspended")
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResellerRuntimeState is a password-free ownership record replicated from a
|
||||
// master panel to its managed nodes. It lets a node enforce reseller
|
||||
// suspension and parent hierarchy locally without copying login credentials.
|
||||
type ResellerRuntimeState struct {
|
||||
OwnerUsername string
|
||||
ParentUsername string
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type resellerRuntimeStateCacheT struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]ResellerRuntimeState
|
||||
}
|
||||
|
||||
var resellerRuntimeStates = &resellerRuntimeStateCacheT{m: make(map[string]ResellerRuntimeState)}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) get(username string) (ResellerRuntimeState, bool) {
|
||||
m.mu.RLock()
|
||||
state, ok := m.m[username]
|
||||
m.mu.RUnlock()
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) set(state ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m[state.OwnerUsername] = state
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) delete(username string) {
|
||||
m.mu.Lock()
|
||||
delete(m.m, username)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) list() []ResellerRuntimeState {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]ResellerRuntimeState, 0, len(m.m))
|
||||
for _, state := range m.m {
|
||||
out = append(out, state)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) replaceAll(states []ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m = make(map[string]ResellerRuntimeState, len(states))
|
||||
for _, state := range states {
|
||||
m.m[state.OwnerUsername] = state
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) ListResellerRuntimeStates(ctx context.Context) ([]ResellerRuntimeState, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT owner_username, parent_username, is_active, expires_at
|
||||
FROM reseller_runtime_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ResellerRuntimeState
|
||||
for rows.Next() {
|
||||
var state ResellerRuntimeState
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&state.OwnerUsername, &state.ParentUsername, &state.IsActive, &expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
state.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertResellerRuntimeState(ctx context.Context, state ResellerRuntimeState) error {
|
||||
var expiresAt interface{}
|
||||
if state.ExpiresAt != nil {
|
||||
expiresAt = *state.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO reseller_runtime_state
|
||||
(owner_username, parent_username, is_active, expires_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (owner_username) DO UPDATE SET
|
||||
parent_username=EXCLUDED.parent_username,
|
||||
is_active=EXCLUDED.is_active,
|
||||
expires_at=EXCLUDED.expires_at,
|
||||
updated_at=NOW()`,
|
||||
state.OwnerUsername, state.ParentUsername, state.IsActive, expiresAt)
|
||||
if err == nil {
|
||||
resellerRuntimeStates.set(state)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteResellerRuntimeState(ctx context.Context, owner string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM reseller_runtime_state WHERE owner_username=$1`, owner); err != nil {
|
||||
return err
|
||||
}
|
||||
resellerRuntimeStates.delete(owner)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeChainActive(username string) error {
|
||||
seen := make(map[string]bool)
|
||||
now := time.Now()
|
||||
for depth := 0; username != "" && depth < 128; depth++ {
|
||||
if seen[username] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[username] = true
|
||||
state, ok := resellerRuntimeStates.get(username)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller runtime state not found")
|
||||
}
|
||||
if !state.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if state.ExpiresAt != nil && now.After(*state.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
username = strings.TrimSpace(state.ParentUsername)
|
||||
}
|
||||
if username != "" {
|
||||
return fmt.Errorf("reseller hierarchy is too deep")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeStateFor(owner string, effectiveActive bool) (ResellerRuntimeState, error) {
|
||||
u, ok := adminUsers.get(owner)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return ResellerRuntimeState{}, fmt.Errorf("reseller account not found")
|
||||
}
|
||||
return ResellerRuntimeState{
|
||||
OwnerUsername: u.Username,
|
||||
ParentUsername: u.ParentUsername,
|
||||
IsActive: effectiveActive,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// syncOwnerChainToManagedServer makes account creation on a managed node safe:
|
||||
// every parent is installed before the child, and no password/hash is sent.
|
||||
func syncOwnerChainToManagedServer(ctx context.Context, ms *ManagedServer, owner string) error {
|
||||
var chain []*AdminUser
|
||||
seen := make(map[string]bool)
|
||||
for current := strings.TrimSpace(owner); current != ""; {
|
||||
if seen[current] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[current] = true
|
||||
u, ok := adminUsers.get(current)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
chain = append(chain, u)
|
||||
current = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
state, err := resellerRuntimeStateFor(chain[i].Username, adminAccountChainActive(chain[i].Username) == nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := resellerRuntimePayloadFromState(state, "sync")
|
||||
if err := sendResellerRuntimeToServer(ctx, ms, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncAllResellerRuntimeStates repairs legacy managed nodes after an upgrade.
|
||||
// It runs asynchronously and never prevents the local panel from starting.
|
||||
func startManagedResellerStateSync(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller state sync: %v", err)
|
||||
return
|
||||
}
|
||||
users := adminUsers.list()
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
return resellerHierarchyDepth(users[i].Username) < resellerHierarchyDepth(users[j].Username)
|
||||
})
|
||||
for _, ms := range servers {
|
||||
for _, u := range users {
|
||||
if u.Role != RoleReseller {
|
||||
continue
|
||||
}
|
||||
action := "suspend"
|
||||
active := adminAccountChainActive(u.Username) == nil
|
||||
if active {
|
||||
action = "reactivate"
|
||||
}
|
||||
state, stateErr := resellerRuntimeStateFor(u.Username, active)
|
||||
if stateErr != nil {
|
||||
continue
|
||||
}
|
||||
if sendErr := sendResellerRuntimeToServer(ctx, ms, resellerRuntimePayloadFromState(state, action)); sendErr != nil {
|
||||
log.Printf("reseller state sync to %s for %s: %v", ms.Name, u.Username, sendErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reconcileLocalResellerRuntimeStates reapplies replicated ownership state
|
||||
// after a managed node restarts.
|
||||
func reconcileLocalResellerRuntimeStates(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
action := "suspend"
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) == nil {
|
||||
action = "reactivate"
|
||||
}
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, action); err != nil {
|
||||
log.Printf("reconcile local reseller runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resellerHierarchyDepth(username string) int {
|
||||
seen := make(map[string]bool)
|
||||
depth := 0
|
||||
for username != "" && depth < 128 {
|
||||
if seen[username] {
|
||||
return 128
|
||||
}
|
||||
seen[username] = true
|
||||
u, ok := adminUsers.get(username)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
username = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
@@ -50,23 +50,6 @@ func TestManagedServerURLValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteErrorSnippetIsBoundedAndSingleLine(t *testing.T) {
|
||||
input := make([]byte, 5000)
|
||||
for i := range input {
|
||||
input[i] = 'x'
|
||||
}
|
||||
copy(input, []byte("first\nsecond\r\tsecret"))
|
||||
got := remoteErrorSnippet(input)
|
||||
if len(got) > 4100 {
|
||||
t.Fatalf("remote error snippet is too long: %d", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
t.Fatalf("remote error snippet retained control character %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMPSignatureRequiresSecretAndValidHMAC(t *testing.T) {
|
||||
const (
|
||||
secret = "test-secret-with-enough-entropy"
|
||||
|
||||
+9
-17
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,20 +10,6 @@ import (
|
||||
|
||||
const maxAdminRequestBody = 8 << 20
|
||||
|
||||
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func writeBadGatewayError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "managed server request failed", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// securePanelHandler applies baseline browser protections and a global request
|
||||
// body ceiling. Endpoint-specific handlers may impose a smaller limit.
|
||||
func securePanelHandler(next http.Handler) http.Handler {
|
||||
@@ -35,8 +20,15 @@ func securePanelHandler(next http.Handler) http.Handler {
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; img-src 'self' data:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
// The panel is deployed in-place by update.sh. Do not let browsers or
|
||||
// reverse proxies keep an older JavaScript bundle after an update, because
|
||||
// stale form serializers can silently omit newly-added config fields.
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||
r.URL.Path == "/" || r.URL.Path == "/index.html" ||
|
||||
strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestBody)
|
||||
|
||||
+16
-2
@@ -66,7 +66,7 @@ func serverConfigGet(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
data, err := os.ReadFile(globalCfgPath)
|
||||
if err != nil {
|
||||
writeInternalError(w, "read server configuration", err)
|
||||
http.Error(w, "failed to read config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -87,6 +87,17 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
// Keep track of optional field presence separately from its boolean value.
|
||||
// This protects a newly-added setting from being reset by a stale cached
|
||||
// panel bundle that does not know how to send the field yet.
|
||||
var fieldPresence struct {
|
||||
PAMAuthEnabled *bool `json:"pam_auth_enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &fieldPresence); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var newCfg Config
|
||||
if err := json.Unmarshal(body, &newCfg); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -104,6 +115,9 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
globalCfgMu.RLock()
|
||||
if globalCfg != nil {
|
||||
newCfg.Users = globalCfg.Users
|
||||
if fieldPresence.PAMAuthEnabled == nil {
|
||||
newCfg.PAMAuthEnabled = globalCfg.PAMAuthEnabled
|
||||
}
|
||||
}
|
||||
globalCfgMu.RUnlock()
|
||||
|
||||
@@ -115,7 +129,7 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil {
|
||||
writeInternalError(w, "write server configuration", err)
|
||||
http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -78,7 +78,7 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
certDir := filepath.Join(tlsCertsDir, dirName)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
writeInternalError(w, "create TLS certificate directory", err)
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
@@ -86,7 +86,7 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
writeInternalError(w, "generate TLS private key", err)
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
@@ -110,22 +110,22 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
writeInternalError(w, "generate TLS certificate", err)
|
||||
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
privDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
writeInternalError(w, "encode TLS private key", err)
|
||||
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS certificate", err)
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS private key", err)
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
"--agree-tos", "-m", email, "-d", domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
writeInternalError(w, "obtain Let's Encrypt certificate", fmt.Errorf("certbot: %w: %s", err, strings.TrimSpace(string(out))))
|
||||
http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -220,17 +220,17 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
certDir := filepath.Join(tlsCertsDir, name)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
writeInternalError(w, "create uploaded TLS certificate directory", err)
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
keyFile := filepath.Join(certDir, "key.pem")
|
||||
if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS certificate", err)
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS private key", err)
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -53,38 +53,6 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "${GO_SHA256:-}" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -281,7 +249,7 @@ prepare_source_from_git() {
|
||||
}
|
||||
|
||||
install_go_if_needed() {
|
||||
local go_version machine goarch go_url go_expected_sha256 current_go need_go
|
||||
local go_version machine goarch go_url current_go need_go
|
||||
go_version="$(awk '$1 == "go" {print $2; exit}' "$SOURCE_DIR/go.mod" 2>/dev/null || echo "1.22.5")"
|
||||
need_go=true
|
||||
|
||||
@@ -302,16 +270,12 @@ install_go_if_needed() {
|
||||
x86_64) goarch="amd64" ;;
|
||||
aarch64) goarch="arm64" ;;
|
||||
armv7l) goarch="armv6l" ;;
|
||||
*) error "Unsupported CPU architecture: $machine" ;;
|
||||
*) goarch="amd64" ;;
|
||||
esac
|
||||
go_expected_sha256="$(trusted_go_sha256 "$go_version" "$goarch" "$SOURCE_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$go_expected_sha256" ]] || error "No trusted Go checksum for ${go_version}/${goarch}; set GO_SHA256 explicitly"
|
||||
go_url="https://go.dev/dl/go${go_version}.linux-${goarch}.tar.gz"
|
||||
info " Downloading Go ${go_version} (${goarch})..."
|
||||
need_cmd wget
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$go_url"
|
||||
verify_sha256_file "$go_expected_sha256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
@@ -516,6 +480,7 @@ write_sshpanel_systemd_override() {
|
||||
echo "[Service]"
|
||||
echo "Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log"
|
||||
echo "Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}"
|
||||
echo "LimitNOFILE=1048576"
|
||||
echo "ExecStartPre="
|
||||
echo "ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs"
|
||||
echo "ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'"
|
||||
|
||||
+140
-171
@@ -3,9 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,6 +18,9 @@ type XrayClientMeta struct {
|
||||
OwnerUsername string
|
||||
ExpiresAt *time.Time
|
||||
MaxConns int
|
||||
DataQuotaBytes int64
|
||||
QuotaAction string
|
||||
QuotaThrottleMbps int
|
||||
CreatedAt time.Time
|
||||
TotalUplinkBytes int64
|
||||
TotalDownlinkBytes int64
|
||||
@@ -37,6 +38,9 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
owner_username TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
max_conns INT NOT NULL DEFAULT 0,
|
||||
data_quota_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
quota_action TEXT NOT NULL DEFAULT 'block',
|
||||
quota_throttle_mbps INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
total_uplink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
total_downlink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
@@ -44,14 +48,13 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
active_connections INT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS data_quota_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS quota_action TEXT NOT NULL DEFAULT 'block'`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS quota_throttle_mbps INT NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_uplink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS last_active TIMESTAMPTZ`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS active_connections INT NOT NULL DEFAULT 0`,
|
||||
// Keep legacy reseller-owned Xray accounts aligned with weighted quota
|
||||
// accounting. A reseller account always consumes at least one slot.
|
||||
`UPDATE xray_clients SET max_conns = 1
|
||||
WHERE owner_username <> '' AND max_conns < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -67,16 +70,20 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro
|
||||
expiresAt = *m.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns, data_quota_bytes, quota_action, quota_throttle_mbps)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
email = EXCLUDED.email,
|
||||
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END,
|
||||
owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
max_conns = EXCLUDED.max_conns`,
|
||||
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
max_conns = EXCLUDED.max_conns,
|
||||
data_quota_bytes = EXCLUDED.data_quota_bytes,
|
||||
quota_action = EXCLUDED.quota_action,
|
||||
quota_throttle_mbps = EXCLUDED.quota_throttle_mbps`,
|
||||
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns,
|
||||
m.DataQuotaBytes, normalizeQuotaAction(m.QuotaAction), quotaThrottleMbpsOrDefault(m.QuotaThrottleMbps))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -85,10 +92,13 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
|
||||
var expiresAt sql.NullTime
|
||||
var lastActive sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
|
||||
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE uuid = $1`, uuid).
|
||||
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections)
|
||||
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns,
|
||||
&m.DataQuotaBytes, &m.QuotaAction, &m.QuotaThrottleMbps, &m.CreatedAt,
|
||||
&m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,13 +112,22 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
|
||||
}
|
||||
|
||||
func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error {
|
||||
// Serialize deletion with the native stats flusher. Otherwise a batch that
|
||||
// was swapped out just before DELETE could finish afterward and, if the same
|
||||
// UUID is recreated quickly, apply stale traffic/active deltas to the new row.
|
||||
xrayMgr.nativeTrafficPersistMu.Lock()
|
||||
defer xrayMgr.nativeTrafficPersistMu.Unlock()
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM xray_clients WHERE uuid = $1`, uuid)
|
||||
if err == nil {
|
||||
xrayMgr.removeNativeQuotaPolicy(uuid)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
|
||||
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
@@ -120,7 +139,8 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro
|
||||
|
||||
func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
|
||||
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername)
|
||||
if err != nil {
|
||||
@@ -138,7 +158,8 @@ func (s *Store) CountXrayClientsByOwner(ctx context.Context, ownerUsername strin
|
||||
|
||||
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
|
||||
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
|
||||
if err != nil {
|
||||
@@ -154,7 +175,9 @@ func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt sql.NullTime
|
||||
var lastActive sql.NullTime
|
||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections); err != nil {
|
||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns,
|
||||
&m.DataQuotaBytes, &m.QuotaAction, &m.QuotaThrottleMbps, &m.CreatedAt,
|
||||
&m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
@@ -210,19 +233,38 @@ func (s *Store) AddXrayClientTrafficBatch(ctx context.Context, deltas map[string
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateXrayClientActive adjusts the native online connection counter.
|
||||
func (s *Store) UpdateXrayClientActive(ctx context.Context, uuid, email string, delta int) error {
|
||||
if uuid == "" || delta == 0 {
|
||||
// AddXrayClientActiveBatch persists native online-counter deltas without
|
||||
// launching a database goroutine/query for every connect and disconnect.
|
||||
func (s *Store) AddXrayClientActiveBatch(ctx context.Context, deltas map[string]xrayPendingActive) error {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
UPDATE xray_clients SET
|
||||
email = CASE WHEN email = '' AND $2 <> '' THEN $2 ELSE email END,
|
||||
name = CASE WHEN name = '' AND $2 <> '' THEN $2 ELSE name END,
|
||||
last_active = CASE WHEN $3::INT > 0 THEN NOW() ELSE last_active END,
|
||||
last_active = CASE WHEN $4::BOOLEAN THEN NOW() ELSE last_active END,
|
||||
active_connections = GREATEST(active_connections + $3::INT, 0)
|
||||
WHERE uuid = $1`, uuid, email, delta)
|
||||
return err
|
||||
WHERE uuid = $1`)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for uuid, d := range deltas {
|
||||
if uuid == "" || (d.Delta == 0 && !d.Connected) {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, uuid, d.Email, d.Delta, d.Connected); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
@@ -237,41 +279,8 @@ func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername stri
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) SumXrayClientQuotaByOwner(ctx context.Context, ownerUsername string) (int, error) {
|
||||
if s == nil || ownerUsername == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var total int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(GREATEST(max_conns, 1)), 0)
|
||||
FROM xray_clients WHERE owner_username=$1`, ownerUsername).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func countOwnedSSHQuota(ownerUsername string) int {
|
||||
total := 0
|
||||
for _, user := range userMgr.List() {
|
||||
if user.Cfg.OwnerUsername == ownerUsername {
|
||||
total += resellerProvisionCost(user.Cfg.MaxConnections)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedXrayQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return 0
|
||||
}
|
||||
total, err := store.SumXrayClientQuotaByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
log.Printf("sum Xray quota for %s: %v", ownerUsername, err)
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
return countOwnedSSHQuota(ownerUsername) + countOwnedXrayQuota(ctx, store, ownerUsername)
|
||||
return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
|
||||
}
|
||||
|
||||
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
|
||||
@@ -301,35 +310,25 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// suspendOwnerXrayClients removes an owner's clients from the live Xray config
|
||||
// while keeping their metadata. That makes reseller suspension reversible.
|
||||
func suspendOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
// 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 nil
|
||||
return
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
log.Printf("xray owner suspension: list %s: %v", ownerUsername, err)
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.InboundTag == "" || !present[client.InboundTag][client.UUID] {
|
||||
for _, m := range clients {
|
||||
xrayMgr.disconnectNativeClient(m.UUID)
|
||||
if m.InboundTag == "" {
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(client.InboundTag, client.UUID); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
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
|
||||
@@ -337,75 +336,42 @@ func suspendOwnerXrayClients(ctx context.Context, store *Store, ownerUsername st
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("suspend Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreOwnerXrayClients restores metadata-backed clients after a reseller is
|
||||
// reactivated. Existing entries are left untouched, so retries are idempotent.
|
||||
func restoreOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
// restoreOwnerXrayClients reactivates non-expired clients after reseller renewal.
|
||||
func restoreOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
log.Printf("xray owner restore: list %s: %v", ownerUsername, err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.ExpiresAt != nil && time.Now().After(*client.ExpiresAt) {
|
||||
for _, m := range clients {
|
||||
if m.InboundTag == "" || (m.ExpiresAt != nil && !m.ExpiresAt.After(now)) {
|
||||
continue
|
||||
}
|
||||
if client.InboundTag == "" {
|
||||
continue
|
||||
}
|
||||
clientsForInbound, ok := present[client.InboundTag]
|
||||
if !ok {
|
||||
failures = append(failures, client.UUID+": inbound "+client.InboundTag+" no longer exists")
|
||||
continue
|
||||
}
|
||||
if clientsForInbound[client.UUID] {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(client.Email)
|
||||
email := m.Email
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(client.Name)
|
||||
email = m.UUID
|
||||
}
|
||||
if email == "" {
|
||||
email = client.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(client.InboundTag, client.UUID, email); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
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
|
||||
}
|
||||
clientsForInbound[client.UUID] = true
|
||||
xrayMgr.setNativeQuotaPolicy(m)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("restore Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker removes expired clients from the live config.
|
||||
// Reseller-owned metadata is retained so a paid renewal can restore access.
|
||||
// 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) {
|
||||
if store == nil {
|
||||
return
|
||||
@@ -414,50 +380,53 @@ 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
|
||||
present := make(map[string]map[string]bool)
|
||||
if inbounds, listErr := xrayMgr.ListInbounds(); listErr == nil {
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range expired {
|
||||
tag := m.InboundTag
|
||||
if tag == "" {
|
||||
if m.OwnerUsername == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if present[tag][m.UUID] {
|
||||
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 m.OwnerUsername == "" {
|
||||
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 {
|
||||
if s == nil || uuid == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE xray_clients SET
|
||||
total_uplink_bytes = 0,
|
||||
total_downlink_bytes = 0,
|
||||
last_active = NULL
|
||||
WHERE uuid = $1`, uuid)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ func (s *Store) UpsertXrayConfig(ctx context.Context, configKey string, data []b
|
||||
|
||||
func (s *Store) ListXrayClientsByInbound(ctx context.Context, inboundTag string) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
|
||||
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE inbound_tag = $1 ORDER BY created_at DESC`, inboundTag)
|
||||
if err != nil {
|
||||
|
||||
+452
-283
File diff suppressed because it is too large
Load Diff
+97
-43
@@ -138,6 +138,14 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
if s.running {
|
||||
return fmt.Errorf("native xray already running")
|
||||
}
|
||||
beginNativeTransportAccepting()
|
||||
started := false
|
||||
defer func() {
|
||||
if !started {
|
||||
stopNativeTransportAccepting()
|
||||
closeAllNativeTransportConnections()
|
||||
}
|
||||
}()
|
||||
if configFile == "" {
|
||||
return fmt.Errorf("native xray: no config file configured")
|
||||
}
|
||||
@@ -196,9 +204,11 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
}
|
||||
return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err)
|
||||
}
|
||||
serveLn := net.Listener(ln)
|
||||
// Track accepted sockets so stop/reload can close them. VPN transports are
|
||||
// not subject to a global website-style connection ceiling.
|
||||
serveLn := trackNativeListener(ln)
|
||||
if group.security == "tls" {
|
||||
serveLn = tls.NewListener(ln, group.tlsConfig)
|
||||
serveLn = tls.NewListener(serveLn, group.tlsConfig)
|
||||
}
|
||||
opened = append(opened, serveLn)
|
||||
xrayGo(fmt.Sprintf("native xray shared xhttp listener %s", addr), func() { group.serve(serveLn) })
|
||||
@@ -212,21 +222,34 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
s.inboundsByTag = active
|
||||
s.running = true
|
||||
s.startTime = time.Now()
|
||||
started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *nativeXrayServer) stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running && len(s.listeners) == 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
for _, l := range s.listeners {
|
||||
_ = l.Close()
|
||||
stopNativeTransportAccepting()
|
||||
listeners := append([]net.Listener(nil), s.listeners...)
|
||||
inbounds := make([]*nativeInbound, 0, len(s.inboundsByTag))
|
||||
for _, ib := range s.inboundsByTag {
|
||||
inbounds = append(inbounds, ib)
|
||||
}
|
||||
s.listeners = nil
|
||||
s.inboundsByTag = nil
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, l := range listeners {
|
||||
_ = l.Close()
|
||||
}
|
||||
closeAllNativeTransportConnections()
|
||||
for _, ib := range inbounds {
|
||||
ib.closeAllXHTTPSessions()
|
||||
}
|
||||
xrayLogf("native xray: stopped")
|
||||
}
|
||||
|
||||
@@ -241,6 +264,12 @@ func (ib *nativeInbound) acceptLoop(ln net.Listener) {
|
||||
xrayLogf("native xray: accept error on %s: %v", ln.Addr(), err)
|
||||
continue
|
||||
}
|
||||
counted, ok := waitWrapTrackedNativeTransportConn(c)
|
||||
if !ok {
|
||||
time.Sleep(nativeOverloadBackoff)
|
||||
continue
|
||||
}
|
||||
c = counted
|
||||
xrayGo(fmt.Sprintf("native xray connection remote=%s", c.RemoteAddr()), func() { ib.serve(c) })
|
||||
}
|
||||
}
|
||||
@@ -262,7 +291,7 @@ func (ib *nativeInbound) serve(raw net.Conn) {
|
||||
tconn := tls.Server(raw, ib.tlsConfig)
|
||||
_ = tconn.SetDeadline(time.Now().Add(tlsHandshakeTimeout))
|
||||
if err := tconn.Handshake(); err != nil {
|
||||
xrayLogf("native xray: tls handshake from %s failed: %v", raw.RemoteAddr(), err)
|
||||
logNativePreAuthRejection("native xray: tls handshake from %s failed: %v", raw.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
_ = tconn.SetDeadline(time.Time{})
|
||||
@@ -275,11 +304,13 @@ func (ib *nativeInbound) serve(raw net.Conn) {
|
||||
case "tcp", "raw", "":
|
||||
// stream is already the protocol stream
|
||||
case "ws", "websocket":
|
||||
_ = conn.SetDeadline(time.Now().Add(tlsHandshakeTimeout))
|
||||
ws, err := wsServerHandshake(conn, ib.path)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: ws handshake from %s failed: %v", raw.RemoteAddr(), err)
|
||||
logNativePreAuthRejection("native xray: ws handshake from %s failed: %v", raw.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
stream = ws
|
||||
case "xhttp", "splithttp":
|
||||
xrayLogf("native xray: inbound %q got raw connection for XHTTP; this transport is served by http.Server", ib.tag)
|
||||
@@ -331,11 +362,7 @@ const (
|
||||
|
||||
func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray VLESS inbound=%q remote=%s", ib.tag, remote))
|
||||
if ib.isXHTTP() {
|
||||
xrayTracef("native xray: vless handshake start inbound=%q transport=%s remote=%s", ib.tag, ib.transport, remote)
|
||||
} else {
|
||||
xrayLogf("native xray: vless handshake start inbound=%q transport=%s remote=%s", ib.tag, ib.transport, remote)
|
||||
}
|
||||
xrayTracef("native xray: vless handshake start inbound=%q transport=%s remote=%s", ib.tag, ib.transport, remote)
|
||||
_ = stream.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
head := make([]byte, 1+16+1) // version + uuid + addonLen
|
||||
@@ -349,7 +376,11 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
|
||||
|
||||
client := ib.getNativeClient(id)
|
||||
if client == nil {
|
||||
xrayLogf("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
|
||||
}
|
||||
if reason := xrayMgr.nativeClientAccessDenied(client.uuid); reason != "" {
|
||||
xrayLogf("native xray: inbound %q rejected VLESS user %s: %s", ib.tag, client.email, reason)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -396,6 +427,18 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
|
||||
}
|
||||
_ = stream.SetReadDeadline(time.Time{})
|
||||
|
||||
switch cmd[0] {
|
||||
case vlessCmdTCP, vlessCmdUDP, vlessCmdMux:
|
||||
default:
|
||||
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, stream)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer releaseConnection()
|
||||
|
||||
// VLESS response header must be sent before relaying payload. CommandMux is
|
||||
// special: official Xray does not read a target from the VLESS header for it;
|
||||
// the following bytes are Mux.Cool/XUDP frames. Reading port/address here
|
||||
@@ -413,7 +456,7 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
|
||||
return
|
||||
}
|
||||
ib.nativeSuccessLogf("native xray: vless/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeTunnel(stream, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
nativeTunnel(stream, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
|
||||
case vlessCmdUDP:
|
||||
backend, target, err := ib.nativeDialUDP(host, port)
|
||||
if err != nil {
|
||||
@@ -421,12 +464,10 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
|
||||
return
|
||||
}
|
||||
ib.nativeSuccessLogf("native xray: vless/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeVLESSUDPTunnel(stream, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
nativeVLESSUDPTunnel(stream, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
|
||||
case vlessCmdMux:
|
||||
ib.nativeSuccessLogf("native xray: vless/mux user=%s remote=%s (inbound %q)", client.email, remote, ib.tag)
|
||||
ib.nativeVLESSMuxTunnel(stream, client.uuid, client.email)
|
||||
default:
|
||||
xrayLogf("native xray: inbound %q VLESS command %d not supported yet", ib.tag, cmd[0])
|
||||
ib.nativeVLESSMuxTunnel(stream, client.uuid, client.email, quotaState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +481,7 @@ func (ib *nativeInbound) logVLESSReadFailure(stage string, remote net.Addr, emai
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
xrayLogf("native xray: vless %s failed inbound=%q transport=%s remote=%s: %v", stage, ib.tag, ib.transport, remote, err)
|
||||
logNativePreAuthRejection("native xray: vless %s failed inbound=%q transport=%s remote=%s: %v", stage, ib.tag, ib.transport, remote, err)
|
||||
} else {
|
||||
xrayLogf("native xray: vless %s failed inbound=%q transport=%s user=%s remote=%s: %v", stage, ib.tag, ib.transport, email, remote, err)
|
||||
}
|
||||
@@ -657,18 +698,18 @@ func normalizeNativeTargetHost(raw string) string {
|
||||
// backend, applying per-direction rate limits and accounting traffic against
|
||||
// the client's email so the panel's online detection keeps working. It mirrors
|
||||
// handleDirectTCPIP in main.go.
|
||||
func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, up, down *rate.Limiter) {
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, quotaState *xrayNativeQuotaState, up, down *rate.Limiter) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray TCP tunnel user=%s", email))
|
||||
|
||||
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
|
||||
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
|
||||
upMeter := newTrafficMeter(uuid, email, true, quotaState)
|
||||
downMeter := newTrafficMeter(uuid, email, false, quotaState)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var closeOnce sync.Once
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
closeAll := func() {
|
||||
closeOnce.Do(func() {
|
||||
cancel()
|
||||
_ = backend.Close()
|
||||
_ = client.Close()
|
||||
})
|
||||
@@ -678,7 +719,7 @@ func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email strin
|
||||
xrayGo("native xray TCP uplink", func() { // client -> backend
|
||||
defer wg.Done()
|
||||
defer closeAll()
|
||||
_, _ = copyWithRateLimit(meteredWriter{w: backend, meter: upMeter}, client, up)
|
||||
_, _ = copyWithRateLimitContext(ctx, xrayQuotaMeteredWriter{w: backend, meter: upMeter, ctx: ctx}, client, up)
|
||||
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
@@ -688,7 +729,7 @@ func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email strin
|
||||
xrayGo("native xray TCP downlink", func() { // backend -> client
|
||||
defer wg.Done()
|
||||
defer closeAll()
|
||||
_, _ = copyWithRateLimit(meteredWriter{w: client, meter: downMeter}, backend, down)
|
||||
_, _ = copyWithRateLimitContext(ctx, xrayQuotaMeteredWriter{w: client, meter: downMeter, ctx: ctx}, backend, down)
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
@@ -700,15 +741,24 @@ func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email strin
|
||||
// trafficMeter accumulates bytes for one direction and flushes them to the
|
||||
// stats manager in batches to avoid locking on every write.
|
||||
type trafficMeter struct {
|
||||
uuid string
|
||||
email string
|
||||
uplink bool
|
||||
n int64
|
||||
uuid string
|
||||
email string
|
||||
uplink bool
|
||||
n int64
|
||||
quotaGeneration uint64
|
||||
state *xrayNativeQuotaState
|
||||
}
|
||||
|
||||
const trafficFlushThreshold = 1024 * 1024
|
||||
|
||||
func newTrafficMeter(uuid, email string, uplink bool, state *xrayNativeQuotaState) *trafficMeter {
|
||||
t := &trafficMeter{uuid: uuid, email: email, uplink: uplink, state: state}
|
||||
t.syncQuotaGeneration()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *trafficMeter) add(n int) {
|
||||
t.syncQuotaGeneration()
|
||||
t.n += int64(n)
|
||||
if t.n >= trafficFlushThreshold {
|
||||
t.flush()
|
||||
@@ -716,29 +766,33 @@ func (t *trafficMeter) add(n int) {
|
||||
}
|
||||
|
||||
func (t *trafficMeter) flush() {
|
||||
t.syncQuotaGeneration()
|
||||
if t.n == 0 || t.email == "" {
|
||||
return
|
||||
}
|
||||
if t.uplink {
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, t.n, 0)
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, t.n, 0, t.quotaGeneration, t.state)
|
||||
} else {
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n)
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n, t.quotaGeneration, t.state)
|
||||
}
|
||||
t.n = 0
|
||||
}
|
||||
|
||||
// meteredWriter counts bytes as they are written through to the wrapped writer.
|
||||
type meteredWriter struct {
|
||||
w io.Writer
|
||||
meter *trafficMeter
|
||||
}
|
||||
|
||||
func (mw meteredWriter) Write(p []byte) (int, error) {
|
||||
n, err := mw.w.Write(p)
|
||||
if n > 0 {
|
||||
mw.meter.add(n)
|
||||
func (t *trafficMeter) syncQuotaGeneration() {
|
||||
var generation uint64
|
||||
if t.state != nil {
|
||||
t.state.mu.Lock()
|
||||
generation = t.state.generation
|
||||
t.state.mu.Unlock()
|
||||
}
|
||||
if t.quotaGeneration == 0 {
|
||||
t.quotaGeneration = generation
|
||||
return
|
||||
}
|
||||
if generation != t.quotaGeneration {
|
||||
t.n = 0
|
||||
t.quotaGeneration = generation
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) upLimiter() *rate.Limiter { return newByteLimiter(ib.upBytesPerSec) }
|
||||
|
||||
+184
-32
@@ -54,7 +54,11 @@ type nativeMuxUplinkItem struct {
|
||||
port uint16
|
||||
}
|
||||
|
||||
const nativeMuxUplinkQueue = 64
|
||||
const (
|
||||
nativeMuxUplinkQueue = 16
|
||||
nativeMuxMaxBufferedBytesPerSession = 1 * 1024 * 1024
|
||||
nativeMuxMaxBufferedBytesGlobal = 128 * 1024 * 1024
|
||||
)
|
||||
|
||||
var nativeMuxFramePool = sync.Pool{
|
||||
New: func() any {
|
||||
@@ -91,6 +95,11 @@ type nativeMuxSession struct {
|
||||
uplink chan nativeMuxUplinkItem
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
finishOnce sync.Once
|
||||
enqueueMu sync.Mutex
|
||||
enqueueWG sync.WaitGroup
|
||||
enqueueDone bool
|
||||
buffered atomic.Int64
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
onClose func(*nativeMuxSession)
|
||||
@@ -98,7 +107,11 @@ type nativeMuxSession struct {
|
||||
globalID [8]byte
|
||||
}
|
||||
|
||||
var nativeMuxGlobalActive atomic.Int64
|
||||
var (
|
||||
nativeMuxGlobalActive atomic.Int64
|
||||
nativeMuxBufferedBytes atomic.Int64
|
||||
nativeMuxBufferRejected atomic.Int64
|
||||
)
|
||||
|
||||
func acquireNativeMuxGlobalSlot() (func(), bool) {
|
||||
limit := int64(nativeMuxGlobalSessionLimit())
|
||||
@@ -117,15 +130,73 @@ func acquireNativeMuxGlobalSlot() (func(), bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func reserveNativeMuxBufferedBytes(s *nativeMuxSession, n int64) bool {
|
||||
if s == nil || n <= 0 {
|
||||
return true
|
||||
}
|
||||
for {
|
||||
current := s.buffered.Load()
|
||||
if current > nativeMuxMaxBufferedBytesPerSession-n {
|
||||
logNativeLimitRejection("mux session buffered bytes", &nativeMuxBufferRejected, nativeMuxMaxBufferedBytesPerSession)
|
||||
return false
|
||||
}
|
||||
if s.buffered.CompareAndSwap(current, current+n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
current := nativeMuxBufferedBytes.Load()
|
||||
if current > nativeMuxMaxBufferedBytesGlobal-n {
|
||||
s.buffered.Add(-n)
|
||||
logNativeLimitRejection("mux global buffered bytes", &nativeMuxBufferRejected, nativeMuxMaxBufferedBytesGlobal)
|
||||
return false
|
||||
}
|
||||
if nativeMuxBufferedBytes.CompareAndSwap(current, current+n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func releaseNativeMuxBufferedBytes(s *nativeMuxSession, n int64) {
|
||||
if s == nil || n <= 0 {
|
||||
return
|
||||
}
|
||||
for {
|
||||
current := s.buffered.Load()
|
||||
release := n
|
||||
if release > current {
|
||||
release = current
|
||||
}
|
||||
if s.buffered.CompareAndSwap(current, current-release) {
|
||||
releaseNativeAtomicBytes(&nativeMuxBufferedBytes, release)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func releaseNativeAtomicBytes(counter *atomic.Int64, n int64) {
|
||||
if counter == nil || n <= 0 {
|
||||
return
|
||||
}
|
||||
for {
|
||||
current := counter.Load()
|
||||
next := current - n
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
if counter.CompareAndSwap(current, next) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nativeVLESSMuxTunnel implements the server side of Xray's Mux.Cool framing
|
||||
// for VLESS CommandMux. CommandMux does not carry a VLESS target address; every
|
||||
// child TCP/UDP request is described by mux frame metadata. UDP is treated as a
|
||||
// packet protocol, not as a byte stream, and XUDP-style GlobalID/endpoint
|
||||
// metadata is accepted for full-cone friendly clients.
|
||||
func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, email string) {
|
||||
func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, email string, quotaState *xrayNativeQuotaState) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray VLESS mux user=%s", email))
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
|
||||
writeMu := &sync.Mutex{}
|
||||
sessions := make(map[uint16]*nativeMuxSession)
|
||||
@@ -256,7 +327,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
|
||||
}
|
||||
}
|
||||
|
||||
s, target, err := ib.newNativeMuxSession(meta.sessionID, meta.network, targetHost, targetPort, isXUDP, meta.globalID, stream, writeMu, uuid, email, removeSession)
|
||||
s, target, err := ib.newNativeMuxSession(meta.sessionID, meta.network, targetHost, targetPort, isXUDP, meta.globalID, stream, writeMu, uuid, email, quotaState, removeSession)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux session %s setup failed: %v", target, err)
|
||||
writeMu.Lock()
|
||||
@@ -281,8 +352,11 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
|
||||
xrayTracef("native xray: vless/mux %s user=%s -> %s session=%d xudp=%v", nativeMuxNetworkName(meta.network), email, target, meta.sessionID, isXUDP)
|
||||
ib2, host2, port2 := ib, targetHost, targetPort
|
||||
xrayGo(fmt.Sprintf("native xray mux session=%d", s.id), func() { s.run(ib2, host2, port2) })
|
||||
if len(pkt.payload) > 0 {
|
||||
s.enqueueUplink(pkt.payload, pkt.host, pkt.port)
|
||||
if len(pkt.payload) > 0 && !s.enqueueUplink(pkt.payload, pkt.host, pkt.port) {
|
||||
closeSession(s.id)
|
||||
writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(stream, meta.sessionID, true)
|
||||
writeMu.Unlock()
|
||||
}
|
||||
|
||||
case nativeMuxStatusKeep:
|
||||
@@ -319,8 +393,11 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
|
||||
pkt.host = meta.host
|
||||
pkt.port = meta.port
|
||||
}
|
||||
if len(pkt.payload) > 0 {
|
||||
s.enqueueUplink(pkt.payload, pkt.host, pkt.port)
|
||||
if len(pkt.payload) > 0 && !s.enqueueUplink(pkt.payload, pkt.host, pkt.port) {
|
||||
closeSession(s.id)
|
||||
writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(stream, meta.sessionID, true)
|
||||
writeMu.Unlock()
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -332,7 +409,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) {
|
||||
func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, quotaState *xrayNativeQuotaState, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) {
|
||||
target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port)))
|
||||
if invalidNativeDestination(host, port) {
|
||||
return nil, target, fmt.Errorf("invalid destination")
|
||||
@@ -351,8 +428,8 @@ func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host strin
|
||||
email: email,
|
||||
upLimiter: ib.upLimiter(),
|
||||
downLimiter: ib.downLimiter(),
|
||||
upMeter: &trafficMeter{uuid: uuid, email: email, uplink: true},
|
||||
downMeter: &trafficMeter{uuid: uuid, email: email, uplink: false},
|
||||
upMeter: newTrafficMeter(uuid, email, true, quotaState),
|
||||
downMeter: newTrafficMeter(uuid, email, false, quotaState),
|
||||
uplink: make(chan nativeMuxUplinkItem, nativeMuxUplinkQueue),
|
||||
closed: make(chan struct{}),
|
||||
onClose: onClose,
|
||||
@@ -365,6 +442,7 @@ func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host strin
|
||||
|
||||
func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray mux run session=%d", s.id))
|
||||
defer s.finish()
|
||||
|
||||
select {
|
||||
case <-s.closed:
|
||||
@@ -410,24 +488,61 @@ func (s *nativeMuxSession) failInit(notifyClient bool) {
|
||||
_ = writeNativeMuxEnd(s.client, s.id, true)
|
||||
s.writeMu.Unlock()
|
||||
}
|
||||
if s.onClose != nil {
|
||||
s.onClose(s)
|
||||
}
|
||||
s.closeBackend()
|
||||
s.finish()
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) {
|
||||
// finish is the single lifecycle exit for a mux child. The backend reader,
|
||||
// uplink loop, parent mux stream, and initialization path can all detect the
|
||||
// terminal condition concurrently, so both cleanup and map removal must be
|
||||
// exactly-once operations.
|
||||
func (s *nativeMuxSession) finish() {
|
||||
s.finishOnce.Do(func() {
|
||||
s.closeBackend()
|
||||
if s.onClose != nil {
|
||||
s.onClose(s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) beginEnqueue() bool {
|
||||
s.enqueueMu.Lock()
|
||||
defer s.enqueueMu.Unlock()
|
||||
if s.enqueueDone {
|
||||
return false
|
||||
}
|
||||
s.enqueueWG.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) bool {
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
return true
|
||||
}
|
||||
if !s.beginEnqueue() {
|
||||
return false
|
||||
}
|
||||
defer s.enqueueWG.Done()
|
||||
|
||||
bytes := int64(len(payload))
|
||||
if !reserveNativeMuxBufferedBytes(s, bytes) {
|
||||
return false
|
||||
}
|
||||
cp := make([]byte, len(payload))
|
||||
copy(cp, payload)
|
||||
select {
|
||||
case s.uplink <- nativeMuxUplinkItem{payload: cp, host: host, port: port}:
|
||||
return true
|
||||
case <-s.closed:
|
||||
releaseNativeMuxBufferedBytes(s, bytes)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) processUplinkItem(item nativeMuxUplinkItem) bool {
|
||||
defer releaseNativeMuxBufferedBytes(s, int64(len(item.payload)))
|
||||
return s.writeBackendItem(item)
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) uplinkLoop() {
|
||||
defer s.upMeter.flush()
|
||||
for {
|
||||
@@ -435,7 +550,7 @@ func (s *nativeMuxSession) uplinkLoop() {
|
||||
case <-s.closed:
|
||||
return
|
||||
case item := <-s.uplink:
|
||||
if !s.writeBackendItem(item) {
|
||||
if !s.processUplinkItem(item) {
|
||||
s.closeBackend()
|
||||
return
|
||||
}
|
||||
@@ -481,6 +596,13 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
quotaReservation, quotaErr := reserveNativePacketQuota(s.upMeter, len(payload))
|
||||
if quotaErr != nil {
|
||||
return false
|
||||
}
|
||||
if err := quotaReservation.wait(s.ctx); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var n int
|
||||
var err error
|
||||
@@ -492,6 +614,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
if isNativeDNSSinkTarget(item.host) || invalidNativeDestination(item.host, item.port) {
|
||||
// AdGuard/blocked endpoints must be ignored at the cheapest possible
|
||||
// point. Do not resolve, dial, log loudly, or keep the mux child busy.
|
||||
quotaReservation.finish(0)
|
||||
xrayTracef("native xray: VLESS mux UDP fast-ignored override sink session=%d target=%s:%d", s.id, item.host, item.port)
|
||||
return true
|
||||
}
|
||||
@@ -503,6 +626,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
s.lastUDPPort = item.port
|
||||
s.lastUDPAddr = addr
|
||||
} else {
|
||||
quotaReservation.finish(0)
|
||||
xrayTracef("native xray: VLESS mux UDP override resolve failed session=%d target=%s:%d: %v", s.id, item.host, item.port, rerr)
|
||||
return true
|
||||
}
|
||||
@@ -512,9 +636,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
if s.network == nativeMuxNetworkUDP && err == nil {
|
||||
_ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout()))
|
||||
}
|
||||
if n > 0 {
|
||||
s.upMeter.add(n)
|
||||
}
|
||||
quotaReservation.finish(n)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux backend write failed session=%d: %v", s.id, err)
|
||||
return false
|
||||
@@ -532,10 +654,7 @@ func (s *nativeMuxSession) readBackendLoop() {
|
||||
_ = writeNativeMuxEnd(s.client, s.id, false)
|
||||
s.writeMu.Unlock()
|
||||
}
|
||||
if s.onClose != nil {
|
||||
s.onClose(s)
|
||||
}
|
||||
s.closeBackend()
|
||||
s.finish()
|
||||
}()
|
||||
|
||||
if s.network == nativeMuxNetworkTCP {
|
||||
@@ -571,14 +690,22 @@ func (s *nativeMuxSession) readTCPBackendLoop() {
|
||||
if err := s.waitDownRate(n); err != nil {
|
||||
return
|
||||
}
|
||||
s.downMeter.add(n)
|
||||
quotaReservation, quotaErr := reserveNativePacketQuota(s.downMeter, n)
|
||||
if quotaErr != nil {
|
||||
return
|
||||
}
|
||||
if err := quotaReservation.wait(s.ctx); err != nil {
|
||||
return
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
werr := writeNativeMuxData(s.client, s.id, nativeMuxStatusKeep, buf[:n])
|
||||
s.writeMu.Unlock()
|
||||
if werr != nil {
|
||||
quotaReservation.finish(0)
|
||||
xrayLogf("native xray: VLESS mux TCP client write failed session=%d: %v", s.id, werr)
|
||||
return
|
||||
}
|
||||
quotaReservation.finish(n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +729,13 @@ func (s *nativeMuxSession) readUDPBackendLoop() bool {
|
||||
if err := s.waitDownRate(n); err != nil {
|
||||
return true
|
||||
}
|
||||
s.downMeter.add(n)
|
||||
quotaReservation, quotaErr := reserveNativePacketQuota(s.downMeter, n)
|
||||
if quotaErr != nil {
|
||||
return true
|
||||
}
|
||||
if err := quotaReservation.wait(s.ctx); err != nil {
|
||||
return true
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
// Include the UDP source endpoint on XUDP responses so clients that rely on
|
||||
// full-cone packet addressing can associate the datagram with the correct
|
||||
@@ -610,27 +743,46 @@ func (s *nativeMuxSession) readUDPBackendLoop() bool {
|
||||
werr := writeNativeMuxPacketData(s.client, s.id, nativeMuxStatusKeep, buf[:n], addr, s.xudp)
|
||||
s.writeMu.Unlock()
|
||||
if werr != nil {
|
||||
quotaReservation.finish(0)
|
||||
xrayLogf("native xray: VLESS mux UDP client write failed session=%d: %v", s.id, werr)
|
||||
return true
|
||||
}
|
||||
quotaReservation.finish(n)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) closeBackend() {
|
||||
s.closeOnce.Do(func() {
|
||||
s.enqueueMu.Lock()
|
||||
s.enqueueDone = true
|
||||
close(s.closed)
|
||||
s.enqueueMu.Unlock()
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
if s.releaseSlot != nil {
|
||||
s.releaseSlot()
|
||||
}
|
||||
if s.tcp != nil {
|
||||
_ = s.tcp.Close()
|
||||
}
|
||||
if s.udp != nil {
|
||||
_ = s.udp.Close()
|
||||
}
|
||||
|
||||
// Wait for producers that passed beginEnqueue before the close flag, then
|
||||
// discard any payloads the consumer did not take. This returns every byte
|
||||
// reservation even when shutdown races a full queue.
|
||||
s.enqueueWG.Wait()
|
||||
for {
|
||||
select {
|
||||
case item := <-s.uplink:
|
||||
releaseNativeMuxBufferedBytes(s, int64(len(item.payload)))
|
||||
item.payload = nil
|
||||
default:
|
||||
if s.releaseSlot != nil {
|
||||
s.releaseSlot()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+257
-1
@@ -1,6 +1,14 @@
|
||||
package main
|
||||
|
||||
import "runtime/debug"
|
||||
import (
|
||||
"net"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const nativeOverloadBackoff = 10 * time.Millisecond
|
||||
|
||||
// xrayRecover prevents a bad client packet, closed HTTP stream, or mux/session
|
||||
// race from taking down the whole sshpanel process. A panic should only kill the
|
||||
@@ -18,3 +26,251 @@ func xrayGo(where string, fn func()) {
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Direct native-inbound tests and embedders may run an accept loop without
|
||||
// the singleton server start method. Production stop() flips this to false.
|
||||
nativeTransportAccepting.Store(true)
|
||||
}
|
||||
|
||||
var (
|
||||
nativeTransportConnections atomic.Int64
|
||||
nativeXHTTPSessions atomic.Int64
|
||||
nativeXHTTPRequests atomic.Int64
|
||||
nativeClientConnsRejected atomic.Int64
|
||||
nativePreAuthRejected atomic.Int64
|
||||
nativeXHTTPRejected atomic.Int64
|
||||
|
||||
nativeTransportAccepting atomic.Bool
|
||||
nativeTransportRegistry = struct {
|
||||
sync.Mutex
|
||||
conns map[*nativeCountedConn]struct{}
|
||||
}{conns: make(map[*nativeCountedConn]struct{})}
|
||||
)
|
||||
|
||||
// acquireNativeCounter tracks a counted resource and returns an exactly-once
|
||||
// release function. Limits here are simultaneous resource-safety windows, not
|
||||
// traffic-volume or request-rate ceilings.
|
||||
func acquireNativeCounter(active *atomic.Int64, limit int) (func(), bool) {
|
||||
for {
|
||||
current := active.Load()
|
||||
if limit > 0 && current >= int64(limit) {
|
||||
return nil, false
|
||||
}
|
||||
if active.CompareAndSwap(current, current+1) {
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
if active.Add(-1) < 0 {
|
||||
active.Store(0)
|
||||
}
|
||||
})
|
||||
}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldLogNativeSample(counter *atomic.Int64) (n int64, ok bool) {
|
||||
n = counter.Add(1)
|
||||
// Keep attacks visible without allowing logging itself to become a CPU/disk
|
||||
// amplifier. The first event and one event per 1024 repetitions are logged.
|
||||
return n, n == 1 || n%1024 == 0
|
||||
}
|
||||
|
||||
func logNativeLimitRejection(kind string, rejected *atomic.Int64, limit int) {
|
||||
n, ok := shouldLogNativeSample(rejected)
|
||||
if ok {
|
||||
xrayLogf("native xray: rejected %s at safety limit=%d (rejected=%d)", kind, limit, n)
|
||||
}
|
||||
}
|
||||
|
||||
func logNativeClientLimitRejection(email string, limit int) {
|
||||
n, ok := shouldLogNativeSample(&nativeClientConnsRejected)
|
||||
if ok {
|
||||
xrayLogf("native xray: rejected authenticated user %s at max_conns=%d (rejected=%d)", email, limit, n)
|
||||
}
|
||||
}
|
||||
|
||||
func logNativePreAuthRejection(format string, args ...interface{}) {
|
||||
if _, ok := shouldLogNativeSample(&nativePreAuthRejected); ok {
|
||||
xrayLogf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func acquireNativeTransportConnection() (func(), bool) {
|
||||
return acquireNativeCounter(&nativeTransportConnections, nativeTransportConnectionLimit())
|
||||
}
|
||||
|
||||
func acquireNativeXHTTPSession() (func(), bool) {
|
||||
return acquireNativeCounter(&nativeXHTTPSessions, nativeXHTTPSessionLimit())
|
||||
}
|
||||
|
||||
func acquireNativeXHTTPRequest() (func(), bool) {
|
||||
return acquireNativeCounter(&nativeXHTTPRequests, nativeXHTTPRequestLimit())
|
||||
}
|
||||
|
||||
func configureNativeTransportSocket(c net.Conn) {
|
||||
if tc, ok := c.(*net.TCPConn); ok {
|
||||
_ = tc.SetKeepAlive(true)
|
||||
_ = tc.SetKeepAlivePeriod(30 * time.Second)
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
}
|
||||
|
||||
// nativeCountedConn releases its global transport slot and unregisters itself
|
||||
// exactly once, even when several tunnel paths race to close the same socket.
|
||||
type nativeCountedConn struct {
|
||||
net.Conn
|
||||
release func()
|
||||
onClose func()
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (c *nativeCountedConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
c.closeErr = c.Conn.Close()
|
||||
if c.release != nil {
|
||||
c.release()
|
||||
}
|
||||
if c.onClose != nil {
|
||||
c.onClose()
|
||||
}
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
// wrapNativeTransportConn applies only the global counter. It is useful for
|
||||
// focused tests and for callers that own connection lifetime themselves.
|
||||
func wrapNativeTransportConn(c net.Conn) (net.Conn, bool) {
|
||||
if c == nil {
|
||||
return nil, false
|
||||
}
|
||||
configureNativeTransportSocket(c)
|
||||
release, ok := acquireNativeTransportConnection()
|
||||
if !ok {
|
||||
_ = c.Close()
|
||||
return nil, false
|
||||
}
|
||||
return &nativeCountedConn{Conn: c, release: release}, true
|
||||
}
|
||||
|
||||
// wrapTrackedNativeTransportConn additionally registers the accepted socket so
|
||||
// stopping/restarting native Xray closes established raw, WebSocket, TLS, HTTP/1
|
||||
// and HTTP/2 transports instead of leaving tunnel goroutines alive.
|
||||
func wrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
|
||||
if c == nil {
|
||||
return nil, false
|
||||
}
|
||||
configureNativeTransportSocket(c)
|
||||
if !nativeTransportAccepting.Load() {
|
||||
_ = c.Close()
|
||||
return nil, false
|
||||
}
|
||||
release, ok := acquireNativeTransportConnection()
|
||||
if !ok {
|
||||
_ = c.Close()
|
||||
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.onClose = func() {
|
||||
nativeTransportRegistry.Lock()
|
||||
delete(nativeTransportRegistry.conns, counted)
|
||||
nativeTransportRegistry.Unlock()
|
||||
}
|
||||
|
||||
nativeTransportRegistry.Lock()
|
||||
if !nativeTransportAccepting.Load() {
|
||||
nativeTransportRegistry.Unlock()
|
||||
_ = counted.Close()
|
||||
return nil, false
|
||||
}
|
||||
nativeTransportRegistry.conns[counted] = struct{}{}
|
||||
nativeTransportRegistry.Unlock()
|
||||
return counted, true
|
||||
}
|
||||
|
||||
// waitWrapTrackedNativeTransportConn is used by raw native accept loops. Waiting
|
||||
// here, before another connection is admitted to the protocol handler, applies
|
||||
// socket/kernel backpressure instead of creating an unbounded goroutine backlog.
|
||||
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() {
|
||||
nativeTransportAccepting.Store(true)
|
||||
}
|
||||
|
||||
func stopNativeTransportAccepting() {
|
||||
nativeTransportAccepting.Store(false)
|
||||
}
|
||||
|
||||
func closeAllNativeTransportConnections() {
|
||||
nativeTransportRegistry.Lock()
|
||||
conns := make([]*nativeCountedConn, 0, len(nativeTransportRegistry.conns))
|
||||
for c := range nativeTransportRegistry.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
nativeTransportRegistry.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// nativeTrackingListener registers every accepted XHTTP socket so a live
|
||||
// stop/reload can close it. It reserves capacity before Accept so overload stays
|
||||
// in the kernel accept queue rather than allocating more Go handlers.
|
||||
type nativeTrackingListener struct {
|
||||
net.Listener
|
||||
}
|
||||
|
||||
func (l nativeTrackingListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
release, ok := acquireNativeTransportConnection()
|
||||
if !ok {
|
||||
// Unlimited admission can only fail if this implementation changes. Avoid
|
||||
// accepting and resetting a socket if that ever happens.
|
||||
time.Sleep(nativeOverloadBackoff)
|
||||
continue
|
||||
}
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
release()
|
||||
return nil, err
|
||||
}
|
||||
configureNativeTransportSocket(c)
|
||||
if counted, ok := registerTrackedNativeTransportConn(c, release); ok {
|
||||
return counted, nil
|
||||
}
|
||||
// Shutdown may race Accept. Registration closes the socket and releases the
|
||||
// slot; the next Accept observes the listener close.
|
||||
time.Sleep(nativeOverloadBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
func trackNativeListener(ln net.Listener) net.Listener {
|
||||
if ln == nil {
|
||||
return nil
|
||||
}
|
||||
return nativeTrackingListener{Listener: ln}
|
||||
}
|
||||
|
||||
+63
-13
@@ -7,29 +7,65 @@ import (
|
||||
)
|
||||
|
||||
type XrayNativeTuning struct {
|
||||
RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"`
|
||||
MuxGlobalSessions int `json:"mux_global_sessions,omitempty"`
|
||||
TracePackets bool `json:"trace_packets,omitempty"`
|
||||
RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"`
|
||||
MuxGlobalSessions int `json:"mux_global_sessions,omitempty"`
|
||||
MaxConcurrentConnections int `json:"max_concurrent_connections,omitempty"`
|
||||
MaxConcurrentXHTTPRequests int `json:"max_concurrent_xhttp_requests,omitempty"`
|
||||
XHTTPMaxSessions int `json:"xhttp_max_sessions,omitempty"`
|
||||
TracePackets bool `json:"trace_packets,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultNativeRuntimeGOMAXPROCS = 0
|
||||
defaultNativeMuxGlobalSessions = 32768
|
||||
// Transport sockets, XHTTP requests, and XHTTP sessions are VPN traffic, not
|
||||
// website requests. Keep the legacy JSON fields for config compatibility, but
|
||||
// always normalize them to unlimited. Actual resource protection is provided by
|
||||
// socket/HTTP flow control and the bounded byte queues in xray_xhttp.go.
|
||||
defaultNativeMaxConnections = -1
|
||||
defaultNativeMaxXHTTPRequests = -1
|
||||
|
||||
fixedNativeMuxMaxSessions = 128
|
||||
fixedNativeMuxMaxSessions = 64
|
||||
fixedNativeMuxUDPIdleMS = 120000
|
||||
fixedNativeMuxUDPReadBuffer = 256 * 1024
|
||||
fixedNativeMuxUDPWriteBuffer = 256 * 1024
|
||||
|
||||
defaultNativeXHTTPMaxSessions = 16384
|
||||
defaultNativeXHTTPBufferedPosts = 512
|
||||
defaultNativeXHTTPMaxSessions = -1
|
||||
// Packet-up posts are also protected by byte budgets in xray_xhttp.go. Keep
|
||||
// the default reorder queue modest so thousands of unauthenticated sessions
|
||||
// cannot consume large amounts of memory merely by allocating empty channel
|
||||
// buffers. Operators may request more, up to the hard cap enforced there.
|
||||
defaultNativeXHTTPBufferedPosts = 64
|
||||
|
||||
// Do not impose an application-level lifetime on a connected XHTTP VPN
|
||||
// session. The official Xray server keeps a connected session for the
|
||||
// lifetime of its stream-down GET; request cancellation and I/O errors own
|
||||
// cleanup. A fixed five-minute sweeper incorrectly killed healthy but idle
|
||||
// VPNs. Zero disables the connected-session sweeper.
|
||||
fixedNativeXHTTPIdleMS = 0
|
||||
// These are simultaneous resource-safety windows, not request-rate limits.
|
||||
// They are deliberately far above the expected 6-8K connected-user load, but
|
||||
// finite so a reconnect storm, broken CDN, or hostile client cannot retain an
|
||||
// unbounded number of sockets, HTTP handlers, sessions, and goroutine stacks.
|
||||
// Transport Accept waits at capacity (kernel backpressure); XHTTP overloads
|
||||
// receive 503 rather than the web-rate-limit semantics of 429.
|
||||
fixedNativeMaxTransportConnections = 65536
|
||||
fixedNativeMaxXHTTPRequests = 65536
|
||||
fixedNativeMaxXHTTPSessions = 65536
|
||||
fixedNativeHTTP2ConcurrentStreams = 4096
|
||||
fixedNativeXHTTPWriteTimeoutMS = 60 * 1000
|
||||
|
||||
// Backstop reaper for connected XHTTP VPN sessions. The stream-down GET's
|
||||
// request context is the primary lifetime owner, but behind a CDN that context
|
||||
// frequently never fires when a client silently drops (mobile networks, CDN
|
||||
// connection pooling, half-open TCP). When it doesn't, an idle SSH backend
|
||||
// never errors either, so the session, its goroutines, socket/fd, and SSH
|
||||
// connection leak until the whole process restarts. That accumulation is what
|
||||
// drove the recurring XHTTP 502s that only a reboot cleared: the origin slowly
|
||||
// ran out of fds/memory and could no longer serve new stream-down GETs.
|
||||
//
|
||||
// This sweeper only ever reaps sessions with genuinely stale lastSeen. lastSeen
|
||||
// is refreshed on every successful read OR write via nativeXHTTPConn.onActivity,
|
||||
// so any tunnel still passing data or keepalives is never touched -- only a
|
||||
// session with zero bytes in BOTH directions for the full window (i.e. one that
|
||||
// looks dead) is closed. 20 minutes is generous enough not to disturb a
|
||||
// genuinely idle-but-alive tunnel while still bounding resource growth under
|
||||
// heavy 6-8K-user churn. Zero disables the connected-session sweeper.
|
||||
fixedNativeXHTTPIdleMS = 20 * 60 * 1000
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -53,6 +89,12 @@ func normalizeNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
|
||||
if out.MuxGlobalSessions <= 0 {
|
||||
out.MuxGlobalSessions = defaultNativeMuxGlobalSessions
|
||||
}
|
||||
// Ignore every old positive/zero admission ceiling. This migration is
|
||||
// deliberately unconditional so upgrading an existing server immediately
|
||||
// removes the old 4K/8K/32K web-style caps without requiring a panel save.
|
||||
out.MaxConcurrentConnections = defaultNativeMaxConnections
|
||||
out.MaxConcurrentXHTTPRequests = defaultNativeMaxXHTTPRequests
|
||||
out.XHTTPMaxSessions = defaultNativeXHTTPMaxSessions
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -79,8 +121,16 @@ func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() }
|
||||
func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions }
|
||||
func nativeMuxUDPReadBufferSize() int { return fixedNativeMuxUDPReadBuffer }
|
||||
func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer }
|
||||
func nativeXHTTPMaxSessionLimit() int { return defaultNativeXHTTPMaxSessions }
|
||||
func nativeXHTTPBufferedPostLimit() int { return defaultNativeXHTTPBufferedPosts }
|
||||
func nativeHTTP2MaxConcurrentStreams() uint32 {
|
||||
return fixedNativeHTTP2ConcurrentStreams
|
||||
}
|
||||
func nativeTransportConnectionLimit() int { return fixedNativeMaxTransportConnections }
|
||||
func nativeXHTTPRequestLimit() int { return fixedNativeMaxXHTTPRequests }
|
||||
func nativeXHTTPSessionLimit() int { return fixedNativeMaxXHTTPSessions }
|
||||
func nativeXHTTPWriteTimeout() time.Duration {
|
||||
return fixedNativeXHTTPWriteTimeoutMS * time.Millisecond
|
||||
}
|
||||
func nativeMuxUDPIdleTimeout() time.Duration {
|
||||
return fixedNativeMuxUDPIdleMS * time.Millisecond
|
||||
}
|
||||
|
||||
+53
-24
@@ -25,18 +25,18 @@ const (
|
||||
// check and caused the server to block waiting for a fake second payload.
|
||||
// XUDP belongs to VLESS CommandMux and is handled separately when Mux support
|
||||
// is implemented.
|
||||
func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, up, down *rate.Limiter) {
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, quotaState *xrayNativeQuotaState, up, down *rate.Limiter) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray VLESS UDP tunnel user=%s", email))
|
||||
|
||||
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
|
||||
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
|
||||
upMeter := newTrafficMeter(uuid, email, true, quotaState)
|
||||
downMeter := newTrafficMeter(uuid, email, false, quotaState)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var closeOnce sync.Once
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
closeAll := func() {
|
||||
closeOnce.Do(func() {
|
||||
cancel()
|
||||
_ = backend.Close()
|
||||
_ = client.Close()
|
||||
})
|
||||
@@ -57,13 +57,18 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(up, len(payload)); err != nil {
|
||||
if err := waitNativeRate(ctx, up, len(payload)); err != nil {
|
||||
return
|
||||
}
|
||||
quotaReservation, err := reserveNativePacketQuota(upMeter, len(payload))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := quotaReservation.wait(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(payload)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
}
|
||||
quotaReservation.finish(n)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS UDP backend write failed: %v", err)
|
||||
return
|
||||
@@ -91,14 +96,22 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
if err := waitNativeRate(ctx, down, n); err != nil {
|
||||
return
|
||||
}
|
||||
quotaReservation, err := reserveNativePacketQuota(downMeter, n)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := quotaReservation.wait(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if err := writeVLESSLengthPacket(client, buf[:n]); err != nil {
|
||||
quotaReservation.finish(0)
|
||||
xrayLogf("native xray: VLESS UDP client write failed: %v", err)
|
||||
return
|
||||
}
|
||||
downMeter.add(n)
|
||||
quotaReservation.finish(n)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -301,18 +314,18 @@ func writeVLESSXUDPPacket(w io.Writer, payload []byte) error {
|
||||
// nativeVMessUDPTunnel maps one VMess body chunk to one UDP datagram. VMess AEAD
|
||||
// chunking already preserves packet boundaries, so no extra VLESS length prefix
|
||||
// is added inside the encrypted body.
|
||||
func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, email string, up, down *rate.Limiter) {
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, email string, quotaState *xrayNativeQuotaState, up, down *rate.Limiter) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray VMess UDP tunnel user=%s", email))
|
||||
|
||||
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
|
||||
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
|
||||
upMeter := newTrafficMeter(uuid, email, true, quotaState)
|
||||
downMeter := newTrafficMeter(uuid, email, false, quotaState)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var closeOnce sync.Once
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
closeAll := func() {
|
||||
closeOnce.Do(func() {
|
||||
cancel()
|
||||
_ = backend.Close()
|
||||
_ = client.Close()
|
||||
})
|
||||
@@ -333,13 +346,18 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
|
||||
if len(pkt) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(up, len(pkt)); err != nil {
|
||||
if err := waitNativeRate(ctx, up, len(pkt)); err != nil {
|
||||
return
|
||||
}
|
||||
quotaReservation, err := reserveNativePacketQuota(upMeter, len(pkt))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := quotaReservation.wait(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(pkt)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
}
|
||||
quotaReservation.finish(n)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VMess UDP backend write failed: %v", err)
|
||||
return
|
||||
@@ -367,14 +385,22 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
if err := waitNativeRate(ctx, down, n); err != nil {
|
||||
return
|
||||
}
|
||||
quotaReservation, err := reserveNativePacketQuota(downMeter, n)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := quotaReservation.wait(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if err := client.WritePacket(buf[:n]); err != nil {
|
||||
quotaReservation.finish(0)
|
||||
xrayLogf("native xray: VMess UDP client write failed: %v", err)
|
||||
return
|
||||
}
|
||||
downMeter.add(n)
|
||||
quotaReservation.finish(n)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -384,9 +410,12 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
|
||||
closeAll()
|
||||
}
|
||||
|
||||
func waitNativeRate(lim *rate.Limiter, n int) error {
|
||||
func waitNativeRate(ctx context.Context, lim *rate.Limiter, n int) error {
|
||||
if lim == nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return lim.WaitN(context.Background(), n)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return lim.WaitN(ctx, n)
|
||||
}
|
||||
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type xrayNativeQuotaState struct {
|
||||
// trafficMu establishes a clean reset boundary. Native stream and packet
|
||||
// writers hold a read lock from quota reservation through the actual write
|
||||
// and metering; traffic resets take the write lock. This prevents an
|
||||
// in-flight pre-reset reservation from being accounted in the new period or
|
||||
// subtracting from freshly reset usage.
|
||||
trafficMu sync.RWMutex
|
||||
mu sync.Mutex
|
||||
usedBytes int64
|
||||
quotaBytes int64
|
||||
action string
|
||||
throttleMbps int
|
||||
limiter *rate.Limiter
|
||||
generation uint64
|
||||
maxConns int
|
||||
activeConns int
|
||||
owner string
|
||||
expiresAt time.Time
|
||||
hasExpiry bool
|
||||
connections map[io.Closer]struct{}
|
||||
}
|
||||
|
||||
func (m *XrayManager) reloadNativeQuotaPolicies() {
|
||||
if statsStore == nil {
|
||||
return
|
||||
}
|
||||
metas, err := statsStore.ListAllXrayClients(context.Background())
|
||||
if err != nil {
|
||||
xrayLogf("xray native quota: load policies failed: %v", err)
|
||||
return
|
||||
}
|
||||
next := make(map[string]*xrayNativeQuotaState, len(metas))
|
||||
for _, meta := range metas {
|
||||
if meta == nil || strings.TrimSpace(meta.UUID) == "" {
|
||||
continue
|
||||
}
|
||||
next[meta.UUID] = newXrayNativeQuotaState(meta)
|
||||
}
|
||||
m.nativeQuotaMu.Lock()
|
||||
m.nativeQuotaByUUID = next
|
||||
m.nativeQuotaMu.Unlock()
|
||||
}
|
||||
|
||||
func newXrayNativeQuotaState(meta *XrayClientMeta) *xrayNativeQuotaState {
|
||||
used := meta.TotalUplinkBytes + meta.TotalDownlinkBytes
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return &xrayNativeQuotaState{
|
||||
usedBytes: used,
|
||||
quotaBytes: meta.DataQuotaBytes,
|
||||
action: normalizeQuotaAction(meta.QuotaAction),
|
||||
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
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *XrayManager) setNativeQuotaPolicy(meta *XrayClientMeta) {
|
||||
if meta == nil || strings.TrimSpace(meta.UUID) == "" {
|
||||
return
|
||||
}
|
||||
uuid := strings.TrimSpace(meta.UUID)
|
||||
m.nativeQuotaMu.Lock()
|
||||
if m.nativeQuotaByUUID == nil {
|
||||
m.nativeQuotaByUUID = make(map[string]*xrayNativeQuotaState)
|
||||
}
|
||||
existing := m.nativeQuotaByUUID[uuid]
|
||||
if existing == nil {
|
||||
m.nativeQuotaByUUID[uuid] = newXrayNativeQuotaState(meta)
|
||||
m.nativeQuotaMu.Unlock()
|
||||
return
|
||||
}
|
||||
m.nativeQuotaMu.Unlock()
|
||||
|
||||
existing.mu.Lock()
|
||||
existing.quotaBytes = meta.DataQuotaBytes
|
||||
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()
|
||||
}
|
||||
|
||||
func (m *XrayManager) removeNativeQuotaPolicy(uuid string) {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
if uuid == "" {
|
||||
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.
|
||||
m.nativeDBMu.Lock()
|
||||
delete(m.nativeTrafficPending, uuid)
|
||||
delete(m.nativeActivePending, uuid)
|
||||
m.nativeDBMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *XrayManager) resetNativeQuotaUsage(uuid string) {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
m.nativeQuotaMu.RLock()
|
||||
state := m.nativeQuotaByUUID[uuid]
|
||||
m.nativeQuotaMu.RUnlock()
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.trafficMu.Lock()
|
||||
defer state.trafficMu.Unlock()
|
||||
state.mu.Lock()
|
||||
state.usedBytes = 0
|
||||
state.limiter = nil
|
||||
state.generation++
|
||||
if state.generation == 0 {
|
||||
state.generation = 1
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *XrayManager) resetNativeTrafficAccounting(ctx context.Context, store *Store, uuid, email string) error {
|
||||
state := m.nativeQuotaState(uuid)
|
||||
if state != nil {
|
||||
state.trafficMu.Lock()
|
||||
defer state.trafficMu.Unlock()
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
}
|
||||
|
||||
m.nativeTrafficPersistMu.Lock()
|
||||
defer m.nativeTrafficPersistMu.Unlock()
|
||||
|
||||
// Remove this client's queued pre-reset delta while holding only the short
|
||||
// map mutex. The database call may take seconds during an outage; keeping
|
||||
// nativeDBMu locked across it would stall traffic/accounting updates for
|
||||
// every other native user and could amplify a slow database into a goroutine
|
||||
// pile-up.
|
||||
m.nativeDBMu.Lock()
|
||||
key := strings.TrimSpace(uuid)
|
||||
var pending xrayPendingTraffic
|
||||
hadPending := false
|
||||
if m.nativeTrafficPending != nil {
|
||||
pending, hadPending = m.nativeTrafficPending[key]
|
||||
delete(m.nativeTrafficPending, key)
|
||||
}
|
||||
m.nativeDBMu.Unlock()
|
||||
|
||||
err := store.ResetXrayClientTraffic(ctx, uuid)
|
||||
if err != nil && hadPending && pending.State == state {
|
||||
m.nativeDBMu.Lock()
|
||||
if m.nativeTrafficPending == nil {
|
||||
m.nativeTrafficPending = make(map[string]xrayPendingTraffic)
|
||||
}
|
||||
current := m.nativeTrafficPending[key]
|
||||
if current.State != nil && current.State != state {
|
||||
current = xrayPendingTraffic{}
|
||||
}
|
||||
if current.Email == "" {
|
||||
current.Email = pending.Email
|
||||
}
|
||||
current.Uplink += pending.Uplink
|
||||
current.Downlink += pending.Downlink
|
||||
current.State = state
|
||||
m.nativeTrafficPending[key] = current
|
||||
m.nativeDBMu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state != nil {
|
||||
state.usedBytes = 0
|
||||
state.limiter = nil
|
||||
state.generation++
|
||||
if state.generation == 0 {
|
||||
state.generation = 1
|
||||
}
|
||||
}
|
||||
|
||||
m.statsMu.Lock()
|
||||
for _, key := range []string{strings.TrimSpace(email), strings.TrimSpace(uuid)} {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if runtime, ok := m.statsByEmail[key]; ok {
|
||||
runtime.Uplink = 0
|
||||
runtime.Downlink = 0
|
||||
m.statsByEmail[key] = runtime
|
||||
}
|
||||
}
|
||||
m.statsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *XrayManager) nativeQuotaState(uuid string) *xrayNativeQuotaState {
|
||||
m.nativeQuotaMu.RLock()
|
||||
state := m.nativeQuotaByUUID[strings.TrimSpace(uuid)]
|
||||
m.nativeQuotaMu.RUnlock()
|
||||
return state
|
||||
}
|
||||
|
||||
// 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, 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()
|
||||
logNativeClientLimitRejection(email, limit)
|
||||
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()
|
||||
}
|
||||
|
||||
m.recordNativeConnect(uuid, email, state)
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
if state != nil {
|
||||
state.mu.Lock()
|
||||
if closer != nil {
|
||||
delete(state.connections, closer)
|
||||
}
|
||||
if state.activeConns > 0 {
|
||||
state.activeConns--
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
m.recordNativeDisconnect(uuid, email, state)
|
||||
})
|
||||
}, 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))
|
||||
}
|
||||
|
||||
func nativeQuotaStateBlocked(state *xrayNativeQuotaState) bool {
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
return state.quotaBytes > 0 && normalizeQuotaAction(state.action) == quotaActionBlock && state.usedBytes >= state.quotaBytes
|
||||
}
|
||||
|
||||
func (m *XrayManager) reserveNativeQuota(state *xrayNativeQuotaState, requested int) (allowed int, limiter *rate.Limiter, stopAfter bool) {
|
||||
if requested <= 0 {
|
||||
return 0, nil, false
|
||||
}
|
||||
if state == nil {
|
||||
return requested, nil, false
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
n := int64(requested)
|
||||
if state.quotaBytes <= 0 {
|
||||
state.usedBytes += n
|
||||
return requested, nil, false
|
||||
}
|
||||
if normalizeQuotaAction(state.action) == quotaActionThrottle {
|
||||
previous := state.usedBytes
|
||||
state.usedBytes += n
|
||||
if previous+n > state.quotaBytes {
|
||||
if state.limiter == nil {
|
||||
bps := mbpsToBytesPerSec(quotaThrottleMbpsOrDefault(state.throttleMbps))
|
||||
burst := int(bps)
|
||||
if burst < copyBufSize {
|
||||
burst = copyBufSize
|
||||
}
|
||||
state.limiter = rate.NewLimiter(rate.Limit(bps), burst)
|
||||
}
|
||||
return requested, state.limiter, false
|
||||
}
|
||||
return requested, nil, false
|
||||
}
|
||||
|
||||
remaining := state.quotaBytes - state.usedBytes
|
||||
if remaining <= 0 {
|
||||
return 0, nil, true
|
||||
}
|
||||
take := n
|
||||
if take > remaining {
|
||||
take = remaining
|
||||
}
|
||||
state.usedBytes += take
|
||||
return int(take), nil, take < n
|
||||
}
|
||||
|
||||
func (m *XrayManager) finishNativeQuotaReservation(state *xrayNativeQuotaState, reserved, written int) {
|
||||
if reserved <= 0 || written >= reserved {
|
||||
return
|
||||
}
|
||||
if written < 0 {
|
||||
written = 0
|
||||
}
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
state.usedBytes -= int64(reserved - written)
|
||||
if state.usedBytes < 0 {
|
||||
state.usedBytes = 0
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
type xrayQuotaMeteredWriter struct {
|
||||
w io.Writer
|
||||
meter *trafficMeter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (mw xrayQuotaMeteredWriter) Write(p []byte) (int, error) {
|
||||
if mw.meter == nil {
|
||||
return mw.w.Write(p)
|
||||
}
|
||||
state := mw.meter.state
|
||||
if state != nil {
|
||||
state.trafficMu.RLock()
|
||||
defer state.trafficMu.RUnlock()
|
||||
}
|
||||
allowed, limiter, stopAfter := xrayMgr.reserveNativeQuota(state, len(p))
|
||||
if allowed <= 0 {
|
||||
return 0, errDataQuotaExceeded
|
||||
}
|
||||
if limiter != nil {
|
||||
ctx := mw.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := limiter.WaitN(ctx, allowed); err != nil {
|
||||
xrayMgr.finishNativeQuotaReservation(state, allowed, 0)
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := mw.w.Write(p[:allowed])
|
||||
xrayMgr.finishNativeQuotaReservation(state, allowed, n)
|
||||
if n > 0 {
|
||||
mw.meter.add(n)
|
||||
}
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if stopAfter || allowed < len(p) || nativeQuotaStateBlocked(state) {
|
||||
return n, errDataQuotaExceeded
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type nativePacketQuotaReservation struct {
|
||||
meter *trafficMeter
|
||||
state *xrayNativeQuotaState
|
||||
limiter *rate.Limiter
|
||||
reserved int
|
||||
finished bool
|
||||
}
|
||||
|
||||
func reserveNativePacketQuota(meter *trafficMeter, n int) (nativePacketQuotaReservation, error) {
|
||||
if meter == nil || n <= 0 {
|
||||
return nativePacketQuotaReservation{}, nil
|
||||
}
|
||||
state := meter.state
|
||||
if state != nil {
|
||||
state.trafficMu.RLock()
|
||||
}
|
||||
allowed, limiter, stopAfter := xrayMgr.reserveNativeQuota(state, n)
|
||||
if allowed != n || stopAfter {
|
||||
if allowed > 0 {
|
||||
xrayMgr.finishNativeQuotaReservation(state, allowed, 0)
|
||||
}
|
||||
if state != nil {
|
||||
state.trafficMu.RUnlock()
|
||||
}
|
||||
return nativePacketQuotaReservation{}, errDataQuotaExceeded
|
||||
}
|
||||
return nativePacketQuotaReservation{
|
||||
meter: meter,
|
||||
state: state,
|
||||
limiter: limiter,
|
||||
reserved: n,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *nativePacketQuotaReservation) wait(ctx context.Context) error {
|
||||
if r == nil || r.finished || r.limiter == nil || r.reserved <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := waitNativeRate(ctx, r.limiter, r.reserved); err != nil {
|
||||
r.finish(0)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *nativePacketQuotaReservation) finish(written int) {
|
||||
if r == nil || r.finished {
|
||||
return
|
||||
}
|
||||
r.finished = true
|
||||
if r.meter == nil || r.reserved <= 0 {
|
||||
if r.state != nil {
|
||||
r.state.trafficMu.RUnlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
xrayMgr.finishNativeQuotaReservation(r.state, r.reserved, written)
|
||||
if written > 0 {
|
||||
r.meter.add(written)
|
||||
}
|
||||
if r.state != nil {
|
||||
r.state.trafficMu.RUnlock()
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -670,7 +670,11 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
|
||||
}
|
||||
client := ib.matchVMess(authid, time.Now().Unix())
|
||||
if client == nil {
|
||||
log.Printf("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
|
||||
}
|
||||
if reason := xrayMgr.nativeClientAccessDenied(client.uuid); reason != "" {
|
||||
log.Printf("native xray: inbound %q rejected VMess user %s: %s", ib.tag, client.email, reason)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -690,6 +694,11 @@ 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, stream)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer releaseConnection()
|
||||
|
||||
respBodyKey := sha256.Sum256(req.bodyKey[:])
|
||||
respBodyIV := sha256.Sum256(req.bodyIV[:])
|
||||
@@ -715,7 +724,7 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
|
||||
return
|
||||
}
|
||||
log.Printf("native xray: vmess/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
nativeTunnel(vc, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
|
||||
case vmessCmdUDP:
|
||||
backend, target, err := ib.nativeDialUDP(req.host, req.port)
|
||||
if err != nil {
|
||||
@@ -723,6 +732,6 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
|
||||
return
|
||||
}
|
||||
log.Printf("native xray: vmess/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeVMessUDPTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
nativeVMessUDPTunnel(vc, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
|
||||
}
|
||||
}
|
||||
|
||||
+572
-87
@@ -14,12 +14,45 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
)
|
||||
|
||||
const (
|
||||
nativeXHTTPMaxSessionIDBytes = 256
|
||||
nativeXHTTPMaxSequenceBytes = 20
|
||||
nativeXHTTPHardMaxHeaderBytes = 256 * 1024
|
||||
nativeXHTTPHardMaxPostBytes int64 = 16 * 1024 * 1024
|
||||
nativeXHTTPMaxBufferedPosts = 512
|
||||
nativeXHTTPMaxBufferedSessionBytes = 16 * 1024 * 1024
|
||||
nativeXHTTPMaxBufferedGlobalBytes = 128 * 1024 * 1024
|
||||
// Tiny/empty packet-up requests still retain queue metadata. Charge a minimum
|
||||
// amount against the byte budgets so the reassembly queue can be count-unlimited
|
||||
// without allowing zero-byte packets to grow the heap without bound.
|
||||
nativeXHTTPMinPacketAccountingBytes int64 = 256
|
||||
)
|
||||
|
||||
var (
|
||||
nativeXHTTPBufferedBytes atomic.Int64
|
||||
nativeXHTTPBufferRejected atomic.Int64
|
||||
errNativeXHTTPUploadBufferFull = errors.New("xhttp upload buffer limit reached")
|
||||
nativeXHTTPMemoryWait = struct {
|
||||
sync.Mutex
|
||||
waiters []*nativeXHTTPMemoryWaiter
|
||||
head int
|
||||
queued int
|
||||
}{}
|
||||
)
|
||||
|
||||
type nativeXHTTPMemoryWaiter struct {
|
||||
bytes int64
|
||||
ready chan struct{}
|
||||
granted bool
|
||||
}
|
||||
|
||||
const (
|
||||
xhttpPlacementPath = "path"
|
||||
xhttpPlacementQuery = "query"
|
||||
@@ -157,7 +190,9 @@ func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
|
||||
|
||||
func (g *nativeXHTTPListener) serve(ln net.Listener) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
|
||||
h2s := &http2.Server{}
|
||||
h2s := &http2.Server{
|
||||
MaxConcurrentStreams: nativeHTTP2MaxConcurrentStreams(),
|
||||
}
|
||||
handler := http.Handler(g)
|
||||
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
|
||||
// listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without
|
||||
@@ -258,6 +293,9 @@ func (ib *nativeInbound) reapStaleXHTTPSessions(idle time.Duration) {
|
||||
|
||||
func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
|
||||
if ib.xhttpMaxHeaderBytes > 0 {
|
||||
if ib.xhttpMaxHeaderBytes > nativeXHTTPHardMaxHeaderBytes {
|
||||
return nativeXHTTPHardMaxHeaderBytes
|
||||
}
|
||||
return ib.xhttpMaxHeaderBytes
|
||||
}
|
||||
// Xray defaults to 8192. Keep a little room for custom headers/cookies used
|
||||
@@ -269,19 +307,25 @@ func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
|
||||
// byte stream to the VLESS/VMess handlers as a net.Conn.
|
||||
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))
|
||||
// XHTTP is a VPN transport, not a web API. A single connected user keeps a
|
||||
// long-lived download handler and can generate many short packet-up handlers.
|
||||
// Rejecting handlers at an application request ceiling turns normal tunnel
|
||||
// bursts into 429s and, through CDNs/reverse proxies, intermittent 502s.
|
||||
// HTTP/2 flow control plus the bounded, cancelable upload queues below provide
|
||||
// backpressure without applying website rate-limit semantics to tunnel traffic.
|
||||
if !ib.isXHTTP() {
|
||||
xrayLogf("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)
|
||||
return
|
||||
}
|
||||
if !ib.xhttpHostAllowed(r.Host) {
|
||||
xrayLogf("native xray: xhttp reject inbound=%q reason=host method=%s path=%q host=%q want=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, ib.xhttpHost, r.RemoteAddr)
|
||||
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=host method=%s path=%q host=%q want=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, ib.xhttpHost, r.RemoteAddr)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
base, ok := ib.matchXHTTPPath(r.URL.Path)
|
||||
if !ok {
|
||||
xrayLogf("native xray: xhttp reject inbound=%q reason=path method=%s path=%q want=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), ib.path, r.Host, r.RemoteAddr)
|
||||
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=path method=%s path=%q want=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), ib.path, r.Host, r.RemoteAddr)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -292,7 +336,21 @@ func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
releaseRequest, ok := acquireNativeXHTTPRequest()
|
||||
if !ok {
|
||||
logNativeLimitRejection("simultaneous XHTTP handlers", &nativeXHTTPRejected, nativeXHTTPRequestLimit())
|
||||
w.Header().Set("Retry-After", "1")
|
||||
http.Error(w, "xhttp transport temporarily busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer releaseRequest()
|
||||
|
||||
sessionID, seqStr := ib.extractXHTTPMeta(r, base)
|
||||
if len(sessionID) > nativeXHTTPMaxSessionIDBytes || len(seqStr) > nativeXHTTPMaxSequenceBytes {
|
||||
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=metadata-size remote=%s", ib.tag, r.RemoteAddr)
|
||||
xhttpBadRequest(w)
|
||||
return
|
||||
}
|
||||
mode := ib.normalizedXHTTPMode()
|
||||
xrayTracef("native xray: xhttp request inbound=%q method=%s proto=%s path=%q host=%q session=%q seq=%q len=%d mode=%s remote=%s", ib.tag, r.Method, r.Proto, r.URL.RequestURI(), r.Host, sessionID, seqStr, r.ContentLength, mode, r.RemoteAddr)
|
||||
|
||||
@@ -519,18 +577,20 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
|
||||
s.touch()
|
||||
return s
|
||||
}
|
||||
if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max {
|
||||
// XHTTP uses many HTTP requests/sessions by design. Returning HTTP 429
|
||||
// makes Xray clients tear down active tunnels, which is worse than allowing
|
||||
// a short soft-limit overflow and relying on stale-session cleanup.
|
||||
xrayTracef("native xray: xhttp session soft limit exceeded inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max)
|
||||
releaseSlot, ok := acquireNativeXHTTPSession()
|
||||
if !ok {
|
||||
logNativeLimitRejection("simultaneous XHTTP sessions", &nativeXHTTPRejected, nativeXHTTPSessionLimit())
|
||||
w.Header().Set("Retry-After", "1")
|
||||
http.Error(w, "xhttp session capacity temporarily busy", http.StatusServiceUnavailable)
|
||||
return nil
|
||||
}
|
||||
s := &nativeXHTTPSession{
|
||||
id: id,
|
||||
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts),
|
||||
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts, nativeXHTTPMaxBufferedSessionBytes),
|
||||
done: make(chan struct{}),
|
||||
connectedCh: make(chan struct{}),
|
||||
lastSeen: time.Now(),
|
||||
releaseSlot: releaseSlot,
|
||||
}
|
||||
ib.xhttpSessions[id] = s
|
||||
xrayTracef("native xray: xhttp session created inbound=%q session=%q active=%d", ib.tag, id, len(ib.xhttpSessions))
|
||||
@@ -539,10 +599,7 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) xhttpMaxActiveSessions() int {
|
||||
if nativeXHTTPMaxSessionLimit() > 0 {
|
||||
return nativeXHTTPMaxSessionLimit()
|
||||
}
|
||||
return defaultNativeXHTTPMaxSessions
|
||||
return nativeXHTTPSessionLimit()
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
|
||||
@@ -570,6 +627,19 @@ func (ib *nativeInbound) deleteXHTTPSession(id string, s *nativeXHTTPSession) {
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) closeAllXHTTPSessions() {
|
||||
ib.xhttpMu.Lock()
|
||||
sessions := make([]*nativeXHTTPSession, 0, len(ib.xhttpSessions))
|
||||
for id, session := range ib.xhttpSessions {
|
||||
delete(ib.xhttpSessions, id)
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
ib.xhttpMu.Unlock()
|
||||
for _, session := range sessions {
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.Request, sess *nativeXHTTPSession) {
|
||||
sess.touch()
|
||||
xrayTracef("native xray: xhttp stream-up inbound=%q session=%q len=%d remote=%s", ib.tag, sess.id, r.ContentLength, r.RemoteAddr)
|
||||
@@ -577,7 +647,7 @@ func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.
|
||||
http.Error(w, "xhttp stream-up mode is not allowed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Reader: r.Body}); err != nil {
|
||||
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Reader: r.Body}, nil); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
@@ -605,18 +675,40 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
|
||||
http.Error(w, "bad xhttp sequence", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Reserve the expected payload rather than the configured maximum. Normal
|
||||
// XHTTP body uploads have a Content-Length, so small packets no longer each
|
||||
// consume a full 1 MB reservation. Unknown/chunked or metadata-carried uploads
|
||||
// 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
|
||||
}
|
||||
defer memory.release()
|
||||
payload, err := ib.readXHTTPPayload(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
memory.shrink(nativeXHTTPAccountedPacketBytes(int64(len(payload))))
|
||||
xrayTracef("native xray: xhttp packet-up inbound=%q session=%q seq=%d payload=%d remote=%s", ib.tag, sess.id, seq, len(payload), r.RemoteAddr)
|
||||
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}); err != nil {
|
||||
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}, memory); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
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) {
|
||||
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
|
||||
http.Error(w, "xhttp session queue failed", http.StatusInternalServerError)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
@@ -719,11 +811,37 @@ func (ib *nativeInbound) readXHTTPBodyPayload(r *http.Request) ([]byte, error) {
|
||||
|
||||
func (ib *nativeInbound) xhttpMaxPostBytes() int64 {
|
||||
if ib.xhttpMaxEachPostBytes > 0 {
|
||||
if ib.xhttpMaxEachPostBytes > nativeXHTTPHardMaxPostBytes {
|
||||
return nativeXHTTPHardMaxPostBytes
|
||||
}
|
||||
return ib.xhttpMaxEachPostBytes
|
||||
}
|
||||
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 nativeXHTTPAccountedPacketBytes(maxBytes)
|
||||
}
|
||||
return nativeXHTTPAccountedPacketBytes(r.ContentLength)
|
||||
}
|
||||
return nativeXHTTPAccountedPacketBytes(maxBytes)
|
||||
}
|
||||
|
||||
func nativeXHTTPAccountedPacketBytes(payloadBytes int64) int64 {
|
||||
if payloadBytes < nativeXHTTPMinPacketAccountingBytes {
|
||||
return nativeXHTTPMinPacketAccountingBytes
|
||||
}
|
||||
return payloadBytes
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -794,7 +912,7 @@ func (ib *nativeInbound) handleXHTTPDownload(w http.ResponseWriter, r *http.Requ
|
||||
// The stream-down HTTP request is the lifetime owner of an XHTTP
|
||||
// session. Log the actual transport cancellation so a CDN/proxy
|
||||
// timeout can be distinguished from a server idle policy.
|
||||
xrayLogf("native xray: xhttp stream-down ended inbound=%q session=%q remote=%s err=%v",
|
||||
xrayTracef("native xray: xhttp stream-down ended inbound=%q session=%q remote=%s err=%v",
|
||||
ib.tag, sessionID, r.RemoteAddr, r.Context().Err())
|
||||
_ = xc.Close()
|
||||
case <-sess.done:
|
||||
@@ -859,6 +977,7 @@ type nativeXHTTPSession struct {
|
||||
mu sync.Mutex
|
||||
connected bool
|
||||
lastSeen time.Time
|
||||
releaseSlot func()
|
||||
}
|
||||
|
||||
func (s *nativeXHTTPSession) touch() {
|
||||
@@ -879,6 +998,9 @@ func (s *nativeXHTTPSession) close() {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.done)
|
||||
s.queue.close()
|
||||
if s.releaseSlot != nil {
|
||||
s.releaseSlot()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -949,12 +1071,19 @@ func (c *nativeXHTTPConn) SetReadDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *nativeXHTTPConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
func (c *nativeXHTTPConn) SetWriteDeadline(t time.Time) error {
|
||||
if dw, ok := c.writer.(interface{ SetWriteDeadline(time.Time) error }); ok {
|
||||
return dw.SetWriteDeadline(t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type nativeXHTTPResponseWriter struct {
|
||||
mu sync.Mutex
|
||||
w http.ResponseWriter
|
||||
closed bool
|
||||
writeMu sync.Mutex
|
||||
stateMu sync.Mutex
|
||||
w http.ResponseWriter
|
||||
closed bool
|
||||
deadline time.Time
|
||||
}
|
||||
|
||||
func newNativeXHTTPResponseWriter(w http.ResponseWriter) *nativeXHTTPResponseWriter {
|
||||
@@ -962,71 +1091,373 @@ func newNativeXHTTPResponseWriter(w http.ResponseWriter) *nativeXHTTPResponseWri
|
||||
}
|
||||
|
||||
func (w *nativeXHTTPResponseWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.writeMu.Lock()
|
||||
defer w.writeMu.Unlock()
|
||||
|
||||
w.stateMu.Lock()
|
||||
closed := w.closed
|
||||
deadline := w.deadline
|
||||
w.stateMu.Unlock()
|
||||
if closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
safetyDeadline := time.Now().Add(nativeXHTTPWriteTimeout())
|
||||
if deadline.IsZero() || deadline.After(safetyDeadline) {
|
||||
deadline = safetyDeadline
|
||||
}
|
||||
controller := http.NewResponseController(w.w)
|
||||
if err := controller.SetWriteDeadline(deadline); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
return 0, err
|
||||
}
|
||||
n, err := w.w.Write(p)
|
||||
if err == nil {
|
||||
flushHTTP(w.w)
|
||||
if flushErr := controller.Flush(); flushErr != nil && !errors.Is(flushErr, http.ErrNotSupported) {
|
||||
err = flushErr
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *nativeXHTTPResponseWriter) close() {
|
||||
w.mu.Lock()
|
||||
// Do not wait for writeMu: Close is commonly called by the request-context
|
||||
// watcher specifically because a CDN write is stalled. Mark the writer closed
|
||||
// and force the active net/http write deadline to expire so Write returns.
|
||||
w.stateMu.Lock()
|
||||
w.closed = true
|
||||
w.mu.Unlock()
|
||||
w.stateMu.Unlock()
|
||||
_ = http.NewResponseController(w.w).SetWriteDeadline(time.Now())
|
||||
}
|
||||
|
||||
func (w *nativeXHTTPResponseWriter) SetWriteDeadline(t time.Time) error {
|
||||
w.stateMu.Lock()
|
||||
w.deadline = t
|
||||
w.stateMu.Unlock()
|
||||
err := http.NewResponseController(w.w).SetWriteDeadline(t)
|
||||
if errors.Is(err, http.ErrNotSupported) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// nativeXHTTPMemoryLease reserves from a process-wide byte budget before a
|
||||
// packet-up handler allocates its payload. The same lease is transferred to the
|
||||
// session queue, so active request bodies and queued reassembly data share one
|
||||
// hard ceiling instead of each having an independent amplification window.
|
||||
type nativeXHTTPMemoryLease struct {
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func acquireNativeXHTTPMemory(n int64) (*nativeXHTTPMemoryLease, bool) {
|
||||
if n <= 0 {
|
||||
return &nativeXHTTPMemoryLease{}, true
|
||||
}
|
||||
nativeXHTTPMemoryWait.Lock()
|
||||
defer nativeXHTTPMemoryWait.Unlock()
|
||||
current := nativeXHTTPBufferedBytes.Load()
|
||||
if nativeXHTTPMemoryWait.queued != 0 || current > nativeXHTTPMaxBufferedGlobalBytes-n {
|
||||
logNativeLimitRejection("XHTTP buffered upload bytes", &nativeXHTTPBufferRejected, nativeXHTTPMaxBufferedGlobalBytes)
|
||||
return nil, false
|
||||
}
|
||||
nativeXHTTPBufferedBytes.Store(current + n)
|
||||
return &nativeXHTTPMemoryLease{bytes: n}, true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
waiter := &nativeXHTTPMemoryWaiter{bytes: n, ready: make(chan struct{})}
|
||||
nativeXHTTPMemoryWait.Lock()
|
||||
current := nativeXHTTPBufferedBytes.Load()
|
||||
if nativeXHTTPMemoryWait.queued == 0 && current <= nativeXHTTPMaxBufferedGlobalBytes-n {
|
||||
nativeXHTTPBufferedBytes.Store(current + n)
|
||||
nativeXHTTPMemoryWait.Unlock()
|
||||
return &nativeXHTTPMemoryLease{bytes: n}, nil
|
||||
}
|
||||
nativeXHTTPMemoryWait.waiters = append(nativeXHTTPMemoryWait.waiters, waiter)
|
||||
nativeXHTTPMemoryWait.queued++
|
||||
nativeXHTTPMemoryWait.Unlock()
|
||||
|
||||
select {
|
||||
case <-waiter.ready:
|
||||
return &nativeXHTTPMemoryLease{bytes: n}, nil
|
||||
case <-ctx.Done():
|
||||
nativeXHTTPMemoryWait.Lock()
|
||||
if waiter.granted {
|
||||
current := nativeXHTTPBufferedBytes.Load() - n
|
||||
if current < 0 {
|
||||
current = 0
|
||||
}
|
||||
nativeXHTTPBufferedBytes.Store(current)
|
||||
} else {
|
||||
for i := nativeXHTTPMemoryWait.head; i < len(nativeXHTTPMemoryWait.waiters); i++ {
|
||||
candidate := nativeXHTTPMemoryWait.waiters[i]
|
||||
if candidate == waiter {
|
||||
nativeXHTTPMemoryWait.waiters[i] = nil
|
||||
nativeXHTTPMemoryWait.queued--
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
grantNativeXHTTPMemoryWaitersLocked()
|
||||
nativeXHTTPMemoryWait.Unlock()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func releaseNativeXHTTPMemory(n int64) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
nativeXHTTPMemoryWait.Lock()
|
||||
current := nativeXHTTPBufferedBytes.Load()
|
||||
next := current - n
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
nativeXHTTPBufferedBytes.Store(next)
|
||||
grantNativeXHTTPMemoryWaitersLocked()
|
||||
nativeXHTTPMemoryWait.Unlock()
|
||||
}
|
||||
|
||||
// grantNativeXHTTPMemoryWaitersLocked wakes only the FIFO waiters whose exact
|
||||
// reservations now fit. The former broadcast channel woke every blocked HTTP
|
||||
// handler after every tiny release, creating a thundering herd and sustained
|
||||
// multi-core CPU usage while the 128 MB budget was full.
|
||||
func grantNativeXHTTPMemoryWaitersLocked() {
|
||||
for nativeXHTTPMemoryWait.queued > 0 {
|
||||
for nativeXHTTPMemoryWait.head < len(nativeXHTTPMemoryWait.waiters) &&
|
||||
nativeXHTTPMemoryWait.waiters[nativeXHTTPMemoryWait.head] == nil {
|
||||
nativeXHTTPMemoryWait.head++
|
||||
}
|
||||
if nativeXHTTPMemoryWait.head >= len(nativeXHTTPMemoryWait.waiters) {
|
||||
nativeXHTTPMemoryWait.waiters = nil
|
||||
nativeXHTTPMemoryWait.head = 0
|
||||
nativeXHTTPMemoryWait.queued = 0
|
||||
return
|
||||
}
|
||||
|
||||
waiter := nativeXHTTPMemoryWait.waiters[nativeXHTTPMemoryWait.head]
|
||||
current := nativeXHTTPBufferedBytes.Load()
|
||||
if current > nativeXHTTPMaxBufferedGlobalBytes-waiter.bytes {
|
||||
compactNativeXHTTPMemoryWaitersLocked()
|
||||
return
|
||||
}
|
||||
nativeXHTTPMemoryWait.waiters[nativeXHTTPMemoryWait.head] = nil
|
||||
nativeXHTTPMemoryWait.head++
|
||||
nativeXHTTPMemoryWait.queued--
|
||||
nativeXHTTPBufferedBytes.Store(current + waiter.bytes)
|
||||
waiter.granted = true
|
||||
close(waiter.ready)
|
||||
}
|
||||
compactNativeXHTTPMemoryWaitersLocked()
|
||||
}
|
||||
|
||||
func compactNativeXHTTPMemoryWaitersLocked() {
|
||||
head := nativeXHTTPMemoryWait.head
|
||||
if head == 0 {
|
||||
return
|
||||
}
|
||||
if nativeXHTTPMemoryWait.queued == 0 {
|
||||
nativeXHTTPMemoryWait.waiters = nil
|
||||
nativeXHTTPMemoryWait.head = 0
|
||||
return
|
||||
}
|
||||
if head < 1024 && head*2 < len(nativeXHTTPMemoryWait.waiters) {
|
||||
return
|
||||
}
|
||||
remaining := copy(nativeXHTTPMemoryWait.waiters, nativeXHTTPMemoryWait.waiters[head:])
|
||||
for i := remaining; i < len(nativeXHTTPMemoryWait.waiters); i++ {
|
||||
nativeXHTTPMemoryWait.waiters[i] = nil
|
||||
}
|
||||
nativeXHTTPMemoryWait.waiters = nativeXHTTPMemoryWait.waiters[:remaining]
|
||||
nativeXHTTPMemoryWait.head = 0
|
||||
}
|
||||
|
||||
func (l *nativeXHTTPMemoryLease) shrink(n int64) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
if n >= l.bytes {
|
||||
return
|
||||
}
|
||||
release := l.bytes - n
|
||||
l.bytes = n
|
||||
releaseNativeXHTTPMemory(release)
|
||||
}
|
||||
|
||||
func (l *nativeXHTTPMemoryLease) release() {
|
||||
if l == nil || l.bytes <= 0 {
|
||||
return
|
||||
}
|
||||
n := l.bytes
|
||||
l.bytes = 0
|
||||
releaseNativeXHTTPMemory(n)
|
||||
}
|
||||
|
||||
type nativeXHTTPPacket struct {
|
||||
Reader io.ReadCloser
|
||||
Payload []byte
|
||||
Seq uint64
|
||||
Reader io.ReadCloser
|
||||
Payload []byte
|
||||
Seq uint64
|
||||
accountedBytes int64
|
||||
}
|
||||
|
||||
type nativeXHTTPUploadQueue struct {
|
||||
pushedPackets chan nativeXHTTPPacket
|
||||
maxPackets int
|
||||
maxBytes int64
|
||||
|
||||
mu sync.Mutex
|
||||
reader io.ReadCloser
|
||||
heap nativeXHTTPHeap
|
||||
nextSeq uint64
|
||||
readDeadline time.Time
|
||||
// readMu serializes the single decoded stream reader with close-time queue
|
||||
// cleanup. pushWG lets close wait until every producer that started before
|
||||
// closedFlag was set has either transferred or released its memory lease.
|
||||
readMu sync.Mutex
|
||||
pushWG sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
reader io.ReadCloser
|
||||
readerQueued bool
|
||||
heap nativeXHTTPHeap
|
||||
nextSeq uint64
|
||||
readDeadline time.Time
|
||||
bufferedBytes int64
|
||||
closedFlag bool
|
||||
spaceChanged chan struct{}
|
||||
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newNativeXHTTPUploadQueue(maxPackets int) *nativeXHTTPUploadQueue {
|
||||
func newNativeXHTTPUploadQueue(maxPackets int, maxBytes int64) *nativeXHTTPUploadQueue {
|
||||
if maxPackets <= 0 {
|
||||
maxPackets = defaultNativeXHTTPBufferedPosts
|
||||
}
|
||||
if maxPackets > nativeXHTTPMaxBufferedPosts {
|
||||
maxPackets = nativeXHTTPMaxBufferedPosts
|
||||
}
|
||||
if maxBytes <= 0 || maxBytes > nativeXHTTPMaxBufferedSessionBytes {
|
||||
maxBytes = nativeXHTTPMaxBufferedSessionBytes
|
||||
}
|
||||
return &nativeXHTTPUploadQueue{
|
||||
pushedPackets: make(chan nativeXHTTPPacket, maxPackets),
|
||||
maxPackets: maxPackets,
|
||||
maxBytes: maxBytes,
|
||||
closed: make(chan struct{}),
|
||||
spaceChanged: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket) error {
|
||||
func (q *nativeXHTTPUploadQueue) beginPush() bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.closedFlag {
|
||||
return false
|
||||
}
|
||||
q.pushWG.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) adoptPayloadMemory(ctx context.Context, memory *nativeXHTTPMemoryLease, n int64) error {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
if memory == nil || memory.bytes != n {
|
||||
return errNativeXHTTPUploadBufferFull
|
||||
}
|
||||
if n > q.maxBytes {
|
||||
return errNativeXHTTPUploadBufferFull
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) releasePayloadMemory(n int64) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
q.mu.Lock()
|
||||
release := n
|
||||
if release > q.bufferedBytes {
|
||||
release = q.bufferedBytes
|
||||
}
|
||||
q.bufferedBytes -= release
|
||||
close(q.spaceChanged)
|
||||
q.spaceChanged = make(chan struct{})
|
||||
q.mu.Unlock()
|
||||
releaseNativeXHTTPMemory(release)
|
||||
}
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket, memory *nativeXHTTPMemoryLease) error {
|
||||
if !q.beginPush() {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
defer q.pushWG.Done()
|
||||
|
||||
readerReserved := false
|
||||
if p.Reader != nil {
|
||||
q.mu.Lock()
|
||||
if q.reader != nil {
|
||||
if q.reader != nil || q.readerQueued || q.closedFlag {
|
||||
q.mu.Unlock()
|
||||
return errors.New("xhttp upload reader already exists")
|
||||
}
|
||||
q.readerQueued = true
|
||||
readerReserved = true
|
||||
q.mu.Unlock()
|
||||
defer func() {
|
||||
if readerReserved {
|
||||
q.mu.Lock()
|
||||
q.readerQueued = false
|
||||
q.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
accountedBytes := int64(0)
|
||||
if p.Reader == nil {
|
||||
accountedBytes = nativeXHTTPAccountedPacketBytes(int64(len(p.Payload)))
|
||||
}
|
||||
if err := q.adoptPayloadMemory(ctx, memory, accountedBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
p.accountedBytes = accountedBytes
|
||||
if accountedBytes > 0 {
|
||||
defer func() {
|
||||
if accountedBytes > 0 {
|
||||
q.releasePayloadMemory(accountedBytes)
|
||||
}
|
||||
}()
|
||||
}
|
||||
select {
|
||||
case q.pushedPackets <- p:
|
||||
select {
|
||||
case <-q.closed:
|
||||
return io.ErrClosedPipe
|
||||
default:
|
||||
}
|
||||
// Ownership has moved to the queue. close() waits for this producer and
|
||||
// then drains/releases anything not consumed by the stream reader.
|
||||
accountedBytes = 0
|
||||
readerReserved = false
|
||||
return nil
|
||||
case <-q.closed:
|
||||
return io.ErrClosedPipe
|
||||
@@ -1037,13 +1468,41 @@ func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket)
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) close() {
|
||||
q.closeOnce.Do(func() {
|
||||
close(q.closed)
|
||||
q.mu.Lock()
|
||||
q.closedFlag = true
|
||||
reader := q.reader
|
||||
close(q.closed)
|
||||
q.mu.Unlock()
|
||||
if reader != nil {
|
||||
_ = reader.Close()
|
||||
}
|
||||
|
||||
q.pushWG.Wait()
|
||||
q.readMu.Lock()
|
||||
// No producers or readers can now change the queue. Drop references to
|
||||
// buffered payloads promptly and return their exact byte reservation.
|
||||
for {
|
||||
select {
|
||||
case p := <-q.pushedPackets:
|
||||
if p.Reader != nil {
|
||||
_ = p.Reader.Close()
|
||||
}
|
||||
p.Payload = nil
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
drained:
|
||||
q.mu.Lock()
|
||||
remaining := q.bufferedBytes
|
||||
q.bufferedBytes = 0
|
||||
for i := range q.heap {
|
||||
q.heap[i].Payload = nil
|
||||
}
|
||||
q.heap = nil
|
||||
q.mu.Unlock()
|
||||
q.readMu.Unlock()
|
||||
releaseNativeXHTTPMemory(remaining)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1085,59 +1544,81 @@ func (q *nativeXHTTPUploadQueue) recv() (nativeXHTTPPacket, error) {
|
||||
}
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
|
||||
q.readMu.Lock()
|
||||
defer q.readMu.Unlock()
|
||||
|
||||
if reader := q.loadReader(); reader != nil {
|
||||
return reader.Read(b)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-q.closed:
|
||||
return 0, io.EOF
|
||||
default:
|
||||
}
|
||||
|
||||
if len(q.heap) == 0 {
|
||||
p, err := q.recv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.Reader != nil {
|
||||
q.setReader(p.Reader)
|
||||
return p.Reader.Read(b)
|
||||
}
|
||||
heap.Push(&q.heap, p)
|
||||
}
|
||||
|
||||
for len(q.heap) > 0 {
|
||||
packet := heap.Pop(&q.heap).(nativeXHTTPPacket)
|
||||
|
||||
if packet.Seq == q.nextSeq {
|
||||
n := copy(b, packet.Payload)
|
||||
if n < len(packet.Payload) {
|
||||
packet.Payload = packet.Payload[n:]
|
||||
heap.Push(&q.heap, packet)
|
||||
} else {
|
||||
q.nextSeq = packet.Seq + 1
|
||||
}
|
||||
return n, nil
|
||||
for {
|
||||
select {
|
||||
case <-q.closed:
|
||||
return 0, io.EOF
|
||||
default:
|
||||
}
|
||||
|
||||
if packet.Seq > q.nextSeq {
|
||||
if len(q.heap) > q.maxPackets {
|
||||
return 0, errors.New("xhttp upload reassembly buffer too large")
|
||||
}
|
||||
heap.Push(&q.heap, packet)
|
||||
if len(q.heap) == 0 {
|
||||
p, err := q.recv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.Reader != nil {
|
||||
return 0, errors.New("xhttp mixed stream-up and packet-up upload")
|
||||
if !q.setReader(p.Reader) {
|
||||
_ = p.Reader.Close()
|
||||
return 0, io.EOF
|
||||
}
|
||||
return p.Reader.Read(b)
|
||||
}
|
||||
select {
|
||||
case <-q.closed:
|
||||
q.releasePayloadMemory(p.accountedBytes)
|
||||
return 0, io.EOF
|
||||
default:
|
||||
}
|
||||
heap.Push(&q.heap, p)
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
for len(q.heap) > 0 {
|
||||
packet := heap.Pop(&q.heap).(nativeXHTTPPacket)
|
||||
|
||||
if packet.Seq == q.nextSeq {
|
||||
if len(packet.Payload) == 0 {
|
||||
q.releasePayloadMemory(packet.accountedBytes)
|
||||
q.nextSeq = packet.Seq + 1
|
||||
continue
|
||||
}
|
||||
n := copy(b, packet.Payload)
|
||||
if n < len(packet.Payload) {
|
||||
q.releasePayloadMemory(int64(n))
|
||||
packet.accountedBytes -= int64(n)
|
||||
packet.Payload = packet.Payload[n:]
|
||||
heap.Push(&q.heap, packet)
|
||||
} else {
|
||||
q.releasePayloadMemory(packet.accountedBytes)
|
||||
q.nextSeq = packet.Seq + 1
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
if packet.Seq > q.nextSeq {
|
||||
heap.Push(&q.heap, packet)
|
||||
p, err := q.recv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.Reader != nil {
|
||||
_ = p.Reader.Close()
|
||||
return 0, errors.New("xhttp mixed stream-up and packet-up upload")
|
||||
}
|
||||
heap.Push(&q.heap, p)
|
||||
continue
|
||||
}
|
||||
|
||||
// A duplicate/late packet is discarded; release the bytes it owned.
|
||||
q.releasePayloadMemory(packet.accountedBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) loadReader() io.ReadCloser {
|
||||
@@ -1146,10 +1627,14 @@ func (q *nativeXHTTPUploadQueue) loadReader() io.ReadCloser {
|
||||
return q.reader
|
||||
}
|
||||
|
||||
func (q *nativeXHTTPUploadQueue) setReader(r io.ReadCloser) {
|
||||
func (q *nativeXHTTPUploadQueue) setReader(r io.ReadCloser) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.closedFlag {
|
||||
return false
|
||||
}
|
||||
q.reader = r
|
||||
q.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
type nativeXHTTPHeap []nativeXHTTPPacket
|
||||
|
||||
Reference in New Issue
Block a user