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 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 ### Comandos úteis
Ver status do serviço: 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 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 ### Useful commands
Check service status: Check service status:
@@ -641,3 +773,92 @@ Common errors:
```json ```json
{"error":"database not configured"} {"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; color:#94a3b8 !important;
background:#070b12 !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}", "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}", "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…", "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": { "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", "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}", "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}", "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…", "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 = { 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", "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", "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.", "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"], { Object.assign(I18N_TEXT["en-US"], {
"Servers":"Servers","Reseller area":"Reseller area","shared quota":"shared quota","available":"available","used":"used","breakdown":"breakdown", "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.", "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", "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", "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" "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", "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.", "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", "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", "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" "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", "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.", "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", "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", "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]))])); const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
let currentLang = detectInitialLanguage(); let currentLang = detectInitialLanguage();
@@ -269,6 +269,8 @@ const xPID = document.getElementById("xPID");
const xUptime = document.getElementById("xUptime"); const xUptime = document.getElementById("xUptime");
const xStatus = document.getElementById("xStatus"); const xStatus = document.getElementById("xStatus");
const xOnlineUsers = document.getElementById("xOnlineUsers"); const xOnlineUsers = document.getElementById("xOnlineUsers");
const xCoreMode = document.getElementById("xCoreMode");
const xSaveModeBtn = document.getElementById("xSaveModeBtn");
const xCfgEditor = document.getElementById("xCfgEditor"); const xCfgEditor = document.getElementById("xCfgEditor");
const xCfgStatus = document.getElementById("xCfgStatus"); const xCfgStatus = document.getElementById("xCfgStatus");
const xLogsBox = document.getElementById("xLogsBox"); const xLogsBox = document.getElementById("xLogsBox");
@@ -326,6 +328,14 @@ const ifaceBody = document.getElementById("ifaceBody");
const ifaceSummary = document.getElementById("ifaceSummary"); const ifaceSummary = document.getElementById("ifaceSummary");
const statsUpdated = document.getElementById("statsUpdated"); const statsUpdated = document.getElementById("statsUpdated");
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn"); 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 // VnStat
const vnstatDailyBody = document.getElementById("vnstatDailyBody"); const vnstatDailyBody = document.getElementById("vnstatDailyBody");
@@ -362,6 +372,25 @@ function selectedXrayServerLabel() {
if (srv) return srv.name || srv.base_url || id; if (srv) return srv.name || srv.base_url || id;
return id === "local" ? "Master node" : 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() { function reloadXrayConfigForSelectedServer() {
const wizPane = document.getElementById("xrayWizardPane"); const wizPane = document.getElementById("xrayWizardPane");
const jsonPane = document.getElementById("xrayCfgPaneJson"); const jsonPane = document.getElementById("xrayCfgPaneJson");
@@ -379,6 +408,16 @@ function fmtBytes(n) {
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB"; const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
return (m/1024).toFixed(1)+" GiB"; 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()) { function localDateKey(d = new Date()) {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0"); 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>`; 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) { function updateCell(row, name, html) {
const cell = row?.querySelector?.(`[data-cell="${name}"]`); const cell = row?.querySelector?.(`[data-cell="${name}"]`);
if (cell && cell.innerHTML !== html) cell.innerHTML = html; if (cell && cell.innerHTML !== html) cell.innerHTML = html;
@@ -496,7 +542,7 @@ function patchRenderedInbounds(inbounds) {
updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c))); updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
updateCell(row, "status", clientStatusHTML(c)); updateCell(row, "status", clientStatusHTML(c));
updateCell(row, "online", clientOnlineHTML(c)); updateCell(row, "online", clientOnlineHTML(c));
updateCell(row, "traffic", escapeHTML(formatBytes(c.total_bytes))); updateCell(row, "traffic", clientTrafficHTML(c));
updateCell(row, "max", escapeHTML(c.max_conns || "∞")); 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")); menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
languageSelect?.addEventListener("change", () => { applyLanguage(languageSelect.value); renderDashboardCounters(); }); 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 }); applyLanguage(currentLang, { persist: false });
startI18nObserver(); startI18nObserver();
@@ -956,6 +1001,7 @@ document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("s
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop")); document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart")); document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats); document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
xSaveModeBtn?.addEventListener("click", saveXrayCoreMode);
document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); }); document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); });
document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true })); document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true }));
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg); document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
@@ -972,8 +1018,9 @@ async function loadXrayStatus() {
xrayChip.className = "chip " + (run ? "green" : "red"); xrayChip.className = "chip " + (run ? "green" : "red");
xRunning.textContent = run ? t("Running") : t("Stopped"); xRunning.textContent = run ? t("Running") : t("Stopped");
xRunning.style.color = run ? "var(--success)" : "var(--danger)"; xRunning.style.color = run ? "var(--success)" : "var(--danger)";
xPID.textContent = s.pid || "--"; xPID.textContent = s.pid || (s.native ? "internal" : "--");
xUptime.textContent = s.uptime || "--"; 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 statsCfgEl = document.getElementById("xStatsConfig");
const repairBtn = document.getElementById("xRepairStatsBtn"); const repairBtn = document.getElementById("xRepairStatsBtn");
if (statsCfgEl) { if (statsCfgEl) {
@@ -997,6 +1044,29 @@ async function loadXrayStatus() {
} catch (e) { if (e.message==="auth") doAuthError(); } } 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() { async function repairXrayStats() {
const btn = document.getElementById("xRepairStatsBtn"); const btn = document.getElementById("xRepairStatsBtn");
if (btn) btn.disabled = true; 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="expiry" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
<td data-cell="status">${clientStatusHTML(c)}</td> <td data-cell="status">${clientStatusHTML(c)}</td>
<td data-cell="online">${clientOnlineHTML(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>`; <td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
const actTd = document.createElement("td"); const actTd = document.createElement("td");
actTd.style.whiteSpace = "nowrap"; 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() }), 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()); 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); setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
@@ -1228,7 +1298,7 @@ async function removeClient(tag, uuid) {
try { try {
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" }); 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()); 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); setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
@@ -1764,6 +1834,8 @@ async function loadManagedServerConfig(id) {
document.getElementById("managedCfgListen").value = c.listen || ""; document.getElementById("managedCfgListen").value = c.listen || "";
document.getElementById("managedCfgExtraListen").value = (c.extra_listen || []).join("\n"); 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("managedCfgLimitUp").value = c.default_limit_mbps_up || 0;
document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0; document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0;
@@ -1775,11 +1847,25 @@ async function loadManagedServerConfig(id) {
document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt; document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt;
toggleManagedDnsttFields(hasDnstt); toggleManagedDnsttFields(hasDnstt);
const d = c.dnstt || {}; 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("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("managedCfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log; document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log;
document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log; document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log;
document.getElementById("managedCfgDnsttLogConnections").checked = !!d.log_connections;
const hasUdpgw = !!c.udpgw; const hasUdpgw = !!c.udpgw;
document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw; document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw;
@@ -1789,6 +1875,8 @@ async function loadManagedServerConfig(id) {
document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0; document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0;
document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || ""; document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || "";
document.getElementById("managedCfgUdpgwMapTTL").value = u.map_ttl || ""; 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; document.getElementById("managedCfgUdpgwDebug").checked = !!u.debug;
managedTlsForwardersState = c.tls_forwarders || []; managedTlsForwardersState = c.tls_forwarders || [];
@@ -1796,6 +1884,7 @@ async function loadManagedServerConfig(id) {
const x = c.xray || {}; const x = c.xray || {};
document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled; document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled;
document.getElementById("managedCfgXrayMode").value = xrayModeFromConfig(x);
document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden"); document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden");
if (st) st.textContent = "Config loaded."; if (st) st.textContent = "Config loaded.";
@@ -1808,9 +1897,12 @@ async function loadManagedServerConfig(id) {
function managedConfigFromForm() { function managedConfigFromForm() {
const extraLines = document.getElementById("managedCfgExtraListen").value const extraLines = document.getElementById("managedCfgExtraListen").value
.split("\n").map(s => s.trim()).filter(Boolean); .split("\n").map(s => s.trim()).filter(Boolean);
const dnsttDomains = readDnsttDomains("managedCfgDnsttDomains");
return { return {
listen: document.getElementById("managedCfgListen").value.trim(), listen: document.getElementById("managedCfgListen").value.trim(),
extra_listen: extraLines, 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", host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
admin_dir: "/opt/sshpanel/admin", admin_dir: "/opt/sshpanel/admin",
default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10), default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10),
@@ -1820,24 +1912,44 @@ function managedConfigFromForm() {
banner: document.getElementById("managedCfgBanner").value, banner: document.getElementById("managedCfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt", banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? { dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
domain: document.getElementById("managedCfgDnsttDomain").value.trim(), domain: dnsttDomains[0] || "",
domains: dnsttDomains,
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(), 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(), privkey_file: document.getElementById("managedCfgDnsttKey").value.trim(),
disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked, disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked,
disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked, disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked,
log_connections: document.getElementById("managedCfgDnsttLogConnections").checked,
} : null, } : null,
udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? { udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? {
listen: document.getElementById("managedCfgUdpgwListen").value.trim(), listen: document.getElementById("managedCfgUdpgwListen").value.trim(),
max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10), max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10),
idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(), idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(),
map_ttl: document.getElementById("managedCfgUdpgwMapTTL").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, debug: document.getElementById("managedCfgUdpgwDebug").checked,
} : null, } : null,
tls_forwarders: managedTlsForwardersState, tls_forwarders: managedTlsForwardersState,
xray: { xray: {
enabled: document.getElementById("managedCfgXrayEnabled").checked, enabled: document.getElementById("managedCfgXrayEnabled").checked,
mode: document.getElementById("managedCfgXrayMode").value === "external" ? "external" : "native",
native: document.getElementById("managedCfgXrayMode").value !== "external",
bin_path: "/opt/sshpanel/xray", bin_path: "/opt/sshpanel/xray",
config_file: "/opt/sshpanel/xray_config.json", config_file: "/opt/sshpanel/xray_config.json",
native_config_file: "/opt/sshpanel/xray_native_config.json",
api_server: "127.0.0.1:10085", api_server: "127.0.0.1:10085",
online_window_seconds: 90, online_window_seconds: 90,
stats_poll_seconds: 15, stats_poll_seconds: 15,
@@ -2025,6 +2137,7 @@ async function loadDashboardStats() {
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const s = await res.json(); const s = await res.json();
updateDashboardStats(s); updateDashboardStats(s);
await loadDnsttHealth();
} catch (e) { } catch (e) {
if (e.message === "auth") doAuthError(); if (e.message === "auth") doAuthError();
else { else {
@@ -2061,6 +2174,76 @@ function updateDashboardStats(s) {
if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`; 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() { async function loadStats() {
try { try {
const res = await api("/api/stats"); 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 (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
await loadDnsttHealth();
} catch (e) { } catch (e) {
if (e.message==="auth") doAuthError(); if (e.message==="auth") doAuthError();
else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats."; else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats.";
@@ -2223,6 +2407,22 @@ async function clearPanelLog() {
// ─── Server Config ──────────────────────────────────────────────────────────── // ─── Server Config ────────────────────────────────────────────────────────────
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig); 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) { function toggleDnsttFields(on) {
const el = document.getElementById("dnsttFields"); const el = document.getElementById("dnsttFields");
el.style.opacity = on ? "1" : ".4"; el.style.opacity = on ? "1" : ".4";
@@ -2245,6 +2445,8 @@ async function loadServerConfig() {
// Network // Network
document.getElementById("cfgListen").value = c.listen || ""; document.getElementById("cfgListen").value = c.listen || "";
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n"); 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 // SSH / general
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0; document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
@@ -2260,11 +2462,25 @@ async function loadServerConfig() {
document.getElementById("cfgDnsttEnabled").checked = hasDnstt; document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
toggleDnsttFields(hasDnstt); toggleDnsttFields(hasDnstt);
const d = c.dnstt || {}; 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("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("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log; document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log; document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections;
// UDPGW // UDPGW
const hasUdpgw = !!c.udpgw; const hasUdpgw = !!c.udpgw;
@@ -2275,6 +2491,8 @@ async function loadServerConfig() {
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0; document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || ""; document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || ""; 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; document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
// TLS forwarders // TLS forwarders
@@ -2284,6 +2502,7 @@ async function loadServerConfig() {
// Xray // Xray
const x = c.xray || {}; const x = c.xray || {};
document.getElementById("cfgXrayEnabled").checked = !!x.enabled; document.getElementById("cfgXrayEnabled").checked = !!x.enabled;
document.getElementById("cfgXrayMode").value = xrayModeFromConfig(x);
st.textContent = "Config loaded."; st.textContent = "Config loaded.";
} catch (e) { } catch (e) {
@@ -2300,10 +2519,13 @@ async function saveServerConfig() {
const extraLines = document.getElementById("cfgExtraListen").value const extraLines = document.getElementById("cfgExtraListen").value
.split("\n").map(s => s.trim()).filter(Boolean); .split("\n").map(s => s.trim()).filter(Boolean);
const dnsttDomains = readDnsttDomains("cfgDnsttDomains");
const cfg = { const cfg = {
listen: document.getElementById("cfgListen").value.trim(), listen: document.getElementById("cfgListen").value.trim(),
extra_listen: extraLines, 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", host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
admin_dir: "/opt/sshpanel/admin", admin_dir: "/opt/sshpanel/admin",
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10), default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
@@ -2313,24 +2535,44 @@ async function saveServerConfig() {
banner: document.getElementById("cfgBanner").value, banner: document.getElementById("cfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt", banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("cfgDnsttEnabled").checked ? { dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
domain: document.getElementById("cfgDnsttDomain").value.trim(), domain: dnsttDomains[0] || "",
domains: dnsttDomains,
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), 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(), privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
log_connections: document.getElementById("cfgDnsttLogConnections").checked,
} : null, } : null,
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? { udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
listen: document.getElementById("cfgUdpgwListen").value.trim(), listen: document.getElementById("cfgUdpgwListen").value.trim(),
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10), max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(), idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
map_ttl: document.getElementById("cfgUdpgwMapTTL").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, debug: document.getElementById("cfgUdpgwDebug").checked,
} : null, } : null,
tls_forwarders: tlsArr, tls_forwarders: tlsArr,
xray: { xray: {
enabled: document.getElementById("cfgXrayEnabled").checked, enabled: document.getElementById("cfgXrayEnabled").checked,
mode: document.getElementById("cfgXrayMode").value === "external" ? "external" : "native",
native: document.getElementById("cfgXrayMode").value !== "external",
bin_path: "/opt/sshpanel/xray", bin_path: "/opt/sshpanel/xray",
config_file: "/opt/sshpanel/xray_config.json", config_file: "/opt/sshpanel/xray_config.json",
native_config_file: "/opt/sshpanel/xray_native_config.json",
api_server: "127.0.0.1:10085", api_server: "127.0.0.1:10085",
online_window_seconds: 90, online_window_seconds: 90,
stats_poll_seconds: 15, stats_poll_seconds: 15,
@@ -2779,7 +3021,7 @@ function wzSaveInbound() {
break; break;
case "xhttp": case "xhttp":
ib.streamSettings.xhttpSettings = { ib.streamSettings.xhttpSettings = {
path: document.getElementById("wzXHTTPPath").value.trim() || "/", path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
host: document.getElementById("wzXHTTPHost").value.trim() || undefined, host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
mode: document.getElementById("wzXHTTPMode").value, mode: document.getElementById("wzXHTTPMode").value,
}; };
+112 -16
View File
@@ -159,17 +159,24 @@
</div> </div>
<div class="grid2 dashboard-lower"> <div class="grid2 dashboard-lower">
<div class="card"> <div class="card hidden" id="dnsttDashboardCard">
<div class="card-hdr"> <div class="card-hdr">
<div class="card-title">Ações rápidas</div> <div class="card-title">DNSTT Health <span class="chip">5s window</span></div>
<span class="chip green">simples</span> <span class="hint" id="dnsttHealthUpdated">--</span>
</div> </div>
<div class="quick-actions"> <div class="metrics">
<button class="quick-action" type="button" data-jump="ssh"><strong>Criar SSH</strong><span>Usuário, senha, validade e limite.</span></button> <div class="metric"><div class="m-label">Active Sessions</div><div class="m-val" id="dnsttActiveSessions">--</div></div>
<button class="quick-action" type="button" data-jump="xray"><strong>Criar Xray</strong><span>UUID, label, validade e conexões.</span></button> <div class="metric"><div class="m-label">Active Streams</div><div class="m-val" id="dnsttActiveStreams">--</div></div>
<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> <div class="metric"><div class="m-label">DNS RX / 5s</div><div class="m-val" id="dnsttDNSRx">--</div></div>
<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="metric"><div class="m-label">Queue</div><div class="m-val" id="dnsttQueueLen">--</div></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>
<div class="card" id="dashboardQuotaCard"> <div class="card" id="dashboardQuotaCard">
<div class="card-hdr"><div class="card-title">Minha cota</div><span class="chip" id="dashQuotaChip">--</span></div> <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-hdr">
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div> <div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
<div class="card-actions xray-admin-only"> <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-ghost btn-sm" id="xStartBtn">Start</button>
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button> <button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button> <button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
@@ -410,7 +422,7 @@
<!-- WebSocket --> <!-- WebSocket -->
<div class="field" id="wzWSPathField" style="display:none;"><label>Path</label><input type="text" id="wzWSPath" placeholder="/ws"/></div> <div class="field" id="wzWSPathField" style="display:none;"><label>Path</label><input type="text" id="wzWSPath" placeholder="/ws"/></div>
<!-- XHTTP --> <!-- 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="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;"> <div class="field" id="wzXHTTPModeField" style="display:none;">
<label>Mode</label> <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> <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> <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>
<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>
<div class="card" style="margin-top:12px;"> <div class="card" style="margin-top:12px;">
@@ -677,8 +694,23 @@
</label> </label>
</div> </div>
<div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;"> <div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
<div class="field"><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> <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"> <div class="field" style="grid-column:1/-1">
<label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label> <label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label>
<div class="field-row"> <div class="field-row">
@@ -697,6 +729,7 @@
</div> </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="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="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>
</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>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>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>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> <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>
</div> </div>
@@ -764,7 +800,8 @@
<div class="card" style="margin-top:12px"> <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> <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> <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> </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> <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> <textarea id="cfgExtraListen" rows="3" style="resize:vertical"></textarea>
</div> </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> </div>
<!-- SSH & general --> <!-- SSH & general -->
@@ -967,14 +1015,49 @@
</label> </label>
</div> </div>
<div id="dnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;"> <div id="dnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
<div class="field"> <div class="field" style="grid-column:1/-1">
<label>Domain</label> <label>NS / Root Domains <span class="hint">one per line</span></label>
<input type="text" id="cfgDnsttDomain" placeholder="t.example.com"/> <textarea id="cfgDnsttDomains" rows="3" placeholder="t.example.com&#10;t.local.lan"></textarea>
</div> </div>
<div class="field"> <div class="field">
<label>UDP Listen</label> <label>UDP Listen</label>
<input type="text" id="cfgDnsttUDP" placeholder="[::]:5300"/> <input type="text" id="cfgDnsttUDP" placeholder="[::]:5300"/>
</div> </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"> <div class="field" style="grid-column:1/-1">
<label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label> <label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label>
<div class="field-row"> <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"> <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 <input type="checkbox" id="cfgDnsttNoConsole"/> Disable Console Log
</label> </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>
</div> </div>
@@ -1026,6 +1112,15 @@
<label>Map TTL</label> <label>Map TTL</label>
<input type="text" id="cfgUdpgwMapTTL" placeholder="90s"/> <input type="text" id="cfgUdpgwMapTTL" placeholder="90s"/>
</div> </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"> <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 <input type="checkbox" id="cfgUdpgwDebug"/> Debug Logging
</label> </label>
@@ -1091,7 +1186,8 @@
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-top:4px;"> <label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-top:4px;">
<input type="checkbox" id="cfgXrayEnabled"/> Enabled <input type="checkbox" id="cfgXrayEnabled"/> Enabled
</label> </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>
</div><!-- /right --> </div><!-- /right -->
@@ -1115,6 +1211,6 @@
</div><!-- /shell --> </div><!-- /shell -->
</div><!-- /app --> </div><!-- /app -->
<script defer src="assets/app.js?v=20260511visualsafesave1"></script> <script defer src="assets/app.js?v=20260622dnsttfakednsfast"></script>
</body> </body>
</html> </html>
+145 -3
View File
@@ -103,6 +103,14 @@ const ifaceBody = document.getElementById("ifaceBody");
const ifaceSummary = document.getElementById("ifaceSummary"); const ifaceSummary = document.getElementById("ifaceSummary");
const statsUpdated = document.getElementById("statsUpdated"); const statsUpdated = document.getElementById("statsUpdated");
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn"); 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 // VnStat
const vnstatDailyBody = document.getElementById("vnstatDailyBody"); const vnstatDailyBody = document.getElementById("vnstatDailyBody");
@@ -136,6 +144,16 @@ function fmtBytes(n) {
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB"; const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
return (m/1024).toFixed(1)+" GiB"; 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()) { function localDateKey(d = new Date()) {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0"); 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")); menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode")); 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("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); });
document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray")); document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray"));
@@ -872,6 +889,76 @@ async function deleteReseller(username) {
// ─── Stats ──────────────────────────────────────────────────────────────────── // ─── Stats ────────────────────────────────────────────────────────────────────
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats); 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() { async function loadStats() {
try { try {
const res = await api("/api/stats"); const res = await api("/api/stats");
@@ -895,6 +982,7 @@ async function loadStats() {
}); });
ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
await loadDnsttHealth();
} catch (e) { if (e.message==="auth") doAuthError(); } } catch (e) { if (e.message==="auth") doAuthError(); }
} }
@@ -1028,6 +1116,22 @@ async function clearPanelLog() {
// ─── Server Config ──────────────────────────────────────────────────────────── // ─── Server Config ────────────────────────────────────────────────────────────
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig); 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) { function toggleDnsttFields(on) {
const el = document.getElementById("dnsttFields"); const el = document.getElementById("dnsttFields");
el.style.opacity = on ? "1" : ".4"; el.style.opacity = on ? "1" : ".4";
@@ -1050,6 +1154,8 @@ async function loadServerConfig() {
// Network // Network
document.getElementById("cfgListen").value = c.listen || ""; document.getElementById("cfgListen").value = c.listen || "";
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n"); 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 // SSH / general
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0; document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
@@ -1065,11 +1171,25 @@ async function loadServerConfig() {
document.getElementById("cfgDnsttEnabled").checked = hasDnstt; document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
toggleDnsttFields(hasDnstt); toggleDnsttFields(hasDnstt);
const d = c.dnstt || {}; 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("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("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log; document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log; document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
if (document.getElementById("cfgDnsttLogConnections")) document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections;
// UDPGW // UDPGW
const hasUdpgw = !!c.udpgw; const hasUdpgw = !!c.udpgw;
@@ -1080,6 +1200,8 @@ async function loadServerConfig() {
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0; document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || ""; document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || ""; 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; document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
// TLS forwarders // TLS forwarders
@@ -1105,10 +1227,13 @@ async function saveServerConfig() {
const extraLines = document.getElementById("cfgExtraListen").value const extraLines = document.getElementById("cfgExtraListen").value
.split("\n").map(s => s.trim()).filter(Boolean); .split("\n").map(s => s.trim()).filter(Boolean);
const dnsttDomains = readDnsttDomains("cfgDnsttDomains");
const cfg = { const cfg = {
listen: document.getElementById("cfgListen").value.trim(), listen: document.getElementById("cfgListen").value.trim(),
extra_listen: extraLines, 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", host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
admin_dir: "/opt/sshpanel/admin", admin_dir: "/opt/sshpanel/admin",
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10), default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
@@ -1118,17 +1243,34 @@ async function saveServerConfig() {
banner: document.getElementById("cfgBanner").value, banner: document.getElementById("cfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt", banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("cfgDnsttEnabled").checked ? { dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
domain: document.getElementById("cfgDnsttDomain").value.trim(), domain: dnsttDomains[0] || "",
domains: dnsttDomains,
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), 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(), privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
log_connections: document.getElementById("cfgDnsttLogConnections") ? document.getElementById("cfgDnsttLogConnections").checked : false,
} : null, } : null,
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? { udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
listen: document.getElementById("cfgUdpgwListen").value.trim(), listen: document.getElementById("cfgUdpgwListen").value.trim(),
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10), max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(), idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
map_ttl: document.getElementById("cfgUdpgwMapTTL").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, debug: document.getElementById("cfgUdpgwDebug").checked,
} : null, } : null,
tls_forwarders: tlsArr, tls_forwarders: tlsArr,
+172 -2
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"net" "net"
"strings" "strings"
"time"
) )
const ( const (
@@ -64,7 +65,50 @@ func normalizeRuntimePorts(cfg *Config) []string {
// DragonCore no longer uses an internal local SSH listener. // DragonCore no longer uses an internal local SSH listener.
cfg.LocalSSHListen = "" 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 { 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) cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen)
if cfg.DNSTT.UDPListen == "" { if cfg.DNSTT.UDPListen == "" {
cfg.DNSTT.UDPListen = defaultDNSTTListen 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) 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 { 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) 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 return warnings
@@ -128,17 +275,40 @@ func tcpAddrAvailableForUDPGW(addr string) error {
return ln.Close() 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 { func udpAddrAvailableForDNSTT(addr string) error {
if addr == "" { if addr == "" {
return nil return nil
} }
globalCfgMu.RLock() 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() globalCfgMu.RUnlock()
if current { if current {
return nil return nil
} }
pc, err := net.ListenPacket("udp", addr) pc, err := listenDNSTTPacket(addr)
if err != nil { if err != nil {
return err return err
} }
+797 -114
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 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 { func (p *listenerPool) HasAll(addrs []string) bool {
if p == nil { if p == nil {
return false return false
@@ -179,6 +199,23 @@ func (p *tlsListenerPool) Has(addr string) bool {
return ok 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 { func (p *tlsListenerPool) HasAll(forwarders []TLSForwarderConfig) bool {
if p == nil { if p == nil {
return false return false
@@ -283,6 +320,7 @@ func joinAddrs(addrs []string) string {
func applyFullConfigReload(newCfg *Config) ConfigReloadReport { func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
report := newReloadReport() report := newReloadReport()
stopProxyAutoRestart()
// Banner // Banner
bt := newCfg.Banner bt := newCfg.Banner
if bt == "" && newCfg.BannerFile != "" { if bt == "" && newCfg.BannerFile != "" {
@@ -372,9 +410,13 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
// Xray — update stored config then restart/stop as needed. // Xray — update stored config then restart/stop as needed.
if newCfg.Xray != nil { if newCfg.Xray != nil {
newCfg.Xray.NormalizeDefaults()
xrayMgr.mu.Lock() xrayMgr.mu.Lock()
xrayMgr.cfg = newCfg.Xray xrayMgr.cfg = newCfg.Xray
xrayMgr.mu.Unlock() xrayMgr.mu.Unlock()
if !newCfg.Xray.UseNative() {
xrayMgr.startStatsPoller()
}
if newCfg.Xray.Enabled { if newCfg.Xray.Enabled {
if err := xrayMgr.Restart(); err != nil { if err := xrayMgr.Restart(); err != nil {
report.warnf("Xray failed to restart: %v", err) report.warnf("Xray failed to restart: %v", err)
@@ -395,6 +437,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
} }
setGlobalCfg(newCfg) setGlobalCfg(newCfg)
startProxyAutoRestart(newCfg)
return report return report
} }
+9 -4
View File
@@ -455,8 +455,11 @@ cat > "$INSTALL_DIR/config.json" <<EOF
"banner_file": "${INSTALL_DIR}/banner.txt", "banner_file": "${INSTALL_DIR}/banner.txt",
"xray": { "xray": {
"enabled": true, "enabled": true,
"mode": "native",
"native": true,
"bin_path": "${INSTALL_DIR}/xray", "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 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 \ || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null \
|| echo "11111111-2222-3333-4444-555555555555") || echo "11111111-2222-3333-4444-555555555555")
# xray_config.json (default VLESS + SOCKS inbounds — no geoip routing needed) # xray_native_config.json is used by the internal emulator. xray_config.json is
cat > "$INSTALL_DIR/xray_config.json" <<EOF # kept only for optional external-xray mode. Both start with the same default.
cat > "$INSTALL_DIR/xray_native_config.json" <<EOF
{ {
"log": { "loglevel": "warning" }, "log": { "loglevel": "warning" },
"inbounds": [ "inbounds": [
@@ -497,7 +501,8 @@ cat > "$INSTALL_DIR/xray_config.json" <<EOF
] ]
} }
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}" info " VLESS UUID: ${UUID}"
# ── 9. DNSTT DNS/53 redirect ───────────────────────────────────────────────── # ── 9. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
+155 -9
View File
@@ -64,6 +64,17 @@ type TLSForwarderConfig struct {
type Config struct { type Config struct {
Listen string `json:"listen"` 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 // Optional extra public listen addresses (multiport). These
// addresses use the same HTTPcleanup and SSH handler as the // addresses use the same HTTPcleanup and SSH handler as the
// primary Listen address. For IPv6, use bracket form, e.g. // 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 // the tunnelled zone, and loads its private key from PrivKeyFile. The
// corresponding public key must be distributed to clients. // corresponding public key must be distributed to clients.
type DNSTTConfig struct { 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"` 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 // Domains optionally lists all DNS root zones/NS domains accepted by this
// recursive resolvers. Note: port 53 may require root privileges; binding // server. This allows the same DNSTT listener/key to answer for public and
// to an unprivileged port and using iptables to redirect port 53 is // local DNS deployments at the same time, for example:
// recommended【561853413345496†L97-L109】. // ["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"` 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 // PrivKeyFile is the path to the Noise server private key. Generate a
// keypair with the dnstt tool (use -gen-key) and copy the resulting // keypair with the dnstt tool (use -gen-key) and copy the resulting
// private key here; the matching public key must be distributed to // 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 // printed to the console. Set this to true in combination with
// disable_stats_log if you want a fully quiet DNSTT server. // disable_stats_log if you want a fully quiet DNSTT server.
DisableConsoleLog bool `json:"disable_console_log"` 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 // 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. // growth if a client sprays packets to many unique destinations.
// Default is 32768. // Default is 32768.
MaxMapEntries int `json:"max_map_entries"` 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 { 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 // Global state
var ( var (
userMgr = &UserManager{users: make(map[string]*UserState)} userMgr = &UserManager{users: make(map[string]*UserState)}
@@ -1030,6 +1147,19 @@ func readNetDev() (map[string]ifaceCounters, error) {
// ---------- Config loading ---------- // ---------- 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) { func loadConfig(path string) (*Config, map[string]*UserState, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -1040,6 +1170,9 @@ func loadConfig(path string) (*Config, map[string]*UserState, error) {
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return nil, nil, fmt.Errorf("parse config: %w", err) return nil, nil, fmt.Errorf("parse config: %w", err)
} }
if cfg.Xray != nil {
cfg.Xray.NormalizeDefaults()
}
if cfg.Listen == "" { if cfg.Listen == "" {
cfg.Listen = ":2222" cfg.Listen = ":2222"
@@ -1721,6 +1854,9 @@ func handleDnsttStats(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
if proxyManagedServerFromRequest(w, r, statsStore, "/api/dnstt", nil, "") {
return
}
// Obtain a copy of the current snapshot. // Obtain a copy of the current snapshot.
stats := GetDNSTTStatsSnapshot() stats := GetDNSTTStatsSnapshot()
w.Header().Set("Content-Type", "application/json") 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) 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") quietFlag := flag.Bool("quiet", false, "override config and disable logs")
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)") userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
flag.Parse() flag.Parse()
cfg, userMap, err := loadConfig(*configPath) resolvedConfigPath := resolveMainConfigPath(*configPath)
cfg, userMap, err := loadConfig(resolvedConfigPath)
if err != nil { if err != nil {
log.Fatalf("failed to load config: %v", err) log.Fatalf("failed to load config: %v", err)
} }
userMgr.ReplaceAll(userMap) userMgr.ReplaceAll(userMap)
// Store config path and live config for hot-reload via admin API. // Store config path and live config for hot-reload via admin API.
globalCfgPath = *configPath globalCfgPath = resolvedConfigPath
setGlobalCfg(cfg) setGlobalCfg(cfg)
userCountEnabled = cfg.UserCount || *userCountFlag userCountEnabled = cfg.UserCount || *userCountFlag
@@ -2677,8 +2814,14 @@ func main() {
if err := store.EnsureXrayClientsSchema(ctx); err != nil { if err := store.EnsureXrayClientsSchema(ctx); err != nil {
log.Printf("xray clients table: %v", err) log.Printf("xray clients table: %v", err)
} else { } else {
if err := store.ResetXrayActiveConnections(ctx); err != nil {
log.Printf("xray active connection reset: %v", err)
}
startXrayClientExpiryChecker(store) startXrayClientExpiryChecker(store)
} }
if err := store.EnsureXrayConfigSchema(ctx); err != nil {
log.Printf("xray config table disabled: %v", err)
}
if err := store.EnsureIfaceUsageTables(ctx); err != nil { if err := store.EnsureIfaceUsageTables(ctx); err != nil {
log.Printf("vnstat usage tables disabled: %v", err) log.Printf("vnstat usage tables disabled: %v", err)
} }
@@ -2892,6 +3035,9 @@ func main() {
log.Printf("failed to start TLS listener: %v", e) 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. // Print user counts once at startup.
updateUserDisplay() 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) http.Error(w, "listen address required", http.StatusBadRequest)
return return
} }
if newCfg.Xray != nil {
newCfg.Xray.NormalizeDefaults()
}
// Preserve file-based users array (not editable through the UI). // Preserve file-based users array (not editable through the UI).
globalCfgMu.RLock() globalCfgMu.RLock()
+145 -5
View File
@@ -20,24 +20,65 @@ import (
"io" "io"
"log" "log"
"net" "net"
"strings"
"sync" "sync"
"time" "time"
) )
var ( var (
udpgwMu sync.Mutex udpgwMu sync.Mutex
udpgwLn net.Listener 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. // stopUDPGW closes the active UDPGW listener, all active UDPGW client TCP
// It is a no-op if UDPGW is not running. // sockets, and the optional auto-restart watchdog. It is a no-op if UDPGW is
// not running.
func stopUDPGW() { func stopUDPGW() {
stopUDPGWAutoRestart()
stopUDPGWInstance()
}
func stopUDPGWInstance() {
udpgwMu.Lock() udpgwMu.Lock()
defer udpgwMu.Unlock() defer udpgwMu.Unlock()
if udpgwLn != nil { if udpgwLn != nil {
_ = udpgwLn.Close() _ = udpgwLn.Close()
udpgwLn = nil 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 { func udpgwRunning() bool {
@@ -53,6 +94,18 @@ func udpgwRunning() bool {
// prevent the gateway from starting, but do not terminate the main // prevent the gateway from starting, but do not terminate the main
// process. // process.
func startUDPGW(cfg *UDPGWConfig) error { 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 { if cfg == nil {
return nil return nil
} }
@@ -166,12 +219,99 @@ func startUDPGW(cfg *UDPGWConfig) error {
log.Printf("udpgw: accept error: %v", err) log.Printf("udpgw: accept error: %v", err)
continue continue
} }
go handleUDPGWClient(conn, c) if !registerUDPGWClient(conn) {
continue
}
go func(client net.Conn) {
defer unregisterUDPGWClient(client)
handleUDPGWClient(client, c)
}(conn)
} }
}() }()
return nil 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 // internalUDPGWConfig mirrors the exported UDPGWConfig but with
// time.Duration fields for TTL and reaper intervals. It does not // time.Duration fields for TTL and reaper intervals. It does not
// embed JSON tags because it is not exposed to the user. // 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" cfg="$INSTALL_DIR/config.json"
xcfg="$INSTALL_DIR/xray_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 if [[ -f "$cfg" ]]; then
python3 - "$cfg" <<'PYEOF' python3 - "$cfg" <<'PYEOF'
import json, sys import json, sys
@@ -373,6 +380,22 @@ if 'banner_file' not in d:
if 'local_ssh_listen' in d: if 'local_ssh_listen' in d:
d.pop('local_ssh_listen', None) d.pop('local_ssh_listen', None)
changed = True 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: if changed:
with open(path, 'w') as f: with open(path, 'w') as f:
json.dump(d, f, indent=2) json.dump(d, f, indent=2)
+105 -28
View File
@@ -11,29 +11,41 @@ import (
// Xray's own config only stores uuid/email/level; expiry, display name, // Xray's own config only stores uuid/email/level; expiry, display name,
// reseller owner, and connection policy live here. // reseller owner, and connection policy live here.
type XrayClientMeta struct { type XrayClientMeta struct {
UUID string UUID string
Name string Name string
Email string Email string
InboundTag string InboundTag string
OwnerUsername string OwnerUsername string
ExpiresAt *time.Time ExpiresAt *time.Time
MaxConns int MaxConns int
CreatedAt time.Time CreatedAt time.Time
TotalUplinkBytes int64
TotalDownlinkBytes int64
LastActive *time.Time
ActiveConnections int
} }
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error { func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
stmts := []string{ stmts := []string{
`CREATE TABLE IF NOT EXISTS xray_clients ( `CREATE TABLE IF NOT EXISTS xray_clients (
uuid TEXT PRIMARY KEY, uuid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
inbound_tag TEXT NOT NULL DEFAULT '', inbound_tag TEXT NOT NULL DEFAULT '',
owner_username TEXT NOT NULL DEFAULT '', owner_username TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
max_conns INT NOT NULL DEFAULT 0, 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 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 { for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil { 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) { func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClientMeta, error) {
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime var expiresAt sql.NullTime
var lastActive sql.NullTime
err := s.db.QueryRowContext(ctx, ` 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). 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 { if err != nil {
return nil, err return nil, err
} }
if expiresAt.Valid { if expiresAt.Valid {
m.ExpiresAt = &expiresAt.Time m.ExpiresAt = &expiresAt.Time
} }
if lastActive.Valid {
m.LastActive = &lastActive.Time
}
return m, nil 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) { func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` 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`) FROM xray_clients ORDER BY created_at DESC`)
if err != nil { if err != nil {
return nil, err 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) { func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` 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) FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername)
if err != nil { if err != nil {
return nil, err 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) { func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` 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()`) FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -127,17 +147,78 @@ func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
for rows.Next() { for rows.Next() {
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime 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 return nil, err
} }
if expiresAt.Valid { if expiresAt.Valid {
m.ExpiresAt = &expiresAt.Time m.ExpiresAt = &expiresAt.Time
} }
if lastActive.Valid {
m.LastActive = &lastActive.Time
}
out = append(out, m) out = append(out, m)
} }
return out, rows.Err() 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 { func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
if store == nil || ownerUsername == "" { if store == nil || ownerUsername == "" {
return 0 return 0
@@ -177,9 +258,7 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str
} }
} }
if needRestart { if needRestart {
if err := xrayMgr.Restart(); err != nil { xrayMgr.restartIfExternalRunning()
log.Printf("xray owner cleanup: restart: %v", err)
}
} }
} }
@@ -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) log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
} }
if needRestart { if needRestart {
if err := xrayMgr.Restart(); err != nil { xrayMgr.restartIfExternalRunning()
log.Printf("xray expiry: restart error: %v", err)
}
} }
} }
}() }()
+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
}
+764 -86
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
}