Native Xray

This commit is contained in:
2026-07-04 17:26:01 -03:00
parent 6cd9626db9
commit 0eaa48ffd0
23 changed files with 7058 additions and 312 deletions
-27
View File
@@ -1,27 +0,0 @@
# Build output
sshpanel
sshpanel.bak
*.bak
# Runtime/generated config
.env
config.json
xray_config.json
banner.txt
# Secrets / keys / certificates
keys/
certs/
*.pem
*.key
ssh_host_*_key
ssh_host_*_key.pub
# Logs / runtime data
logs/
*.log
# Local/editor
.DS_Store
.vscode/
.idea/
+221
View File
@@ -151,6 +151,72 @@ systemctl status sshpanel-dnstt-redirect --no-pager -l
sudo iptables -t nat -S PREROUTING | grep 5300
```
### Reinício automático do DNSTT
O DNSTT pode ser reiniciado automaticamente sem reiniciar a VPS e sem derrubar o painel inteiro. No painel, abra **DragonCore → DNSTT Tunnel** e configure:
- **Auto Restart Interval**: intervalo como `30m`, `2h` ou `6h`; use `0s`, `off` ou deixe vazio para desativar.
- **Restart Grace Delay**: pausa antes de reabrir a porta UDP; padrão `2s`.
Também é possível editar diretamente o `config.json`:
```json
"dnstt": {
"domain": "t.example.com",
"udp_listen": "[::]:5300",
"privkey_file": "/opt/sshpanel/dnstt.key",
"auto_restart_interval": "6h",
"auto_restart_grace": "2s"
}
```
### Vários domínios/NS no DNSTT
O DNSTT aceita múltiplos domínios raiz no mesmo listener UDP e com a mesma chave. No painel, abra **DragonCore → DNSTT Tunnel → NS / Root Domains** e coloque um domínio por linha. Isso permite usar um domínio público e outro domínio local da sua rede no mesmo servidor.
Exemplo:
```json
"dnstt": {
"domain": "t.example.com",
"domains": [
"t.example.com",
"t.local.lan"
],
"udp_listen": "[::]:5300",
"privkey_file": "/opt/sshpanel/dnstt.key"
}
```
`domain` continua existindo para compatibilidade. O primeiro item de `domains` é usado como domínio principal.
Para testar com DNS local, aponte o NS/A do domínio local para o IP LAN do servidor DNSTT ou configure seu DNS local para encaminhar essa zona para o IP/porta UDP do DNSTT.
### Reinício automático do proxy e UDPGW
O proxy e o UDPGW também podem ser reiniciados por intervalo. Estes reinícios são **hard restart** para substituir o temporizador em `screen` que reiniciava tudo:
- **Proxy Auto Restart Interval** reinicia os listeners públicos (`listen`, `extra_listen` e TLS forwarders) e fecha as sessões SSH ativas.
- **UDPGW Auto Restart Interval** fecha o listener UDPGW e todos os clientes UDPGW conectados antes de subir novamente.
- Use valores como `6h`, `12h` ou `24h`; `0s`, `off` ou vazio desativa.
- **Restart Grace Delay** define a pausa antes de abrir novamente; padrão `2s`.
Exemplo no `config.json`:
```json
{
"listen": "0.0.0.0:80",
"extra_listen": ["0.0.0.0:8080"],
"proxy_auto_restart_interval": "24h",
"proxy_auto_restart_grace": "2s",
"udpgw": {
"listen": "0.0.0.0:7400",
"auto_restart_interval": "24h",
"auto_restart_grace": "2s"
}
}
```
### Comandos úteis
Ver status do serviço:
@@ -471,6 +537,72 @@ systemctl status sshpanel-dnstt-redirect --no-pager -l
sudo iptables -t nat -S PREROUTING | grep 5300
```
### DNSTT auto restart
DNSTT can be restarted automatically without rebooting the VPS and without restarting the whole panel. In the panel, open **DragonCore → DNSTT Tunnel** and configure:
- **Auto Restart Interval**: duration like `30m`, `2h`, or `6h`; use `0s`, `off`, or leave it empty to disable.
- **Restart Grace Delay**: pause before reopening the UDP port; default is `2s`.
You can also edit `config.json` directly:
```json
"dnstt": {
"domain": "t.example.com",
"udp_listen": "[::]:5300",
"privkey_file": "/opt/sshpanel/dnstt.key",
"auto_restart_interval": "6h",
"auto_restart_grace": "2s"
}
```
### Multiple DNSTT NS/root domains
DNSTT can accept multiple root domains on the same UDP listener with the same key. In the panel, open **DragonCore → DNSTT Tunnel → NS / Root Domains** and enter one domain per line. This lets you use a public domain and a local network domain on the same server.
Example:
```json
"dnstt": {
"domain": "t.example.com",
"domains": [
"t.example.com",
"t.local.lan"
],
"udp_listen": "[::]:5300",
"privkey_file": "/opt/sshpanel/dnstt.key"
}
```
`domain` is kept for backward compatibility. The first item in `domains` is mirrored as the primary domain.
For local DNS testing, point the local domain's NS/A record to the DNSTT server LAN IP or configure your local DNS server to forward that zone to the DNSTT UDP IP/port.
### Proxy and UDPGW auto restart
The proxy and UDPGW can also restart by interval. These are **hard restarts**, intended to replace a `screen` timer that restarted everything:
- **Proxy Auto Restart Interval** restarts public listeners (`listen`, `extra_listen`, and TLS forwarders) and closes active SSH sessions.
- **UDPGW Auto Restart Interval** closes the UDPGW listener and all connected UDPGW clients before starting again.
- Use values like `6h`, `12h`, or `24h`; `0s`, `off`, or empty disables it.
- **Restart Grace Delay** controls the pause before reopening; default is `2s`.
Example `config.json`:
```json
{
"listen": "0.0.0.0:80",
"extra_listen": ["0.0.0.0:8080"],
"proxy_auto_restart_interval": "24h",
"proxy_auto_restart_grace": "2s",
"udpgw": {
"listen": "0.0.0.0:7400",
"auto_restart_interval": "24h",
"auto_restart_grace": "2s"
}
}
```
### Useful commands
Check service status:
@@ -641,3 +773,92 @@ Common errors:
```json
{"error":"database not configured"}
```
### DNSTT scale guard for high-user servers
The integrated DNSTT service includes overload protection so thousands of DNS tunnel users cannot exhaust RAM or crash the whole panel as easily.
DNSTT config fields:
```json
{
"dnstt": {
"max_sessions": 10000,
"max_streams": 15000,
"pending_responses": 20000,
"stream_buffer": 262144,
"udp_read_buffer": 16777216,
"udp_write_buffer": 16777216,
"log_connections": false
}
}
```
Values can be changed in the admin panel under **DNSTT Tunnel**. Use `0` to keep the safe default. Use `-1` only for `max_sessions` or `max_streams` if you intentionally want no hard limit.
Recommended busy-server values:
- `max_sessions`: `10000`
- `max_streams`: `15000`
- `pending_responses`: `20000`
- `stream_buffer`: `262144`
- `udp_read_buffer`: `16777216`
- `udp_write_buffer`: `16777216`
- `log_connections`: `false`
DNSTT now also recovers panics inside DNSTT goroutines, rejects new sessions/streams when limits are reached, and reports these counters in `/api/dnstt`. The admin panel shows them on the main **Dashboard** when DNSTT is enabled. If `dnstt` is disabled in the config, the dashboard card is hidden completely. `/api/dnstt` also returns an `enabled` flag. The old dashboard quick-action button card was removed:
- `active_sessions`
- `active_streams`
- `sess_rejected`
- `stream_rejected`
- `panic_recovered`
- `rec_dropped`
- `parse_err`
- `ch_len`
For very large DNSTT deployments, raise Linux socket buffer limits too, for example:
```bash
cat >/etc/sysctl.d/99-dragon-dnstt.conf <<'SYSCTL'
net.core.rmem_max=67108864
net.core.wmem_max=67108864
net.core.netdev_max_backlog=250000
net.ipv4.udp_mem=262144 524288 1048576
SYSCTL
sysctl --system
```
### DNSTT built-in local DNS / fake DNS over IPv6
DNSTT can now open an extra internal DNS listener for local testing without a second DNS server.
This listener feeds DNS tunnel packets directly into the same integrated DNSTT session pool and private key.
Example IPv6-only config:
```json
{
"dnstt": {
"domain": "t.example.com",
"domains": ["t.example.com", "t.local.lan"],
"udp_listen": "[::]:5300",
"fake_dns_enabled": true,
"fake_dns_listen": "[2001:db8::1234]:53",
"fake_dns_domain": "t.local.lan",
"fake_dns_workers": 4,
"dns_response_workers": 1,
"privkey_file": "/opt/sshpanel/dnstt.key"
}
}
```
Notes:
- `fake_dns_listen` accepts IPv6 bracket syntax such as `[2001:db8::1234]:53` or `[::]:53`.
- IPv6 listeners are opened with `udp6`, so they do not try to reserve IPv4 port 53. This lets an existing IPv4 master DNS keep using IPv4 port 53 while DNSTT uses a new IPv6 address.
- The built-in local DNS listener only accepts `fake_dns_domain`, for example `t.local.lan`.
- `fake_dns_workers` adds concurrent UDP read/parse workers for the local DNS listener. Use `0` for the automatic default; `4` to `8` is a good starting range for busy servers.
- `dns_response_workers` shards DNS response sending. Keep it at `0` or `1` unless the DNSTT **Queue** grows under load; then test `2` to `4`.
- The normal `udp_listen` listener still accepts the full `domains` list.
- Port 53 may require root privileges or the `CAP_NET_BIND_SERVICE` capability.
- These fields can be changed from the admin panel under **DNSTT Tunnel**.
+11
View File
@@ -627,3 +627,14 @@ select:disabled {
color:#94a3b8 !important;
background:#070b12 !important;
}
/* Xray runtime mode selector */
.input-sm{
min-height:30px;
padding:4px 8px;
border-radius:10px;
border:1px solid rgba(34,211,238,.26);
background:#070b12;
color:#f3f7ff;
font-size:.78rem;
}
+260 -18
View File
@@ -42,7 +42,7 @@ const I18N_TEXT = {
"Server Load":"Server Load","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Updated: {time}","Error loading stats.":"Error loading stats.","Normal load":"Normal load","Moderate load":"Moderate load","High load":"High load","Cleaning interface totals…":"Cleaning interface totals…","Interface totals cleaned. Auto-clean remains every 30 days.":"Interface totals cleaned. Auto-clean remains every 30 days.","Error cleaning totals: {error}":"Error cleaning totals: {error}",
"VnStat Usage":"VnStat Usage","Today total":"Today total","This month total":"This month total","Interfaces tracked":"Interfaces tracked","daily / monthly":"daily / monthly","Daily usage":"Daily usage","Monthly usage":"Monthly usage","Day":"Day","Month":"Month","Clean usage":"Clean usage","Clean VnStat history":"Clean VnStat history","VnStat history does not auto-clean. Use the button when you want to reset it.":"VnStat history does not auto-clean. Use the button when you want to reset it.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.","Loading VnStat usage…":"Loading VnStat usage…","VnStat history cleaned.":"VnStat history cleaned.","Error loading VnStat usage: {error}":"Error loading VnStat usage: {error}","Error cleaning VnStat history: {error}":"Error cleaning VnStat history: {error}",
"Panel / system":"Panel / system","Select a log source and click Refresh.":"Select a log source and click Refresh.","Clean panel log":"Clean panel log","No log lines yet.":"No log lines yet.","Panel log cleaned · {path} · max {max}":"Panel log cleaned · {path} · max {max}","Cleaning panel log…":"Cleaning panel log…",
"Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes"
"Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Auto Restart Interval":"Auto Restart Interval","Restart Grace Delay":"Restart Grace Delay","0s/off disables":"0s/off disables","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes"
},
"pt-BR": {
"Dashboard":"Painel","Overview":"Visão geral","Accounts":"Contas","Administration":"Administração","Server":"Servidor","System":"Sistema","Settings":"Configurações","Traffic":"Tráfego","Monitoring":"Monitoramento",
@@ -59,7 +59,7 @@ const I18N_TEXT = {
"Server Load":"Carga do servidor","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Atualizado: {time}","Error loading stats.":"Erro ao carregar stats.","Normal load":"Carga normal","Moderate load":"Carga moderada","High load":"Carga alta","Cleaning interface totals…":"Limpando totais das interfaces…","Interface totals cleaned. Auto-clean remains every 30 days.":"Totais das interfaces limpos. A limpeza automática continua a cada 30 dias.","Error cleaning totals: {error}":"Erro ao limpar totais: {error}",
"VnStat Usage":"Uso do VnStat","Today total":"Total hoje","This month total":"Total este mês","Interfaces tracked":"Interfaces monitoradas","daily / monthly":"diário / mensal","Daily usage":"Uso diário","Monthly usage":"Uso mensal","Day":"Dia","Month":"Mês","Clean usage":"Limpar uso","Clean VnStat history":"Limpar histórico VnStat","VnStat history does not auto-clean. Use the button when you want to reset it.":"O histórico VnStat não é limpo automaticamente. Use o botão quando quiser resetar.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Os totais podem ser limpos aqui e têm limpeza automática a cada 30 dias. O histórico VnStat é separado.","Loading VnStat usage…":"Carregando uso do VnStat…","VnStat history cleaned.":"Histórico VnStat limpo.","Error loading VnStat usage: {error}":"Erro ao carregar uso do VnStat: {error}","Error cleaning VnStat history: {error}":"Erro ao limpar histórico VnStat: {error}",
"Panel / system":"Painel / sistema","Select a log source and click Refresh.":"Selecione uma fonte de log e clique em Atualizar.","Clean panel log":"Limpar log do painel","No log lines yet.":"Ainda não há linhas de log.","Panel log cleaned · {path} · max {max}":"Log do painel limpo · {path} · máx {max}","Cleaning panel log…":"Limpando log do painel…",
"Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações"
"Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","Proxy Auto Restart":"Reinício automático do proxy","Proxy Auto Restart Interval":"Intervalo de reinício automático do proxy","Proxy Restart Grace Delay":"Atraso para reiniciar proxy","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Auto Restart Interval":"Intervalo de reinício automático","Restart Grace Delay":"Atraso para reiniciar","0s/off disables":"0s/off desativa","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações"
}
};
const I18N_ALIASES = {
@@ -69,14 +69,14 @@ const I18N_ALIASES = {
"Minha conta":"My Account","Usuários":"Users","Usuário":"User","Autenticação":"Auth","Conexões":"Conn","Máximo":"Max","Dono":"Owner","Ações":"Actions","Criar / atualizar usuário":"Create / update user","Mostrar formulário":"Show form","Ocultar formulário":"Hide form","Salvar usuário":"Save user","Cancelar":"Cancel","Gerar":"Gen","Copiar":"Copy","Editar":"Edit","Excluir":"Del","Recarregar":"Reload","Atualizar":"Refresh",
"Rodando":"Running","Parado":"Stopped","rodando":"running","parado":"stopped","desativado":"disabled","API de contadores":"Counters API","Reparar contadores":"Repair counters","Iniciar":"Start","Parar":"Stop","Reiniciar":"Restart","Inbounds e clientes":"Inbounds & Clients","Configuração Xray":"Xray Config","Editor de configuração":"Config editor","Carregar JSON":"Load JSON","Salvar e reiniciar":"Save & Restart","Logs do sistema":"System Logs","últimas 200 linhas":"last 200 lines","Clientes Xray":"Xray clients","Núcleo Xray":"Xray Core","Ativado":"Enabled","Tempo ativo":"Uptime","Precisa de reparo":"Needs repair",
"Nome":"Name","Nome de exibição":"Display Name","Data de vencimento":"Expiry Date","Máx. conexões":"Max Connections","Ilimitado":"Unlimited","Ativo":"Active","Suspenso":"Suspended","Expirado":"Expired","Sem vencimento":"No expiration","ocioso":"idle","Nenhum cliente.":"No clients.","Adicionar cliente":"Add Client","Novo usuário.":"New user.","Carregado.":"Loaded.","Salvo.":"Saved.","Salvando…":"Saving…","Erro ao carregar usuários.":"Error loading users.","Erro ao excluir.":"Error deleting.","Credenciais inválidas.":"Invalid credentials.","Conta suspensa ou expirada.":"Account suspended or expired.","Falha no login.":"Login failed.","Erro de rede.":"Network error.","Sessão expirada — faça login novamente.":"Session expired — please sign in again.",
"Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live."
"Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","Reinício automático do proxy":"Proxy Auto Restart","Intervalo de reinício automático do proxy":"Proxy Auto Restart Interval","Atraso para reiniciar proxy":"Proxy Restart Grace Delay","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live."
};
Object.assign(I18N_TEXT["en-US"], {
"Servers":"Servers","Reseller area":"Reseller area","shared quota":"shared quota","available":"available","used":"used","breakdown":"breakdown",
"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.",
"Loading inbounds…":"Loading inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"active ·","expired":"expired",
"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
"Public Key — share with dnstt clients":"Public Key — share with DNSTT clients","auto-saved to /opt/sshpanel/dnstt.key":"auto-saved to /opt/sshpanel/dnstt.key",
"Max UDP Sessions Per Client":"Max UDP Sessions Per Client","(not total server users)":"(not total server users)","Service Name":"Service Name","Mode":"Mode","Protocol":"Protocol","Port":"Port","Tag":"Tag","Listen IP":"Listen IP","Method":"Method","Host":"Host","Path":"Path","Dest":"Dest","Short ID":"Short ID","Server Name":"Server Name","Cert File Path":"Cert File Path","Key File Path":"Key File Path","Certificate source:":"Certificate source:","Self-Signed":"Self-Signed","Paste PEM":"Paste PEM","File Path":"File Path","Save PEM":"Save PEM","Generate":"Generate","Public Key":"Public Key","Debug Logging":"Debug Logging","Name":"Name","Private Key PEM":"Private Key PEM","Certificate PEM":"Certificate PEM","Domain Name":"Domain Name"
});
@@ -84,7 +84,7 @@ Object.assign(I18N_TEXT["pt-BR"], {
"Servers":"Servidores","Reseller area":"Área do revendedor","shared quota":"cota única","available":"disponíveis","used":"usadas","breakdown":"divisão",
"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.",
"Loading inbounds…":"Carregando inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"ativas ·","expired":"expiradas",
"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085",
"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085",
"Public Key — share with dnstt clients":"Chave pública — compartilhe com clientes DNSTT","auto-saved to /opt/sshpanel/dnstt.key":"salva automaticamente em /opt/sshpanel/dnstt.key",
"Max UDP Sessions Per Client":"Máx. sessões UDP por cliente","(not total server users)":"(não é o total de usuários do servidor)","Service Name":"Nome do serviço","Mode":"Modo","Protocol":"Protocolo","Port":"Porta","Tag":"Tag","Listen IP":"IP de listen","Method":"Método","Host":"Host","Path":"Caminho","Dest":"Destino","Short ID":"ID curto","Server Name":"Nome do servidor","Cert File Path":"Caminho do arquivo cert","Key File Path":"Caminho do arquivo key","Certificate source:":"Fonte do certificado:","Self-Signed":"Autoassinado","Paste PEM":"Colar PEM","File Path":"Caminho do arquivo","Save PEM":"Salvar PEM","Generate":"Gerar","Public Key":"Chave pública","Debug Logging":"Log de debug","Name":"Nome","Private Key PEM":"Chave privada PEM","Certificate PEM":"Certificado PEM","Domain Name":"Nome do domínio"
});
@@ -92,9 +92,9 @@ Object.assign(I18N_ALIASES, {
"Servidores":"Servers","Área do revendedor":"Reseller area","cota única":"shared quota","disponíveis":"available","usadas":"used","divisão":"breakdown",
"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.",
"Carregando inbounds…":"Loading inbounds…","Loading inbounds…":"Loading inbounds…","ativas ·":"active ·","expiradas":"expired",
"Binário: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
"Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients",
"Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Chave pública":"Public Key","Nome do domínio":"Domain Name"
"Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name"
});
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();
@@ -269,6 +269,8 @@ const xPID = document.getElementById("xPID");
const xUptime = document.getElementById("xUptime");
const xStatus = document.getElementById("xStatus");
const xOnlineUsers = document.getElementById("xOnlineUsers");
const xCoreMode = document.getElementById("xCoreMode");
const xSaveModeBtn = document.getElementById("xSaveModeBtn");
const xCfgEditor = document.getElementById("xCfgEditor");
const xCfgStatus = document.getElementById("xCfgStatus");
const xLogsBox = document.getElementById("xLogsBox");
@@ -326,6 +328,14 @@ const ifaceBody = document.getElementById("ifaceBody");
const ifaceSummary = document.getElementById("ifaceSummary");
const statsUpdated = document.getElementById("statsUpdated");
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
const dnsttActiveSessions = document.getElementById("dnsttActiveSessions");
const dnsttActiveStreams = document.getElementById("dnsttActiveStreams");
const dnsttDNSRx = document.getElementById("dnsttDNSRx");
const dnsttQueueLen = document.getElementById("dnsttQueueLen");
const dnsttHealthBody = document.getElementById("dnsttHealthBody");
const dnsttHealthSummary = document.getElementById("dnsttHealthSummary");
// VnStat
const vnstatDailyBody = document.getElementById("vnstatDailyBody");
@@ -362,6 +372,25 @@ function selectedXrayServerLabel() {
if (srv) return srv.name || srv.base_url || id;
return id === "local" ? "Master node" : id;
}
function xrayModeFromConfig(x) {
const mode = String(x?.mode || "").toLowerCase();
return mode === "external" ? "external" : "native";
}
function applyXrayModeToConfig(cfg, mode) {
mode = mode === "external" ? "external" : "native";
cfg.xray = cfg.xray && typeof cfg.xray === "object" ? cfg.xray : {};
cfg.xray.mode = mode;
cfg.xray.native = mode === "native";
cfg.xray.bin_path = cfg.xray.bin_path || "/opt/sshpanel/xray";
cfg.xray.config_file = cfg.xray.config_file || "/opt/sshpanel/xray_config.json";
cfg.xray.native_config_file = cfg.xray.native_config_file || "/opt/sshpanel/xray_native_config.json";
cfg.xray.api_server = cfg.xray.api_server || "127.0.0.1:10085";
cfg.xray.online_window_seconds = cfg.xray.online_window_seconds || 90;
cfg.xray.stats_poll_seconds = cfg.xray.stats_poll_seconds || 15;
return cfg;
}
function reloadXrayConfigForSelectedServer() {
const wizPane = document.getElementById("xrayWizardPane");
const jsonPane = document.getElementById("xrayCfgPaneJson");
@@ -379,6 +408,16 @@ function fmtBytes(n) {
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
return (m/1024).toFixed(1)+" GiB";
}
function fmtInt(n) {
const v = Number(n);
return Number.isFinite(v) ? v.toLocaleString() : "--";
}
function fmtDnsttTimestamp(ts) {
if (!ts) return "Waiting for DNSTT stats…";
const d = new Date(ts);
if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…";
return "Updated: " + d.toLocaleTimeString();
}
function localDateKey(d = new Date()) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -461,6 +500,13 @@ function clientOnlineHTML(c) {
return `${c.online ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("offline")}</span>`}<div class="hint">${escapeHTML(formatLastActive(c.last_active))}</div>`;
}
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>`;
}
function updateCell(row, name, html) {
const cell = row?.querySelector?.(`[data-cell="${name}"]`);
if (cell && cell.innerHTML !== html) cell.innerHTML = html;
@@ -496,7 +542,7 @@ function patchRenderedInbounds(inbounds) {
updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
updateCell(row, "status", clientStatusHTML(c));
updateCell(row, "online", clientOnlineHTML(c));
updateCell(row, "traffic", escapeHTML(formatBytes(c.total_bytes)));
updateCell(row, "traffic", clientTrafficHTML(c));
updateCell(row, "max", escapeHTML(c.max_conns || "∞"));
}
}
@@ -550,7 +596,6 @@ document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
languageSelect?.addEventListener("change", () => { applyLanguage(languageSelect.value); renderDashboardCounters(); });
document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump)));
applyLanguage(currentLang, { persist: false });
startI18nObserver();
@@ -956,6 +1001,7 @@ document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("s
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
xSaveModeBtn?.addEventListener("click", saveXrayCoreMode);
document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); });
document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true }));
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
@@ -972,8 +1018,9 @@ async function loadXrayStatus() {
xrayChip.className = "chip " + (run ? "green" : "red");
xRunning.textContent = run ? t("Running") : t("Stopped");
xRunning.style.color = run ? "var(--success)" : "var(--danger)";
xPID.textContent = s.pid || "--";
xPID.textContent = s.pid || (s.native ? "internal" : "--");
xUptime.textContent = s.uptime || "--";
if (xCoreMode) xCoreMode.value = String(s.mode || (s.native ? "native" : "external")).toLowerCase() === "external" ? "external" : "native";
const statsCfgEl = document.getElementById("xStatsConfig");
const repairBtn = document.getElementById("xRepairStatsBtn");
if (statsCfgEl) {
@@ -997,6 +1044,29 @@ async function loadXrayStatus() {
} catch (e) { if (e.message==="auth") doAuthError(); }
}
async function saveXrayCoreMode() {
const mode = xCoreMode?.value === "external" ? "external" : "native";
const target = selectedXrayServerLabel();
const selectedID = selectedXrayServer() || "local";
if (xStatus) xStatus.textContent = `Saving Xray mode on ${target}...`;
try {
const getRes = await api(withServerParam("/api/servers/config", selectedID));
if (!getRes.ok) throw new Error(await getRes.text());
const cfg = await getRes.json();
applyXrayModeToConfig(cfg, mode);
const postRes = await api(withServerParam("/api/servers/config", selectedID), { method:"POST", body: JSON.stringify(cfg) });
if (!postRes.ok) throw new Error(await postRes.text());
if (xStatus) xStatus.textContent = mode === "native"
? `Saved on ${target}: using internal native emulator.`
: `Saved on ${target}: using external Xray binary.`;
setTimeout(loadXrayStatus, 700);
setTimeout(() => loadInbounds({ force: true }), 1200);
} catch (e) {
if (e.message === "auth") doAuthError();
else if (xStatus) xStatus.textContent = t("Error: {error}", {error: e.message});
}
}
async function repairXrayStats() {
const btn = document.getElementById("xRepairStatsBtn");
if (btn) btn.disabled = true;
@@ -1155,7 +1225,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="traffic" style="font-size:.7rem;">${escapeHTML(formatBytes(c.total_bytes))}</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");
actTd.style.whiteSpace = "nowrap";
@@ -1215,7 +1285,7 @@ async function addClient(tag) {
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns, server_id: selectedXrayServer() }),
});
if (!res.ok) throw new Error(await res.text());
xStatus.textContent = t("Client {id}… added. Restarting Xray…", {id: uuid.slice(0,8)});
xStatus.textContent = t("Client {id}… added. Native mode hot-reloads without restart.", {id: uuid.slice(0,8)});
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) {
if (e.message==="auth") doAuthError();
@@ -1228,7 +1298,7 @@ async function removeClient(tag, uuid) {
try {
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error(await res.text());
xStatus.textContent = t("Client removed. Restarting Xray…");
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart.");
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) {
if (e.message==="auth") doAuthError();
@@ -1764,6 +1834,8 @@ async function loadManagedServerConfig(id) {
document.getElementById("managedCfgListen").value = c.listen || "";
document.getElementById("managedCfgExtraListen").value = (c.extra_listen || []).join("\n");
document.getElementById("managedCfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
document.getElementById("managedCfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
document.getElementById("managedCfgLimitUp").value = c.default_limit_mbps_up || 0;
document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0;
@@ -1775,11 +1847,25 @@ async function loadManagedServerConfig(id) {
document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt;
toggleManagedDnsttFields(hasDnstt);
const d = c.dnstt || {};
document.getElementById("managedCfgDnsttDomain").value = d.domain || "";
document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d);
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "";
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";
document.getElementById("managedCfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
document.getElementById("managedCfgDnsttRespWorkers").value = d.dns_response_workers || 0;
document.getElementById("managedCfgDnsttAutoRestart").value = d.auto_restart_interval || "";
document.getElementById("managedCfgDnsttRestartGrace").value = d.auto_restart_grace || "";
document.getElementById("managedCfgDnsttMaxSessions").value = d.max_sessions || 0;
document.getElementById("managedCfgDnsttMaxStreams").value = d.max_streams || 0;
document.getElementById("managedCfgDnsttPendingResponses").value = d.pending_responses || 0;
document.getElementById("managedCfgDnsttStreamBuffer").value = d.stream_buffer || 0;
document.getElementById("managedCfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
document.getElementById("managedCfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
document.getElementById("managedCfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log;
document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log;
document.getElementById("managedCfgDnsttLogConnections").checked = !!d.log_connections;
const hasUdpgw = !!c.udpgw;
document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw;
@@ -1789,6 +1875,8 @@ async function loadManagedServerConfig(id) {
document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0;
document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || "";
document.getElementById("managedCfgUdpgwMapTTL").value = u.map_ttl || "";
document.getElementById("managedCfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
document.getElementById("managedCfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
document.getElementById("managedCfgUdpgwDebug").checked = !!u.debug;
managedTlsForwardersState = c.tls_forwarders || [];
@@ -1796,6 +1884,7 @@ async function loadManagedServerConfig(id) {
const x = c.xray || {};
document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled;
document.getElementById("managedCfgXrayMode").value = xrayModeFromConfig(x);
document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden");
if (st) st.textContent = "Config loaded.";
@@ -1808,9 +1897,12 @@ async function loadManagedServerConfig(id) {
function managedConfigFromForm() {
const extraLines = document.getElementById("managedCfgExtraListen").value
.split("\n").map(s => s.trim()).filter(Boolean);
const dnsttDomains = readDnsttDomains("managedCfgDnsttDomains");
return {
listen: document.getElementById("managedCfgListen").value.trim(),
extra_listen: extraLines,
proxy_auto_restart_interval: document.getElementById("managedCfgProxyAutoRestart").value.trim(),
proxy_auto_restart_grace: document.getElementById("managedCfgProxyRestartGrace").value.trim(),
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
admin_dir: "/opt/sshpanel/admin",
default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10),
@@ -1820,24 +1912,44 @@ function managedConfigFromForm() {
banner: document.getElementById("managedCfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
domain: document.getElementById("managedCfgDnsttDomain").value.trim(),
domain: dnsttDomains[0] || "",
domains: dnsttDomains,
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(),
fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked,
fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(),
fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(),
fake_dns_workers: parseInt(document.getElementById("managedCfgDnsttFakeWorkers").value || "0", 10),
dns_response_workers: parseInt(document.getElementById("managedCfgDnsttRespWorkers").value || "0", 10),
auto_restart_interval: document.getElementById("managedCfgDnsttAutoRestart").value.trim(),
auto_restart_grace: document.getElementById("managedCfgDnsttRestartGrace").value.trim(),
max_sessions: parseInt(document.getElementById("managedCfgDnsttMaxSessions").value || "0", 10),
max_streams: parseInt(document.getElementById("managedCfgDnsttMaxStreams").value || "0", 10),
pending_responses: parseInt(document.getElementById("managedCfgDnsttPendingResponses").value || "0", 10),
stream_buffer: parseInt(document.getElementById("managedCfgDnsttStreamBuffer").value || "0", 10),
udp_read_buffer: parseInt(document.getElementById("managedCfgDnsttUDPReadBuffer").value || "0", 10),
udp_write_buffer: parseInt(document.getElementById("managedCfgDnsttUDPWriteBuffer").value || "0", 10),
privkey_file: document.getElementById("managedCfgDnsttKey").value.trim(),
disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked,
disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked,
log_connections: document.getElementById("managedCfgDnsttLogConnections").checked,
} : null,
udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? {
listen: document.getElementById("managedCfgUdpgwListen").value.trim(),
max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10),
idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(),
map_ttl: document.getElementById("managedCfgUdpgwMapTTL").value.trim(),
auto_restart_interval: document.getElementById("managedCfgUdpgwAutoRestart").value.trim(),
auto_restart_grace: document.getElementById("managedCfgUdpgwRestartGrace").value.trim(),
debug: document.getElementById("managedCfgUdpgwDebug").checked,
} : null,
tls_forwarders: managedTlsForwardersState,
xray: {
enabled: document.getElementById("managedCfgXrayEnabled").checked,
mode: document.getElementById("managedCfgXrayMode").value === "external" ? "external" : "native",
native: document.getElementById("managedCfgXrayMode").value !== "external",
bin_path: "/opt/sshpanel/xray",
config_file: "/opt/sshpanel/xray_config.json",
native_config_file: "/opt/sshpanel/xray_native_config.json",
api_server: "127.0.0.1:10085",
online_window_seconds: 90,
stats_poll_seconds: 15,
@@ -2025,6 +2137,7 @@ async function loadDashboardStats() {
if (!res.ok) throw new Error(await res.text());
const s = await res.json();
updateDashboardStats(s);
await loadDnsttHealth();
} catch (e) {
if (e.message === "auth") doAuthError();
else {
@@ -2061,6 +2174,76 @@ function updateDashboardStats(s) {
if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`;
}
async function loadDnsttHealth() {
if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return;
if (currentRole !== "superadmin") {
dnsttDashboardCard?.classList.add("hidden");
return;
}
try {
const res = await api("/api/dnstt");
if (!res.ok) throw new Error(await res.text());
const d = await res.json();
const enabled = d.enabled !== false;
if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled);
if (!enabled) return;
if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions);
if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams);
if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx);
if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len);
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp);
const rows = [
["Session rejected", d.sess_rejected],
["Stream rejected", d.stream_rejected],
["DNS parse errors", d.parse_err],
["No EDNS", d.no_edns],
["EDNS limit 512", d.limit512],
["Local DNS workers", d.fake_dns_workers],
["Response workers", d.dns_response_workers],
["Responses queued", d.rec_queued],
["Responses dropped", d.rec_dropped],
["Responses sent", d.resp_sent],
["Response bytes", d.resp_bytes],
["Empty responses", d.resp_empty],
["Data responses", d.resp_data],
["Oversize responses", d.resp_oversize],
["KCP sessions new", d.kcp_new],
["KCP sessions ended", d.kcp_end],
["SMUX streams new", d.smux_new],
["SMUX streams ended", d.smux_end],
["Panic recovered", d.panic_recovered],
];
if (dnsttHealthBody) {
dnsttHealthBody.innerHTML = "";
for (let i = 0; i < rows.length; i += 2) {
const a = rows[i];
const b = rows[i + 1] || ["", ""];
const tr = document.createElement("tr");
tr.innerHTML = `<td>${a[0]}</td><td>${fmtInt(a[1])}</td><td>${b[0]}</td><td>${b[0] ? fmtInt(b[1]) : ""}</td>`;
dnsttHealthBody.appendChild(tr);
}
}
const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0);
if (dnsttHealthSummary) {
if (d.running === false) {
dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs.";
} else {
dnsttHealthSummary.textContent = bad > 0
? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.`
: "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window.";
}
}
} catch (e) {
if (e.message === "auth") throw e;
dnsttDashboardCard?.classList.add("hidden");
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats.";
if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable.";
}
}
async function loadStats() {
try {
const res = await api("/api/stats");
@@ -2087,6 +2270,7 @@ async function loadStats() {
});
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
await loadDnsttHealth();
} catch (e) {
if (e.message==="auth") doAuthError();
else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats.";
@@ -2223,6 +2407,22 @@ async function clearPanelLog() {
// ─── Server Config ────────────────────────────────────────────────────────────
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
function dnsttDomainsText(d) {
const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []);
return domains.join("\n");
}
function readDnsttDomains(id) {
const el = document.getElementById(id);
if (!el) return [];
const seen = new Set();
return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => {
if (seen.has(s)) return false;
seen.add(s);
return true;
});
}
function toggleDnsttFields(on) {
const el = document.getElementById("dnsttFields");
el.style.opacity = on ? "1" : ".4";
@@ -2245,6 +2445,8 @@ async function loadServerConfig() {
// Network
document.getElementById("cfgListen").value = c.listen || "";
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
// SSH / general
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
@@ -2260,11 +2462,25 @@ async function loadServerConfig() {
document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
toggleDnsttFields(hasDnstt);
const d = c.dnstt || {};
document.getElementById("cfgDnsttDomain").value = d.domain || "";
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
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";
document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0;
document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || "";
document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || "";
document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0;
document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0;
document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0;
document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0;
document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections;
// UDPGW
const hasUdpgw = !!c.udpgw;
@@ -2275,6 +2491,8 @@ async function loadServerConfig() {
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
// TLS forwarders
@@ -2284,6 +2502,7 @@ async function loadServerConfig() {
// Xray
const x = c.xray || {};
document.getElementById("cfgXrayEnabled").checked = !!x.enabled;
document.getElementById("cfgXrayMode").value = xrayModeFromConfig(x);
st.textContent = "Config loaded.";
} catch (e) {
@@ -2300,10 +2519,13 @@ async function saveServerConfig() {
const extraLines = document.getElementById("cfgExtraListen").value
.split("\n").map(s => s.trim()).filter(Boolean);
const dnsttDomains = readDnsttDomains("cfgDnsttDomains");
const cfg = {
listen: document.getElementById("cfgListen").value.trim(),
extra_listen: extraLines,
proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart").value.trim(),
proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace").value.trim(),
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
admin_dir: "/opt/sshpanel/admin",
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
@@ -2313,24 +2535,44 @@ async function saveServerConfig() {
banner: document.getElementById("cfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
domain: document.getElementById("cfgDnsttDomain").value.trim(),
domain: dnsttDomains[0] || "",
domains: dnsttDomains,
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked,
fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(),
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(),
fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers").value || "0", 10),
dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers").value || "0", 10),
auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(),
auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(),
max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions").value || "0", 10),
max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams").value || "0", 10),
pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses").value || "0", 10),
stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer").value || "0", 10),
udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer").value || "0", 10),
udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer").value || "0", 10),
privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
log_connections: document.getElementById("cfgDnsttLogConnections").checked,
} : null,
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
listen: document.getElementById("cfgUdpgwListen").value.trim(),
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart").value.trim(),
auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace").value.trim(),
debug: document.getElementById("cfgUdpgwDebug").checked,
} : null,
tls_forwarders: tlsArr,
xray: {
enabled: document.getElementById("cfgXrayEnabled").checked,
mode: document.getElementById("cfgXrayMode").value === "external" ? "external" : "native",
native: document.getElementById("cfgXrayMode").value !== "external",
bin_path: "/opt/sshpanel/xray",
config_file: "/opt/sshpanel/xray_config.json",
native_config_file: "/opt/sshpanel/xray_native_config.json",
api_server: "127.0.0.1:10085",
online_window_seconds: 90,
stats_poll_seconds: 15,
@@ -2779,7 +3021,7 @@ function wzSaveInbound() {
break;
case "xhttp":
ib.streamSettings.xhttpSettings = {
path: document.getElementById("wzXHTTPPath").value.trim() || "/",
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
mode: document.getElementById("wzXHTTPMode").value,
};
+112 -16
View File
@@ -159,17 +159,24 @@
</div>
<div class="grid2 dashboard-lower">
<div class="card">
<div class="card hidden" id="dnsttDashboardCard">
<div class="card-hdr">
<div class="card-title">Ações rápidas</div>
<span class="chip green">simples</span>
<div class="card-title">DNSTT Health <span class="chip">5s window</span></div>
<span class="hint" id="dnsttHealthUpdated">--</span>
</div>
<div class="quick-actions">
<button class="quick-action" type="button" data-jump="ssh"><strong>Criar SSH</strong><span>Usuário, senha, validade e limite.</span></button>
<button class="quick-action" type="button" data-jump="xray"><strong>Criar Xray</strong><span>UUID, label, validade e conexões.</span></button>
<button class="quick-action superadmin-only hidden" type="button" data-jump="resellers"><strong>Novo revendedor</strong><span>Plano, validade e limite de contas.</span></button>
<button class="quick-action superadmin-only hidden" type="button" data-jump="server"><strong>Configurar serviços</strong><span>Portas, DNSTT, UDPGW e TLS.</span></button>
<div class="metrics">
<div class="metric"><div class="m-label">Active Sessions</div><div class="m-val" id="dnsttActiveSessions">--</div></div>
<div class="metric"><div class="m-label">Active Streams</div><div class="m-val" id="dnsttActiveStreams">--</div></div>
<div class="metric"><div class="m-label">DNS RX / 5s</div><div class="m-val" id="dnsttDNSRx">--</div></div>
<div class="metric"><div class="m-label">Queue</div><div class="m-val" id="dnsttQueueLen">--</div></div>
</div>
<div class="tbl-wrap" style="margin-top:10px;">
<table>
<thead><tr><th>Counter</th><th>Value</th><th>Counter</th><th>Value</th></tr></thead>
<tbody id="dnsttHealthBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="dnsttHealthSummary">DNSTT counters are loaded from /api/dnstt.</span><span class="hint">Reject/drop/panic counters should normally stay at 0.</span></div>
</div>
<div class="card" id="dashboardQuotaCard">
<div class="card-hdr"><div class="card-title">Minha cota</div><span class="chip" id="dashQuotaChip">--</span></div>
@@ -321,6 +328,11 @@
<div class="card-hdr">
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
<div class="card-actions xray-admin-only">
<select id="xCoreMode" class="input-sm" title="Xray runtime mode">
<option value="native">Internal native emulator</option>
<option value="external">External xray binary</option>
</select>
<button class="btn btn-ghost btn-sm" id="xSaveModeBtn">Save mode</button>
<button class="btn btn-ghost btn-sm" id="xStartBtn">Start</button>
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
@@ -410,7 +422,7 @@
<!-- WebSocket -->
<div class="field" id="wzWSPathField" style="display:none;"><label>Path</label><input type="text" id="wzWSPath" placeholder="/ws"/></div>
<!-- XHTTP -->
<div class="field" id="wzXHTTPPathField" style="display:none;"><label>Path</label><input type="text" id="wzXHTTPPath" placeholder="/xhttp"/></div>
<div class="field" id="wzXHTTPPathField" style="display:none;"><label>Path</label><input type="text" id="wzXHTTPPath" placeholder="/xhttp" value="/xhttp"/></div>
<div class="field" id="wzXHTTPHostField" style="display:none;"><label>Host <span class="hint">(SNI)</span></label><input type="text" id="wzXHTTPHost" placeholder="example.com"/></div>
<div class="field" id="wzXHTTPModeField" style="display:none;">
<label>Mode</label>
@@ -645,6 +657,11 @@
<label>Extra Listen Addresses <span class="hint">(one per line, e.g. 0.0.0.0:8080)</span></label>
<textarea id="managedCfgExtraListen" rows="4" style="width:100%;box-sizing:border-box;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:inherit;padding:10px;resize:vertical;"></textarea>
</div>
<div class="form-grid" style="margin-top:8px;">
<div class="field"><label>Proxy Auto Restart Interval <span class="hint">0s/off disables</span></label><input type="text" id="managedCfgProxyAutoRestart" placeholder="24h"/></div>
<div class="field"><label>Proxy Restart Grace Delay</label><input type="text" id="managedCfgProxyRestartGrace" placeholder="2s"/></div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes public proxy listeners and active SSH sessions, then starts the listeners again. Use duration values like 6h, 12h, or 24h.</div>
</div>
</div>
<div class="card" style="margin-top:12px;">
@@ -677,8 +694,23 @@
</label>
</div>
<div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
<div class="field"><label>Domain</label><input type="text" id="managedCfgDnsttDomain" placeholder="t.example.com"/></div>
<div class="field" style="grid-column:1/-1"><label>NS / Root Domains <span class="hint">one per line</span></label><textarea id="managedCfgDnsttDomains" rows="3" placeholder="t.example.com&#10;t.local.lan"></textarea></div>
<div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="[::]: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>
<div class="field"><label>Local DNS Workers <span class="hint">0=auto</span></label><input type="number" id="managedCfgDnsttFakeWorkers" min="0" placeholder="4"/></div>
<div class="field"><label>Response Workers <span class="hint">0=safe</span></label><input type="number" id="managedCfgDnsttRespWorkers" min="0" placeholder="1"/></div>
<div class="field"><label>Auto Restart Interval <span class="hint">0s/off disables</span></label><input type="text" id="managedCfgDnsttAutoRestart" placeholder="6h"/></div>
<div class="field"><label>Restart Grace Delay</label><input type="text" id="managedCfgDnsttRestartGrace" placeholder="2s"/></div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Restarts only the DNSTT UDP listener, not the VPS or the full panel. Use duration values like 30m, 2h, or 6h.</div>
<div class="field"><label>Max Sessions <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="managedCfgDnsttMaxSessions" placeholder="10000"/></div>
<div class="field"><label>Max Streams <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="managedCfgDnsttMaxStreams" placeholder="15000"/></div>
<div class="field"><label>Pending DNS Responses</label><input type="number" id="managedCfgDnsttPendingResponses" placeholder="20000"/></div>
<div class="field"><label>Stream Buffer Bytes</label><input type="number" id="managedCfgDnsttStreamBuffer" placeholder="262144"/></div>
<div class="field"><label>UDP Read Buffer Bytes</label><input type="number" id="managedCfgDnsttUDPReadBuffer" placeholder="16777216"/></div>
<div class="field"><label>UDP Write Buffer Bytes</label><input type="number" id="managedCfgDnsttUDPWriteBuffer" placeholder="16777216"/></div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Scale guard: bounded sessions/streams stop RAM explosions under thousands of DNSTT users. Keep verbose connection logs disabled on busy servers.</div>
<div class="field" style="grid-column:1/-1">
<label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label>
<div class="field-row">
@@ -697,6 +729,7 @@
</div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttNoStats"/> Disable Stats Log</label>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttNoConsole"/> Disable Console Log</label>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttLogConnections"/> Verbose Connection Logs</label>
</div>
</div>
@@ -713,6 +746,9 @@
<div class="field"><label>Max UDP Sessions Per Client <span class="hint">(not total server users)</span></label><input type="number" id="managedCfgUdpgwMaxConns" min="0" placeholder="10"/></div>
<div class="field"><label>Idle Timeout</label><input type="text" id="managedCfgUdpgwIdle" placeholder="2m"/></div>
<div class="field"><label>Map TTL</label><input type="text" id="managedCfgUdpgwMapTTL" placeholder="90s"/></div>
<div class="field"><label>Auto Restart Interval <span class="hint">0s/off disables</span></label><input type="text" id="managedCfgUdpgwAutoRestart" placeholder="24h"/></div>
<div class="field"><label>Restart Grace Delay</label><input type="text" id="managedCfgUdpgwRestartGrace" placeholder="2s"/></div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes the UDPGW listener and every connected UDPGW client, then starts UDPGW again.</div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgUdpgwDebug"/> Debug Logging</label>
</div>
</div>
@@ -764,7 +800,8 @@
<div class="card" style="margin-top:12px">
<div class="card-hdr"><div class="card-title">Xray Core</div><span class="chip green">live</span></div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-top:4px;"><input type="checkbox" id="managedCfgXrayEnabled"/> Enabled</label>
<div class="hint" style="margin-top:6px;color:var(--muted);">Binary: /opt/sshpanel/xray &nbsp;·&nbsp; Config: /opt/sshpanel/xray_config.json &nbsp;·&nbsp; Online counters use Xray Stats API on 127.0.0.1:10085</div>
<div class="field" style="margin-top:8px;"><label>Runtime mode</label><select id="managedCfgXrayMode"><option value="native">Internal native emulator</option><option value="external">External xray binary</option></select></div>
<div class="hint" style="margin-top:6px;color:var(--muted);">Native runs inside DragonCoreSSH. External uses /opt/sshpanel/xray with DB-backed /opt/sshpanel/xray_config.json and Stats API on 127.0.0.1:10085.</div>
</div>
</div>
</div>
@@ -912,6 +949,17 @@
<label>Extra Listen Addresses <span class="hint">(one per line, e.g. 0.0.0.0:8080)</span></label>
<textarea id="cfgExtraListen" rows="3" style="resize:vertical"></textarea>
</div>
<div class="form-grid" style="margin-top:8px;">
<div class="field">
<label>Proxy Auto Restart Interval <span class="hint">0s/off disables</span></label>
<input type="text" id="cfgProxyAutoRestart" placeholder="24h"/>
</div>
<div class="field">
<label>Proxy Restart Grace Delay</label>
<input type="text" id="cfgProxyRestartGrace" placeholder="2s"/>
</div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes public proxy listeners and active SSH sessions, then starts the listeners again. Use duration values like 6h, 12h, or 24h.</div>
</div>
</div>
<!-- SSH & general -->
@@ -967,14 +1015,49 @@
</label>
</div>
<div id="dnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
<div class="field">
<label>Domain</label>
<input type="text" id="cfgDnsttDomain" placeholder="t.example.com"/>
<div class="field" style="grid-column:1/-1">
<label>NS / Root Domains <span class="hint">one per line</span></label>
<textarea id="cfgDnsttDomains" rows="3" placeholder="t.example.com&#10;t.local.lan"></textarea>
</div>
<div class="field">
<label>UDP Listen</label>
<input type="text" id="cfgDnsttUDP" placeholder="[::]: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
</label>
<div class="field">
<label>Local DNS Listen <span class="hint">IPv6 ok</span></label>
<input type="text" id="cfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/>
</div>
<div class="field">
<label>Local DNS Domain</label>
<input type="text" id="cfgDnsttFakeDomain" placeholder="t.local.lan"/>
</div>
<div class="field">
<label>Local DNS Workers <span class="hint">0=auto</span></label>
<input type="number" id="cfgDnsttFakeWorkers" min="0" placeholder="4"/>
</div>
<div class="field">
<label>Response Workers <span class="hint">0=safe</span></label>
<input type="number" id="cfgDnsttRespWorkers" min="0" placeholder="1"/>
</div>
<div class="field">
<label>Auto Restart Interval <span class="hint">0s/off disables</span></label>
<input type="text" id="cfgDnsttAutoRestart" placeholder="6h"/>
</div>
<div class="field">
<label>Restart Grace Delay</label>
<input type="text" id="cfgDnsttRestartGrace" placeholder="2s"/>
</div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Restarts only the DNSTT UDP listener, not the VPS or the full panel. Use duration values like 30m, 2h, or 6h.</div>
<div class="field"><label>Max Sessions <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="cfgDnsttMaxSessions" placeholder="10000"/></div>
<div class="field"><label>Max Streams <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="cfgDnsttMaxStreams" placeholder="15000"/></div>
<div class="field"><label>Pending DNS Responses</label><input type="number" id="cfgDnsttPendingResponses" placeholder="20000"/></div>
<div class="field"><label>Stream Buffer Bytes</label><input type="number" id="cfgDnsttStreamBuffer" placeholder="262144"/></div>
<div class="field"><label>UDP Read Buffer Bytes</label><input type="number" id="cfgDnsttUDPReadBuffer" placeholder="16777216"/></div>
<div class="field"><label>UDP Write Buffer Bytes</label><input type="number" id="cfgDnsttUDPWriteBuffer" placeholder="16777216"/></div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Scale guard: bounded sessions/streams stop RAM explosions under thousands of DNSTT users. Keep verbose connection logs disabled on busy servers.</div>
<div class="field" style="grid-column:1/-1">
<label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label>
<div class="field-row">
@@ -997,6 +1080,9 @@
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
<input type="checkbox" id="cfgDnsttNoConsole"/> Disable Console Log
</label>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
<input type="checkbox" id="cfgDnsttLogConnections"/> Verbose Connection Logs
</label>
</div>
</div>
@@ -1026,6 +1112,15 @@
<label>Map TTL</label>
<input type="text" id="cfgUdpgwMapTTL" placeholder="90s"/>
</div>
<div class="field">
<label>Auto Restart Interval <span class="hint">0s/off disables</span></label>
<input type="text" id="cfgUdpgwAutoRestart" placeholder="24h"/>
</div>
<div class="field">
<label>Restart Grace Delay</label>
<input type="text" id="cfgUdpgwRestartGrace" placeholder="2s"/>
</div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes the UDPGW listener and every connected UDPGW client, then starts UDPGW again.</div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
<input type="checkbox" id="cfgUdpgwDebug"/> Debug Logging
</label>
@@ -1091,7 +1186,8 @@
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-top:4px;">
<input type="checkbox" id="cfgXrayEnabled"/> Enabled
</label>
<div class="hint" style="margin-top:6px;color:var(--muted);">Binary: /opt/sshpanel/xray &nbsp;·&nbsp; Config: /opt/sshpanel/xray_config.json &nbsp;·&nbsp; Online counters use Xray Stats API on 127.0.0.1:10085</div>
<div class="field" style="margin-top:8px;"><label>Runtime mode</label><select id="cfgXrayMode"><option value="native">Internal native emulator</option><option value="external">External xray binary</option></select></div>
<div class="hint" style="margin-top:6px;color:var(--muted);">Native runs inside DragonCoreSSH. External uses /opt/sshpanel/xray with DB-backed /opt/sshpanel/xray_config.json and Stats API on 127.0.0.1:10085.</div>
</div>
</div><!-- /right -->
@@ -1115,6 +1211,6 @@
</div><!-- /shell -->
</div><!-- /app -->
<script defer src="assets/app.js?v=20260511visualsafesave1"></script>
<script defer src="assets/app.js?v=20260622dnsttfakednsfast"></script>
</body>
</html>
+145 -3
View File
@@ -103,6 +103,14 @@ const ifaceBody = document.getElementById("ifaceBody");
const ifaceSummary = document.getElementById("ifaceSummary");
const statsUpdated = document.getElementById("statsUpdated");
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
const dnsttActiveSessions = document.getElementById("dnsttActiveSessions");
const dnsttActiveStreams = document.getElementById("dnsttActiveStreams");
const dnsttDNSRx = document.getElementById("dnsttDNSRx");
const dnsttQueueLen = document.getElementById("dnsttQueueLen");
const dnsttHealthBody = document.getElementById("dnsttHealthBody");
const dnsttHealthSummary = document.getElementById("dnsttHealthSummary");
// VnStat
const vnstatDailyBody = document.getElementById("vnstatDailyBody");
@@ -136,6 +144,16 @@ function fmtBytes(n) {
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
return (m/1024).toFixed(1)+" GiB";
}
function fmtInt(n) {
const v = Number(n);
return Number.isFinite(v) ? v.toLocaleString() : "--";
}
function fmtDnsttTimestamp(ts) {
if (!ts) return "Waiting for DNSTT stats…";
const d = new Date(ts);
if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…";
return "Updated: " + d.toLocaleTimeString();
}
function localDateKey(d = new Date()) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -213,7 +231,6 @@ document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode"));
document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump)));
document.getElementById("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); });
document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray"));
@@ -872,6 +889,76 @@ async function deleteReseller(username) {
// ─── Stats ────────────────────────────────────────────────────────────────────
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
async function loadDnsttHealth() {
if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return;
if (currentRole !== "superadmin") {
dnsttDashboardCard?.classList.add("hidden");
return;
}
try {
const res = await api("/api/dnstt");
if (!res.ok) throw new Error(await res.text());
const d = await res.json();
const enabled = d.enabled !== false;
if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled);
if (!enabled) return;
if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions);
if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams);
if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx);
if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len);
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp);
const rows = [
["Session rejected", d.sess_rejected],
["Stream rejected", d.stream_rejected],
["DNS parse errors", d.parse_err],
["No EDNS", d.no_edns],
["EDNS limit 512", d.limit512],
["Local DNS workers", d.fake_dns_workers],
["Response workers", d.dns_response_workers],
["Responses queued", d.rec_queued],
["Responses dropped", d.rec_dropped],
["Responses sent", d.resp_sent],
["Response bytes", d.resp_bytes],
["Empty responses", d.resp_empty],
["Data responses", d.resp_data],
["Oversize responses", d.resp_oversize],
["KCP sessions new", d.kcp_new],
["KCP sessions ended", d.kcp_end],
["SMUX streams new", d.smux_new],
["SMUX streams ended", d.smux_end],
["Panic recovered", d.panic_recovered],
];
if (dnsttHealthBody) {
dnsttHealthBody.innerHTML = "";
for (let i = 0; i < rows.length; i += 2) {
const a = rows[i];
const b = rows[i + 1] || ["", ""];
const tr = document.createElement("tr");
tr.innerHTML = `<td>${a[0]}</td><td>${fmtInt(a[1])}</td><td>${b[0]}</td><td>${b[0] ? fmtInt(b[1]) : ""}</td>`;
dnsttHealthBody.appendChild(tr);
}
}
const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0);
if (dnsttHealthSummary) {
if (d.running === false) {
dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs.";
} else {
dnsttHealthSummary.textContent = bad > 0
? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.`
: "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window.";
}
}
} catch (e) {
if (e.message === "auth") throw e;
dnsttDashboardCard?.classList.add("hidden");
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats.";
if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable.";
}
}
async function loadStats() {
try {
const res = await api("/api/stats");
@@ -895,6 +982,7 @@ async function loadStats() {
});
ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
await loadDnsttHealth();
} catch (e) { if (e.message==="auth") doAuthError(); }
}
@@ -1028,6 +1116,22 @@ async function clearPanelLog() {
// ─── Server Config ────────────────────────────────────────────────────────────
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
function dnsttDomainsText(d) {
const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []);
return domains.join("\n");
}
function readDnsttDomains(id) {
const el = document.getElementById(id);
if (!el) return [];
const seen = new Set();
return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => {
if (seen.has(s)) return false;
seen.add(s);
return true;
});
}
function toggleDnsttFields(on) {
const el = document.getElementById("dnsttFields");
el.style.opacity = on ? "1" : ".4";
@@ -1050,6 +1154,8 @@ async function loadServerConfig() {
// Network
document.getElementById("cfgListen").value = c.listen || "";
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
if (document.getElementById("cfgProxyAutoRestart")) document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
if (document.getElementById("cfgProxyRestartGrace")) document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
// SSH / general
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
@@ -1065,11 +1171,25 @@ async function loadServerConfig() {
document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
toggleDnsttFields(hasDnstt);
const d = c.dnstt || {};
document.getElementById("cfgDnsttDomain").value = d.domain || "";
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
if (document.getElementById("cfgDnsttFakeEnabled")) document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
if (document.getElementById("cfgDnsttFakeListen")) document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
if (document.getElementById("cfgDnsttFakeDomain")) document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
if (document.getElementById("cfgDnsttFakeWorkers")) document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
if (document.getElementById("cfgDnsttRespWorkers")) document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0;
document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || "";
document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || "";
if (document.getElementById("cfgDnsttMaxSessions")) document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0;
if (document.getElementById("cfgDnsttMaxStreams")) document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0;
if (document.getElementById("cfgDnsttPendingResponses")) document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0;
if (document.getElementById("cfgDnsttStreamBuffer")) document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0;
if (document.getElementById("cfgDnsttUDPReadBuffer")) document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
if (document.getElementById("cfgDnsttUDPWriteBuffer")) document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
if (document.getElementById("cfgDnsttLogConnections")) document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections;
// UDPGW
const hasUdpgw = !!c.udpgw;
@@ -1080,6 +1200,8 @@ async function loadServerConfig() {
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
if (document.getElementById("cfgUdpgwAutoRestart")) document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
if (document.getElementById("cfgUdpgwRestartGrace")) document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
// TLS forwarders
@@ -1105,10 +1227,13 @@ async function saveServerConfig() {
const extraLines = document.getElementById("cfgExtraListen").value
.split("\n").map(s => s.trim()).filter(Boolean);
const dnsttDomains = readDnsttDomains("cfgDnsttDomains");
const cfg = {
listen: document.getElementById("cfgListen").value.trim(),
extra_listen: extraLines,
proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart") ? document.getElementById("cfgProxyAutoRestart").value.trim() : "",
proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace") ? document.getElementById("cfgProxyRestartGrace").value.trim() : "",
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
admin_dir: "/opt/sshpanel/admin",
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
@@ -1118,17 +1243,34 @@ async function saveServerConfig() {
banner: document.getElementById("cfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
domain: document.getElementById("cfgDnsttDomain").value.trim(),
domain: dnsttDomains[0] || "",
domains: dnsttDomains,
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled") ? document.getElementById("cfgDnsttFakeEnabled").checked : false,
fake_dns_listen: document.getElementById("cfgDnsttFakeListen") ? document.getElementById("cfgDnsttFakeListen").value.trim() : "",
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain") ? document.getElementById("cfgDnsttFakeDomain").value.trim() : "",
fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers") ? document.getElementById("cfgDnsttFakeWorkers").value || "0" : "0", 10),
dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers") ? document.getElementById("cfgDnsttRespWorkers").value || "0" : "0", 10),
auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(),
auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(),
max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions") ? document.getElementById("cfgDnsttMaxSessions").value || "0" : "0", 10),
max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams") ? document.getElementById("cfgDnsttMaxStreams").value || "0" : "0", 10),
pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses") ? document.getElementById("cfgDnsttPendingResponses").value || "0" : "0", 10),
stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer") ? document.getElementById("cfgDnsttStreamBuffer").value || "0" : "0", 10),
udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer") ? document.getElementById("cfgDnsttUDPReadBuffer").value || "0" : "0", 10),
udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer") ? document.getElementById("cfgDnsttUDPWriteBuffer").value || "0" : "0", 10),
privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
log_connections: document.getElementById("cfgDnsttLogConnections") ? document.getElementById("cfgDnsttLogConnections").checked : false,
} : null,
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
listen: document.getElementById("cfgUdpgwListen").value.trim(),
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart") ? document.getElementById("cfgUdpgwAutoRestart").value.trim() : "",
auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace") ? document.getElementById("cfgUdpgwRestartGrace").value.trim() : "",
debug: document.getElementById("cfgUdpgwDebug").checked,
} : null,
tls_forwarders: tlsArr,
+172 -2
View File
@@ -5,6 +5,7 @@ import (
"log"
"net"
"strings"
"time"
)
const (
@@ -64,7 +65,50 @@ func normalizeRuntimePorts(cfg *Config) []string {
// DragonCore no longer uses an internal local SSH listener.
cfg.LocalSSHListen = ""
cfg.ProxyAutoRestartInterval = strings.TrimSpace(cfg.ProxyAutoRestartInterval)
if cfg.ProxyAutoRestartInterval != "" && cfg.ProxyAutoRestartInterval != "0" && cfg.ProxyAutoRestartInterval != "0s" && !strings.EqualFold(cfg.ProxyAutoRestartInterval, "off") && !strings.EqualFold(cfg.ProxyAutoRestartInterval, "disabled") {
if d, err := time.ParseDuration(cfg.ProxyAutoRestartInterval); err != nil {
warn("proxy auto restart interval %q is invalid; disabling auto restart", cfg.ProxyAutoRestartInterval)
cfg.ProxyAutoRestartInterval = ""
} else if d < time.Minute {
warn("proxy auto restart interval %q is below 1m; disabling auto restart", cfg.ProxyAutoRestartInterval)
cfg.ProxyAutoRestartInterval = ""
}
}
cfg.ProxyAutoRestartGrace = strings.TrimSpace(cfg.ProxyAutoRestartGrace)
if cfg.ProxyAutoRestartGrace != "" {
if d, err := time.ParseDuration(cfg.ProxyAutoRestartGrace); err != nil || d < 0 {
warn("proxy auto restart grace %q is invalid; using default 2s", cfg.ProxyAutoRestartGrace)
cfg.ProxyAutoRestartGrace = ""
} else if d > time.Minute {
warn("proxy auto restart grace %q is above 1m; clamping to 1m", cfg.ProxyAutoRestartGrace)
cfg.ProxyAutoRestartGrace = "1m"
}
}
if cfg.DNSTT != nil {
cfg.DNSTT.FakeDNSDomain = strings.TrimSpace(cfg.DNSTT.FakeDNSDomain)
if cfg.DNSTT.FakeDNSEnabled {
if cfg.DNSTT.FakeDNSDomain == "" {
cfg.DNSTT.FakeDNSDomain = "t.local.lan"
}
// Automatically add the local/fake test zone to the accepted DNSTT
// domains so the tunnel handler can decode traffic for it.
cfg.DNSTT.Domains = append(cfg.DNSTT.Domains, cfg.DNSTT.FakeDNSDomain)
}
cfg.DNSTT.Domains = normalizeDNSTTDomainList(cfg.DNSTT.Domain, cfg.DNSTT.Domains)
if len(cfg.DNSTT.Domains) > 0 {
cfg.DNSTT.Domain = cfg.DNSTT.Domains[0]
} else {
cfg.DNSTT.Domain = strings.TrimSpace(cfg.DNSTT.Domain)
}
if cfg.DNSTT.FakeDNSEnabled {
localDomains := normalizeDNSTTDomainList(cfg.DNSTT.FakeDNSDomain, nil)
if len(localDomains) > 0 {
cfg.DNSTT.FakeDNSDomain = localDomains[0]
}
}
cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen)
if cfg.DNSTT.UDPListen == "" {
cfg.DNSTT.UDPListen = defaultDNSTTListen
@@ -77,6 +121,88 @@ func normalizeRuntimePorts(cfg *Config) []string {
warn("default DNSTT UDP listener %s is also unavailable: %v", cfg.DNSTT.UDPListen, err2)
}
}
if cfg.DNSTT.FakeDNSEnabled {
cfg.DNSTT.FakeDNSListen = strings.TrimSpace(cfg.DNSTT.FakeDNSListen)
if cfg.DNSTT.FakeDNSListen == "" {
cfg.DNSTT.FakeDNSListen = "[::]:53"
}
if !sameUDPListenAddress(cfg.DNSTT.FakeDNSListen, cfg.DNSTT.UDPListen) {
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.FakeDNSListen); err != nil {
warn("built-in DNSTT local DNS listener %s is unavailable: %v", cfg.DNSTT.FakeDNSListen, err)
}
}
}
cfg.DNSTT.AutoRestartInterval = strings.TrimSpace(cfg.DNSTT.AutoRestartInterval)
if cfg.DNSTT.AutoRestartInterval != "" && cfg.DNSTT.AutoRestartInterval != "0" && cfg.DNSTT.AutoRestartInterval != "0s" && !strings.EqualFold(cfg.DNSTT.AutoRestartInterval, "off") && !strings.EqualFold(cfg.DNSTT.AutoRestartInterval, "disabled") {
if d, err := time.ParseDuration(cfg.DNSTT.AutoRestartInterval); err != nil {
warn("DNSTT auto restart interval %q is invalid; disabling auto restart", cfg.DNSTT.AutoRestartInterval)
cfg.DNSTT.AutoRestartInterval = ""
} else if d < time.Minute {
warn("DNSTT auto restart interval %q is below 1m; disabling auto restart", cfg.DNSTT.AutoRestartInterval)
cfg.DNSTT.AutoRestartInterval = ""
}
}
cfg.DNSTT.AutoRestartGrace = strings.TrimSpace(cfg.DNSTT.AutoRestartGrace)
if cfg.DNSTT.AutoRestartGrace != "" {
if d, err := time.ParseDuration(cfg.DNSTT.AutoRestartGrace); err != nil || d < 0 {
warn("DNSTT auto restart grace %q is invalid; using default 2s", cfg.DNSTT.AutoRestartGrace)
cfg.DNSTT.AutoRestartGrace = ""
} else if d > time.Minute {
warn("DNSTT auto restart grace %q is above 1m; clamping to 1m", cfg.DNSTT.AutoRestartGrace)
cfg.DNSTT.AutoRestartGrace = "1m"
}
}
if cfg.DNSTT.MaxSessions < -1 {
warn("DNSTT max_sessions %d is invalid; using unlimited (-1)", cfg.DNSTT.MaxSessions)
cfg.DNSTT.MaxSessions = -1
}
if cfg.DNSTT.MaxStreams < -1 {
warn("DNSTT max_streams %d is invalid; using unlimited (-1)", cfg.DNSTT.MaxStreams)
cfg.DNSTT.MaxStreams = -1
}
if cfg.DNSTT.PendingResponses > 0 {
if cfg.DNSTT.PendingResponses < minDNSTTPendingResponses {
warn("DNSTT pending_responses %d is too low; clamping to %d", cfg.DNSTT.PendingResponses, minDNSTTPendingResponses)
cfg.DNSTT.PendingResponses = minDNSTTPendingResponses
} else if cfg.DNSTT.PendingResponses > maxDNSTTPendingResponses {
warn("DNSTT pending_responses %d is too high; clamping to %d", cfg.DNSTT.PendingResponses, maxDNSTTPendingResponses)
cfg.DNSTT.PendingResponses = maxDNSTTPendingResponses
}
}
if cfg.DNSTT.StreamBuffer > 0 {
if cfg.DNSTT.StreamBuffer < minDNSTTStreamBuffer {
warn("DNSTT stream_buffer %d is too low; clamping to %d", cfg.DNSTT.StreamBuffer, minDNSTTStreamBuffer)
cfg.DNSTT.StreamBuffer = minDNSTTStreamBuffer
} else if cfg.DNSTT.StreamBuffer > maxDNSTTStreamBuffer {
warn("DNSTT stream_buffer %d is too high; clamping to %d", cfg.DNSTT.StreamBuffer, maxDNSTTStreamBuffer)
cfg.DNSTT.StreamBuffer = maxDNSTTStreamBuffer
}
}
if cfg.DNSTT.UDPReadBuffer < 0 {
warn("DNSTT udp_read_buffer %d is invalid; using default", cfg.DNSTT.UDPReadBuffer)
cfg.DNSTT.UDPReadBuffer = 0
}
if cfg.DNSTT.UDPWriteBuffer < 0 {
warn("DNSTT udp_write_buffer %d is invalid; using default", cfg.DNSTT.UDPWriteBuffer)
cfg.DNSTT.UDPWriteBuffer = 0
}
if cfg.DNSTT.FakeDNSWorkers < 0 {
warn("DNSTT fake_dns_workers %d is invalid; using automatic default", cfg.DNSTT.FakeDNSWorkers)
cfg.DNSTT.FakeDNSWorkers = 0
} else if cfg.DNSTT.FakeDNSWorkers > maxDNSTTFakeDNSWorkers {
warn("DNSTT fake_dns_workers %d is too high; clamping to %d", cfg.DNSTT.FakeDNSWorkers, maxDNSTTFakeDNSWorkers)
cfg.DNSTT.FakeDNSWorkers = maxDNSTTFakeDNSWorkers
}
if cfg.DNSTT.DNSResponseWorkers < 0 {
warn("DNSTT dns_response_workers %d is invalid; using default", cfg.DNSTT.DNSResponseWorkers)
cfg.DNSTT.DNSResponseWorkers = 0
} else if cfg.DNSTT.DNSResponseWorkers > maxDNSTTResponseWorkers {
warn("DNSTT dns_response_workers %d is too high; clamping to %d", cfg.DNSTT.DNSResponseWorkers, maxDNSTTResponseWorkers)
cfg.DNSTT.DNSResponseWorkers = maxDNSTTResponseWorkers
}
}
if cfg.UDPGW != nil {
@@ -92,6 +218,27 @@ func normalizeRuntimePorts(cfg *Config) []string {
warn("default UDPGW listener %s is also unavailable: %v", cfg.UDPGW.Listen, err2)
}
}
cfg.UDPGW.AutoRestartInterval = strings.TrimSpace(cfg.UDPGW.AutoRestartInterval)
if cfg.UDPGW.AutoRestartInterval != "" && cfg.UDPGW.AutoRestartInterval != "0" && cfg.UDPGW.AutoRestartInterval != "0s" && !strings.EqualFold(cfg.UDPGW.AutoRestartInterval, "off") && !strings.EqualFold(cfg.UDPGW.AutoRestartInterval, "disabled") {
if d, err := time.ParseDuration(cfg.UDPGW.AutoRestartInterval); err != nil {
warn("UDPGW auto restart interval %q is invalid; disabling auto restart", cfg.UDPGW.AutoRestartInterval)
cfg.UDPGW.AutoRestartInterval = ""
} else if d < time.Minute {
warn("UDPGW auto restart interval %q is below 1m; disabling auto restart", cfg.UDPGW.AutoRestartInterval)
cfg.UDPGW.AutoRestartInterval = ""
}
}
cfg.UDPGW.AutoRestartGrace = strings.TrimSpace(cfg.UDPGW.AutoRestartGrace)
if cfg.UDPGW.AutoRestartGrace != "" {
if d, err := time.ParseDuration(cfg.UDPGW.AutoRestartGrace); err != nil || d < 0 {
warn("UDPGW auto restart grace %q is invalid; using default 2s", cfg.UDPGW.AutoRestartGrace)
cfg.UDPGW.AutoRestartGrace = ""
} else if d > time.Minute {
warn("UDPGW auto restart grace %q is above 1m; clamping to 1m", cfg.UDPGW.AutoRestartGrace)
cfg.UDPGW.AutoRestartGrace = "1m"
}
}
}
return warnings
@@ -128,17 +275,40 @@ func tcpAddrAvailableForUDPGW(addr string) error {
return ln.Close()
}
func normalizeDNSTTDomainList(primary string, domains []string) []string {
seen := make(map[string]bool, len(domains)+1)
out := make([]string, 0, len(domains)+1)
add := func(v string) {
v = strings.TrimSpace(v)
v = strings.TrimSuffix(v, ".")
v = strings.ToLower(v)
if v == "" || seen[v] {
return
}
seen[v] = true
out = append(out, v)
}
add(primary)
for _, d := range domains {
add(d)
}
return out
}
func udpAddrAvailableForDNSTT(addr string) error {
if addr == "" {
return nil
}
globalCfgMu.RLock()
current := globalCfg != nil && globalCfg.DNSTT != nil && globalCfg.DNSTT.UDPListen == addr && dnsttRunning()
current := false
if globalCfg != nil && globalCfg.DNSTT != nil && dnsttRunning() {
current = sameUDPListenAddress(globalCfg.DNSTT.UDPListen, addr) || sameUDPListenAddress(globalCfg.DNSTT.FakeDNSListen, addr)
}
globalCfgMu.RUnlock()
if current {
return nil
}
pc, err := net.ListenPacket("udp", addr)
pc, err := listenDNSTTPacket(addr)
if err != nil {
return err
}
+751 -68
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -98,6 +98,26 @@ func (p *listenerPool) Has(addr string) bool {
return ok
}
// StopAll closes every listener in the pool. Active SSH sessions are not owned
// by this pool; callers that want a hard restart should also close tracked SSH
// server connections through userMgr.DisconnectAll().
func (p *listenerPool) StopAll(reason string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for addr, ln := range p.entries {
_ = ln.Close()
delete(p.entries, addr)
if reason != "" {
log.Printf("hotreload: stopped %s (%s)", addr, reason)
} else {
log.Printf("hotreload: stopped %s", addr)
}
}
}
func (p *listenerPool) HasAll(addrs []string) bool {
if p == nil {
return false
@@ -179,6 +199,23 @@ func (p *tlsListenerPool) Has(addr string) bool {
return ok
}
func (p *tlsListenerPool) StopAll(reason string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for addr, ln := range p.entries {
_ = ln.Close()
delete(p.entries, addr)
if reason != "" {
log.Printf("hotreload: stopped TLS %s (%s)", addr, reason)
} else {
log.Printf("hotreload: stopped TLS %s", addr)
}
}
}
func (p *tlsListenerPool) HasAll(forwarders []TLSForwarderConfig) bool {
if p == nil {
return false
@@ -283,6 +320,7 @@ func joinAddrs(addrs []string) string {
func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
report := newReloadReport()
stopProxyAutoRestart()
// Banner
bt := newCfg.Banner
if bt == "" && newCfg.BannerFile != "" {
@@ -372,9 +410,13 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
// Xray — update stored config then restart/stop as needed.
if newCfg.Xray != nil {
newCfg.Xray.NormalizeDefaults()
xrayMgr.mu.Lock()
xrayMgr.cfg = newCfg.Xray
xrayMgr.mu.Unlock()
if !newCfg.Xray.UseNative() {
xrayMgr.startStatsPoller()
}
if newCfg.Xray.Enabled {
if err := xrayMgr.Restart(); err != nil {
report.warnf("Xray failed to restart: %v", err)
@@ -395,6 +437,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
}
setGlobalCfg(newCfg)
startProxyAutoRestart(newCfg)
return report
}
+9 -4
View File
@@ -455,8 +455,11 @@ cat > "$INSTALL_DIR/config.json" <<EOF
"banner_file": "${INSTALL_DIR}/banner.txt",
"xray": {
"enabled": true,
"mode": "native",
"native": true,
"bin_path": "${INSTALL_DIR}/xray",
"config_file": "${INSTALL_DIR}/xray_config.json"
"config_file": "${INSTALL_DIR}/xray_config.json",
"native_config_file": "${INSTALL_DIR}/xray_native_config.json"
}
}
EOF
@@ -467,8 +470,9 @@ UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null \
|| python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null \
|| echo "11111111-2222-3333-4444-555555555555")
# xray_config.json (default VLESS + SOCKS inbounds — no geoip routing needed)
cat > "$INSTALL_DIR/xray_config.json" <<EOF
# xray_native_config.json is used by the internal emulator. xray_config.json is
# kept only for optional external-xray mode. Both start with the same default.
cat > "$INSTALL_DIR/xray_native_config.json" <<EOF
{
"log": { "loglevel": "warning" },
"inbounds": [
@@ -497,7 +501,8 @@ cat > "$INSTALL_DIR/xray_config.json" <<EOF
]
}
EOF
chmod 600 "$INSTALL_DIR/xray_config.json"
cp -f "$INSTALL_DIR/xray_native_config.json" "$INSTALL_DIR/xray_config.json"
chmod 600 "$INSTALL_DIR/xray_native_config.json" "$INSTALL_DIR/xray_config.json"
info " VLESS UUID: ${UUID}"
# ── 9. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
+155 -9
View File
@@ -64,6 +64,17 @@ type TLSForwarderConfig struct {
type Config struct {
Listen string `json:"listen"`
// ProxyAutoRestartInterval controls a watchdog that periodically hard-restarts
// the public proxy listeners (listen, extra_listen, and TLS forwarders) and
// closes active SSH sessions. Empty, "0s", "off", or "disabled" turns it off.
// Valid examples: "30m", "6h", "24h". Minimum accepted value is 1m.
ProxyAutoRestartInterval string `json:"proxy_auto_restart_interval,omitempty"`
// ProxyAutoRestartGrace is the delay between closing proxy listeners/sessions
// and binding them again during an auto restart. Empty defaults to "2s".
ProxyAutoRestartGrace string `json:"proxy_auto_restart_grace,omitempty"`
// Optional extra public listen addresses (multiport). These
// addresses use the same HTTPcleanup and SSH handler as the
// primary Listen address. For IPv6, use bracket form, e.g.
@@ -126,14 +137,46 @@ type Config struct {
// the tunnelled zone, and loads its private key from PrivKeyFile. The
// corresponding public key must be distributed to clients.
type DNSTTConfig struct {
// Domain is the root of the DNS zone reserved for the tunnel (e.g. "t.example.com").
// Domain is the primary/root DNS zone reserved for the tunnel (e.g. "t.example.com").
// It is kept for backward compatibility and is also used as the first client
// domain when Domains is empty.
Domain string `json:"domain"`
// UDPListen is the UDP address to listen on for incoming DNS queries.
// The address should be IPv6formatted (e.g. "[::]:5300") and reachable by
// recursive resolvers. Note: port 53 may require root privileges; binding
// to an unprivileged port and using iptables to redirect port 53 is
// recommended【561853413345496†L97-L109】.
// Domains optionally lists all DNS root zones/NS domains accepted by this
// server. This allows the same DNSTT listener/key to answer for public and
// local DNS deployments at the same time, for example:
// ["t.example.com", "t.local.lan"]. The first normalized value is mirrored
// into Domain for older clients/UI code.
Domains []string `json:"domains,omitempty"`
// UDPListen is the UDP address to listen on for incoming DNS tunnel queries.
// IPv6 addresses must use bracket form, for example "[::]:5300" or
// "[2001:db8::1234]:53". IPv6 listeners are opened with udp6 so they do
// not require a spare IPv4 address on the same port.
UDPListen string `json:"udp_listen"`
// FakeDNSEnabled starts an extra built-in DNS listener for local/LAN testing.
// It uses the same DNSTT private key and session pool, but only accepts the
// FakeDNSDomain zone. This avoids needing a second DNS server for tests such
// as t.local.lan over a dedicated IPv6 address.
FakeDNSEnabled bool `json:"fake_dns_enabled,omitempty"`
// FakeDNSListen is the IPv4/IPv6 UDP address for the built-in local DNS
// listener. For your case use an IPv6 address, for example
// "[2001:db8::1234]:53" or "[::]:53".
FakeDNSListen string `json:"fake_dns_listen,omitempty"`
// FakeDNSDomain is the local DNSTT zone accepted by the built-in DNS listener.
// If empty while FakeDNSEnabled is true, it defaults to "t.local.lan".
FakeDNSDomain string `json:"fake_dns_domain,omitempty"`
// FakeDNSWorkers controls how many concurrent UDP read/parse workers are used
// by the built-in local DNS listener. Zero uses a safe automatic default.
FakeDNSWorkers int `json:"fake_dns_workers,omitempty"`
// DNSResponseWorkers controls how many DNS response sender shards are used.
// Zero keeps the safest default of one sender. Increase carefully only when
// the DNSTT pending queue grows under load.
DNSResponseWorkers int `json:"dns_response_workers,omitempty"`
// PrivKeyFile is the path to the Noise server private key. Generate a
// keypair with the dnstt tool (use -gen-key) and copy the resulting
// private key here; the matching public key must be distributed to
@@ -158,6 +201,41 @@ type DNSTTConfig struct {
// printed to the console. Set this to true in combination with
// disable_stats_log if you want a fully quiet DNSTT server.
DisableConsoleLog bool `json:"disable_console_log"`
// AutoRestartInterval controls a watchdog that periodically cycles only the
// integrated DNSTT UDP listener. Empty, "0s", "off", or "disabled" turns it
// off. Valid examples: "30m", "2h", "6h". Minimum accepted value is 1m.
AutoRestartInterval string `json:"auto_restart_interval,omitempty"`
// AutoRestartGrace is the delay between closing the old DNSTT UDP socket and
// binding a new one during an auto restart. Empty defaults to "2s".
AutoRestartGrace string `json:"auto_restart_grace,omitempty"`
// MaxSessions limits concurrently open DNSTT/KCP sessions. Zero uses a safe
// default for large public servers. Negative disables the limit.
MaxSessions int `json:"max_sessions,omitempty"`
// MaxStreams limits concurrently open smux/SSH streams across all DNSTT
// sessions. Zero uses a safe default. Negative disables the limit.
MaxStreams int `json:"max_streams,omitempty"`
// PendingResponses controls the buffer of queued DNS responses waiting for
// sendLoop. Zero uses the default. Keeping it bounded prevents RAM spikes.
PendingResponses int `json:"pending_responses,omitempty"`
// StreamBuffer sets smux MaxStreamBuffer in bytes. Zero uses the default.
// Lower values reduce RAM use when thousands of clients are connected.
StreamBuffer int `json:"stream_buffer,omitempty"`
// UDPReadBuffer and UDPWriteBuffer request OS socket buffers in bytes. Zero
// uses the default. The kernel may clamp these unless sysctl limits are raised.
UDPReadBuffer int `json:"udp_read_buffer,omitempty"`
UDPWriteBuffer int `json:"udp_write_buffer,omitempty"`
// LogConnections enables per-session and per-stream DNSTT logs. Leave off on
// servers with thousands of users because connection logging can become the
// bottleneck and make crashes more likely.
LogConnections bool `json:"log_connections,omitempty"`
}
// UDPGWConfig defines the settings for the integrated UDP gateway. The
@@ -214,6 +292,16 @@ type UDPGWConfig struct {
// growth if a client sprays packets to many unique destinations.
// Default is 32768.
MaxMapEntries int `json:"max_map_entries"`
// AutoRestartInterval controls a watchdog that periodically hard-restarts
// the integrated UDPGW listener and closes all connected UDPGW clients. Empty,
// "0s", "off", or "disabled" turns it off. Valid examples: "30m", "6h", "24h".
// Minimum accepted value is 1m.
AutoRestartInterval string `json:"auto_restart_interval,omitempty"`
// AutoRestartGrace is the delay between closing the old UDPGW listener/clients
// and binding a new one during an auto restart. Empty defaults to "2s".
AutoRestartGrace string `json:"auto_restart_grace,omitempty"`
}
type UserConfig struct {
@@ -341,6 +429,35 @@ func (m *UserManager) DisconnectUser(username string) {
}
}
// DisconnectAll closes every authenticated SSH connection currently tracked by
// the user manager. It is used by the proxy hard auto-restart to mimic a real
// service reboot instead of only reopening listening sockets.
func (m *UserManager) DisconnectAll() int {
m.mu.RLock()
states := make([]*UserState, 0, len(m.users))
for _, u := range m.users {
states = append(states, u)
}
m.mu.RUnlock()
seen := make(map[*ssh.ServerConn]struct{})
for _, u := range states {
if u == nil {
continue
}
u.mu.Lock()
for c := range u.conns {
seen[c] = struct{}{}
}
u.mu.Unlock()
}
for c := range seen {
_ = c.Close()
}
return len(seen)
}
// Global state
var (
userMgr = &UserManager{users: make(map[string]*UserState)}
@@ -1030,6 +1147,19 @@ func readNetDev() (map[string]ifaceCounters, error) {
// ---------- Config loading ----------
const defaultInstalledConfigPath = "/opt/sshpanel/config.json"
func resolveMainConfigPath(path string) string {
path = strings.TrimSpace(path)
if path != "" {
return path
}
if _, err := os.Stat("config.json"); err == nil {
return "config.json"
}
return defaultInstalledConfigPath
}
func loadConfig(path string) (*Config, map[string]*UserState, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -1040,6 +1170,9 @@ func loadConfig(path string) (*Config, map[string]*UserState, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, nil, fmt.Errorf("parse config: %w", err)
}
if cfg.Xray != nil {
cfg.Xray.NormalizeDefaults()
}
if cfg.Listen == "" {
cfg.Listen = ":2222"
@@ -1721,6 +1854,9 @@ func handleDnsttStats(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if proxyManagedServerFromRequest(w, r, statsStore, "/api/dnstt", nil, "") {
return
}
// Obtain a copy of the current snapshot.
stats := GetDNSTTStatsSnapshot()
w.Header().Set("Content-Type", "application/json")
@@ -2621,19 +2757,20 @@ func main() {
log.Printf("GOMEMLIMIT auto-set to 80%% of RAM: %d MB", limit/1024/1024)
}
configPath := flag.String("config", "config.json", "path to JSON config file")
configPath := flag.String("config", "", "path to JSON config file (default: ./config.json if present, otherwise /opt/sshpanel/config.json)")
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
flag.Parse()
cfg, userMap, err := loadConfig(*configPath)
resolvedConfigPath := resolveMainConfigPath(*configPath)
cfg, userMap, err := loadConfig(resolvedConfigPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
userMgr.ReplaceAll(userMap)
// Store config path and live config for hot-reload via admin API.
globalCfgPath = *configPath
globalCfgPath = resolvedConfigPath
setGlobalCfg(cfg)
userCountEnabled = cfg.UserCount || *userCountFlag
@@ -2677,8 +2814,14 @@ func main() {
if err := store.EnsureXrayClientsSchema(ctx); err != nil {
log.Printf("xray clients table: %v", err)
} else {
if err := store.ResetXrayActiveConnections(ctx); err != nil {
log.Printf("xray active connection reset: %v", err)
}
startXrayClientExpiryChecker(store)
}
if err := store.EnsureXrayConfigSchema(ctx); err != nil {
log.Printf("xray config table disabled: %v", err)
}
if err := store.EnsureIfaceUsageTables(ctx); err != nil {
log.Printf("vnstat usage tables disabled: %v", err)
}
@@ -2892,6 +3035,9 @@ func main() {
log.Printf("failed to start TLS listener: %v", e)
}
// Start proxy hard auto-restart after the initial listeners are bound.
startProxyAutoRestart(cfg)
// Print user counts once at startup.
updateUserDisplay()
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"context"
"log"
"strings"
"sync"
"time"
)
var (
proxyAutoMu sync.Mutex
proxyAutoCancel context.CancelFunc
)
// startProxyAutoRestart starts a watchdog that periodically hard-restarts the
// public SSH/HTTP proxy layer. Unlike a normal hot reload, this intentionally
// closes active SSH sessions so the behavior is close to a service reboot.
func startProxyAutoRestart(cfg *Config) {
interval := proxyAutoRestartInterval(cfg)
if interval <= 0 {
return
}
grace := proxyAutoRestartGrace(cfg)
cfgCopy := cloneProxyRestartConfig(cfg)
ctx, cancel := context.WithCancel(context.Background())
proxyAutoMu.Lock()
if proxyAutoCancel != nil {
proxyAutoCancel()
}
proxyAutoCancel = cancel
proxyAutoMu.Unlock()
go func() {
t := time.NewTicker(interval)
defer t.Stop()
log.Printf("proxy auto restart enabled: interval=%s grace=%s mode=hard", interval, grace)
for {
select {
case <-ctx.Done():
return
case <-t.C:
restartProxyHard(ctx, cfgCopy, grace)
}
}
}()
}
func stopProxyAutoRestart() {
proxyAutoMu.Lock()
defer proxyAutoMu.Unlock()
if proxyAutoCancel != nil {
proxyAutoCancel()
proxyAutoCancel = nil
}
}
func cloneProxyRestartConfig(cfg *Config) *Config {
if cfg == nil {
return nil
}
out := &Config{
Listen: cfg.Listen,
ProxyAutoRestartInterval: cfg.ProxyAutoRestartInterval,
ProxyAutoRestartGrace: cfg.ProxyAutoRestartGrace,
}
out.ExtraListen = append([]string(nil), cfg.ExtraListen...)
out.TLSForwarders = append([]TLSForwarderConfig(nil), cfg.TLSForwarders...)
return out
}
func restartProxyHard(ctx context.Context, cfg *Config, grace time.Duration) {
if cfg == nil {
return
}
log.Printf("proxy auto restart: stopping public proxy listeners and active SSH sessions")
if publicPool != nil {
publicPool.StopAll("proxy auto restart")
}
if tlsPool != nil {
tlsPool.StopAll("proxy auto restart")
}
closed := userMgr.DisconnectAll()
if closed > 0 {
log.Printf("proxy auto restart: closed %d active SSH session(s)", closed)
}
if !sleepOrContextDone(ctx, grace) {
return
}
publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...)
for attempt := 1; ; attempt++ {
errs := []error{}
if publicPool != nil {
errs = append(errs, publicPool.Sync(publicAddrs)...)
}
if tlsPool != nil {
errs = append(errs, tlsPool.Sync(cfg.TLSForwarders)...)
}
ok := len(errs) == 0
if publicPool != nil && !publicPool.HasAll(publicAddrs) {
ok = false
}
if tlsPool != nil && !tlsPool.HasAll(cfg.TLSForwarders) {
ok = false
}
if ok {
log.Printf("proxy auto restart: public proxy restarted")
return
}
for _, err := range errs {
log.Printf("proxy auto restart: start attempt %d failed: %v", attempt, err)
}
if len(errs) == 0 {
log.Printf("proxy auto restart: start attempt %d incomplete; one or more listeners are still down", attempt)
}
if !sleepOrContextDone(ctx, 10*time.Second) {
return
}
}
}
func proxyAutoRestartInterval(cfg *Config) time.Duration {
if cfg == nil {
return 0
}
raw := strings.TrimSpace(cfg.ProxyAutoRestartInterval)
if raw == "" || raw == "0" || raw == "0s" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
return 0
}
d, err := time.ParseDuration(raw)
if err != nil {
log.Printf("proxy auto restart disabled: invalid interval %q: %v", raw, err)
return 0
}
if d < time.Minute {
log.Printf("proxy auto restart disabled: interval %q is below minimum 1m", raw)
return 0
}
return d
}
func proxyAutoRestartGrace(cfg *Config) time.Duration {
if cfg == nil || strings.TrimSpace(cfg.ProxyAutoRestartGrace) == "" {
return 2 * time.Second
}
d, err := time.ParseDuration(strings.TrimSpace(cfg.ProxyAutoRestartGrace))
if err != nil || d < 0 {
log.Printf("proxy auto restart: invalid grace %q, using 2s", cfg.ProxyAutoRestartGrace)
return 2 * time.Second
}
if d > time.Minute {
return time.Minute
}
return d
}
func sleepOrContextDone(ctx context.Context, d time.Duration) bool {
if d <= 0 {
select {
case <-ctx.Done():
return false
default:
return true
}
}
select {
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}
+3
View File
@@ -92,6 +92,9 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "listen address required", http.StatusBadRequest)
return
}
if newCfg.Xray != nil {
newCfg.Xray.NormalizeDefaults()
}
// Preserve file-based users array (not editable through the UI).
globalCfgMu.RLock()
+143 -3
View File
@@ -20,6 +20,7 @@ import (
"io"
"log"
"net"
"strings"
"sync"
"time"
)
@@ -27,17 +28,57 @@ import (
var (
udpgwMu sync.Mutex
udpgwLn net.Listener
udpgwClients = make(map[net.Conn]struct{})
udpgwAutoMu sync.Mutex
udpgwAutoCancel context.CancelFunc
)
// stopUDPGW closes the active UDPGW listener, causing the accept loop to exit.
// It is a no-op if UDPGW is not running.
// stopUDPGW closes the active UDPGW listener, all active UDPGW client TCP
// sockets, and the optional auto-restart watchdog. It is a no-op if UDPGW is
// not running.
func stopUDPGW() {
stopUDPGWAutoRestart()
stopUDPGWInstance()
}
func stopUDPGWInstance() {
udpgwMu.Lock()
defer udpgwMu.Unlock()
if udpgwLn != nil {
_ = udpgwLn.Close()
udpgwLn = nil
}
for conn := range udpgwClients {
_ = conn.Close()
delete(udpgwClients, conn)
}
}
func stopUDPGWAutoRestart() {
udpgwAutoMu.Lock()
defer udpgwAutoMu.Unlock()
if udpgwAutoCancel != nil {
udpgwAutoCancel()
udpgwAutoCancel = nil
}
}
func registerUDPGWClient(conn net.Conn) bool {
udpgwMu.Lock()
defer udpgwMu.Unlock()
if udpgwLn == nil {
_ = conn.Close()
return false
}
udpgwClients[conn] = struct{}{}
return true
}
func unregisterUDPGWClient(conn net.Conn) {
udpgwMu.Lock()
delete(udpgwClients, conn)
udpgwMu.Unlock()
}
func udpgwRunning() bool {
@@ -53,6 +94,18 @@ func udpgwRunning() bool {
// prevent the gateway from starting, but do not terminate the main
// process.
func startUDPGW(cfg *UDPGWConfig) error {
if cfg == nil {
return nil
}
stopUDPGWAutoRestart()
if err := startUDPGWInstance(cfg); err != nil {
return err
}
startUDPGWAutoRestart(cfg)
return nil
}
func startUDPGWInstance(cfg *UDPGWConfig) error {
if cfg == nil {
return nil
}
@@ -166,12 +219,99 @@ func startUDPGW(cfg *UDPGWConfig) error {
log.Printf("udpgw: accept error: %v", err)
continue
}
go handleUDPGWClient(conn, c)
if !registerUDPGWClient(conn) {
continue
}
go func(client net.Conn) {
defer unregisterUDPGWClient(client)
handleUDPGWClient(client, c)
}(conn)
}
}()
return nil
}
func startUDPGWAutoRestart(cfg *UDPGWConfig) {
interval := udpgwAutoRestartInterval(cfg)
if interval <= 0 {
return
}
grace := udpgwAutoRestartGrace(cfg)
cfgCopy := *cfg
ctx, cancel := context.WithCancel(context.Background())
udpgwAutoMu.Lock()
if udpgwAutoCancel != nil {
udpgwAutoCancel()
}
udpgwAutoCancel = cancel
udpgwAutoMu.Unlock()
go func() {
t := time.NewTicker(interval)
defer t.Stop()
log.Printf("udpgw: auto restart enabled: interval=%s grace=%s mode=hard", interval, grace)
for {
select {
case <-ctx.Done():
return
case <-t.C:
log.Printf("udpgw: auto restart: stopping listener and connected clients")
stopUDPGWInstance()
if !sleepOrContextDone(ctx, grace) {
return
}
for attempt := 1; ; attempt++ {
if err := startUDPGWInstance(&cfgCopy); err != nil {
log.Printf("udpgw: auto restart: start attempt %d failed: %v", attempt, err)
if !sleepOrContextDone(ctx, 10*time.Second) {
return
}
continue
}
log.Printf("udpgw: auto restart: listener and client handler restarted")
break
}
}
}
}()
}
func udpgwAutoRestartInterval(cfg *UDPGWConfig) time.Duration {
if cfg == nil {
return 0
}
raw := strings.TrimSpace(cfg.AutoRestartInterval)
if raw == "" || raw == "0" || raw == "0s" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
return 0
}
d, err := time.ParseDuration(raw)
if err != nil {
log.Printf("udpgw: auto restart disabled: invalid interval %q: %v", raw, err)
return 0
}
if d < time.Minute {
log.Printf("udpgw: auto restart disabled: interval %q is below minimum 1m", raw)
return 0
}
return d
}
func udpgwAutoRestartGrace(cfg *UDPGWConfig) time.Duration {
if cfg == nil || strings.TrimSpace(cfg.AutoRestartGrace) == "" {
return 2 * time.Second
}
d, err := time.ParseDuration(strings.TrimSpace(cfg.AutoRestartGrace))
if err != nil || d < 0 {
log.Printf("udpgw: auto restart: invalid grace %q, using 2s", cfg.AutoRestartGrace)
return 2 * time.Second
}
if d > time.Minute {
return time.Minute
}
return d
}
// internalUDPGWConfig mirrors the exported UDPGWConfig but with
// time.Duration fields for TTL and reaper intervals. It does not
// embed JSON tags because it is not exposed to the user.
+23
View File
@@ -356,6 +356,13 @@ patch_configs() {
cfg="$INSTALL_DIR/config.json"
xcfg="$INSTALL_DIR/xray_config.json"
native_xcfg="$INSTALL_DIR/xray_native_config.json"
if [[ ! -f "$native_xcfg" && -f "$xcfg" ]]; then
cp -f "$xcfg" "$native_xcfg"
chmod 600 "$native_xcfg" || true
info " Created independent native Xray config: $native_xcfg"
fi
if [[ -f "$cfg" ]]; then
python3 - "$cfg" <<'PYEOF'
import json, sys
@@ -373,6 +380,22 @@ if 'banner_file' not in d:
if 'local_ssh_listen' in d:
d.pop('local_ssh_listen', None)
changed = True
x = d.get('xray')
if isinstance(x, dict):
mode = str(x.get('mode') or '').strip().lower()
if mode not in ('native', 'external'):
x['mode'] = 'native'
x['native'] = True
changed = True
elif mode == 'native' and x.get('native') is not True:
x['native'] = True
changed = True
elif mode == 'external' and x.get('native') is not False:
x['native'] = False
changed = True
x.setdefault('bin_path', '/opt/sshpanel/xray')
x.setdefault('config_file', '/opt/sshpanel/xray_config.json')
x.setdefault('native_config_file', '/opt/sshpanel/xray_native_config.json')
if changed:
with open(path, 'w') as f:
json.dump(d, f, indent=2)
+90 -13
View File
@@ -19,6 +19,10 @@ type XrayClientMeta struct {
ExpiresAt *time.Time
MaxConns int
CreatedAt time.Time
TotalUplinkBytes int64
TotalDownlinkBytes int64
LastActive *time.Time
ActiveConnections int
}
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
@@ -31,9 +35,17 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
owner_username TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
max_conns INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total_uplink_bytes BIGINT NOT NULL DEFAULT 0,
total_downlink_bytes BIGINT NOT NULL DEFAULT 0,
last_active TIMESTAMPTZ,
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 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`,
}
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
@@ -65,16 +77,21 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro
func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClientMeta, error) {
m := &XrayClientMeta{}
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, 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)
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections)
if err != nil {
return nil, err
}
if expiresAt.Valid {
m.ExpiresAt = &expiresAt.Time
}
if lastActive.Valid {
m.LastActive = &lastActive.Time
}
return m, nil
}
@@ -85,7 +102,8 @@ func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error {
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, 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 {
return nil, err
@@ -96,7 +114,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, 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 {
return nil, err
@@ -113,7 +132,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, 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 {
return nil, err
@@ -127,17 +147,78 @@ func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
for rows.Next() {
m := &XrayClientMeta{}
var expiresAt sql.NullTime
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
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 {
return nil, err
}
if expiresAt.Valid {
m.ExpiresAt = &expiresAt.Time
}
if lastActive.Valid {
m.LastActive = &lastActive.Time
}
out = append(out, m)
}
return out, rows.Err()
}
// ResetXrayActiveConnections clears stale online counters after the panel starts.
// Native mode then increments/decrements active_connections for real live streams.
func (s *Store) ResetXrayActiveConnections(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `UPDATE xray_clients SET active_connections = 0`)
return err
}
// AddXrayClientTrafficBatch persists native-emulator traffic deltas. It keeps
// totals in PostgreSQL so bandwidth remains visible after panel restarts.
func (s *Store) AddXrayClientTrafficBatch(ctx context.Context, deltas map[string]xrayPendingTraffic) error {
if len(deltas) == 0 {
return nil
}
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,
total_uplink_bytes = GREATEST(total_uplink_bytes + GREATEST($3::BIGINT, 0), 0),
total_downlink_bytes = GREATEST(total_downlink_bytes + GREATEST($4::BIGINT, 0), 0),
last_active = NOW()
WHERE uuid = $1`)
if err != nil {
_ = tx.Rollback()
return err
}
defer stmt.Close()
for uuid, d := range deltas {
if uuid == "" || (d.Uplink == 0 && d.Downlink == 0) {
continue
}
if _, err := stmt.ExecContext(ctx, uuid, d.Email, d.Uplink, d.Downlink); err != nil {
_ = tx.Rollback()
return err
}
}
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 {
return nil
}
_, err := s.db.ExecContext(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,
active_connections = GREATEST(active_connections + $3::INT, 0)
WHERE uuid = $1`, uuid, email, delta)
return err
}
func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
if store == nil || ownerUsername == "" {
return 0
@@ -177,9 +258,7 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str
}
}
if needRestart {
if err := xrayMgr.Restart(); err != nil {
log.Printf("xray owner cleanup: restart: %v", err)
}
xrayMgr.restartIfExternalRunning()
}
}
@@ -220,9 +299,7 @@ func startXrayClientExpiryChecker(store *Store) {
log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
}
if needRestart {
if err := xrayMgr.Restart(); err != nil {
log.Printf("xray expiry: restart error: %v", err)
}
xrayMgr.restartIfExternalRunning()
}
}
}()
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
// EnsureXrayConfigSchema creates the DB table used as the canonical store for
// Xray JSON configs. The config_file path is used as a stable key so local and
// remote nodes can keep independent configs in the same database if needed.
func (s *Store) EnsureXrayConfigSchema(ctx context.Context) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS xray_configs (
config_key TEXT PRIMARY KEY,
config_json JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`ALTER TABLE xray_configs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
}
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) GetXrayConfig(ctx context.Context, configKey string) ([]byte, bool, error) {
if configKey == "" {
configKey = "default"
}
var raw string
err := s.db.QueryRowContext(ctx, `SELECT config_json::text FROM xray_configs WHERE config_key = $1`, configKey).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return nil, false, nil
}
return nil, false, err
}
if !json.Valid([]byte(raw)) {
return nil, false, fmt.Errorf("stored Xray config %q is not valid JSON", configKey)
}
return []byte(raw), true, nil
}
func (s *Store) UpsertXrayConfig(ctx context.Context, configKey string, data []byte) error {
if configKey == "" {
configKey = "default"
}
if !json.Valid(data) {
return fmt.Errorf("invalid Xray JSON config")
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO xray_configs (config_key, config_json, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (config_key) DO UPDATE SET
config_json = EXCLUDED.config_json,
updated_at = NOW()`, configKey, string(data))
return err
}
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,
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 {
return nil, err
}
defer rows.Close()
return scanXrayClientMetaRows(rows)
}
// ImportXrayClientsFromConfig mirrors client UUIDs found in an existing Xray
// JSON config into xray_clients. This lets native mode inherit users from the
// external Xray config and keeps the DB as the hot-reloadable client index.
// It intentionally preserves owner/expiry/quota fields for existing rows.
func (s *Store) ImportXrayClientsFromConfig(ctx context.Context, data []byte) (int, error) {
if s == nil || len(data) == 0 {
return 0, nil
}
var cfg struct {
Inbounds []struct {
Tag string `json:"tag"`
Protocol string `json:"protocol"`
Settings struct {
Clients []struct {
ID string `json:"id"`
Password string `json:"password"`
Email string `json:"email"`
} `json:"clients"`
Users []struct {
ID string `json:"id"`
Password string `json:"password"`
Email string `json:"email"`
} `json:"users"`
} `json:"settings"`
} `json:"inbounds"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
return 0, err
}
imported := 0
for _, ib := range cfg.Inbounds {
proto := strings.ToLower(strings.TrimSpace(ib.Protocol))
if proto != "vless" && proto != "vmess" && proto != "trojan" {
continue
}
inboundTag := strings.TrimSpace(ib.Tag)
configClients := ib.Settings.Clients
if len(ib.Settings.Users) > 0 {
configClients = append(configClients, ib.Settings.Users...)
}
for _, c := range configClients {
uuid := strings.TrimSpace(c.ID)
if uuid == "" {
uuid = strings.TrimSpace(c.Password)
}
if uuid == "" {
continue
}
email := strings.TrimSpace(c.Email)
if email == "" {
email = uuid
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO xray_clients (uuid, name, email, inbound_tag)
VALUES ($1, $2, $3, $4)
ON CONFLICT (uuid) DO UPDATE SET
email = CASE WHEN xray_clients.email = '' THEN EXCLUDED.email ELSE xray_clients.email END,
name = CASE WHEN xray_clients.name = '' THEN EXCLUDED.name ELSE xray_clients.name END,
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END`,
uuid, email, email, inboundTag)
if err != nil {
return imported, err
}
imported++
}
}
return imported, nil
}
+746 -68
View File
File diff suppressed because it is too large Load Diff
+1178
View File
File diff suppressed because it is too large Load Diff
+580
View File
@@ -0,0 +1,580 @@
package main
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"io"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
)
// startEchoServer starts a TCP server that echoes everything back and returns
// its port and a cleanup func.
func startEchoServer(t *testing.T) (int, func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("echo listen: %v", err)
}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go io.Copy(c, c)
}
}()
return ln.Addr().(*net.TCPAddr).Port, func() { ln.Close() }
}
// newTestInbound builds a native VLESS inbound with one known client, listening
// on an ephemeral port. Returns the inbound, the listen port, the client uuid,
// and a cleanup func.
func newTestInbound(t *testing.T, transport, path string) (*nativeInbound, int, [16]byte, func()) {
t.Helper()
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("rand: %v", err)
}
ib := &nativeInbound{
tag: "test",
protocol: "vless",
transport: transport,
path: path,
clientsByID: map[[16]byte]*nativeXrayClient{id: {id: id, uuid: "test", email: "test@t"}},
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("inbound listen: %v", err)
}
go ib.acceptLoop(ln)
port := ln.Addr().(*net.TCPAddr).Port
return ib, port, id, func() { ln.Close() }
}
// vlessHeader builds a VLESS TCP request header targeting 127.0.0.1:targetPort.
func vlessHeader(id [16]byte, targetPort int) []byte {
var b bytes.Buffer
b.WriteByte(0) // version
b.Write(id[:]) // uuid
b.WriteByte(0) // addon length
b.WriteByte(vlessCmdTCP) // command
b.WriteByte(byte(targetPort >> 8))
b.WriteByte(byte(targetPort))
b.WriteByte(atypIPv4) // address type
b.Write([]byte{127, 0, 0, 1})
return b.Bytes()
}
func TestVLESSOverTCP(t *testing.T) {
echoPort, stopEcho := startEchoServer(t)
defer stopEcho()
_, port, id, stop := newTestInbound(t, "tcp", "")
defer stop()
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(port)))
if err != nil {
t.Fatalf("dial inbound: %v", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
// Send header + payload.
if _, err := conn.Write(vlessHeader(id, echoPort)); err != nil {
t.Fatalf("write header: %v", err)
}
if _, err := conn.Write([]byte("ping-tcp")); err != nil {
t.Fatalf("write payload: %v", err)
}
// Read 2-byte VLESS response header.
resp := make([]byte, 2)
if _, err := io.ReadFull(conn, resp); err != nil {
t.Fatalf("read response header: %v", err)
}
if resp[0] != 0 {
t.Fatalf("bad response version: %v", resp)
}
// Read the echoed payload.
got := make([]byte, len("ping-tcp"))
if _, err := io.ReadFull(conn, got); err != nil {
t.Fatalf("read echo: %v", err)
}
if string(got) != "ping-tcp" {
t.Fatalf("echo mismatch: got %q", got)
}
}
func TestVLESSRejectsUnknownUUID(t *testing.T) {
echoPort, stopEcho := startEchoServer(t)
defer stopEcho()
_, port, _, stop := newTestInbound(t, "tcp", "")
defer stop()
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(port)))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(3 * time.Second))
var bad [16]byte // all-zero uuid, not registered
conn.Write(vlessHeader(bad, echoPort))
conn.Write([]byte("should-not-echo"))
// Server must reject: connection closed with no response bytes.
if n, err := conn.Read(make([]byte, 1)); err == nil && n > 0 {
t.Fatalf("expected rejection, but server responded with %d bytes", n)
}
}
func TestVLESSOverWebSocket(t *testing.T) {
echoPort, stopEcho := startEchoServer(t)
defer stopEcho()
_, port, id, stop := newTestInbound(t, "ws", "/vlws")
defer stop()
raw, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(port)))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer raw.Close()
raw.SetDeadline(time.Now().Add(5 * time.Second))
ws := wsClientHandshake(t, raw, "/vlws")
// One frame carrying header + payload.
payload := append(vlessHeader(id, echoPort), []byte("ping-ws")...)
if _, err := ws.Write(payload); err != nil {
t.Fatalf("ws write: %v", err)
}
// Read response header (2 bytes) + echo, possibly spanning frames.
buf := make([]byte, 0, 32)
want := 2 + len("ping-ws")
for len(buf) < want {
chunk := make([]byte, 64)
n, err := ws.Read(chunk)
if err != nil {
t.Fatalf("ws read: %v (got %q)", err, buf)
}
buf = append(buf, chunk[:n]...)
}
if buf[0] != 0 {
t.Fatalf("bad ws vless response: %v", buf[:2])
}
if string(buf[2:want]) != "ping-ws" {
t.Fatalf("ws echo mismatch: got %q", buf[2:want])
}
}
func TestVLESSOverXHTTPPacketUp(t *testing.T) {
echoPort, stopEcho := startEchoServer(t)
defer stopEcho()
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("rand: %v", err)
}
ib := &nativeInbound{
tag: "test-xhttp",
protocol: "vless",
transport: "xhttp",
path: normalizeXHTTPPath("/xhttp"),
xhttpMode: "packet-up",
xhttpMaxEachPostBytes: 1_000_000,
xhttpMaxBufferedPosts: 30,
xhttpSessions: make(map[string]*nativeXHTTPSession),
clientsByID: map[[16]byte]*nativeXrayClient{id: {id: id, uuid: "test", email: "test@t"}},
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("xhttp listen: %v", err)
}
defer ln.Close()
go ib.serveXHTTPListener(ln)
port := ln.Addr().(*net.TCPAddr).Port
client := &http.Client{Timeout: 5 * time.Second}
session := "session-test"
baseURL := "http://" + net.JoinHostPort("127.0.0.1", itoa(port)) + "/xhttp/" + session
respCh := make(chan *http.Response, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.Get(baseURL)
if err != nil {
errCh <- err
return
}
respCh <- resp
}()
var resp *http.Response
select {
case resp = <-respCh:
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("xhttp GET status: %s", resp.Status)
}
case err := <-errCh:
t.Fatalf("xhttp GET: %v", err)
case <-time.After(2 * time.Second):
t.Fatalf("xhttp GET did not open")
}
payload := append(vlessHeader(id, echoPort), []byte("ping-xhttp")...)
postResp, err := client.Post(baseURL+"/0", "application/octet-stream", bytes.NewReader(payload))
if err != nil {
t.Fatalf("xhttp POST: %v", err)
}
postResp.Body.Close()
if postResp.StatusCode != http.StatusOK {
t.Fatalf("xhttp POST status: %s", postResp.Status)
}
got := make([]byte, 2+len("ping-xhttp"))
if _, err := io.ReadFull(resp.Body, got); err != nil {
t.Fatalf("xhttp read response: %v", err)
}
if got[0] != 0 {
t.Fatalf("bad xhttp vless response: %v", got[:2])
}
if string(got[2:]) != "ping-xhttp" {
t.Fatalf("xhttp echo mismatch: got %q", got[2:])
}
}
func TestVLESSOverXHTTPPacketUpGET(t *testing.T) {
echoPort, stopEcho := startEchoServer(t)
defer stopEcho()
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("rand: %v", err)
}
ib := &nativeInbound{
tag: "test-xhttp-get-packet",
protocol: "vless",
transport: "xhttp",
path: normalizeXHTTPPath("/xhttp"),
xhttpMode: "packet-up",
xhttpMaxEachPostBytes: 1_000_000,
xhttpMaxBufferedPosts: 30,
xhttpSessions: make(map[string]*nativeXHTTPSession),
clientsByID: map[[16]byte]*nativeXrayClient{id: {id: id, uuid: "test", email: "test@t"}},
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("xhttp listen: %v", err)
}
defer ln.Close()
go ib.serveXHTTPListener(ln)
port := ln.Addr().(*net.TCPAddr).Port
client := &http.Client{Timeout: 5 * time.Second}
session := "session-get-packet"
baseURL := "http://" + net.JoinHostPort("127.0.0.1", itoa(port)) + "/xhttp/" + session
respCh := make(chan *http.Response, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.Get(baseURL)
if err != nil {
errCh <- err
return
}
respCh <- resp
}()
var resp *http.Response
select {
case resp = <-respCh:
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("xhttp GET status: %s", resp.Status)
}
case err := <-errCh:
t.Fatalf("xhttp GET: %v", err)
case <-time.After(2 * time.Second):
t.Fatalf("xhttp GET did not open")
}
payload := append(vlessHeader(id, echoPort), []byte("ping-xhttp-get")...)
req, err := http.NewRequest(http.MethodGet, baseURL+"/0", bytes.NewReader(payload))
if err != nil {
t.Fatalf("xhttp GET packet req: %v", err)
}
req.ContentLength = int64(len(payload))
packetResp, err := client.Do(req)
if err != nil {
t.Fatalf("xhttp GET packet: %v", err)
}
packetResp.Body.Close()
if packetResp.StatusCode != http.StatusOK {
t.Fatalf("xhttp GET packet status: %s", packetResp.Status)
}
got := make([]byte, 2+len("ping-xhttp-get"))
if _, err := io.ReadFull(resp.Body, got); err != nil {
t.Fatalf("xhttp read response: %v", err)
}
if got[0] != 0 {
t.Fatalf("bad xhttp vless response: %v", got[:2])
}
if string(got[2:]) != "ping-xhttp-get" {
t.Fatalf("xhttp echo mismatch: got %q", got[2:])
}
}
func TestXHTTPRejectsBrowserGETWithoutSession(t *testing.T) {
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("rand: %v", err)
}
ib := &nativeInbound{
tag: "test-xhttp-browser",
protocol: "vless",
transport: "xhttp",
path: normalizeXHTTPPath("/"),
xhttpMode: "packet-up",
xhttpMaxEachPostBytes: 1_000_000,
xhttpMaxBufferedPosts: 30,
xhttpSessions: make(map[string]*nativeXHTTPSession),
clientsByID: map[[16]byte]*nativeXrayClient{id: {id: id, uuid: "test", email: "test@t"}},
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("xhttp listen: %v", err)
}
defer ln.Close()
go ib.serveXHTTPListener(ln)
port := ln.Addr().(*net.TCPAddr).Port
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("http://" + net.JoinHostPort("127.0.0.1", itoa(port)) + "/")
if err != nil {
t.Fatalf("browser GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("browser GET status = %d, want 400", resp.StatusCode)
}
}
func TestParseUUID(t *testing.T) {
got, err := parseUUID("b831381d-6324-4d53-ad4f-8cda48b30811")
if err != nil {
t.Fatalf("parseUUID: %v", err)
}
want := [16]byte{0xb8, 0x31, 0x38, 0x1d, 0x63, 0x24, 0x4d, 0x53, 0xad, 0x4f, 0x8c, 0xda, 0x48, 0xb3, 0x08, 0x11}
if got != want {
t.Fatalf("uuid mismatch: %x != %x", got, want)
}
if _, err := parseUUID("not-a-uuid"); err == nil {
t.Fatalf("expected error for bad uuid")
}
}
// --- minimal websocket client for the test ---
type testWSConn struct {
net.Conn
r *bufio.Reader
readBuf []byte
}
func wsClientHandshake(t *testing.T, conn net.Conn, path string) *testWSConn {
t.Helper()
var keyBytes [16]byte
rand.Read(keyBytes[:])
key := base64.StdEncoding.EncodeToString(keyBytes[:])
req := "GET " + path + " HTTP/1.1\r\n" +
"Host: test\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: " + key + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n"
if _, err := conn.Write([]byte(req)); err != nil {
t.Fatalf("ws client write handshake: %v", err)
}
br := bufio.NewReader(conn)
statusLine, err := br.ReadString('\n')
if err != nil {
t.Fatalf("ws client read status: %v", err)
}
if !strings.Contains(statusLine, "101") {
t.Fatalf("ws handshake not 101: %q", statusLine)
}
// Verify accept header and consume the rest of the header block.
sum := sha1.Sum([]byte(key + wsMagicGUID))
wantAccept := base64.StdEncoding.EncodeToString(sum[:])
sawAccept := false
for {
line, err := br.ReadString('\n')
if err != nil {
t.Fatalf("ws client read headers: %v", err)
}
if strings.Contains(line, wantAccept) {
sawAccept = true
}
if line == "\r\n" {
break
}
}
if !sawAccept {
t.Fatalf("ws server did not return correct Sec-WebSocket-Accept")
}
return &testWSConn{Conn: conn, r: br}
}
func (c *testWSConn) Write(p []byte) (int, error) {
// Masked client binary frame.
var mask [4]byte
rand.Read(mask[:])
n := len(p)
var hdr []byte
switch {
case n < 126:
hdr = []byte{0x82, 0x80 | byte(n)}
case n <= 0xffff:
hdr = []byte{0x82, 0x80 | 126, byte(n >> 8), byte(n)}
default:
hdr = make([]byte, 4)
hdr[0] = 0x82
hdr[1] = 0x80 | 127
// (8-byte length omitted; test payloads are small)
}
frame := append([]byte{}, hdr...)
frame = append(frame, mask[:]...)
masked := make([]byte, n)
for i := range p {
masked[i] = p[i] ^ mask[i&3]
}
frame = append(frame, masked...)
if _, err := c.Conn.Write(frame); err != nil {
return 0, err
}
return n, nil
}
func (c *testWSConn) Read(p []byte) (int, error) {
for len(c.readBuf) == 0 {
var h [2]byte
if _, err := io.ReadFull(c.r, h[:]); err != nil {
return 0, err
}
length := int64(h[1] & 0x7f)
switch length {
case 126:
var ext [2]byte
io.ReadFull(c.r, ext[:])
length = int64(binary.BigEndian.Uint16(ext[:]))
case 127:
var ext [8]byte
io.ReadFull(c.r, ext[:])
length = int64(binary.BigEndian.Uint64(ext[:]))
}
payload := make([]byte, length)
if _, err := io.ReadFull(c.r, payload); err != nil {
return 0, err
}
c.readBuf = payload
}
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
return n, nil
}
func itoa(n int) string { return strconv.Itoa(n) }
func TestVLESSUDPClassicDNSPacketFraming(t *testing.T) {
// A normal DNS query commonly has bytes 2/3 == 0x01/0x00. The old native
// auto-XUDP detector interpreted that as XUDP metadata and blocked waiting
// for another payload, so DNS over VLESS UDP never returned.
dnsQuery := []byte{
0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x04, 'f', 'a', 's', 't',
0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01,
}
var framed bytes.Buffer
if err := writeVLESSLengthPacket(&framed, dnsQuery); err != nil {
t.Fatalf("write dns frame: %v", err)
}
got, err := readVLESSLengthPacket(&framed)
if err != nil {
t.Fatalf("read dns frame: %v", err)
}
if !bytes.Equal(got, dnsQuery) {
t.Fatalf("dns payload changed: got %x want %x", got, dnsQuery)
}
}
func TestNormalizeNativeListenHostIPv6(t *testing.T) {
cases := map[string]string{
"2804:10f8:ce00:520::7": "2804:10f8:ce00:520::7",
"[2804:10f8:ce00:520::7]": "2804:10f8:ce00:520::7",
"[[2804:10f8:ce00:520::7]]": "2804:10f8:ce00:520::7",
"[2804:10f8:ce00:520::7]:443": "2804:10f8:ce00:520::7",
"0.0.0.0:443": "0.0.0.0",
"127.0.0.1": "127.0.0.1",
"": "0.0.0.0",
}
for in, want := range cases {
if got := normalizeNativeListenHost(in); got != want {
t.Fatalf("normalizeNativeListenHost(%q) = %q, want %q", in, got, want)
}
}
}
func TestNativeDialTargetKeepsIPv6Targets(t *testing.T) {
if got := normalizeNativeTargetHost("[2606:4700:4700::1111]"); got != "2606:4700:4700::1111" {
t.Fatalf("normalizeNativeTargetHost IPv6 bracket = %q", got)
}
if got := nativeDialNetwork("tcp", "2606:4700:4700::1111"); got != "tcp6" {
t.Fatalf("IPv6 TCP target must use tcp6, got %q", got)
}
if got := nativeDialNetwork("udp", "2606:4700:4700::1111"); got != "udp6" {
t.Fatalf("IPv6 UDP target must use udp6, got %q", got)
}
if got := nativeDialNetwork("tcp", "fast.com"); got != "tcp" {
t.Fatalf("domain targets must stay dual-stack tcp, got %q", got)
}
if got := nativeDialNetwork("udp", "one.one.one.one"); got != "udp" {
t.Fatalf("domain targets must stay dual-stack udp, got %q", got)
}
}
func TestNativeLocalAddrForIPv6Tunnel(t *testing.T) {
local := nativeLocalAddrForDial("tcp", "2606:4700:4700::1111", "[2804:10f8:ce00:520::7]")
tcpAddr, ok := local.(*net.TCPAddr)
if !ok {
t.Fatalf("expected TCP local addr for IPv6 target, got %T", local)
}
if got := tcpAddr.IP.String(); got != "2804:10f8:ce00:520::7" {
t.Fatalf("wrong TCP local IPv6 source: %q", got)
}
udpLocal := nativeLocalAddrForDial("udp", "2606:4700:4700::1111", "2804:10f8:ce00:520::7")
udpAddr, ok := udpLocal.(*net.UDPAddr)
if !ok {
t.Fatalf("expected UDP local addr for IPv6 target, got %T", udpLocal)
}
if got := udpAddr.IP.String(); got != "2804:10f8:ce00:520::7" {
t.Fatalf("wrong UDP local IPv6 source: %q", got)
}
if local := nativeLocalAddrForDial("tcp", "2606:4700:4700::1111", "0.0.0.0"); local != nil {
t.Fatalf("must not bind IPv4 source to IPv6 target: %#v", local)
}
}
+381
View File
@@ -0,0 +1,381 @@
package main
import (
"context"
"encoding/binary"
"fmt"
"io"
"log"
"net"
"sync"
"time"
"golang.org/x/time/rate"
)
const (
nativeUDPMaxPacket = 65535
nativeUDPBufferSize = 64 * 1024
nativeUDPIdle = 2 * time.Minute
)
// nativeVLESSUDPTunnel implements VLESS UDP-over-stream framing for a normal
// VLESS CommandUDP request. Xray uses classic 2-byte length-prefixed packets
// for this command. Do not auto-detect XUDP here: real DNS queries often have
// bytes 2/3 equal to 0x01/0x00, which looked like our old loose XUDP metadata
// 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)
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
var wg sync.WaitGroup
closeAll := func() {
_ = backend.Close()
_ = client.Close()
}
wg.Add(1)
go func() {
defer wg.Done()
for {
payload, err := readVLESSLengthPacket(client)
if err != nil {
if err != io.EOF {
log.Printf("native xray: VLESS UDP client read failed: %v", err)
}
return
}
if len(payload) == 0 {
continue
}
if err := waitNativeRate(up, len(payload)); err != nil {
return
}
n, err := backend.Write(payload)
if n > 0 {
upMeter.add(n)
}
if err != nil {
log.Printf("native xray: VLESS UDP backend write failed: %v", err)
return
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
buf := make([]byte, nativeUDPBufferSize)
for {
_ = backend.SetReadDeadline(time.Now().Add(nativeUDPIdle))
n, err := backend.Read(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return
}
if err != io.EOF {
log.Printf("native xray: VLESS UDP backend read failed: %v", err)
}
return
}
if n <= 0 {
continue
}
if err := waitNativeRate(down, n); err != nil {
return
}
if err := writeVLESSLengthPacket(client, buf[:n]); err != nil {
log.Printf("native xray: VLESS UDP client write failed: %v", err)
return
}
downMeter.add(n)
}
}()
wg.Wait()
upMeter.flush()
downMeter.flush()
closeAll()
}
type vlessUDPPacketCodec struct {
mu sync.RWMutex
decided bool
xudp bool
}
func (c *vlessUDPPacketCodec) setXUDP(v bool) {
c.mu.Lock()
if !c.decided {
c.decided = true
c.xudp = v
}
c.mu.Unlock()
}
func (c *vlessUDPPacketCodec) useXUDP() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.decided && c.xudp
}
func (c *vlessUDPPacketCodec) Read(r io.Reader) ([]byte, error) {
if c.useXUDP() {
return readVLESSXUDPPacket(r)
}
return c.readAuto(r)
}
func (c *vlessUDPPacketCodec) Write(w io.Writer, payload []byte) error {
if c.useXUDP() {
return writeVLESSXUDPPacket(w, payload)
}
return writeVLESSLengthPacket(w, payload)
}
func (c *vlessUDPPacketCodec) readAuto(r io.Reader) ([]byte, error) {
var lenBuf [2]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
n := int(binary.BigEndian.Uint16(lenBuf[:]))
if n == 0 {
c.setXUDP(false)
return []byte{}, nil
}
if n > nativeUDPMaxPacket {
return nil, fmt.Errorf("udp packet too large: %d", n)
}
// XUDP starts with a metadata frame length, not a payload length. Metadata is
// small and has command/option bytes at offsets 2/3 after the two-byte mux ID.
// Read a possible metadata frame once and fall back to normal length-prefixed
// UDP if it does not match the XUDP shape. This lets the native emulator work
// with clients whose default packet encoding is xudp while preserving classic
// VLESS UDP framing.
if n >= 4 && n <= 512 {
candidate := make([]byte, n)
if _, err := io.ReadFull(r, candidate); err != nil {
return nil, err
}
if isVLESSXUDPMetadata(candidate) {
c.setXUDP(true)
return readVLESSXUDPPayloadAfterMeta(r, candidate)
}
c.setXUDP(false)
return candidate, nil
}
c.setXUDP(false)
pkt := make([]byte, n)
_, err := io.ReadFull(r, pkt)
return pkt, err
}
func readVLESSLengthPacket(r io.Reader) ([]byte, error) {
var lenBuf [2]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
n := int(binary.BigEndian.Uint16(lenBuf[:]))
if n == 0 {
return []byte{}, nil
}
if n > nativeUDPMaxPacket {
return nil, fmt.Errorf("udp packet too large: %d", n)
}
pkt := make([]byte, n)
_, err := io.ReadFull(r, pkt)
return pkt, err
}
func writeVLESSLengthPacket(w io.Writer, payload []byte) error {
if len(payload) > nativeUDPMaxPacket {
return fmt.Errorf("udp packet too large: %d", len(payload))
}
var lenBuf [2]byte
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(payload)))
if _, err := w.Write(lenBuf[:]); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
func isVLESSXUDPMetadata(meta []byte) bool {
if len(meta) < 4 {
return false
}
cmd := meta[2]
opt := meta[3]
if cmd != 1 && cmd != 2 && cmd != 4 { // New, Keep, End/discard
return false
}
return opt == 0 || opt == 1
}
func readVLESSXUDPPacket(r io.Reader) ([]byte, error) {
for {
var lenBuf [2]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
n := int(binary.BigEndian.Uint16(lenBuf[:]))
if n < 4 || n > 512 {
return nil, fmt.Errorf("bad xudp metadata length: %d", n)
}
meta := make([]byte, n)
if _, err := io.ReadFull(r, meta); err != nil {
return nil, err
}
if !isVLESSXUDPMetadata(meta) {
return nil, fmt.Errorf("bad xudp metadata command/option")
}
payload, err := readVLESSXUDPPayloadAfterMeta(r, meta)
if err != nil {
return nil, err
}
if payload != nil {
return payload, nil
}
}
}
func readVLESSXUDPPayloadAfterMeta(r io.Reader, meta []byte) ([]byte, error) {
if len(meta) < 4 {
return nil, io.ErrUnexpectedEOF
}
if meta[2] == 4 { // discard/end marker
return nil, nil
}
if meta[3] != 1 { // no payload attached
return nil, nil
}
var lenBuf [2]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
n := int(binary.BigEndian.Uint16(lenBuf[:]))
if n == 0 {
return []byte{}, nil
}
if n > nativeUDPMaxPacket {
return nil, fmt.Errorf("xudp payload too large: %d", n)
}
pkt := make([]byte, n)
_, err := io.ReadFull(r, pkt)
return pkt, err
}
func writeVLESSXUDPPacket(w io.Writer, payload []byte) error {
if len(payload) > nativeUDPMaxPacket {
return fmt.Errorf("udp packet too large: %d", len(payload))
}
// Metadata length 4, mux session id 0, command Keep, option payload-present.
// This is accepted by Xray's xudp.PacketReader for responses when the UDP
// destination is already known from the request header.
var header [8]byte
binary.BigEndian.PutUint16(header[0:2], 4)
header[2] = 0
header[3] = 0
header[4] = 2 // Keep
header[5] = 1 // Opt: payload follows
binary.BigEndian.PutUint16(header[6:8], uint16(len(payload)))
if _, err := w.Write(header[:]); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
// 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)
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
var wg sync.WaitGroup
closeAll := func() {
_ = backend.Close()
_ = client.Close()
}
wg.Add(1)
go func() {
defer wg.Done()
for {
pkt, err := client.ReadPacket()
if err != nil {
if err != io.EOF {
log.Printf("native xray: VMess UDP client read failed: %v", err)
}
return
}
if len(pkt) == 0 {
continue
}
if err := waitNativeRate(up, len(pkt)); err != nil {
return
}
n, err := backend.Write(pkt)
if n > 0 {
upMeter.add(n)
}
if err != nil {
log.Printf("native xray: VMess UDP backend write failed: %v", err)
return
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
buf := make([]byte, nativeUDPBufferSize)
for {
_ = backend.SetReadDeadline(time.Now().Add(nativeUDPIdle))
n, err := backend.Read(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return
}
if err != io.EOF {
log.Printf("native xray: VMess UDP backend read failed: %v", err)
}
return
}
if n <= 0 {
continue
}
if err := waitNativeRate(down, n); err != nil {
return
}
if err := client.WritePacket(buf[:n]); err != nil {
log.Printf("native xray: VMess UDP client write failed: %v", err)
return
}
downMeter.add(n)
}
}()
wg.Wait()
upMeter.flush()
downMeter.flush()
closeAll()
}
func waitNativeRate(lim *rate.Limiter, n int) error {
if lim == nil || n <= 0 {
return nil
}
return lim.WaitN(context.Background(), n)
}
+728
View File
@@ -0,0 +1,728 @@
package main
// Pure-Go VMess (AEAD) server, part of the in-process Xray emulator.
//
// This implements the modern "VMess AEAD" protocol (alterId = 0) exactly as
// spoken by current Xray/v2ray clients: AEAD-authenticated request header,
// AES-128-GCM / ChaCha20-Poly1305 chunked body with SHAKE-masked lengths,
// optional global padding and authenticated length, and the AEAD response
// header + body. Byte offsets, KDF labels and orderings follow the v2fly/xray
// reference (proxy/vmess/{aead,encoding}). Legacy MD5-auth VMess (alterId > 0)
// is intentionally not supported.
//
// TCP and UDP commands are served. Mux is intentionally not supported in the
// native emulator yet.
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"hash"
"hash/crc32"
"hash/fnv"
"io"
"log"
"net"
"sync"
"time"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/sha3"
)
// ---------- constants ----------
const vmessCmdKeyMagic = "c48619fe-8f02-49e0-b9e9-edf763e17e21"
const (
kdfSaltVMessAEADKDF = "VMess AEAD KDF"
kdfLabelAuthIDEncryptionKey = "AES Auth ID Encryption"
kdfLabelReqHeaderLenKey = "VMess Header AEAD Key_Length"
kdfLabelReqHeaderLenIV = "VMess Header AEAD Nonce_Length"
kdfLabelReqHeaderKey = "VMess Header AEAD Key"
kdfLabelReqHeaderIV = "VMess Header AEAD Nonce"
kdfLabelRespHeaderLenKey = "AEAD Resp Header Len Key"
kdfLabelRespHeaderLenIV = "AEAD Resp Header Len IV"
kdfLabelRespHeaderKey = "AEAD Resp Header Key"
kdfLabelRespHeaderIV = "AEAD Resp Header IV"
kdfLabelAuthLen = "auth_len"
)
// VMess request option flags (header byte 34).
const (
vmessOptChunkStream = 0x01
vmessOptChunkMasking = 0x04
vmessOptGlobalPadding = 0x08
vmessOptAuthenticatedLength = 0x10
)
// VMess security types (low nibble of header byte 35).
const (
vmessSecAES128GCM = 3
vmessSecChaCha20Poly1305 = 4
vmessSecNone = 5
)
// VMess commands (header byte 37).
const (
vmessCmdTCP = 1
vmessCmdUDP = 2
vmessCmdMux = 3
)
const vmessTimeWindowSeconds = 120
// ---------- KDF ("VMess AEAD KDF", nested HMAC-SHA256) ----------
type hmacCreator struct {
parent *hmacCreator
value []byte
}
func newHMAC(f func() hash.Hash, key []byte) hash.Hash {
return hmac.New(f, key)
}
func (h *hmacCreator) create() hash.Hash {
if h.parent == nil {
return newHMAC(sha256.New, h.value)
}
return newHMAC(h.parent.create, h.value)
}
func vmessKDF(key []byte, path ...string) []byte {
c := &hmacCreator{value: []byte(kdfSaltVMessAEADKDF)}
for _, p := range path {
c = &hmacCreator{value: []byte(p), parent: c}
}
h := c.create()
h.Write(key)
return h.Sum(nil)
}
func vmessKDF16(key []byte, path ...string) []byte {
return vmessKDF(key, path...)[:16]
}
// ---------- command key / crypto helpers ----------
func vmessCmdKey(uuid [16]byte) [16]byte {
h := md5.New()
h.Write(uuid[:])
h.Write([]byte(vmessCmdKeyMagic))
var out [16]byte
copy(out[:], h.Sum(nil))
return out
}
func newAESGCM(key []byte) cipher.AEAD {
block, err := aes.NewCipher(key)
if err != nil {
panic(err) // only happens on wrong key length — a programmer error
}
gcm, err := cipher.NewGCM(block)
if err != nil {
panic(err)
}
return gcm
}
// vmessChaChaKey expands a 16-byte key into the 32-byte ChaCha20 key VMess uses:
// MD5(key) || MD5(MD5(key)).
func vmessChaChaKey(key []byte) []byte {
h1 := md5.Sum(key)
h2 := md5.Sum(h1[:])
out := make([]byte, 32)
copy(out[0:16], h1[:])
copy(out[16:32], h2[:])
return out
}
func absInt64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
func shakeNext(s sha3.ShakeHash) uint16 {
var b [2]byte
_, _ = s.Read(b[:])
return binary.BigEndian.Uint16(b[:])
}
// ---------- auth ID matching ----------
// matchVMess tries every VMess client's auth-ID cipher against the 16-byte
// auth ID, returning the client whose key decrypts to a CRC-valid, in-window
// timestamp. This is O(clients) AES blocks per connection.
func (ib *nativeInbound) matchVMess(authid [16]byte, now int64) *nativeXrayClient {
ib.clientMu.RLock()
defer ib.clientMu.RUnlock()
for _, c := range ib.clientsByID {
if c.authIDCipher == nil {
continue
}
var dec [16]byte
c.authIDCipher.Decrypt(dec[:], authid[:])
if crc32.ChecksumIEEE(dec[0:12]) != binary.BigEndian.Uint32(dec[12:16]) {
continue
}
t := int64(binary.BigEndian.Uint64(dec[0:8]))
if t < 0 || absInt64(t-now) > vmessTimeWindowSeconds {
continue
}
return c
}
return nil
}
// ---------- request header ----------
type vmessRequest struct {
bodyIV [16]byte
bodyKey [16]byte
respV byte
option byte
security byte
command byte
host string
port uint16
}
// openVMessHeader reads and decrypts the AEAD request header from r, given the
// user's command key and the already-read 16-byte auth ID. r must be positioned
// immediately after the auth ID.
func openVMessHeader(cmdKey [16]byte, authid [16]byte, r io.Reader) ([]byte, error) {
var lenBlock [18]byte // 2-byte length + 16-byte tag
if _, err := io.ReadFull(r, lenBlock[:]); err != nil {
return nil, err
}
var connNonce [8]byte
if _, err := io.ReadFull(r, connNonce[:]); err != nil {
return nil, err
}
aid := string(authid[:])
cn := string(connNonce[:])
lenGCM := newAESGCM(vmessKDF16(cmdKey[:], kdfLabelReqHeaderLenKey, aid, cn))
lenNonce := vmessKDF(cmdKey[:], kdfLabelReqHeaderLenIV, aid, cn)[:12]
lenPlain, err := lenGCM.Open(nil, lenNonce, lenBlock[:], authid[:])
if err != nil {
return nil, fmt.Errorf("vmess: header length decrypt: %w", err)
}
headerLen := int(binary.BigEndian.Uint16(lenPlain))
if headerLen < 38 || headerLen > 512 {
return nil, fmt.Errorf("vmess: implausible header length %d", headerLen)
}
payload := make([]byte, headerLen+16)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, err
}
payGCM := newAESGCM(vmessKDF16(cmdKey[:], kdfLabelReqHeaderKey, aid, cn))
payNonce := vmessKDF(cmdKey[:], kdfLabelReqHeaderIV, aid, cn)[:12]
header, err := payGCM.Open(nil, payNonce, payload, authid[:])
if err != nil {
return nil, fmt.Errorf("vmess: header payload decrypt: %w", err)
}
return header, nil
}
// parseVMessHeader parses the decrypted request header plaintext.
func parseVMessHeader(h []byte) (vmessRequest, error) {
var req vmessRequest
if len(h) < 40 {
return req, errors.New("vmess: header too short")
}
if h[0] != 1 {
return req, fmt.Errorf("vmess: unsupported version %d", h[0])
}
copy(req.bodyIV[:], h[1:17])
copy(req.bodyKey[:], h[17:33])
req.respV = h[33]
req.option = h[34]
req.security = h[35] & 0x0f
paddingLen := int(h[35] >> 4)
req.command = h[37]
req.port = binary.BigEndian.Uint16(h[38:40])
host, next, err := parseVMessAddress(h, 40)
if err != nil {
return req, err
}
req.host = host
if next+paddingLen+4 != len(h) {
return req, fmt.Errorf("vmess: header length mismatch (addr end %d + pad %d + 4 != %d)", next, paddingLen, len(h))
}
f := fnv.New32a()
f.Write(h[:len(h)-4])
if binary.BigEndian.Uint32(h[len(h)-4:]) != f.Sum32() {
return req, errors.New("vmess: header checksum mismatch")
}
return req, nil
}
func parseVMessAddress(h []byte, off int) (host string, next int, err error) {
if off >= len(h) {
return "", 0, io.ErrUnexpectedEOF
}
atyp := h[off]
off++
switch atyp {
case atypIPv4:
if off+4 > len(h) {
return "", 0, io.ErrUnexpectedEOF
}
host = net.IP(h[off : off+4]).String()
off += 4
case atypIPv6:
if off+16 > len(h) {
return "", 0, io.ErrUnexpectedEOF
}
host = net.IP(h[off : off+16]).String()
off += 16
case atypDomain:
if off >= len(h) {
return "", 0, io.ErrUnexpectedEOF
}
l := int(h[off])
off++
if off+l > len(h) {
return "", 0, io.ErrUnexpectedEOF
}
host = string(h[off : off+l])
off += l
default:
return "", 0, fmt.Errorf("vmess: unknown address type %d", atyp)
}
return host, off, nil
}
// ---------- response header ----------
func writeVMessResponseHeader(w io.Writer, respBodyKey, respBodyIV [16]byte, respV byte) error {
header := []byte{respV, 0, 0, 0} // V echo, option 0, command 0, command-data-length 0
lenGCM := newAESGCM(vmessKDF16(respBodyKey[:], kdfLabelRespHeaderLenKey))
lenNonce := vmessKDF(respBodyIV[:], kdfLabelRespHeaderLenIV)[:12]
var lenPlain [2]byte
binary.BigEndian.PutUint16(lenPlain[:], uint16(len(header)))
lenSealed := lenGCM.Seal(nil, lenNonce, lenPlain[:], nil) // AAD nil
payGCM := newAESGCM(vmessKDF16(respBodyKey[:], kdfLabelRespHeaderKey))
payNonce := vmessKDF(respBodyIV[:], kdfLabelRespHeaderIV)[:12]
paySealed := payGCM.Seal(nil, payNonce, header, nil) // AAD nil
out := make([]byte, 0, len(lenSealed)+len(paySealed))
out = append(out, lenSealed...)
out = append(out, paySealed...)
_, err := w.Write(out)
return err
}
// ---------- authenticated-length size parser ----------
// vmessAuthLen encodes/decodes the 2-byte chunk length as an AEAD-sealed field
// (option AuthenticatedLength). It always derives its key from the *request*
// body key/IV, in both directions, per the reference.
type vmessAuthLen struct {
aead cipher.AEAD
count uint16
ivTail [10]byte
nonce [12]byte
}
func newVMessAuthLen(reqBodyKey, reqBodyIV [16]byte, chacha bool) *vmessAuthLen {
keyMat := vmessKDF16(reqBodyKey[:], kdfLabelAuthLen)
var aead cipher.AEAD
if chacha {
a, _ := chacha20poly1305.New(vmessChaChaKey(keyMat))
aead = a
} else {
aead = newAESGCM(keyMat)
}
al := &vmessAuthLen{aead: aead}
copy(al.ivTail[:], reqBodyIV[2:12])
return al
}
func (al *vmessAuthLen) nextNonce() []byte {
binary.BigEndian.PutUint16(al.nonce[0:2], al.count)
copy(al.nonce[2:12], al.ivTail[:])
al.count++
return al.nonce[:12]
}
func (al *vmessAuthLen) decode(r io.Reader) (int, error) {
var buf [18]byte
if _, err := io.ReadFull(r, buf[:]); err != nil {
return 0, err
}
plain, err := al.aead.Open(nil, al.nextNonce(), buf[:], nil)
if err != nil {
return 0, fmt.Errorf("vmess: auth-len decrypt: %w", err)
}
return int(binary.BigEndian.Uint16(plain)) + 16, nil
}
func (al *vmessAuthLen) encode(out *bytes.Buffer, size int) {
var lenPlain [2]byte
binary.BigEndian.PutUint16(lenPlain[:], uint16(size-16))
out.Write(al.aead.Seal(nil, al.nextNonce(), lenPlain[:], nil))
}
// ---------- body chunk reader/writer ----------
const vmessMaxChunk = 64*1024 + 64
type vmessChunkReader struct {
r io.Reader
aead cipher.AEAD // nil for security "none"
overhead int
ivTail [10]byte
count uint16
nonce [12]byte
shake sha3.ShakeHash // non-nil when chunk masking is enabled
authLen *vmessAuthLen // non-nil when authenticated length is enabled
globalPad bool
leftover []byte
eof bool
}
func (cr *vmessChunkReader) nextNonce() []byte {
binary.BigEndian.PutUint16(cr.nonce[0:2], cr.count)
copy(cr.nonce[2:12], cr.ivTail[:])
cr.count++
return cr.nonce[:12]
}
func (cr *vmessChunkReader) readChunk() ([]byte, error) {
// Padding length is always drawn from the SHAKE stream before the size.
pad := 0
if cr.shake != nil && cr.globalPad {
pad = int(shakeNext(cr.shake) % 64)
}
var size int
switch {
case cr.authLen != nil:
s, err := cr.authLen.decode(cr.r)
if err != nil {
return nil, err
}
size = s
case cr.shake != nil:
var b [2]byte
if _, err := io.ReadFull(cr.r, b[:]); err != nil {
return nil, err
}
size = int(shakeNext(cr.shake) ^ binary.BigEndian.Uint16(b[:]))
default:
var b [2]byte
if _, err := io.ReadFull(cr.r, b[:]); err != nil {
return nil, err
}
size = int(binary.BigEndian.Uint16(b[:]))
}
// size == overhead + pad means an empty (terminating) chunk.
if size == cr.overhead+pad {
return nil, io.EOF
}
if size < cr.overhead+pad || size > vmessMaxChunk {
return nil, fmt.Errorf("vmess: bad chunk size %d", size)
}
data := make([]byte, size)
if _, err := io.ReadFull(cr.r, data); err != nil {
return nil, err
}
sealed := data[:size-pad] // trailing pad bytes are clear-text, discarded
if cr.aead == nil {
return sealed, nil
}
plain, err := cr.aead.Open(nil, cr.nextNonce(), sealed, nil)
if err != nil {
return nil, fmt.Errorf("vmess: body decrypt: %w", err)
}
return plain, nil
}
func (cr *vmessChunkReader) Read(p []byte) (int, error) {
for len(cr.leftover) == 0 {
if cr.eof {
return 0, io.EOF
}
chunk, err := cr.readChunk()
if err == io.EOF {
cr.eof = true
return 0, io.EOF
}
if err != nil {
return 0, err
}
cr.leftover = chunk
}
n := copy(p, cr.leftover)
cr.leftover = cr.leftover[n:]
return n, nil
}
type vmessChunkWriter struct {
w io.Writer
aead cipher.AEAD
overhead int
ivTail [10]byte
count uint16
nonce [12]byte
shake sha3.ShakeHash
authLen *vmessAuthLen
globalPad bool
}
func (cw *vmessChunkWriter) nextNonce() []byte {
binary.BigEndian.PutUint16(cw.nonce[0:2], cw.count)
copy(cw.nonce[2:12], cw.ivTail[:])
cw.count++
return cw.nonce[:12]
}
func (cw *vmessChunkWriter) writeChunk(p []byte) error {
var out bytes.Buffer
pad := 0
if cw.shake != nil && cw.globalPad {
pad = int(shakeNext(cw.shake) % 64)
}
size := len(p) + cw.overhead + pad
switch {
case cw.authLen != nil:
cw.authLen.encode(&out, size)
case cw.shake != nil:
var b [2]byte
binary.BigEndian.PutUint16(b[:], shakeNext(cw.shake)^uint16(size))
out.Write(b[:])
default:
var b [2]byte
binary.BigEndian.PutUint16(b[:], uint16(size))
out.Write(b[:])
}
if cw.aead != nil {
out.Write(cw.aead.Seal(nil, cw.nextNonce(), p, nil))
} else {
out.Write(p)
}
if pad > 0 {
padBytes := make([]byte, pad)
_, _ = rand.Read(padBytes)
out.Write(padBytes)
}
_, err := cw.w.Write(out.Bytes())
return err
}
// ---------- vmessConn: net.Conn view of a decoded VMess session ----------
type nativeVMessStream interface {
net.Conn
ReadPacket() ([]byte, error)
WritePacket([]byte) error
}
type vmessConn struct {
net.Conn
reader *vmessChunkReader
writer *vmessChunkWriter
terminateOnClose bool
closeOnce sync.Once
}
func (c *vmessConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
// ReadPacket returns exactly one decrypted VMess body chunk. UDP-over-VMess uses
// one VMess chunk per UDP datagram, so packet handling must bypass the streamy
// Read method that can merge/split chunks.
func (c *vmessConn) ReadPacket() ([]byte, error) { return c.reader.readChunk() }
func (c *vmessConn) WritePacket(p []byte) error { return c.writer.writeChunk(p) }
func (c *vmessConn) Write(p []byte) (int, error) {
// Bound each chunk well under the uint16 length field.
const maxChunk = 16 * 1024
total := 0
for len(p) > 0 {
n := len(p)
if n > maxChunk {
n = maxChunk
}
if err := c.writer.writeChunk(p[:n]); err != nil {
return total, err
}
total += n
p = p[n:]
}
return total, nil
}
func (c *vmessConn) Close() error {
c.closeOnce.Do(func() {
if c.terminateOnClose {
_ = c.writer.writeChunk(nil) // terminating empty chunk
}
})
return c.Conn.Close()
}
// vmessRawConn is used for VMess security=none when the client did not request
// ChunkStream. Xray's own server returns a raw reader/writer in that exact case;
// treating the following TLS ClientHello/HTTP bytes as a VMess chunk length makes
// real clients authenticate but then pass no data.
type vmessRawConn struct {
net.Conn
}
func (c *vmessRawConn) ReadPacket() ([]byte, error) {
buf := make([]byte, 64*1024)
n, err := c.Conn.Read(buf)
if n > 0 {
return buf[:n], nil
}
return nil, err
}
func (c *vmessRawConn) WritePacket(p []byte) error {
_, err := c.Conn.Write(p)
return err
}
func newVMessConn(stream net.Conn, req vmessRequest, respBodyKey, respBodyIV [16]byte) (nativeVMessStream, error) {
chunkMask := req.option&vmessOptChunkMasking != 0
globalPad := req.option&vmessOptGlobalPadding != 0
authLen := req.option&vmessOptAuthenticatedLength != 0
chacha := req.security == vmessSecChaCha20Poly1305
if req.security == vmessSecNone && req.option&vmessOptChunkStream == 0 {
return &vmessRawConn{Conn: stream}, nil
}
var readAEAD, writeAEAD cipher.AEAD
overhead := 16
switch req.security {
case vmessSecAES128GCM:
readAEAD = newAESGCM(req.bodyKey[:])
writeAEAD = newAESGCM(respBodyKey[:])
case vmessSecChaCha20Poly1305:
ra, _ := chacha20poly1305.New(vmessChaChaKey(req.bodyKey[:]))
wa, _ := chacha20poly1305.New(vmessChaChaKey(respBodyKey[:]))
readAEAD, writeAEAD = ra, wa
case vmessSecNone:
overhead = 0
default:
return nil, fmt.Errorf("vmess: unsupported security %d", req.security)
}
cr := &vmessChunkReader{r: stream, aead: readAEAD, overhead: overhead, globalPad: globalPad}
copy(cr.ivTail[:], req.bodyIV[2:12])
cw := &vmessChunkWriter{w: stream, aead: writeAEAD, overhead: overhead, globalPad: globalPad}
copy(cw.ivTail[:], respBodyIV[2:12])
if chunkMask {
rs := sha3.NewShake128()
rs.Write(req.bodyIV[:])
cr.shake = rs
ws := sha3.NewShake128()
ws.Write(respBodyIV[:])
cw.shake = ws
}
if authLen {
cr.authLen = newVMessAuthLen(req.bodyKey, req.bodyIV, chacha)
cw.authLen = newVMessAuthLen(req.bodyKey, req.bodyIV, chacha)
}
return &vmessConn{Conn: stream, reader: cr, writer: cw, terminateOnClose: req.option&vmessOptChunkStream != 0 || req.security != vmessSecNone}, nil
}
// ---------- handler ----------
func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
_ = stream.SetReadDeadline(time.Now().Add(30 * time.Second))
var authid [16]byte
if _, err := io.ReadFull(stream, authid[:]); err != nil {
return
}
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)
return
}
header, err := openVMessHeader(client.cmdKey, authid, stream)
if err != nil {
log.Printf("native xray: inbound %q VMess header open failed from %s: %v", ib.tag, remote, err)
return
}
req, err := parseVMessHeader(header)
if err != nil {
log.Printf("native xray: inbound %q VMess header parse failed from %s: %v", ib.tag, remote, err)
return
}
_ = stream.SetReadDeadline(time.Time{})
if req.command != vmessCmdTCP && req.command != vmessCmdUDP {
log.Printf("native xray: inbound %q VMess command %d not supported yet", ib.tag, req.command)
return
}
respBodyKey := sha256.Sum256(req.bodyKey[:])
respBodyIV := sha256.Sum256(req.bodyIV[:])
var rk, riv [16]byte
copy(rk[:], respBodyKey[:16])
copy(riv[:], respBodyIV[:16])
if err := writeVMessResponseHeader(stream, rk, riv, req.respV); err != nil {
return
}
vc, err := newVMessConn(stream, req, rk, riv)
if err != nil {
log.Printf("native xray: inbound %q VMess codec: %v", ib.tag, err)
return
}
switch req.command {
case vmessCmdTCP:
backend, target, err := ib.nativeDialTCP(req.host, req.port)
if err != nil {
log.Printf("native xray: inbound %q VMess TCP dial %s failed: %v", ib.tag, target, err)
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())
case vmessCmdUDP:
backend, target, err := ib.nativeDialUDP(req.host, req.port)
if err != nil {
log.Printf("native xray: inbound %q VMess UDP dial %s failed: %v", ib.tag, target, err)
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())
}
}
+906
View File
@@ -0,0 +1,906 @@
package main
import (
"container/heap"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
const (
xhttpPlacementPath = "path"
xhttpPlacementQuery = "query"
xhttpPlacementHeader = "header"
xhttpPlacementCookie = "cookie"
xhttpPlacementBody = "body"
xhttpPlacementAuto = "auto"
)
// isXHTTP reports whether this inbound uses XHTTP/SplitHTTP. Xray historically
// uses both names; the panel uses "xhttp" while upstream registers "splithttp".
func (ib *nativeInbound) isXHTTP() bool {
switch strings.ToLower(ib.transport) {
case "xhttp", "splithttp":
return true
default:
return false
}
}
func normalizeXHTTPPath(path string) string {
path = strings.TrimSpace(path)
if i := strings.Index(path, "?"); i >= 0 {
path = path[:i]
}
if path == "" || path[0] != '/' {
path = "/" + path
}
if !strings.HasSuffix(path, "/") {
path += "/"
}
return path
}
func mergeNativeXHTTPSettings(primary, fallback nativeXHTTPSettingsJSON) nativeXHTTPSettingsJSON {
out := primary
if out.Host == "" {
out.Host = fallback.Host
}
if out.Path == "" {
out.Path = fallback.Path
}
if out.Mode == "" {
out.Mode = fallback.Mode
}
if !out.NoSSEHeader {
out.NoSSEHeader = fallback.NoSSEHeader
}
if out.SessionIDPlacement == "" {
out.SessionIDPlacement = fallback.SessionIDPlacement
}
if out.SessionIDKey == "" {
out.SessionIDKey = fallback.SessionIDKey
}
if out.SeqPlacement == "" {
out.SeqPlacement = fallback.SeqPlacement
}
if out.SeqKey == "" {
out.SeqKey = fallback.SeqKey
}
if out.UplinkDataPlacement == "" {
out.UplinkDataPlacement = fallback.UplinkDataPlacement
}
if out.UplinkDataKey == "" {
out.UplinkDataKey = fallback.UplinkDataKey
}
if out.ScMaxEachPostBytes == nil {
out.ScMaxEachPostBytes = fallback.ScMaxEachPostBytes
}
if out.ScMaxBufferedPosts == 0 {
out.ScMaxBufferedPosts = fallback.ScMaxBufferedPosts
}
if out.ServerMaxHeaderBytes == 0 {
out.ServerMaxHeaderBytes = fallback.ServerMaxHeaderBytes
}
return out
}
func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
h2s := &http2.Server{}
handler := http.Handler(ib)
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
// listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without
// h2c, some clients/CDNs can reach the port but the request never reaches the
// XHTTP handler, which makes the proxy look dead with no useful target logs.
if ib.security != "tls" {
handler = h2c.NewHandler(ib, h2s)
}
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 4 * time.Second,
MaxHeaderBytes: ib.xhttpServerMaxHeaderBytes(),
}
if ib.security == "tls" && ib.tlsConfig != nil {
srv.TLSConfig = ib.tlsConfig
_ = http2.ConfigureServer(srv, h2s)
}
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) && !isListenerClosed(err) {
log.Printf("native xray: XHTTP server for inbound %q stopped: %v", ib.tag, err)
}
}
func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
if ib.xhttpMaxHeaderBytes > 0 {
return ib.xhttpMaxHeaderBytes
}
// Xray defaults to 8192. Keep a little room for custom headers/cookies used
// by packet-up mode while still preventing unbounded memory use.
return 64 * 1024
}
// ServeHTTP terminates the XHTTP/SplitHTTP transport and exposes the decoded
// byte stream to the VLESS/VMess handlers as a net.Conn.
func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !ib.isXHTTP() {
log.Printf("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) {
log.Printf("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)
xhttpBadRequest(w)
return
}
base, ok := ib.matchXHTTPPath(r.URL.Path)
if !ok {
log.Printf("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)
xhttpBadRequest(w)
return
}
ib.writeXHTTPCommonHeaders(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
sessionID, seqStr := ib.extractXHTTPMeta(r, base)
mode := ib.normalizedXHTTPMode()
log.Printf("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)
// Xray's SplitHTTP treats GET with a sequence id as an uplink packet, not as
// stream-down. Some clients use this when the upload payload is carried in
// headers/cookies instead of the body. The previous native handler always
// treated GET as download and dropped those packets, so normal sites such as
// fast.com could authenticate but then stall with no upstream data.
if r.Method == http.MethodGet && sessionID != "" && seqStr != "" {
sess := ib.upsertXHTTPSession(sessionID)
ib.handleXHTTPPacketUpload(w, r, sess, seqStr)
return
}
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if sessionID == "" {
// Do not look like a fake web site. A plain browser request is not an
// XHTTP stream. External Xray normally answers this kind of access as a
// bad request because the required XHTTP metadata/padding is missing.
xhttpBadRequest(w)
return
}
sess := ib.upsertXHTTPSession(sessionID)
ib.handleXHTTPDownload(w, r, sess, sessionID)
return
}
if !isXHTTPUploadMethod(r.Method) {
w.Header().Set("Allow", "GET, POST, PUT, PATCH, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if sessionID == "" {
if mode != "auto" && mode != "stream-one" && mode != "stream-up" {
http.Error(w, "xhttp stream-one mode is not allowed", http.StatusBadRequest)
return
}
if r.Body == nil || (r.ContentLength == 0 && len(r.TransferEncoding) == 0) {
xhttpBadRequest(w)
return
}
ib.handleXHTTPStreamOne(w, r)
return
}
sess := ib.upsertXHTTPSession(sessionID)
if seqStr == "" {
ib.handleXHTTPStreamUpload(w, r, sess)
return
}
ib.handleXHTTPPacketUpload(w, r, sess, seqStr)
}
func xhttpBadRequest(w http.ResponseWriter) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}
func (ib *nativeInbound) normalizedXHTTPMode() string {
mode := strings.ToLower(strings.TrimSpace(ib.xhttpMode))
if mode == "" {
return "auto"
}
return mode
}
func isXHTTPUploadMethod(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
return true
default:
return false
}
}
func (ib *nativeInbound) xhttpHostAllowed(reqHost string) bool {
want := strings.TrimSpace(ib.xhttpHost)
if want == "" {
return true
}
for _, h := range strings.Split(want, ",") {
h = strings.TrimSpace(h)
if h == "" {
continue
}
if strings.EqualFold(reqHost, h) {
return true
}
reqBare := stripHostPort(reqHost)
wantBare := stripHostPort(h)
if strings.EqualFold(reqBare, wantBare) {
return true
}
}
return false
}
func stripHostPort(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if strings.HasPrefix(h, "[") {
if end := strings.Index(h, "]"); end >= 0 {
return strings.Trim(h[1:end], "[]")
}
}
if host, _, err := net.SplitHostPort(h); err == nil {
return strings.Trim(host, "[]")
}
if i := strings.LastIndex(h, ":"); i > -1 && strings.Count(h, ":") == 1 {
return h[:i]
}
return strings.Trim(h, "[]")
}
func (ib *nativeInbound) matchXHTTPPath(reqPath string) (base string, ok bool) {
base = ib.path
if base == "" {
base = "/"
}
base = normalizeXHTTPPath(base)
if strings.HasPrefix(reqPath, base) {
return base, true
}
trimmed := strings.TrimSuffix(base, "/")
if trimmed == "" {
trimmed = "/"
}
if reqPath == trimmed {
return base, true
}
return base, false
}
func (ib *nativeInbound) writeXHTTPCommonHeaders(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
if r.Method == http.MethodOptions {
if m := r.Header.Get("Access-Control-Request-Method"); m != "" {
w.Header().Set("Access-Control-Allow-Methods", m)
} else {
w.Header().Set("Access-Control-Allow-Methods", "*")
}
if h := r.Header.Get("Access-Control-Request-Headers"); h != "" {
w.Header().Set("Access-Control-Allow-Headers", h)
} else {
w.Header().Set("Access-Control-Allow-Headers", "*")
}
}
}
func (ib *nativeInbound) extractXHTTPMeta(r *http.Request, base string) (sessionID, seqStr string) {
sessionPlacement := firstNonEmpty(ib.xhttpSessionPlacement, xhttpPlacementPath)
seqPlacement := firstNonEmpty(ib.xhttpSeqPlacement, xhttpPlacementPath)
sessionKey := firstNonEmpty(ib.xhttpSessionKey, defaultXHTTPMetaKey(sessionPlacement, true))
seqKey := firstNonEmpty(ib.xhttpSeqKey, defaultXHTTPMetaKey(seqPlacement, false))
var parts []string
pathPart := 0
if sessionPlacement == xhttpPlacementPath || seqPlacement == xhttpPlacementPath {
rest := ""
if strings.HasPrefix(r.URL.Path, base) {
rest = r.URL.Path[len(base):]
}
rest = strings.Trim(rest, "/")
if rest != "" {
parts = strings.Split(rest, "/")
}
}
if sessionPlacement == xhttpPlacementPath {
if len(parts) > pathPart {
sessionID = parts[pathPart]
pathPart++
}
} else {
sessionID = extractXHTTPValue(r, sessionPlacement, sessionKey)
}
if seqPlacement == xhttpPlacementPath {
if len(parts) > pathPart {
seqStr = parts[pathPart]
}
} else {
seqStr = extractXHTTPValue(r, seqPlacement, seqKey)
}
return sessionID, seqStr
}
func defaultXHTTPMetaKey(placement string, session bool) string {
switch placement {
case xhttpPlacementHeader:
if session {
return "X-Session"
}
return "X-Seq"
case xhttpPlacementCookie, xhttpPlacementQuery:
if session {
return "x_session"
}
return "x_seq"
default:
return ""
}
}
func extractXHTTPValue(r *http.Request, placement, key string) string {
if key == "" {
return ""
}
switch placement {
case xhttpPlacementQuery:
return r.URL.Query().Get(key)
case xhttpPlacementHeader:
return r.Header.Get(key)
case xhttpPlacementCookie:
if c, err := r.Cookie(key); err == nil {
return c.Value
}
}
return ""
}
func (ib *nativeInbound) upsertXHTTPSession(id string) *nativeXHTTPSession {
ib.xhttpMu.Lock()
defer ib.xhttpMu.Unlock()
if ib.xhttpSessions == nil {
ib.xhttpSessions = make(map[string]*nativeXHTTPSession)
}
if s := ib.xhttpSessions[id]; s != nil {
return s
}
s := &nativeXHTTPSession{
id: id,
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts),
done: make(chan struct{}),
}
ib.xhttpSessions[id] = s
log.Printf("native xray: xhttp session created inbound=%q session=%q", ib.tag, id)
go ib.reapUnconnectedXHTTPSession(id, s)
return s
}
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
t := time.NewTimer(30 * time.Second)
defer t.Stop()
select {
case <-t.C:
s.mu.Lock()
connected := s.connected
s.mu.Unlock()
if !connected {
ib.deleteXHTTPSession(id, s)
s.close()
}
case <-s.done:
}
}
func (ib *nativeInbound) deleteXHTTPSession(id string, s *nativeXHTTPSession) {
ib.xhttpMu.Lock()
defer ib.xhttpMu.Unlock()
if ib.xhttpSessions[id] == s {
delete(ib.xhttpSessions, id)
}
}
func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.Request, sess *nativeXHTTPSession) {
log.Printf("native xray: xhttp stream-up inbound=%q session=%q len=%d remote=%s", ib.tag, sess.id, r.ContentLength, r.RemoteAddr)
if ib.xhttpMode != "" && ib.xhttpMode != "auto" && ib.xhttpMode != "stream-up" && ib.xhttpMode != "stream-down" {
http.Error(w, "xhttp stream-up mode is not allowed", http.StatusBadRequest)
return
}
if err := sess.queue.push(nativeXHTTPPacket{Reader: r.Body}); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
flushHTTP(w)
select {
case <-r.Context().Done():
case <-sess.done:
}
}
func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.Request, sess *nativeXHTTPSession, seqStr string) {
if ib.xhttpMode != "" && ib.xhttpMode != "auto" && ib.xhttpMode != "packet-up" && ib.xhttpMode != "stream-down" {
http.Error(w, "xhttp packet-up mode is not allowed", http.StatusBadRequest)
return
}
seq, err := strconv.ParseUint(seqStr, 10, 64)
if err != nil {
http.Error(w, "bad xhttp sequence", http.StatusBadRequest)
return
}
payload, err := ib.readXHTTPPayload(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("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(nativeXHTTPPacket{Payload: payload, Seq: seq}); err != nil {
log.Printf("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
http.Error(w, err.Error(), http.StatusConflict)
return
}
if len(payload) == 0 {
w.Header().Set("Cache-Control", "no-store")
}
w.WriteHeader(http.StatusOK)
}
func (ib *nativeInbound) readXHTTPPayload(r *http.Request) ([]byte, error) {
placement := firstNonEmpty(ib.xhttpUplinkDataPlacement, xhttpPlacementBody)
key := firstNonEmpty(ib.xhttpUplinkDataKey, "X-Data")
var headerPayload, cookiePayload, bodyPayload []byte
var err error
if placement == xhttpPlacementAuto || placement == xhttpPlacementHeader {
headerPayload, err = readXHTTPHeaderPayload(r, key)
if err != nil {
return nil, err
}
}
if placement == xhttpPlacementAuto || placement == xhttpPlacementCookie {
cookiePayload, err = readXHTTPCookiePayload(r, key)
if err != nil {
return nil, err
}
}
if placement == xhttpPlacementAuto || placement == xhttpPlacementBody {
bodyPayload, err = ib.readXHTTPBodyPayload(r)
if err != nil {
return nil, err
}
}
var payload []byte
switch placement {
case xhttpPlacementHeader:
payload = headerPayload
case xhttpPlacementCookie:
payload = cookiePayload
case xhttpPlacementBody:
payload = bodyPayload
case xhttpPlacementAuto:
payload = append(payload, headerPayload...)
payload = append(payload, cookiePayload...)
payload = append(payload, bodyPayload...)
default:
return nil, fmt.Errorf("unsupported xhttp uplink data placement %q", placement)
}
if int64(len(payload)) > ib.xhttpMaxPostBytes() {
return nil, fmt.Errorf("xhttp upload too large")
}
return payload, nil
}
func readXHTTPHeaderPayload(r *http.Request, key string) ([]byte, error) {
chunks := make([]string, 0, 4)
for i := 0; ; i++ {
chunk := r.Header.Get(fmt.Sprintf("%s-%d", key, i))
if chunk == "" {
break
}
chunks = append(chunks, chunk)
}
if len(chunks) == 0 {
return nil, nil
}
return base64.RawURLEncoding.DecodeString(strings.Join(chunks, ""))
}
func readXHTTPCookiePayload(r *http.Request, key string) ([]byte, error) {
chunks := make([]string, 0, 4)
for i := 0; ; i++ {
cookieName := fmt.Sprintf("%s_%d", key, i)
c, err := r.Cookie(cookieName)
if err != nil {
break
}
chunks = append(chunks, c.Value)
}
if len(chunks) == 0 {
return nil, nil
}
return base64.RawURLEncoding.DecodeString(strings.Join(chunks, ""))
}
func (ib *nativeInbound) readXHTTPBodyPayload(r *http.Request) ([]byte, error) {
maxBytes := ib.xhttpMaxPostBytes()
if r.ContentLength > maxBytes {
return nil, fmt.Errorf("xhttp upload too large")
}
payload, err := io.ReadAll(io.LimitReader(r.Body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(payload)) > maxBytes {
return nil, fmt.Errorf("xhttp upload too large")
}
return payload, nil
}
func (ib *nativeInbound) xhttpMaxPostBytes() int64 {
if ib.xhttpMaxEachPostBytes > 0 {
return ib.xhttpMaxEachPostBytes
}
return 1_000_000
}
func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) {
log.Printf("native xray: xhttp stream-one inbound=%q len=%d remote=%s", ib.tag, r.ContentLength, r.RemoteAddr)
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Cache-Control", "no-store")
if !ib.xhttpNoSSEHeader {
w.Header().Set("Content-Type", "text/event-stream")
}
w.WriteHeader(http.StatusOK)
flushHTTP(w)
remote := remoteAddrFromHTTPRequest(r)
xc := &nativeXHTTPConn{
reader: r.Body,
writer: &nativeXHTTPResponseWriter{w: w},
remote: remote,
local: dummyLocalAddr(r),
onClose: func() {
_ = r.Body.Close()
},
}
ib.dispatchXHTTPConn(xc, remote)
_ = xc.Close()
}
func (ib *nativeInbound) handleXHTTPDownload(w http.ResponseWriter, r *http.Request, sess *nativeXHTTPSession, sessionID string) {
log.Printf("native xray: xhttp stream-down inbound=%q session=%q proto=%s remote=%s", ib.tag, sessionID, r.Proto, r.RemoteAddr)
sess.markConnected()
defer ib.deleteXHTTPSession(sessionID, sess)
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Cache-Control", "no-store")
if !ib.xhttpNoSSEHeader {
w.Header().Set("Content-Type", "text/event-stream")
}
w.WriteHeader(http.StatusOK)
flushHTTP(w)
remote := remoteAddrFromHTTPRequest(r)
var reader io.Reader = sess.queue
xc := &nativeXHTTPConn{
reader: reader,
writer: &nativeXHTTPResponseWriter{w: w},
remote: remote,
local: dummyLocalAddr(r),
}
xc.onClose = sess.close
ib.dispatchXHTTPConn(xc, remote)
_ = xc.Close()
}
func (ib *nativeInbound) dispatchXHTTPConn(xc net.Conn, remote net.Addr) {
log.Printf("native xray: xhttp dispatch inbound=%q protocol=%s remote=%s", ib.tag, ib.protocol, remote)
switch ib.protocol {
case "vless":
ib.handleVLESS(xc, remote)
case "vmess":
ib.handleVMess(xc, remote)
default:
log.Printf("native xray: inbound %q XHTTP protocol %q not supported", ib.tag, ib.protocol)
}
}
func remoteAddrFromHTTPRequest(r *http.Request) net.Addr {
addr, err := net.ResolveTCPAddr("tcp", r.RemoteAddr)
if err == nil {
return addr
}
return &net.TCPAddr{IP: net.IPv4zero, Port: 0}
}
func dummyLocalAddr(r *http.Request) net.Addr {
if r.TLS != nil && r.Host != "" {
return &net.TCPAddr{IP: net.IPv4zero, Port: 443}
}
return &net.TCPAddr{IP: net.IPv4zero, Port: 80}
}
func flushHTTP(w http.ResponseWriter) {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
type nativeXHTTPSession struct {
id string
queue *nativeXHTTPUploadQueue
done chan struct{}
closeOnce sync.Once
mu sync.Mutex
connected bool
}
func (s *nativeXHTTPSession) markConnected() {
s.mu.Lock()
s.connected = true
s.mu.Unlock()
}
func (s *nativeXHTTPSession) close() {
s.closeOnce.Do(func() {
close(s.done)
s.queue.close()
})
}
type nativeXHTTPConn struct {
reader io.Reader
writer io.Writer
remote net.Addr
local net.Addr
deadlineMu sync.Mutex
readDeadline time.Time
closeOnce sync.Once
onClose func()
}
func (c *nativeXHTTPConn) Read(p []byte) (int, error) {
if dr, ok := c.reader.(interface{ SetReadDeadline(time.Time) error }); ok {
c.deadlineMu.Lock()
d := c.readDeadline
c.deadlineMu.Unlock()
_ = dr.SetReadDeadline(d)
}
return c.reader.Read(p)
}
func (c *nativeXHTTPConn) Write(p []byte) (int, error) { return c.writer.Write(p) }
func (c *nativeXHTTPConn) Close() error {
c.closeOnce.Do(func() {
if c.onClose != nil {
c.onClose()
}
})
return nil
}
func (c *nativeXHTTPConn) LocalAddr() net.Addr { return c.local }
func (c *nativeXHTTPConn) RemoteAddr() net.Addr { return c.remote }
func (c *nativeXHTTPConn) SetDeadline(t time.Time) error {
_ = c.SetReadDeadline(t)
return c.SetWriteDeadline(t)
}
func (c *nativeXHTTPConn) SetReadDeadline(t time.Time) error {
c.deadlineMu.Lock()
c.readDeadline = t
c.deadlineMu.Unlock()
if dr, ok := c.reader.(interface{ SetReadDeadline(time.Time) error }); ok {
return dr.SetReadDeadline(t)
}
return nil
}
func (c *nativeXHTTPConn) SetWriteDeadline(time.Time) error { return nil }
type nativeXHTTPResponseWriter struct {
mu sync.Mutex
w http.ResponseWriter
closed bool
}
func (w *nativeXHTTPResponseWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0, io.ErrClosedPipe
}
n, err := w.w.Write(p)
if err == nil {
flushHTTP(w.w)
}
return n, err
}
func (w *nativeXHTTPResponseWriter) close() {
w.mu.Lock()
w.closed = true
w.mu.Unlock()
}
type nativeXHTTPPacket struct {
Reader io.ReadCloser
Payload []byte
Seq uint64
}
type nativeXHTTPUploadQueue struct {
pushedPackets chan nativeXHTTPPacket
heap nativeXHTTPHeap
nextSeq uint64
maxPackets int
closed chan struct{}
closeOnce sync.Once
reader io.ReadCloser
deadlineMu sync.Mutex
readDeadline time.Time
}
func newNativeXHTTPUploadQueue(maxPackets int) *nativeXHTTPUploadQueue {
if maxPackets <= 0 {
maxPackets = 30
}
return &nativeXHTTPUploadQueue{
pushedPackets: make(chan nativeXHTTPPacket, maxPackets),
maxPackets: maxPackets,
closed: make(chan struct{}),
}
}
func (q *nativeXHTTPUploadQueue) push(p nativeXHTTPPacket) error {
select {
case q.pushedPackets <- p:
return nil
case <-q.closed:
return io.ErrClosedPipe
}
}
func (q *nativeXHTTPUploadQueue) close() {
q.closeOnce.Do(func() {
close(q.closed)
if q.reader != nil {
_ = q.reader.Close()
}
})
}
func (q *nativeXHTTPUploadQueue) SetReadDeadline(t time.Time) error {
q.deadlineMu.Lock()
q.readDeadline = t
q.deadlineMu.Unlock()
return nil
}
func (q *nativeXHTTPUploadQueue) deadlineChan() <-chan time.Time {
q.deadlineMu.Lock()
d := q.readDeadline
q.deadlineMu.Unlock()
if d.IsZero() {
return nil
}
return time.After(time.Until(d))
}
func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
if q.reader != nil {
return q.reader.Read(b)
}
if len(q.heap) == 0 {
select {
case <-q.deadlineChan():
return 0, os.ErrDeadlineExceeded
case p := <-q.pushedPackets:
if p.Reader != nil {
q.reader = p.Reader
return q.reader.Read(b)
}
heap.Push(&q.heap, p)
case <-q.closed:
return 0, io.EOF
}
}
for len(q.heap) > 0 {
packet := heap.Pop(&q.heap).(nativeXHTTPPacket)
if packet.Seq == q.nextSeq {
if len(packet.Payload) == 0 {
q.nextSeq = packet.Seq + 1
continue
}
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
}
if packet.Seq > q.nextSeq {
if len(q.heap) > q.maxPackets {
return 0, errors.New("xhttp packet queue is too large")
}
heap.Push(&q.heap, packet)
select {
case <-q.deadlineChan():
return 0, os.ErrDeadlineExceeded
case p := <-q.pushedPackets:
if p.Reader != nil {
return 0, errors.New("xhttp mixed stream-up and packet-up upload")
}
heap.Push(&q.heap, p)
case <-q.closed:
return 0, io.EOF
}
}
}
select {
case <-q.deadlineChan():
return 0, os.ErrDeadlineExceeded
case <-q.closed:
return 0, io.EOF
case p := <-q.pushedPackets:
if p.Reader != nil {
q.reader = p.Reader
return q.reader.Read(b)
}
heap.Push(&q.heap, p)
return q.Read(b)
}
}
type nativeXHTTPHeap []nativeXHTTPPacket
func (h nativeXHTTPHeap) Len() int { return len(h) }
func (h nativeXHTTPHeap) Less(i, j int) bool { return h[i].Seq < h[j].Seq }
func (h nativeXHTTPHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *nativeXHTTPHeap) Push(x any) { *h = append(*h, x.(nativeXHTTPPacket)) }
func (h *nativeXHTTPHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}