diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 67c4eb1..0000000 --- a/.gitignore +++ /dev/null @@ -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/ diff --git a/README.md b/README.md index b6ab63b..6f12fc2 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,72 @@ systemctl status sshpanel-dnstt-redirect --no-pager -l sudo iptables -t nat -S PREROUTING | grep 5300 ``` +### Reinício automático do DNSTT + +O DNSTT pode ser reiniciado automaticamente sem reiniciar a VPS e sem derrubar o painel inteiro. No painel, abra **DragonCore → DNSTT Tunnel** e configure: + +- **Auto Restart Interval**: intervalo como `30m`, `2h` ou `6h`; use `0s`, `off` ou deixe vazio para desativar. +- **Restart Grace Delay**: pausa antes de reabrir a porta UDP; padrão `2s`. + +Também é possível editar diretamente o `config.json`: + +```json +"dnstt": { + "domain": "t.example.com", + "udp_listen": "[::]:5300", + "privkey_file": "/opt/sshpanel/dnstt.key", + "auto_restart_interval": "6h", + "auto_restart_grace": "2s" +} +``` + +### Vários domínios/NS no DNSTT + +O DNSTT aceita múltiplos domínios raiz no mesmo listener UDP e com a mesma chave. No painel, abra **DragonCore → DNSTT Tunnel → NS / Root Domains** e coloque um domínio por linha. Isso permite usar um domínio público e outro domínio local da sua rede no mesmo servidor. + +Exemplo: + +```json +"dnstt": { + "domain": "t.example.com", + "domains": [ + "t.example.com", + "t.local.lan" + ], + "udp_listen": "[::]:5300", + "privkey_file": "/opt/sshpanel/dnstt.key" +} +``` + +`domain` continua existindo para compatibilidade. O primeiro item de `domains` é usado como domínio principal. + +Para testar com DNS local, aponte o NS/A do domínio local para o IP LAN do servidor DNSTT ou configure seu DNS local para encaminhar essa zona para o IP/porta UDP do DNSTT. + +### Reinício automático do proxy e UDPGW + +O proxy e o UDPGW também podem ser reiniciados por intervalo. Estes reinícios são **hard restart** para substituir o temporizador em `screen` que reiniciava tudo: + +- **Proxy Auto Restart Interval** reinicia os listeners públicos (`listen`, `extra_listen` e TLS forwarders) e fecha as sessões SSH ativas. +- **UDPGW Auto Restart Interval** fecha o listener UDPGW e todos os clientes UDPGW conectados antes de subir novamente. +- Use valores como `6h`, `12h` ou `24h`; `0s`, `off` ou vazio desativa. +- **Restart Grace Delay** define a pausa antes de abrir novamente; padrão `2s`. + +Exemplo no `config.json`: + +```json +{ + "listen": "0.0.0.0:80", + "extra_listen": ["0.0.0.0:8080"], + "proxy_auto_restart_interval": "24h", + "proxy_auto_restart_grace": "2s", + "udpgw": { + "listen": "0.0.0.0:7400", + "auto_restart_interval": "24h", + "auto_restart_grace": "2s" + } +} +``` + ### Comandos úteis Ver status do serviço: @@ -471,6 +537,72 @@ systemctl status sshpanel-dnstt-redirect --no-pager -l sudo iptables -t nat -S PREROUTING | grep 5300 ``` +### DNSTT auto restart + +DNSTT can be restarted automatically without rebooting the VPS and without restarting the whole panel. In the panel, open **DragonCore → DNSTT Tunnel** and configure: + +- **Auto Restart Interval**: duration like `30m`, `2h`, or `6h`; use `0s`, `off`, or leave it empty to disable. +- **Restart Grace Delay**: pause before reopening the UDP port; default is `2s`. + +You can also edit `config.json` directly: + +```json +"dnstt": { + "domain": "t.example.com", + "udp_listen": "[::]:5300", + "privkey_file": "/opt/sshpanel/dnstt.key", + "auto_restart_interval": "6h", + "auto_restart_grace": "2s" +} +``` + +### Multiple DNSTT NS/root domains + +DNSTT can accept multiple root domains on the same UDP listener with the same key. In the panel, open **DragonCore → DNSTT Tunnel → NS / Root Domains** and enter one domain per line. This lets you use a public domain and a local network domain on the same server. + +Example: + +```json +"dnstt": { + "domain": "t.example.com", + "domains": [ + "t.example.com", + "t.local.lan" + ], + "udp_listen": "[::]:5300", + "privkey_file": "/opt/sshpanel/dnstt.key" +} +``` + +`domain` is kept for backward compatibility. The first item in `domains` is mirrored as the primary domain. + +For local DNS testing, point the local domain's NS/A record to the DNSTT server LAN IP or configure your local DNS server to forward that zone to the DNSTT UDP IP/port. + +### Proxy and UDPGW auto restart + +The proxy and UDPGW can also restart by interval. These are **hard restarts**, intended to replace a `screen` timer that restarted everything: + +- **Proxy Auto Restart Interval** restarts public listeners (`listen`, `extra_listen`, and TLS forwarders) and closes active SSH sessions. +- **UDPGW Auto Restart Interval** closes the UDPGW listener and all connected UDPGW clients before starting again. +- Use values like `6h`, `12h`, or `24h`; `0s`, `off`, or empty disables it. +- **Restart Grace Delay** controls the pause before reopening; default is `2s`. + +Example `config.json`: + +```json +{ + "listen": "0.0.0.0:80", + "extra_listen": ["0.0.0.0:8080"], + "proxy_auto_restart_interval": "24h", + "proxy_auto_restart_grace": "2s", + "udpgw": { + "listen": "0.0.0.0:7400", + "auto_restart_interval": "24h", + "auto_restart_grace": "2s" + } +} +``` + ### Useful commands Check service status: @@ -641,3 +773,92 @@ Common errors: ```json {"error":"database not configured"} ``` + +### DNSTT scale guard for high-user servers + +The integrated DNSTT service includes overload protection so thousands of DNS tunnel users cannot exhaust RAM or crash the whole panel as easily. + +DNSTT config fields: + +```json +{ + "dnstt": { + "max_sessions": 10000, + "max_streams": 15000, + "pending_responses": 20000, + "stream_buffer": 262144, + "udp_read_buffer": 16777216, + "udp_write_buffer": 16777216, + "log_connections": false + } +} +``` + +Values can be changed in the admin panel under **DNSTT Tunnel**. Use `0` to keep the safe default. Use `-1` only for `max_sessions` or `max_streams` if you intentionally want no hard limit. + +Recommended busy-server values: + +- `max_sessions`: `10000` +- `max_streams`: `15000` +- `pending_responses`: `20000` +- `stream_buffer`: `262144` +- `udp_read_buffer`: `16777216` +- `udp_write_buffer`: `16777216` +- `log_connections`: `false` + +DNSTT now also recovers panics inside DNSTT goroutines, rejects new sessions/streams when limits are reached, and reports these counters in `/api/dnstt`. The admin panel shows them on the main **Dashboard** when DNSTT is enabled. If `dnstt` is disabled in the config, the dashboard card is hidden completely. `/api/dnstt` also returns an `enabled` flag. The old dashboard quick-action button card was removed: + +- `active_sessions` +- `active_streams` +- `sess_rejected` +- `stream_rejected` +- `panic_recovered` +- `rec_dropped` +- `parse_err` +- `ch_len` + +For very large DNSTT deployments, raise Linux socket buffer limits too, for example: + +```bash +cat >/etc/sysctl.d/99-dragon-dnstt.conf <<'SYSCTL' +net.core.rmem_max=67108864 +net.core.wmem_max=67108864 +net.core.netdev_max_backlog=250000 +net.ipv4.udp_mem=262144 524288 1048576 +SYSCTL +sysctl --system +``` + +### DNSTT built-in local DNS / fake DNS over IPv6 + +DNSTT can now open an extra internal DNS listener for local testing without a second DNS server. +This listener feeds DNS tunnel packets directly into the same integrated DNSTT session pool and private key. + +Example IPv6-only config: + +```json +{ + "dnstt": { + "domain": "t.example.com", + "domains": ["t.example.com", "t.local.lan"], + "udp_listen": "[::]:5300", + "fake_dns_enabled": true, + "fake_dns_listen": "[2001:db8::1234]:53", + "fake_dns_domain": "t.local.lan", + "fake_dns_workers": 4, + "dns_response_workers": 1, + "privkey_file": "/opt/sshpanel/dnstt.key" + } +} +``` + +Notes: + +- `fake_dns_listen` accepts IPv6 bracket syntax such as `[2001:db8::1234]:53` or `[::]:53`. +- IPv6 listeners are opened with `udp6`, so they do not try to reserve IPv4 port 53. This lets an existing IPv4 master DNS keep using IPv4 port 53 while DNSTT uses a new IPv6 address. +- The built-in local DNS listener only accepts `fake_dns_domain`, for example `t.local.lan`. +- `fake_dns_workers` adds concurrent UDP read/parse workers for the local DNS listener. Use `0` for the automatic default; `4` to `8` is a good starting range for busy servers. +- `dns_response_workers` shards DNS response sending. Keep it at `0` or `1` unless the DNSTT **Queue** grows under load; then test `2` to `4`. +- The normal `udp_listen` listener still accepts the full `domains` list. +- Port 53 may require root privileges or the `CAP_NET_BIND_SERVICE` capability. +- These fields can be changed from the admin panel under **DNSTT Tunnel**. diff --git a/admin/assets/app.css b/admin/assets/app.css index 296153e..88fb670 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -627,3 +627,14 @@ select:disabled { color:#94a3b8 !important; background:#070b12 !important; } + +/* Xray runtime mode selector */ +.input-sm{ + min-height:30px; + padding:4px 8px; + border-radius:10px; + border:1px solid rgba(34,211,238,.26); + background:#070b12; + color:#f3f7ff; + font-size:.78rem; +} diff --git a/admin/assets/app.js b/admin/assets/app.js index 7949998..8a9ec9b 100644 --- a/admin/assets/app.js +++ b/admin/assets/app.js @@ -42,7 +42,7 @@ const I18N_TEXT = { "Server Load":"Server Load","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Updated: {time}","Error loading stats.":"Error loading stats.","Normal load":"Normal load","Moderate load":"Moderate load","High load":"High load","Cleaning interface totals…":"Cleaning interface totals…","Interface totals cleaned. Auto-clean remains every 30 days.":"Interface totals cleaned. Auto-clean remains every 30 days.","Error cleaning totals: {error}":"Error cleaning totals: {error}", "VnStat Usage":"VnStat Usage","Today total":"Today total","This month total":"This month total","Interfaces tracked":"Interfaces tracked","daily / monthly":"daily / monthly","Daily usage":"Daily usage","Monthly usage":"Monthly usage","Day":"Day","Month":"Month","Clean usage":"Clean usage","Clean VnStat history":"Clean VnStat history","VnStat history does not auto-clean. Use the button when you want to reset it.":"VnStat history does not auto-clean. Use the button when you want to reset it.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.","Loading VnStat usage…":"Loading VnStat usage…","VnStat history cleaned.":"VnStat history cleaned.","Error loading VnStat usage: {error}":"Error loading VnStat usage: {error}","Error cleaning VnStat history: {error}":"Error cleaning VnStat history: {error}", "Panel / system":"Panel / system","Select a log source and click Refresh.":"Select a log source and click Refresh.","Clean panel log":"Clean panel log","No log lines yet.":"No log lines yet.","Panel log cleaned · {path} · max {max}":"Panel log cleaned · {path} · max {max}","Cleaning panel log…":"Cleaning panel log…", - "Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes" + "Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Auto Restart Interval":"Auto Restart Interval","Restart Grace Delay":"Restart Grace Delay","0s/off disables":"0s/off disables","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes" }, "pt-BR": { "Dashboard":"Painel","Overview":"Visão geral","Accounts":"Contas","Administration":"Administração","Server":"Servidor","System":"Sistema","Settings":"Configurações","Traffic":"Tráfego","Monitoring":"Monitoramento", @@ -59,7 +59,7 @@ const I18N_TEXT = { "Server Load":"Carga do servidor","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Atualizado: {time}","Error loading stats.":"Erro ao carregar stats.","Normal load":"Carga normal","Moderate load":"Carga moderada","High load":"Carga alta","Cleaning interface totals…":"Limpando totais das interfaces…","Interface totals cleaned. Auto-clean remains every 30 days.":"Totais das interfaces limpos. A limpeza automática continua a cada 30 dias.","Error cleaning totals: {error}":"Erro ao limpar totais: {error}", "VnStat Usage":"Uso do VnStat","Today total":"Total hoje","This month total":"Total este mês","Interfaces tracked":"Interfaces monitoradas","daily / monthly":"diário / mensal","Daily usage":"Uso diário","Monthly usage":"Uso mensal","Day":"Dia","Month":"Mês","Clean usage":"Limpar uso","Clean VnStat history":"Limpar histórico VnStat","VnStat history does not auto-clean. Use the button when you want to reset it.":"O histórico VnStat não é limpo automaticamente. Use o botão quando quiser resetar.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Os totais podem ser limpos aqui e têm limpeza automática a cada 30 dias. O histórico VnStat é separado.","Loading VnStat usage…":"Carregando uso do VnStat…","VnStat history cleaned.":"Histórico VnStat limpo.","Error loading VnStat usage: {error}":"Erro ao carregar uso do VnStat: {error}","Error cleaning VnStat history: {error}":"Erro ao limpar histórico VnStat: {error}", "Panel / system":"Painel / sistema","Select a log source and click Refresh.":"Selecione uma fonte de log e clique em Atualizar.","Clean panel log":"Limpar log do painel","No log lines yet.":"Ainda não há linhas de log.","Panel log cleaned · {path} · max {max}":"Log do painel limpo · {path} · máx {max}","Cleaning panel log…":"Limpando log do painel…", - "Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações" + "Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","Proxy Auto Restart":"Reinício automático do proxy","Proxy Auto Restart Interval":"Intervalo de reinício automático do proxy","Proxy Restart Grace Delay":"Atraso para reiniciar proxy","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Auto Restart Interval":"Intervalo de reinício automático","Restart Grace Delay":"Atraso para reiniciar","0s/off disables":"0s/off desativa","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações" } }; const I18N_ALIASES = { @@ -69,14 +69,14 @@ const I18N_ALIASES = { "Minha conta":"My Account","Usuários":"Users","Usuário":"User","Autenticação":"Auth","Conexões":"Conn","Máximo":"Max","Dono":"Owner","Ações":"Actions","Criar / atualizar usuário":"Create / update user","Mostrar formulário":"Show form","Ocultar formulário":"Hide form","Salvar usuário":"Save user","Cancelar":"Cancel","Gerar":"Gen","Copiar":"Copy","Editar":"Edit","Excluir":"Del","Recarregar":"Reload","Atualizar":"Refresh", "Rodando":"Running","Parado":"Stopped","rodando":"running","parado":"stopped","desativado":"disabled","API de contadores":"Counters API","Reparar contadores":"Repair counters","Iniciar":"Start","Parar":"Stop","Reiniciar":"Restart","Inbounds e clientes":"Inbounds & Clients","Configuração Xray":"Xray Config","Editor de configuração":"Config editor","Carregar JSON":"Load JSON","Salvar e reiniciar":"Save & Restart","Logs do sistema":"System Logs","últimas 200 linhas":"last 200 lines","Clientes Xray":"Xray clients","Núcleo Xray":"Xray Core","Ativado":"Enabled","Tempo ativo":"Uptime","Precisa de reparo":"Needs repair", "Nome":"Name","Nome de exibição":"Display Name","Data de vencimento":"Expiry Date","Máx. conexões":"Max Connections","Ilimitado":"Unlimited","Ativo":"Active","Suspenso":"Suspended","Expirado":"Expired","Sem vencimento":"No expiration","ocioso":"idle","Nenhum cliente.":"No clients.","Adicionar cliente":"Add Client","Novo usuário.":"New user.","Carregado.":"Loaded.","Salvo.":"Saved.","Salvando…":"Saving…","Erro ao carregar usuários.":"Error loading users.","Erro ao excluir.":"Error deleting.","Credenciais inválidas.":"Invalid credentials.","Conta suspensa ou expirada.":"Account suspended or expired.","Falha no login.":"Login failed.","Erro de rede.":"Network error.","Sessão expirada — faça login novamente.":"Session expired — please sign in again.", - "Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live." + "Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","Reinício automático do proxy":"Proxy Auto Restart","Intervalo de reinício automático do proxy":"Proxy Auto Restart Interval","Atraso para reiniciar proxy":"Proxy Restart Grace Delay","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live." }; Object.assign(I18N_TEXT["en-US"], { "Servers":"Servers","Reseller area":"Reseller area","shared quota":"shared quota","available":"available","used":"used","breakdown":"breakdown", "Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.", "Loading inbounds…":"Loading inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"active ·","expired":"expired", - "Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", + "Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", "Public Key — share with dnstt clients":"Public Key — share with DNSTT clients","auto-saved to /opt/sshpanel/dnstt.key":"auto-saved to /opt/sshpanel/dnstt.key", "Max UDP Sessions Per Client":"Max UDP Sessions Per Client","(not total server users)":"(not total server users)","Service Name":"Service Name","Mode":"Mode","Protocol":"Protocol","Port":"Port","Tag":"Tag","Listen IP":"Listen IP","Method":"Method","Host":"Host","Path":"Path","Dest":"Dest","Short ID":"Short ID","Server Name":"Server Name","Cert File Path":"Cert File Path","Key File Path":"Key File Path","Certificate source:":"Certificate source:","Self-Signed":"Self-Signed","Paste PEM":"Paste PEM","File Path":"File Path","Save PEM":"Save PEM","Generate":"Generate","Public Key":"Public Key","Debug Logging":"Debug Logging","Name":"Name","Private Key PEM":"Private Key PEM","Certificate PEM":"Certificate PEM","Domain Name":"Domain Name" }); @@ -84,7 +84,7 @@ Object.assign(I18N_TEXT["pt-BR"], { "Servers":"Servidores","Reseller area":"Área do revendedor","shared quota":"cota única","available":"disponíveis","used":"usadas","breakdown":"divisão", "Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.", "Loading inbounds…":"Carregando inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"ativas ·","expired":"expiradas", - "Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085", + "Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085", "Public Key — share with dnstt clients":"Chave pública — compartilhe com clientes DNSTT","auto-saved to /opt/sshpanel/dnstt.key":"salva automaticamente em /opt/sshpanel/dnstt.key", "Max UDP Sessions Per Client":"Máx. sessões UDP por cliente","(not total server users)":"(não é o total de usuários do servidor)","Service Name":"Nome do serviço","Mode":"Modo","Protocol":"Protocolo","Port":"Porta","Tag":"Tag","Listen IP":"IP de listen","Method":"Método","Host":"Host","Path":"Caminho","Dest":"Destino","Short ID":"ID curto","Server Name":"Nome do servidor","Cert File Path":"Caminho do arquivo cert","Key File Path":"Caminho do arquivo key","Certificate source:":"Fonte do certificado:","Self-Signed":"Autoassinado","Paste PEM":"Colar PEM","File Path":"Caminho do arquivo","Save PEM":"Salvar PEM","Generate":"Gerar","Public Key":"Chave pública","Debug Logging":"Log de debug","Name":"Nome","Private Key PEM":"Chave privada PEM","Certificate PEM":"Certificado PEM","Domain Name":"Nome do domínio" }); @@ -92,9 +92,9 @@ Object.assign(I18N_ALIASES, { "Servidores":"Servers","Área do revendedor":"Reseller area","cota única":"shared quota","disponíveis":"available","usadas":"used","divisão":"breakdown", "Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.", "Carregando inbounds…":"Loading inbounds…","Loading inbounds…":"Loading inbounds…","ativas ·":"active ·","expiradas":"expired", - "Binário: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", + "Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085", "Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients", - "Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Chave pública":"Public Key","Nome do domínio":"Domain Name" + "Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name" }); const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))])); let currentLang = detectInitialLanguage(); @@ -269,6 +269,8 @@ const xPID = document.getElementById("xPID"); const xUptime = document.getElementById("xUptime"); const xStatus = document.getElementById("xStatus"); const xOnlineUsers = document.getElementById("xOnlineUsers"); +const xCoreMode = document.getElementById("xCoreMode"); +const xSaveModeBtn = document.getElementById("xSaveModeBtn"); const xCfgEditor = document.getElementById("xCfgEditor"); const xCfgStatus = document.getElementById("xCfgStatus"); const xLogsBox = document.getElementById("xLogsBox"); @@ -326,6 +328,14 @@ const ifaceBody = document.getElementById("ifaceBody"); const ifaceSummary = document.getElementById("ifaceSummary"); const statsUpdated = document.getElementById("statsUpdated"); const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn"); +const dnsttDashboardCard = document.getElementById("dnsttDashboardCard"); +const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated"); +const dnsttActiveSessions = document.getElementById("dnsttActiveSessions"); +const dnsttActiveStreams = document.getElementById("dnsttActiveStreams"); +const dnsttDNSRx = document.getElementById("dnsttDNSRx"); +const dnsttQueueLen = document.getElementById("dnsttQueueLen"); +const dnsttHealthBody = document.getElementById("dnsttHealthBody"); +const dnsttHealthSummary = document.getElementById("dnsttHealthSummary"); // VnStat const vnstatDailyBody = document.getElementById("vnstatDailyBody"); @@ -362,6 +372,25 @@ function selectedXrayServerLabel() { if (srv) return srv.name || srv.base_url || id; return id === "local" ? "Master node" : id; } +function xrayModeFromConfig(x) { + const mode = String(x?.mode || "").toLowerCase(); + return mode === "external" ? "external" : "native"; +} + +function applyXrayModeToConfig(cfg, mode) { + mode = mode === "external" ? "external" : "native"; + cfg.xray = cfg.xray && typeof cfg.xray === "object" ? cfg.xray : {}; + cfg.xray.mode = mode; + cfg.xray.native = mode === "native"; + cfg.xray.bin_path = cfg.xray.bin_path || "/opt/sshpanel/xray"; + cfg.xray.config_file = cfg.xray.config_file || "/opt/sshpanel/xray_config.json"; + cfg.xray.native_config_file = cfg.xray.native_config_file || "/opt/sshpanel/xray_native_config.json"; + cfg.xray.api_server = cfg.xray.api_server || "127.0.0.1:10085"; + cfg.xray.online_window_seconds = cfg.xray.online_window_seconds || 90; + cfg.xray.stats_poll_seconds = cfg.xray.stats_poll_seconds || 15; + return cfg; +} + function reloadXrayConfigForSelectedServer() { const wizPane = document.getElementById("xrayWizardPane"); const jsonPane = document.getElementById("xrayCfgPaneJson"); @@ -379,6 +408,16 @@ function fmtBytes(n) { const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB"; return (m/1024).toFixed(1)+" GiB"; } +function fmtInt(n) { + const v = Number(n); + return Number.isFinite(v) ? v.toLocaleString() : "--"; +} +function fmtDnsttTimestamp(ts) { + if (!ts) return "Waiting for DNSTT stats…"; + const d = new Date(ts); + if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…"; + return "Updated: " + d.toLocaleTimeString(); +} function localDateKey(d = new Date()) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -461,6 +500,13 @@ function clientOnlineHTML(c) { return `${c.online ? `${t("online")}` : `${t("offline")}`}
${escapeHTML(formatLastActive(c.last_active))}
`; } +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))}
↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}
`; +} + function updateCell(row, name, html) { const cell = row?.querySelector?.(`[data-cell="${name}"]`); if (cell && cell.innerHTML !== html) cell.innerHTML = html; @@ -496,7 +542,7 @@ function patchRenderedInbounds(inbounds) { updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c))); updateCell(row, "status", clientStatusHTML(c)); updateCell(row, "online", clientOnlineHTML(c)); - updateCell(row, "traffic", escapeHTML(formatBytes(c.total_bytes))); + updateCell(row, "traffic", clientTrafficHTML(c)); updateCell(row, "max", escapeHTML(c.max_conns || "∞")); } } @@ -550,7 +596,6 @@ document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open")); drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); languageSelect?.addEventListener("change", () => { applyLanguage(languageSelect.value); renderDashboardCounters(); }); -document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump))); applyLanguage(currentLang, { persist: false }); startI18nObserver(); @@ -956,6 +1001,7 @@ document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("s document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop")); document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart")); document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats); +xSaveModeBtn?.addEventListener("click", saveXrayCoreMode); document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); }); document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true })); document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg); @@ -972,8 +1018,9 @@ async function loadXrayStatus() { xrayChip.className = "chip " + (run ? "green" : "red"); xRunning.textContent = run ? t("Running") : t("Stopped"); xRunning.style.color = run ? "var(--success)" : "var(--danger)"; - xPID.textContent = s.pid || "--"; + xPID.textContent = s.pid || (s.native ? "internal" : "--"); xUptime.textContent = s.uptime || "--"; + if (xCoreMode) xCoreMode.value = String(s.mode || (s.native ? "native" : "external")).toLowerCase() === "external" ? "external" : "native"; const statsCfgEl = document.getElementById("xStatsConfig"); const repairBtn = document.getElementById("xRepairStatsBtn"); if (statsCfgEl) { @@ -997,6 +1044,29 @@ async function loadXrayStatus() { } catch (e) { if (e.message==="auth") doAuthError(); } } +async function saveXrayCoreMode() { + const mode = xCoreMode?.value === "external" ? "external" : "native"; + const target = selectedXrayServerLabel(); + const selectedID = selectedXrayServer() || "local"; + if (xStatus) xStatus.textContent = `Saving Xray mode on ${target}...`; + try { + const getRes = await api(withServerParam("/api/servers/config", selectedID)); + if (!getRes.ok) throw new Error(await getRes.text()); + const cfg = await getRes.json(); + applyXrayModeToConfig(cfg, mode); + const postRes = await api(withServerParam("/api/servers/config", selectedID), { method:"POST", body: JSON.stringify(cfg) }); + if (!postRes.ok) throw new Error(await postRes.text()); + if (xStatus) xStatus.textContent = mode === "native" + ? `Saved on ${target}: using internal native emulator.` + : `Saved on ${target}: using external Xray binary.`; + setTimeout(loadXrayStatus, 700); + setTimeout(() => loadInbounds({ force: true }), 1200); + } catch (e) { + if (e.message === "auth") doAuthError(); + else if (xStatus) xStatus.textContent = t("Error: {error}", {error: e.message}); + } +} + async function repairXrayStats() { const btn = document.getElementById("xRepairStatsBtn"); if (btn) btn.disabled = true; @@ -1155,7 +1225,7 @@ function renderInbounds(inbounds, options = {}) { ${escapeHTML(clientExpiryLabel(c))} ${clientStatusHTML(c)} ${clientOnlineHTML(c)} - ${escapeHTML(formatBytes(c.total_bytes))} + ${clientTrafficHTML(c)} ${escapeHTML(c.max_conns || "∞")}`; const actTd = document.createElement("td"); actTd.style.whiteSpace = "nowrap"; @@ -1215,7 +1285,7 @@ async function addClient(tag) { body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns, server_id: selectedXrayServer() }), }); if (!res.ok) throw new Error(await res.text()); - xStatus.textContent = t("Client {id}… added. Restarting Xray…", {id: uuid.slice(0,8)}); + xStatus.textContent = t("Client {id}… added. Native mode hot-reloads without restart.", {id: uuid.slice(0,8)}); setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); } catch (e) { if (e.message==="auth") doAuthError(); @@ -1228,7 +1298,7 @@ async function removeClient(tag, uuid) { try { const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" }); if (!res.ok && res.status !== 204) throw new Error(await res.text()); - xStatus.textContent = t("Client removed. Restarting Xray…"); + xStatus.textContent = t("Client removed. Native mode hot-reloads without restart."); setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500); } catch (e) { if (e.message==="auth") doAuthError(); @@ -1764,6 +1834,8 @@ async function loadManagedServerConfig(id) { document.getElementById("managedCfgListen").value = c.listen || ""; document.getElementById("managedCfgExtraListen").value = (c.extra_listen || []).join("\n"); + document.getElementById("managedCfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; + document.getElementById("managedCfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; document.getElementById("managedCfgLimitUp").value = c.default_limit_mbps_up || 0; document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0; @@ -1775,11 +1847,25 @@ async function loadManagedServerConfig(id) { document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt; toggleManagedDnsttFields(hasDnstt); const d = c.dnstt || {}; - document.getElementById("managedCfgDnsttDomain").value = d.domain || ""; + document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d); document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || ""; + document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; + document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || ""; + document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; + document.getElementById("managedCfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; + document.getElementById("managedCfgDnsttRespWorkers").value = d.dns_response_workers || 0; + document.getElementById("managedCfgDnsttAutoRestart").value = d.auto_restart_interval || ""; + document.getElementById("managedCfgDnsttRestartGrace").value = d.auto_restart_grace || ""; + document.getElementById("managedCfgDnsttMaxSessions").value = d.max_sessions || 0; + document.getElementById("managedCfgDnsttMaxStreams").value = d.max_streams || 0; + document.getElementById("managedCfgDnsttPendingResponses").value = d.pending_responses || 0; + document.getElementById("managedCfgDnsttStreamBuffer").value = d.stream_buffer || 0; + document.getElementById("managedCfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; + document.getElementById("managedCfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; document.getElementById("managedCfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key"; document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log; document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log; + document.getElementById("managedCfgDnsttLogConnections").checked = !!d.log_connections; const hasUdpgw = !!c.udpgw; document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw; @@ -1789,6 +1875,8 @@ async function loadManagedServerConfig(id) { document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0; document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || ""; document.getElementById("managedCfgUdpgwMapTTL").value = u.map_ttl || ""; + document.getElementById("managedCfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; + document.getElementById("managedCfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; document.getElementById("managedCfgUdpgwDebug").checked = !!u.debug; managedTlsForwardersState = c.tls_forwarders || []; @@ -1796,6 +1884,7 @@ async function loadManagedServerConfig(id) { const x = c.xray || {}; document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled; + document.getElementById("managedCfgXrayMode").value = xrayModeFromConfig(x); document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden"); if (st) st.textContent = "Config loaded."; @@ -1808,9 +1897,12 @@ async function loadManagedServerConfig(id) { function managedConfigFromForm() { const extraLines = document.getElementById("managedCfgExtraListen").value .split("\n").map(s => s.trim()).filter(Boolean); + const dnsttDomains = readDnsttDomains("managedCfgDnsttDomains"); return { listen: document.getElementById("managedCfgListen").value.trim(), extra_listen: extraLines, + proxy_auto_restart_interval: document.getElementById("managedCfgProxyAutoRestart").value.trim(), + proxy_auto_restart_grace: document.getElementById("managedCfgProxyRestartGrace").value.trim(), host_key_file: "/opt/sshpanel/ssh_host_rsa_key", admin_dir: "/opt/sshpanel/admin", default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10), @@ -1820,24 +1912,44 @@ function managedConfigFromForm() { banner: document.getElementById("managedCfgBanner").value, banner_file: "/opt/sshpanel/banner.txt", dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? { - domain: document.getElementById("managedCfgDnsttDomain").value.trim(), + domain: dnsttDomains[0] || "", + domains: dnsttDomains, udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(), + fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked, + fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(), + fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(), + fake_dns_workers: parseInt(document.getElementById("managedCfgDnsttFakeWorkers").value || "0", 10), + dns_response_workers: parseInt(document.getElementById("managedCfgDnsttRespWorkers").value || "0", 10), + auto_restart_interval: document.getElementById("managedCfgDnsttAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("managedCfgDnsttRestartGrace").value.trim(), + max_sessions: parseInt(document.getElementById("managedCfgDnsttMaxSessions").value || "0", 10), + max_streams: parseInt(document.getElementById("managedCfgDnsttMaxStreams").value || "0", 10), + pending_responses: parseInt(document.getElementById("managedCfgDnsttPendingResponses").value || "0", 10), + stream_buffer: parseInt(document.getElementById("managedCfgDnsttStreamBuffer").value || "0", 10), + udp_read_buffer: parseInt(document.getElementById("managedCfgDnsttUDPReadBuffer").value || "0", 10), + udp_write_buffer: parseInt(document.getElementById("managedCfgDnsttUDPWriteBuffer").value || "0", 10), privkey_file: document.getElementById("managedCfgDnsttKey").value.trim(), disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked, disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked, + log_connections: document.getElementById("managedCfgDnsttLogConnections").checked, } : null, udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? { listen: document.getElementById("managedCfgUdpgwListen").value.trim(), max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10), idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(), map_ttl: document.getElementById("managedCfgUdpgwMapTTL").value.trim(), + auto_restart_interval: document.getElementById("managedCfgUdpgwAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("managedCfgUdpgwRestartGrace").value.trim(), debug: document.getElementById("managedCfgUdpgwDebug").checked, } : null, tls_forwarders: managedTlsForwardersState, xray: { enabled: document.getElementById("managedCfgXrayEnabled").checked, + mode: document.getElementById("managedCfgXrayMode").value === "external" ? "external" : "native", + native: document.getElementById("managedCfgXrayMode").value !== "external", bin_path: "/opt/sshpanel/xray", config_file: "/opt/sshpanel/xray_config.json", + native_config_file: "/opt/sshpanel/xray_native_config.json", api_server: "127.0.0.1:10085", online_window_seconds: 90, stats_poll_seconds: 15, @@ -2025,6 +2137,7 @@ async function loadDashboardStats() { if (!res.ok) throw new Error(await res.text()); const s = await res.json(); updateDashboardStats(s); + await loadDnsttHealth(); } catch (e) { if (e.message === "auth") doAuthError(); else { @@ -2061,6 +2174,76 @@ function updateDashboardStats(s) { if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`; } +async function loadDnsttHealth() { + if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return; + if (currentRole !== "superadmin") { + dnsttDashboardCard?.classList.add("hidden"); + return; + } + try { + const res = await api("/api/dnstt"); + if (!res.ok) throw new Error(await res.text()); + const d = await res.json(); + const enabled = d.enabled !== false; + if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled); + if (!enabled) return; + + if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions); + if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams); + if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx); + if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len); + if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp); + + const rows = [ + ["Session rejected", d.sess_rejected], + ["Stream rejected", d.stream_rejected], + ["DNS parse errors", d.parse_err], + ["No EDNS", d.no_edns], + ["EDNS limit 512", d.limit512], + ["Local DNS workers", d.fake_dns_workers], + ["Response workers", d.dns_response_workers], + ["Responses queued", d.rec_queued], + ["Responses dropped", d.rec_dropped], + ["Responses sent", d.resp_sent], + ["Response bytes", d.resp_bytes], + ["Empty responses", d.resp_empty], + ["Data responses", d.resp_data], + ["Oversize responses", d.resp_oversize], + ["KCP sessions new", d.kcp_new], + ["KCP sessions ended", d.kcp_end], + ["SMUX streams new", d.smux_new], + ["SMUX streams ended", d.smux_end], + ["Panic recovered", d.panic_recovered], + ]; + if (dnsttHealthBody) { + dnsttHealthBody.innerHTML = ""; + for (let i = 0; i < rows.length; i += 2) { + const a = rows[i]; + const b = rows[i + 1] || ["", ""]; + const tr = document.createElement("tr"); + tr.innerHTML = `${a[0]}${fmtInt(a[1])}${b[0]}${b[0] ? fmtInt(b[1]) : ""}`; + dnsttHealthBody.appendChild(tr); + } + } + const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0); + if (dnsttHealthSummary) { + if (d.running === false) { + dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs."; + } else { + dnsttHealthSummary.textContent = bad > 0 + ? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.` + : "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window."; + } + } + } catch (e) { + if (e.message === "auth") throw e; + dnsttDashboardCard?.classList.add("hidden"); + if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats."; + if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable."; + } +} + + async function loadStats() { try { const res = await api("/api/stats"); @@ -2087,6 +2270,7 @@ async function loadStats() { }); if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); + await loadDnsttHealth(); } catch (e) { if (e.message==="auth") doAuthError(); else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats."; @@ -2223,6 +2407,22 @@ async function clearPanelLog() { // ─── Server Config ──────────────────────────────────────────────────────────── document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig); + +function dnsttDomainsText(d) { + const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []); + return domains.join("\n"); +} +function readDnsttDomains(id) { + const el = document.getElementById(id); + if (!el) return []; + const seen = new Set(); + return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => { + if (seen.has(s)) return false; + seen.add(s); + return true; + }); +} + function toggleDnsttFields(on) { const el = document.getElementById("dnsttFields"); el.style.opacity = on ? "1" : ".4"; @@ -2245,6 +2445,8 @@ async function loadServerConfig() { // Network document.getElementById("cfgListen").value = c.listen || ""; document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n"); + document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; + document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; // SSH / general document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0; @@ -2260,11 +2462,25 @@ async function loadServerConfig() { document.getElementById("cfgDnsttEnabled").checked = hasDnstt; toggleDnsttFields(hasDnstt); const d = c.dnstt || {}; - document.getElementById("cfgDnsttDomain").value = d.domain || ""; + document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d); document.getElementById("cfgDnsttUDP").value = d.udp_listen || ""; + document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; + document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || ""; + document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; + document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; + document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0; + document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || ""; + document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || ""; + document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0; + document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0; + document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0; + document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0; + document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; + document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key"; document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log; document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log; + document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections; // UDPGW const hasUdpgw = !!c.udpgw; @@ -2275,6 +2491,8 @@ async function loadServerConfig() { document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0; document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || ""; document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || ""; + document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; + document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; document.getElementById("cfgUdpgwDebug").checked = !!u.debug; // TLS forwarders @@ -2284,6 +2502,7 @@ async function loadServerConfig() { // Xray const x = c.xray || {}; document.getElementById("cfgXrayEnabled").checked = !!x.enabled; + document.getElementById("cfgXrayMode").value = xrayModeFromConfig(x); st.textContent = "Config loaded."; } catch (e) { @@ -2300,10 +2519,13 @@ async function saveServerConfig() { const extraLines = document.getElementById("cfgExtraListen").value .split("\n").map(s => s.trim()).filter(Boolean); + const dnsttDomains = readDnsttDomains("cfgDnsttDomains"); const cfg = { listen: document.getElementById("cfgListen").value.trim(), extra_listen: extraLines, + proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart").value.trim(), + proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace").value.trim(), host_key_file: "/opt/sshpanel/ssh_host_rsa_key", admin_dir: "/opt/sshpanel/admin", default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10), @@ -2313,24 +2535,44 @@ async function saveServerConfig() { banner: document.getElementById("cfgBanner").value, banner_file: "/opt/sshpanel/banner.txt", dnstt: document.getElementById("cfgDnsttEnabled").checked ? { - domain: document.getElementById("cfgDnsttDomain").value.trim(), + domain: dnsttDomains[0] || "", + domains: dnsttDomains, udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), + fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked, + fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(), + fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(), + fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers").value || "0", 10), + dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers").value || "0", 10), + auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(), + max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions").value || "0", 10), + max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams").value || "0", 10), + pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses").value || "0", 10), + stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer").value || "0", 10), + udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer").value || "0", 10), + udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer").value || "0", 10), privkey_file: document.getElementById("cfgDnsttKey").value.trim(), disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, + log_connections: document.getElementById("cfgDnsttLogConnections").checked, } : null, udpgw: document.getElementById("cfgUdpgwEnabled").checked ? { listen: document.getElementById("cfgUdpgwListen").value.trim(), max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10), idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(), map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(), + auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace").value.trim(), debug: document.getElementById("cfgUdpgwDebug").checked, } : null, tls_forwarders: tlsArr, xray: { enabled: document.getElementById("cfgXrayEnabled").checked, + mode: document.getElementById("cfgXrayMode").value === "external" ? "external" : "native", + native: document.getElementById("cfgXrayMode").value !== "external", bin_path: "/opt/sshpanel/xray", config_file: "/opt/sshpanel/xray_config.json", + native_config_file: "/opt/sshpanel/xray_native_config.json", api_server: "127.0.0.1:10085", online_window_seconds: 90, stats_poll_seconds: 15, @@ -2779,7 +3021,7 @@ function wzSaveInbound() { break; case "xhttp": ib.streamSettings.xhttpSettings = { - path: document.getElementById("wzXHTTPPath").value.trim() || "/", + path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp", host: document.getElementById("wzXHTTPHost").value.trim() || undefined, mode: document.getElementById("wzXHTTPMode").value, }; diff --git a/admin/index.html b/admin/index.html index 3245eba..584d1d5 100644 --- a/admin/index.html +++ b/admin/index.html @@ -159,17 +159,24 @@
-
+
@@ -912,6 +949,17 @@
+
+
+ + +
+
+ + +
+
Hard restart: closes public proxy listeners and active SSH sessions, then starts the listeners again. Use duration values like 6h, 12h, or 24h.
+
@@ -967,14 +1015,49 @@
-
- - +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Restarts only the DNSTT UDP listener, not the VPS or the full panel. Use duration values like 30m, 2h, or 6h.
+
+
+
+
+
+
+
Scale guard: bounded sessions/streams stop RAM explosions under thousands of DNSTT users. Keep verbose connection logs disabled on busy servers.
@@ -997,6 +1080,9 @@ +
@@ -1026,6 +1112,15 @@
+
+ + +
+
+ + +
+
Hard restart: closes the UDPGW listener and every connected UDPGW client, then starts UDPGW again.
@@ -1091,7 +1186,8 @@ -
Binary: /opt/sshpanel/xray  ·  Config: /opt/sshpanel/xray_config.json  ·  Online counters use Xray Stats API on 127.0.0.1:10085
+
+
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.
@@ -1115,6 +1211,6 @@ - + diff --git a/admin_script.js b/admin_script.js index e595009..bd1b309 100644 --- a/admin_script.js +++ b/admin_script.js @@ -103,6 +103,14 @@ const ifaceBody = document.getElementById("ifaceBody"); const ifaceSummary = document.getElementById("ifaceSummary"); const statsUpdated = document.getElementById("statsUpdated"); const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn"); +const dnsttDashboardCard = document.getElementById("dnsttDashboardCard"); +const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated"); +const dnsttActiveSessions = document.getElementById("dnsttActiveSessions"); +const dnsttActiveStreams = document.getElementById("dnsttActiveStreams"); +const dnsttDNSRx = document.getElementById("dnsttDNSRx"); +const dnsttQueueLen = document.getElementById("dnsttQueueLen"); +const dnsttHealthBody = document.getElementById("dnsttHealthBody"); +const dnsttHealthSummary = document.getElementById("dnsttHealthSummary"); // VnStat const vnstatDailyBody = document.getElementById("vnstatDailyBody"); @@ -136,6 +144,16 @@ function fmtBytes(n) { const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB"; return (m/1024).toFixed(1)+" GiB"; } +function fmtInt(n) { + const v = Number(n); + return Number.isFinite(v) ? v.toLocaleString() : "--"; +} +function fmtDnsttTimestamp(ts) { + if (!ts) return "Waiting for DNSTT stats…"; + const d = new Date(ts); + if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…"; + return "Updated: " + d.toLocaleTimeString(); +} function localDateKey(d = new Date()) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -213,7 +231,6 @@ document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open")); drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode")); -document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump))); document.getElementById("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); }); document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray")); @@ -872,6 +889,76 @@ async function deleteReseller(username) { // ─── Stats ──────────────────────────────────────────────────────────────────── document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats); +async function loadDnsttHealth() { + if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return; + if (currentRole !== "superadmin") { + dnsttDashboardCard?.classList.add("hidden"); + return; + } + try { + const res = await api("/api/dnstt"); + if (!res.ok) throw new Error(await res.text()); + const d = await res.json(); + const enabled = d.enabled !== false; + if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled); + if (!enabled) return; + + if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions); + if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams); + if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx); + if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len); + if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp); + + const rows = [ + ["Session rejected", d.sess_rejected], + ["Stream rejected", d.stream_rejected], + ["DNS parse errors", d.parse_err], + ["No EDNS", d.no_edns], + ["EDNS limit 512", d.limit512], + ["Local DNS workers", d.fake_dns_workers], + ["Response workers", d.dns_response_workers], + ["Responses queued", d.rec_queued], + ["Responses dropped", d.rec_dropped], + ["Responses sent", d.resp_sent], + ["Response bytes", d.resp_bytes], + ["Empty responses", d.resp_empty], + ["Data responses", d.resp_data], + ["Oversize responses", d.resp_oversize], + ["KCP sessions new", d.kcp_new], + ["KCP sessions ended", d.kcp_end], + ["SMUX streams new", d.smux_new], + ["SMUX streams ended", d.smux_end], + ["Panic recovered", d.panic_recovered], + ]; + if (dnsttHealthBody) { + dnsttHealthBody.innerHTML = ""; + for (let i = 0; i < rows.length; i += 2) { + const a = rows[i]; + const b = rows[i + 1] || ["", ""]; + const tr = document.createElement("tr"); + tr.innerHTML = `${a[0]}${fmtInt(a[1])}${b[0]}${b[0] ? fmtInt(b[1]) : ""}`; + dnsttHealthBody.appendChild(tr); + } + } + const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0); + if (dnsttHealthSummary) { + if (d.running === false) { + dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs."; + } else { + dnsttHealthSummary.textContent = bad > 0 + ? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.` + : "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window."; + } + } + } catch (e) { + if (e.message === "auth") throw e; + dnsttDashboardCard?.classList.add("hidden"); + if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats."; + if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable."; + } +} + + async function loadStats() { try { const res = await api("/api/stats"); @@ -895,6 +982,7 @@ async function loadStats() { }); ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); + await loadDnsttHealth(); } catch (e) { if (e.message==="auth") doAuthError(); } } @@ -1028,6 +1116,22 @@ async function clearPanelLog() { // ─── Server Config ──────────────────────────────────────────────────────────── document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig); + +function dnsttDomainsText(d) { + const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []); + return domains.join("\n"); +} +function readDnsttDomains(id) { + const el = document.getElementById(id); + if (!el) return []; + const seen = new Set(); + return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => { + if (seen.has(s)) return false; + seen.add(s); + return true; + }); +} + function toggleDnsttFields(on) { const el = document.getElementById("dnsttFields"); el.style.opacity = on ? "1" : ".4"; @@ -1050,6 +1154,8 @@ async function loadServerConfig() { // Network document.getElementById("cfgListen").value = c.listen || ""; document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n"); + if (document.getElementById("cfgProxyAutoRestart")) document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || ""; + if (document.getElementById("cfgProxyRestartGrace")) document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || ""; // SSH / general document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0; @@ -1065,11 +1171,25 @@ async function loadServerConfig() { document.getElementById("cfgDnsttEnabled").checked = hasDnstt; toggleDnsttFields(hasDnstt); const d = c.dnstt || {}; - document.getElementById("cfgDnsttDomain").value = d.domain || ""; + document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d); document.getElementById("cfgDnsttUDP").value = d.udp_listen || ""; + if (document.getElementById("cfgDnsttFakeEnabled")) document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled; + if (document.getElementById("cfgDnsttFakeListen")) document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || ""; + if (document.getElementById("cfgDnsttFakeDomain")) document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan"; + if (document.getElementById("cfgDnsttFakeWorkers")) document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0; + if (document.getElementById("cfgDnsttRespWorkers")) document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0; + document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || ""; + document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || ""; + if (document.getElementById("cfgDnsttMaxSessions")) document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0; + if (document.getElementById("cfgDnsttMaxStreams")) document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0; + if (document.getElementById("cfgDnsttPendingResponses")) document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0; + if (document.getElementById("cfgDnsttStreamBuffer")) document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0; + if (document.getElementById("cfgDnsttUDPReadBuffer")) document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0; + if (document.getElementById("cfgDnsttUDPWriteBuffer")) document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0; document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key"; document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log; document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log; + if (document.getElementById("cfgDnsttLogConnections")) document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections; // UDPGW const hasUdpgw = !!c.udpgw; @@ -1080,6 +1200,8 @@ async function loadServerConfig() { document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0; document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || ""; document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || ""; + if (document.getElementById("cfgUdpgwAutoRestart")) document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || ""; + if (document.getElementById("cfgUdpgwRestartGrace")) document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || ""; document.getElementById("cfgUdpgwDebug").checked = !!u.debug; // TLS forwarders @@ -1105,10 +1227,13 @@ async function saveServerConfig() { const extraLines = document.getElementById("cfgExtraListen").value .split("\n").map(s => s.trim()).filter(Boolean); + const dnsttDomains = readDnsttDomains("cfgDnsttDomains"); const cfg = { listen: document.getElementById("cfgListen").value.trim(), extra_listen: extraLines, + proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart") ? document.getElementById("cfgProxyAutoRestart").value.trim() : "", + proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace") ? document.getElementById("cfgProxyRestartGrace").value.trim() : "", host_key_file: "/opt/sshpanel/ssh_host_rsa_key", admin_dir: "/opt/sshpanel/admin", default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10), @@ -1118,17 +1243,34 @@ async function saveServerConfig() { banner: document.getElementById("cfgBanner").value, banner_file: "/opt/sshpanel/banner.txt", dnstt: document.getElementById("cfgDnsttEnabled").checked ? { - domain: document.getElementById("cfgDnsttDomain").value.trim(), + domain: dnsttDomains[0] || "", + domains: dnsttDomains, udp_listen: document.getElementById("cfgDnsttUDP").value.trim(), + fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled") ? document.getElementById("cfgDnsttFakeEnabled").checked : false, + fake_dns_listen: document.getElementById("cfgDnsttFakeListen") ? document.getElementById("cfgDnsttFakeListen").value.trim() : "", + fake_dns_domain: document.getElementById("cfgDnsttFakeDomain") ? document.getElementById("cfgDnsttFakeDomain").value.trim() : "", + fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers") ? document.getElementById("cfgDnsttFakeWorkers").value || "0" : "0", 10), + dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers") ? document.getElementById("cfgDnsttRespWorkers").value || "0" : "0", 10), + auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(), + auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(), + max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions") ? document.getElementById("cfgDnsttMaxSessions").value || "0" : "0", 10), + max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams") ? document.getElementById("cfgDnsttMaxStreams").value || "0" : "0", 10), + pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses") ? document.getElementById("cfgDnsttPendingResponses").value || "0" : "0", 10), + stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer") ? document.getElementById("cfgDnsttStreamBuffer").value || "0" : "0", 10), + udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer") ? document.getElementById("cfgDnsttUDPReadBuffer").value || "0" : "0", 10), + udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer") ? document.getElementById("cfgDnsttUDPWriteBuffer").value || "0" : "0", 10), privkey_file: document.getElementById("cfgDnsttKey").value.trim(), disable_stats_log: document.getElementById("cfgDnsttNoStats").checked, disable_console_log: document.getElementById("cfgDnsttNoConsole").checked, + log_connections: document.getElementById("cfgDnsttLogConnections") ? document.getElementById("cfgDnsttLogConnections").checked : false, } : null, udpgw: document.getElementById("cfgUdpgwEnabled").checked ? { listen: document.getElementById("cfgUdpgwListen").value.trim(), max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10), idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(), map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(), + auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart") ? document.getElementById("cfgUdpgwAutoRestart").value.trim() : "", + auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace") ? document.getElementById("cfgUdpgwRestartGrace").value.trim() : "", debug: document.getElementById("cfgUdpgwDebug").checked, } : null, tls_forwarders: tlsArr, diff --git a/config_safety.go b/config_safety.go index 9e9a82f..052af63 100644 --- a/config_safety.go +++ b/config_safety.go @@ -5,6 +5,7 @@ import ( "log" "net" "strings" + "time" ) const ( @@ -64,7 +65,50 @@ func normalizeRuntimePorts(cfg *Config) []string { // DragonCore no longer uses an internal local SSH listener. cfg.LocalSSHListen = "" + cfg.ProxyAutoRestartInterval = strings.TrimSpace(cfg.ProxyAutoRestartInterval) + if cfg.ProxyAutoRestartInterval != "" && cfg.ProxyAutoRestartInterval != "0" && cfg.ProxyAutoRestartInterval != "0s" && !strings.EqualFold(cfg.ProxyAutoRestartInterval, "off") && !strings.EqualFold(cfg.ProxyAutoRestartInterval, "disabled") { + if d, err := time.ParseDuration(cfg.ProxyAutoRestartInterval); err != nil { + warn("proxy auto restart interval %q is invalid; disabling auto restart", cfg.ProxyAutoRestartInterval) + cfg.ProxyAutoRestartInterval = "" + } else if d < time.Minute { + warn("proxy auto restart interval %q is below 1m; disabling auto restart", cfg.ProxyAutoRestartInterval) + cfg.ProxyAutoRestartInterval = "" + } + } + cfg.ProxyAutoRestartGrace = strings.TrimSpace(cfg.ProxyAutoRestartGrace) + if cfg.ProxyAutoRestartGrace != "" { + if d, err := time.ParseDuration(cfg.ProxyAutoRestartGrace); err != nil || d < 0 { + warn("proxy auto restart grace %q is invalid; using default 2s", cfg.ProxyAutoRestartGrace) + cfg.ProxyAutoRestartGrace = "" + } else if d > time.Minute { + warn("proxy auto restart grace %q is above 1m; clamping to 1m", cfg.ProxyAutoRestartGrace) + cfg.ProxyAutoRestartGrace = "1m" + } + } + if cfg.DNSTT != nil { + cfg.DNSTT.FakeDNSDomain = strings.TrimSpace(cfg.DNSTT.FakeDNSDomain) + if cfg.DNSTT.FakeDNSEnabled { + if cfg.DNSTT.FakeDNSDomain == "" { + cfg.DNSTT.FakeDNSDomain = "t.local.lan" + } + // Automatically add the local/fake test zone to the accepted DNSTT + // domains so the tunnel handler can decode traffic for it. + cfg.DNSTT.Domains = append(cfg.DNSTT.Domains, cfg.DNSTT.FakeDNSDomain) + } + cfg.DNSTT.Domains = normalizeDNSTTDomainList(cfg.DNSTT.Domain, cfg.DNSTT.Domains) + if len(cfg.DNSTT.Domains) > 0 { + cfg.DNSTT.Domain = cfg.DNSTT.Domains[0] + } else { + cfg.DNSTT.Domain = strings.TrimSpace(cfg.DNSTT.Domain) + } + if cfg.DNSTT.FakeDNSEnabled { + localDomains := normalizeDNSTTDomainList(cfg.DNSTT.FakeDNSDomain, nil) + if len(localDomains) > 0 { + cfg.DNSTT.FakeDNSDomain = localDomains[0] + } + } + cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen) if cfg.DNSTT.UDPListen == "" { cfg.DNSTT.UDPListen = defaultDNSTTListen @@ -77,6 +121,88 @@ func normalizeRuntimePorts(cfg *Config) []string { warn("default DNSTT UDP listener %s is also unavailable: %v", cfg.DNSTT.UDPListen, err2) } } + + if cfg.DNSTT.FakeDNSEnabled { + cfg.DNSTT.FakeDNSListen = strings.TrimSpace(cfg.DNSTT.FakeDNSListen) + if cfg.DNSTT.FakeDNSListen == "" { + cfg.DNSTT.FakeDNSListen = "[::]:53" + } + if !sameUDPListenAddress(cfg.DNSTT.FakeDNSListen, cfg.DNSTT.UDPListen) { + if err := udpAddrAvailableForDNSTT(cfg.DNSTT.FakeDNSListen); err != nil { + warn("built-in DNSTT local DNS listener %s is unavailable: %v", cfg.DNSTT.FakeDNSListen, err) + } + } + } + + cfg.DNSTT.AutoRestartInterval = strings.TrimSpace(cfg.DNSTT.AutoRestartInterval) + if cfg.DNSTT.AutoRestartInterval != "" && cfg.DNSTT.AutoRestartInterval != "0" && cfg.DNSTT.AutoRestartInterval != "0s" && !strings.EqualFold(cfg.DNSTT.AutoRestartInterval, "off") && !strings.EqualFold(cfg.DNSTT.AutoRestartInterval, "disabled") { + if d, err := time.ParseDuration(cfg.DNSTT.AutoRestartInterval); err != nil { + warn("DNSTT auto restart interval %q is invalid; disabling auto restart", cfg.DNSTT.AutoRestartInterval) + cfg.DNSTT.AutoRestartInterval = "" + } else if d < time.Minute { + warn("DNSTT auto restart interval %q is below 1m; disabling auto restart", cfg.DNSTT.AutoRestartInterval) + cfg.DNSTT.AutoRestartInterval = "" + } + } + cfg.DNSTT.AutoRestartGrace = strings.TrimSpace(cfg.DNSTT.AutoRestartGrace) + if cfg.DNSTT.AutoRestartGrace != "" { + if d, err := time.ParseDuration(cfg.DNSTT.AutoRestartGrace); err != nil || d < 0 { + warn("DNSTT auto restart grace %q is invalid; using default 2s", cfg.DNSTT.AutoRestartGrace) + cfg.DNSTT.AutoRestartGrace = "" + } else if d > time.Minute { + warn("DNSTT auto restart grace %q is above 1m; clamping to 1m", cfg.DNSTT.AutoRestartGrace) + cfg.DNSTT.AutoRestartGrace = "1m" + } + } + + if cfg.DNSTT.MaxSessions < -1 { + warn("DNSTT max_sessions %d is invalid; using unlimited (-1)", cfg.DNSTT.MaxSessions) + cfg.DNSTT.MaxSessions = -1 + } + if cfg.DNSTT.MaxStreams < -1 { + warn("DNSTT max_streams %d is invalid; using unlimited (-1)", cfg.DNSTT.MaxStreams) + cfg.DNSTT.MaxStreams = -1 + } + if cfg.DNSTT.PendingResponses > 0 { + if cfg.DNSTT.PendingResponses < minDNSTTPendingResponses { + warn("DNSTT pending_responses %d is too low; clamping to %d", cfg.DNSTT.PendingResponses, minDNSTTPendingResponses) + cfg.DNSTT.PendingResponses = minDNSTTPendingResponses + } else if cfg.DNSTT.PendingResponses > maxDNSTTPendingResponses { + warn("DNSTT pending_responses %d is too high; clamping to %d", cfg.DNSTT.PendingResponses, maxDNSTTPendingResponses) + cfg.DNSTT.PendingResponses = maxDNSTTPendingResponses + } + } + if cfg.DNSTT.StreamBuffer > 0 { + if cfg.DNSTT.StreamBuffer < minDNSTTStreamBuffer { + warn("DNSTT stream_buffer %d is too low; clamping to %d", cfg.DNSTT.StreamBuffer, minDNSTTStreamBuffer) + cfg.DNSTT.StreamBuffer = minDNSTTStreamBuffer + } else if cfg.DNSTT.StreamBuffer > maxDNSTTStreamBuffer { + warn("DNSTT stream_buffer %d is too high; clamping to %d", cfg.DNSTT.StreamBuffer, maxDNSTTStreamBuffer) + cfg.DNSTT.StreamBuffer = maxDNSTTStreamBuffer + } + } + if cfg.DNSTT.UDPReadBuffer < 0 { + warn("DNSTT udp_read_buffer %d is invalid; using default", cfg.DNSTT.UDPReadBuffer) + cfg.DNSTT.UDPReadBuffer = 0 + } + if cfg.DNSTT.UDPWriteBuffer < 0 { + warn("DNSTT udp_write_buffer %d is invalid; using default", cfg.DNSTT.UDPWriteBuffer) + cfg.DNSTT.UDPWriteBuffer = 0 + } + if cfg.DNSTT.FakeDNSWorkers < 0 { + warn("DNSTT fake_dns_workers %d is invalid; using automatic default", cfg.DNSTT.FakeDNSWorkers) + cfg.DNSTT.FakeDNSWorkers = 0 + } else if cfg.DNSTT.FakeDNSWorkers > maxDNSTTFakeDNSWorkers { + warn("DNSTT fake_dns_workers %d is too high; clamping to %d", cfg.DNSTT.FakeDNSWorkers, maxDNSTTFakeDNSWorkers) + cfg.DNSTT.FakeDNSWorkers = maxDNSTTFakeDNSWorkers + } + if cfg.DNSTT.DNSResponseWorkers < 0 { + warn("DNSTT dns_response_workers %d is invalid; using default", cfg.DNSTT.DNSResponseWorkers) + cfg.DNSTT.DNSResponseWorkers = 0 + } else if cfg.DNSTT.DNSResponseWorkers > maxDNSTTResponseWorkers { + warn("DNSTT dns_response_workers %d is too high; clamping to %d", cfg.DNSTT.DNSResponseWorkers, maxDNSTTResponseWorkers) + cfg.DNSTT.DNSResponseWorkers = maxDNSTTResponseWorkers + } } if cfg.UDPGW != nil { @@ -92,6 +218,27 @@ func normalizeRuntimePorts(cfg *Config) []string { warn("default UDPGW listener %s is also unavailable: %v", cfg.UDPGW.Listen, err2) } } + + cfg.UDPGW.AutoRestartInterval = strings.TrimSpace(cfg.UDPGW.AutoRestartInterval) + if cfg.UDPGW.AutoRestartInterval != "" && cfg.UDPGW.AutoRestartInterval != "0" && cfg.UDPGW.AutoRestartInterval != "0s" && !strings.EqualFold(cfg.UDPGW.AutoRestartInterval, "off") && !strings.EqualFold(cfg.UDPGW.AutoRestartInterval, "disabled") { + if d, err := time.ParseDuration(cfg.UDPGW.AutoRestartInterval); err != nil { + warn("UDPGW auto restart interval %q is invalid; disabling auto restart", cfg.UDPGW.AutoRestartInterval) + cfg.UDPGW.AutoRestartInterval = "" + } else if d < time.Minute { + warn("UDPGW auto restart interval %q is below 1m; disabling auto restart", cfg.UDPGW.AutoRestartInterval) + cfg.UDPGW.AutoRestartInterval = "" + } + } + cfg.UDPGW.AutoRestartGrace = strings.TrimSpace(cfg.UDPGW.AutoRestartGrace) + if cfg.UDPGW.AutoRestartGrace != "" { + if d, err := time.ParseDuration(cfg.UDPGW.AutoRestartGrace); err != nil || d < 0 { + warn("UDPGW auto restart grace %q is invalid; using default 2s", cfg.UDPGW.AutoRestartGrace) + cfg.UDPGW.AutoRestartGrace = "" + } else if d > time.Minute { + warn("UDPGW auto restart grace %q is above 1m; clamping to 1m", cfg.UDPGW.AutoRestartGrace) + cfg.UDPGW.AutoRestartGrace = "1m" + } + } } return warnings @@ -128,17 +275,40 @@ func tcpAddrAvailableForUDPGW(addr string) error { return ln.Close() } +func normalizeDNSTTDomainList(primary string, domains []string) []string { + seen := make(map[string]bool, len(domains)+1) + out := make([]string, 0, len(domains)+1) + add := func(v string) { + v = strings.TrimSpace(v) + v = strings.TrimSuffix(v, ".") + v = strings.ToLower(v) + if v == "" || seen[v] { + return + } + seen[v] = true + out = append(out, v) + } + add(primary) + for _, d := range domains { + add(d) + } + return out +} + func udpAddrAvailableForDNSTT(addr string) error { if addr == "" { return nil } globalCfgMu.RLock() - current := globalCfg != nil && globalCfg.DNSTT != nil && globalCfg.DNSTT.UDPListen == addr && dnsttRunning() + current := false + if globalCfg != nil && globalCfg.DNSTT != nil && dnsttRunning() { + current = sameUDPListenAddress(globalCfg.DNSTT.UDPListen, addr) || sameUDPListenAddress(globalCfg.DNSTT.FakeDNSListen, addr) + } globalCfgMu.RUnlock() if current { return nil } - pc, err := net.ListenPacket("udp", addr) + pc, err := listenDNSTTPacket(addr) if err != nil { return err } diff --git a/dnstt_integration.go b/dnstt_integration.go index 96a1dd1..612b735 100644 --- a/dnstt_integration.go +++ b/dnstt_integration.go @@ -11,6 +11,7 @@ package main import ( "bytes" + "context" "encoding/base32" "encoding/binary" "encoding/json" @@ -21,6 +22,8 @@ import ( "net" "net/http" "os" + "runtime" + "sort" "strings" "sync" "sync/atomic" @@ -38,17 +41,40 @@ import ( var ( dnsttConnMu sync.Mutex - dnsttConn net.PacketConn // active UDP socket; closing it stops runDNSTT + // active UDP sockets; closing them stops every DNSTT DNS listener. There may + // be more than one when the built-in local/fake DNS listener is enabled. + dnsttConns []net.PacketConn + + dnsttAutoMu sync.Mutex + dnsttAutoCancel context.CancelFunc ) -// stopDNSTT closes the active DNSTT UDP listener, causing runDNSTT to exit. -// It is a no-op if DNSTT is not running. +// stopDNSTT closes the active DNSTT UDP listener and stops the optional +// auto-restart watchdog. It is a no-op if DNSTT is not running. func stopDNSTT() { + stopDNSTTAutoRestart() + stopDNSTTListener() +} + +// stopDNSTTListener closes only the UDP listener. The auto-restart watchdog +// uses this helper so it can cycle DNSTT without disabling itself. +func stopDNSTTListener() { dnsttConnMu.Lock() defer dnsttConnMu.Unlock() - if dnsttConn != nil { - _ = dnsttConn.Close() - dnsttConn = nil + for _, c := range dnsttConns { + if c != nil { + _ = c.Close() + } + } + dnsttConns = nil +} + +func stopDNSTTAutoRestart() { + dnsttAutoMu.Lock() + defer dnsttAutoMu.Unlock() + if dnsttAutoCancel != nil { + dnsttAutoCancel() + dnsttAutoCancel = nil } } @@ -87,6 +113,32 @@ var maxUDPPayload = 1280 - 40 - 8 // If you want strict RFC behaviour, set this to 512. const noEDNSFallbackPayload = 932 +const ( + defaultDNSTTMaxSessions = 10000 + defaultDNSTTMaxStreams = 15000 + defaultDNSTTPendingResponses = 20000 + defaultDNSTTStreamBuffer = 256 * 1024 + defaultDNSTTUDPBuffer = 16 * 1024 * 1024 + defaultDNSTTResponseWorkers = 1 + maxDNSTTFakeDNSWorkers = 64 + maxDNSTTResponseWorkers = 16 + minDNSTTStreamBuffer = 64 * 1024 + maxDNSTTStreamBuffer = 2 * 1024 * 1024 + minDNSTTPendingResponses = 1024 + maxDNSTTPendingResponses = 200000 +) + +type dnsttRuntimeLimits struct { + MaxSessions int + MaxStreams int + PendingResponses int + StreamBuffer int + UDPReadBuffer int + UDPWriteBuffer int + FakeDNSWorkers int + DNSResponseWorkers int +} + // dnsttPrintStats controls whether periodic statistics are printed to stderr. // It is set based on the DNSTT configuration provided by the main program. // When false, the periodic stats will still be collected and made available @@ -94,29 +146,43 @@ const noEDNSFallbackPayload = 932 // true to preserve existing behaviour. var dnsttPrintStats = true +// dnsttLogConnections controls high-volume per-session/per-stream logs. It is +// intentionally false by default because thousands of users can turn logging +// into the bottleneck and make the process unstable. +var dnsttLogConnections atomic.Bool + // DnsttStatsSnapshot holds a recent snapshot of DNSTT counters over the // previous 5‑second window. It is updated every 5 seconds by the // runDNSTT goroutine. The Timestamp field records when the snapshot was // taken. These values are surfaced via the admin API so that the web // panel can display tunnel health without reading stderr logs. type DnsttStatsSnapshot struct { - Timestamp time.Time `json:"timestamp"` - DNSRx uint64 `json:"dns_rx"` - ParseErr uint64 `json:"parse_err"` - NoEDNS uint64 `json:"no_edns"` - Limit512 uint64 `json:"limit512"` - RecQueued uint64 `json:"rec_queued"` - RecDropped uint64 `json:"rec_dropped"` - RespSent uint64 `json:"resp_sent"` - RespBytes uint64 `json:"resp_bytes"` - RespEmpty uint64 `json:"resp_empty"` - RespData uint64 `json:"resp_data"` - RespOversize uint64 `json:"resp_oversize"` - KCPNew uint64 `json:"kcp_new"` - KCPEnd uint64 `json:"kcp_end"` - SmuxNew uint64 `json:"smux_new"` - SmuxEnd uint64 `json:"smux_end"` - ChLen int `json:"ch_len"` + Timestamp time.Time `json:"timestamp"` + Enabled bool `json:"enabled"` + Running bool `json:"running"` + DNSRx uint64 `json:"dns_rx"` + ParseErr uint64 `json:"parse_err"` + NoEDNS uint64 `json:"no_edns"` + Limit512 uint64 `json:"limit512"` + RecQueued uint64 `json:"rec_queued"` + RecDropped uint64 `json:"rec_dropped"` + RespSent uint64 `json:"resp_sent"` + RespBytes uint64 `json:"resp_bytes"` + RespEmpty uint64 `json:"resp_empty"` + RespData uint64 `json:"resp_data"` + RespOversize uint64 `json:"resp_oversize"` + KCPNew uint64 `json:"kcp_new"` + KCPEnd uint64 `json:"kcp_end"` + SmuxNew uint64 `json:"smux_new"` + SmuxEnd uint64 `json:"smux_end"` + SessRejected uint64 `json:"sess_rejected"` + StreamRejected uint64 `json:"stream_rejected"` + PanicRecovered uint64 `json:"panic_recovered"` + ActiveSessions int64 `json:"active_sessions"` + ActiveStreams int64 `json:"active_streams"` + ChLen int `json:"ch_len"` + FakeDNSWorkers int `json:"fake_dns_workers,omitempty"` + DNSResponseWorkers int `json:"dns_response_workers,omitempty"` } var ( @@ -129,8 +195,111 @@ var ( // defensive copy of the snapshot. func GetDNSTTStatsSnapshot() DnsttStatsSnapshot { dnsttStatsMu.Lock() - defer dnsttStatsMu.Unlock() - return lastDnsttStats + snap := lastDnsttStats + dnsttStatsMu.Unlock() + snap.Enabled = dnsttEnabledInCurrentConfig() + snap.Running = dnsttRunning() + snap.ActiveSessions = atomic.LoadInt64(&dnsttActiveSessions) + snap.ActiveStreams = atomic.LoadInt64(&dnsttActiveStreams) + return snap +} + +func dnsttEnabledInCurrentConfig() bool { + globalCfgMu.RLock() + defer globalCfgMu.RUnlock() + return globalCfg != nil && globalCfg.DNSTT != nil +} + +func defaultDNSTTFakeDNSWorkers() int { + n := runtime.GOMAXPROCS(0) + if n < 2 { + return 2 + } + if n > 8 { + return 8 + } + return n +} + +func dnsttLimitsFromConfig(cfg *DNSTTConfig) dnsttRuntimeLimits { + limits := dnsttRuntimeLimits{ + MaxSessions: defaultDNSTTMaxSessions, + MaxStreams: defaultDNSTTMaxStreams, + PendingResponses: defaultDNSTTPendingResponses, + StreamBuffer: defaultDNSTTStreamBuffer, + UDPReadBuffer: defaultDNSTTUDPBuffer, + UDPWriteBuffer: defaultDNSTTUDPBuffer, + FakeDNSWorkers: defaultDNSTTFakeDNSWorkers(), + DNSResponseWorkers: defaultDNSTTResponseWorkers, + } + if cfg == nil { + return limits + } + if cfg.MaxSessions != 0 { + limits.MaxSessions = cfg.MaxSessions + } + if cfg.MaxStreams != 0 { + limits.MaxStreams = cfg.MaxStreams + } + if cfg.PendingResponses > 0 { + limits.PendingResponses = cfg.PendingResponses + } + if limits.PendingResponses < minDNSTTPendingResponses { + limits.PendingResponses = minDNSTTPendingResponses + } + if limits.PendingResponses > maxDNSTTPendingResponses { + limits.PendingResponses = maxDNSTTPendingResponses + } + if cfg.StreamBuffer > 0 { + limits.StreamBuffer = cfg.StreamBuffer + } + if limits.StreamBuffer < minDNSTTStreamBuffer { + limits.StreamBuffer = minDNSTTStreamBuffer + } + if limits.StreamBuffer > maxDNSTTStreamBuffer { + limits.StreamBuffer = maxDNSTTStreamBuffer + } + if cfg.UDPReadBuffer > 0 { + limits.UDPReadBuffer = cfg.UDPReadBuffer + } + if cfg.UDPWriteBuffer > 0 { + limits.UDPWriteBuffer = cfg.UDPWriteBuffer + } + if cfg.FakeDNSWorkers > 0 { + limits.FakeDNSWorkers = cfg.FakeDNSWorkers + } + if limits.FakeDNSWorkers < 1 { + limits.FakeDNSWorkers = 1 + } + if limits.FakeDNSWorkers > maxDNSTTFakeDNSWorkers { + limits.FakeDNSWorkers = maxDNSTTFakeDNSWorkers + } + if cfg.DNSResponseWorkers > 0 { + limits.DNSResponseWorkers = cfg.DNSResponseWorkers + } + if limits.DNSResponseWorkers < 1 { + limits.DNSResponseWorkers = 1 + } + if limits.DNSResponseWorkers > maxDNSTTResponseWorkers { + limits.DNSResponseWorkers = maxDNSTTResponseWorkers + } + if limits.DNSResponseWorkers > limits.PendingResponses { + limits.DNSResponseWorkers = limits.PendingResponses + } + return limits +} + +func dnsttRecover(where string) { + if r := recover(); r != nil { + atomic.AddUint64(&dnsttStats.PanicRecovered, 1) + dnsttLog.Printf("panic recovered in %s: %v", where, r) + } +} + +func dnsttLogDropEvery(counter uint64, format string, args ...interface{}) { + if counter == 1 || counter%1000 == 0 { + dnsttLog.Printf(format, args...) + } } // dnsttCounters holds aggregated counters used to debug tunnel instability. @@ -160,10 +329,18 @@ type dnsttCounters struct { KCPSessionsEnd uint64 SmuxStreamsNew uint64 SmuxStreamsEnd uint64 + SessRejected uint64 + StreamRejected uint64 + PanicRecovered uint64 } var dnsttStats dnsttCounters +var ( + dnsttActiveSessions int64 + dnsttActiveStreams int64 +) + var maxEncodedPayloadCache sync.Map // map[int]int // dnsttClientPayloadCap tracks an inferred per-client UDP payload capability. @@ -329,11 +506,24 @@ func getDNSTTLogLines() []string { return dnsttLogBuf.GetLines() } -// startDNSTT starts the integrated dnstt server if cfg is non-nil. It reads -// the Noise private key from cfg.PrivKeyFile, parses cfg.Domain into a dns.Name, -// and then launches runDNSTT in a goroutine. Any errors during start are -// logged. The SSH server configuration is used when handling streams. +// startDNSTT starts the integrated dnstt server if cfg is non-nil. It reads +// the Noise private key from cfg.PrivKeyFile, parses the configured DNSTT root +// domains into dns.Name values, and then launches runDNSTT in a goroutine. Any +// errors during start are logged. The SSH server configuration is used when +// handling streams. func startDNSTT(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error { + if cfg == nil { + return nil + } + stopDNSTTAutoRestart() + if err := startDNSTTInstance(cfg, sshConf); err != nil { + return err + } + startDNSTTAutoRestart(cfg, sshConf) + return nil +} + +func startDNSTTInstance(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error { if cfg == nil { return nil } @@ -342,6 +532,8 @@ func startDNSTT(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error { // Configure whether periodic DNSTT statistics should be emitted to stderr. // When DisableStatsLog is true, stats will be collected but log lines are suppressed. dnsttPrintStats = !cfg.DisableStatsLog + dnsttLogConnections.Store(cfg.LogConnections) + limits := dnsttLimitsFromConfig(cfg) // Initialise the log buffer once. Use a capacity of 100 lines (~few KB). if dnsttLogBuf == nil { dnsttLogBuf = newDNSTTLogBuffer(100) @@ -368,44 +560,248 @@ func startDNSTT(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error { dnsttLog.Print(msg.Error()) return msg } - // Parse the domain name. dns.ParseName accepts a domain with a trailing - // dot or without. Any error here will abort the dnstt server. - domain, err := dns.ParseName(cfg.Domain) - if err != nil { - msg := fmt.Errorf("invalid domain %q: %w", cfg.Domain, err) + // Parse the accepted DNS root domains. dns.ParseName accepts a domain with a + // trailing dot or without. Any invalid value aborts the dnstt server so a bad + // local/test domain does not silently create a broken listener. + domainStrings := normalizeDNSTTDomainList(cfg.Domain, cfg.Domains) + if cfg.FakeDNSEnabled { + cfg.FakeDNSListen = strings.TrimSpace(cfg.FakeDNSListen) + if cfg.FakeDNSListen == "" { + cfg.FakeDNSListen = "[::]:53" + } + cfg.FakeDNSDomain = strings.TrimSpace(cfg.FakeDNSDomain) + if cfg.FakeDNSDomain == "" { + cfg.FakeDNSDomain = "t.local.lan" + } + // Include the local/fake zone in the normal domain list too, so the same + // server accepts that test domain from either listener if needed. + domainStrings = normalizeDNSTTDomainList(cfg.Domain, append(cfg.Domains, cfg.FakeDNSDomain)) + } + if len(domainStrings) == 0 { + msg := fmt.Errorf("dnstt: at least one domain is required") dnsttLog.Print(msg.Error()) return msg } + cfg.Domain = domainStrings[0] + cfg.Domains = domainStrings + domains, err := parseDNSTTDomains(domainStrings) + if err != nil { + dnsttLog.Print(err.Error()) + return err + } + udpListen := cfg.UDPListen if udpListen == "" { udpListen = defaultDNSTTListen cfg.UDPListen = udpListen } + fakeDomains := domains + if cfg.FakeDNSEnabled { + localDomains := normalizeDNSTTDomainList(cfg.FakeDNSDomain, nil) + if len(localDomains) == 0 { + localDomains = []string{"t.local.lan"} + cfg.FakeDNSDomain = localDomains[0] + } + fakeDomains, err = parseDNSTTDomains(localDomains) + if err != nil { + dnsttLog.Print(err.Error()) + return err + } + } + // Bind synchronously so the admin panel can immediately know whether DNSTT // really started or failed because of a bad address/locked port. - dnsConn, err := net.ListenPacket("udp", udpListen) + listeners := []dnsttListenerSpec{} + mainConn, err := listenDNSTTPacket(udpListen) if err != nil { msg := fmt.Errorf("dnstt: opening UDP listener on %s: %w", udpListen, err) dnsttLog.Print(msg.Error()) return msg } + listeners = append(listeners, dnsttListenerSpec{Label: "dnstt", Listen: udpListen, Conn: mainConn, Domains: domains}) + if cfg.FakeDNSEnabled { + if sameUDPListenAddress(cfg.FakeDNSListen, udpListen) { + dnsttLog.Printf("built-in local DNS listener shares main DNSTT address %q; using main listener", udpListen) + } else { + fakeConn, err := listenDNSTTPacket(cfg.FakeDNSListen) + if err != nil { + _ = closeDNSTTListenerSpecs(listeners) + msg := fmt.Errorf("dnstt: opening built-in local DNS listener on %s: %w", cfg.FakeDNSListen, err) + dnsttLog.Print(msg.Error()) + return msg + } + listeners = append(listeners, dnsttListenerSpec{Label: "local-dns", Listen: cfg.FakeDNSListen, Conn: fakeConn, Domains: fakeDomains}) + } + } // Log initialisation parameters so DNSTT startup is visible even when // quiet logging is enabled. This helps with debugging. - dnsttLog.Printf("starting: domain=%q udp_listen=%q privkey=%q", cfg.Domain, udpListen, cfg.PrivKeyFile) + dnsttLog.Printf("starting: domains=%q udp_listen=%q fake_dns_enabled=%v fake_dns_listen=%q fake_dns_domain=%q privkey=%q max_sessions=%d max_streams=%d pending_responses=%d stream_buffer=%d udp_rbuf=%d udp_wbuf=%d fake_dns_workers=%d dns_response_workers=%d log_connections=%v", domainStrings, udpListen, cfg.FakeDNSEnabled, cfg.FakeDNSListen, cfg.FakeDNSDomain, cfg.PrivKeyFile, limits.MaxSessions, limits.MaxStreams, limits.PendingResponses, limits.StreamBuffer, limits.UDPReadBuffer, limits.UDPWriteBuffer, limits.FakeDNSWorkers, limits.DNSResponseWorkers, dnsttLogConnections.Load()) go func() { - if err := runDNSTTOnConn(privkey, domain, udpListen, dnsConn); err != nil && !errors.Is(err, net.ErrClosed) { + defer dnsttRecover("runDNSTTOnListeners") + if err := runDNSTTOnListeners(privkey, listeners, limits); err != nil && !errors.Is(err, net.ErrClosed) { dnsttLog.Printf("server exited with error: %v", err) } }() return nil } +func parseDNSTTDomains(domainStrings []string) ([]dns.Name, error) { + domains := make([]dns.Name, 0, len(domainStrings)) + for _, raw := range domainStrings { + domain, err := dns.ParseName(raw) + if err != nil { + return nil, fmt.Errorf("invalid DNSTT domain %q: %w", raw, err) + } + domains = append(domains, domain) + } + // Match the most specific suffix first. This prevents a broad root like + // example.com from stealing queries that should be handled by t.example.com. + sort.SliceStable(domains, func(i, j int) bool { + return len(domains[i]) > len(domains[j]) + }) + return domains, nil +} + +// dnsttListenerSpec describes one UDP DNS socket feeding the same DNSTT/KCP +// session pool. The normal listener may accept all configured domains, while +// the built-in local/fake DNS listener can be restricted to one test zone such +// as t.local.lan. +type dnsttListenerSpec struct { + Label string + Listen string + Conn net.PacketConn + Domains []dns.Name +} + +func closeDNSTTListenerSpecs(listeners []dnsttListenerSpec) error { + var first error + for _, l := range listeners { + if l.Conn == nil { + continue + } + if err := l.Conn.Close(); err != nil && first == nil { + first = err + } + } + return first +} + +func listenDNSTTPacket(addr string) (net.PacketConn, error) { + network := "udp" + if host, _, err := net.SplitHostPort(addr); err == nil { + host = strings.Trim(host, "[]") + if ip := net.ParseIP(host); ip != nil { + if ip.To4() == nil { + // Important for the user's IPv6-only test case: udp6 avoids trying to + // reserve IPv4 port 53 when another DNS server already owns IPv4. + network = "udp6" + } else { + network = "udp4" + } + } + } + return net.ListenPacket(network, addr) +} + +func sameUDPListenAddress(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if a == "" || b == "" { + return false + } + return strings.EqualFold(a, b) +} + +func startDNSTTAutoRestart(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) { + interval := dnsttAutoRestartInterval(cfg) + if interval <= 0 { + return + } + grace := dnsttAutoRestartGrace(cfg) + cfgCopy := *cfg + ctx, cancel := context.WithCancel(context.Background()) + dnsttAutoMu.Lock() + if dnsttAutoCancel != nil { + dnsttAutoCancel() + } + dnsttAutoCancel = cancel + dnsttAutoMu.Unlock() + + go func() { + t := time.NewTicker(interval) + defer t.Stop() + dnsttLog.Printf("auto restart enabled: interval=%s grace=%s", interval, grace) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + dnsttLog.Printf("auto restart: cycling DNSTT listener") + stopDNSTTListener() + select { + case <-ctx.Done(): + return + case <-time.After(grace): + } + for attempt := 1; ; attempt++ { + if err := startDNSTTInstance(&cfgCopy, sshConf); err != nil { + dnsttLog.Printf("auto restart: start attempt %d failed: %v", attempt, err) + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Second): + } + continue + } + dnsttLog.Printf("auto restart: DNSTT listener restarted") + break + } + } + } + }() +} + +func dnsttAutoRestartInterval(cfg *DNSTTConfig) 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 { + dnsttLog.Printf("auto restart disabled: invalid interval %q: %v", raw, err) + return 0 + } + if d < time.Minute { + dnsttLog.Printf("auto restart disabled: interval %q is below minimum 1m", raw) + return 0 + } + return d +} + +func dnsttAutoRestartGrace(cfg *DNSTTConfig) 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 { + dnsttLog.Printf("auto restart: invalid grace %q, using 2s", cfg.AutoRestartGrace) + return 2 * time.Second + } + if d > time.Minute { + return time.Minute + } + return d +} + func dnsttRunning() bool { dnsttConnMu.Lock() defer dnsttConnMu.Unlock() - return dnsttConn != nil + return len(dnsttConns) > 0 } // handleDNSTTStream accepts a smux.Stream from a client and hands it off to @@ -417,13 +813,17 @@ func handleDNSTTStream(stream *smux.Stream, conv uint32) error { // Assign a per-stream sequence number to help correlate open/close events. sid := atomic.AddUint64(&dnsttStats.StreamSeq, 1) start := time.Now() - dnsttLog.Printf("ssh stream begin: conv=%d sid=%d", conv, sid) + if dnsttLogConnections.Load() { + dnsttLog.Printf("ssh stream begin: conv=%d sid=%d", conv, sid) + } sc := &streamConn{Stream: stream} // Delegate to the existing SSH connection handler. This call blocks // until the SSH connection terminates. The smux stream will be closed // by handleConn when it returns. handleConn(sc, dnsttSSHConfig) - dnsttLog.Printf("ssh stream end: conv=%d sid=%d duration=%s", conv, sid, time.Since(start)) + if dnsttLogConnections.Load() { + dnsttLog.Printf("ssh stream end: conv=%d sid=%d duration=%s", conv, sid, time.Since(start)) + } return nil } @@ -453,7 +853,7 @@ func (d dummyAddr) String() string { return "dnstt" } // acceptDNSTTStreams wraps a KCP session in a Noise channel and an smux // Session, then waits for smux streams. Each stream is passed to // handleDNSTTStream. Any errors from the Noise or smux layers are returned. -func acceptDNSTTStreams(conn *kcp.UDPSession, privkey []byte) error { +func acceptDNSTTStreams(conn *kcp.UDPSession, privkey []byte, limits dnsttRuntimeLimits) error { // Put a Noise channel on top of the KCP conn. rw, err := noise.NewServer(conn, privkey) if err != nil { @@ -464,7 +864,9 @@ func acceptDNSTTStreams(conn *kcp.UDPSession, privkey []byte) error { smuxConfig := smux.DefaultConfig() smuxConfig.Version = 2 smuxConfig.KeepAliveTimeout = idleTimeout - smuxConfig.MaxStreamBuffer = 1 * 1024 * 1024 + if limits.StreamBuffer > 0 { + smuxConfig.MaxStreamBuffer = limits.StreamBuffer + } sess, err := smux.Server(rw, smuxConfig) if err != nil { return err @@ -479,16 +881,27 @@ func acceptDNSTTStreams(conn *kcp.UDPSession, privkey []byte) error { } return err } - // Log the creation of each new smux stream. Reporting the conv helps - // to correlate streams with their parent KCP session. + if limits.MaxStreams > 0 && atomic.LoadInt64(&dnsttActiveStreams) >= int64(limits.MaxStreams) { + rej := atomic.AddUint64(&dnsttStats.StreamRejected, 1) + dnsttLogDropEvery(rej, "rejecting DNSTT stream: active_streams=%d max_streams=%d rejected=%d", atomic.LoadInt64(&dnsttActiveStreams), limits.MaxStreams, rej) + _ = stream.Close() + continue + } + atomic.AddInt64(&dnsttActiveStreams, 1) atomic.AddUint64(&dnsttStats.SmuxStreamsNew, 1) - dnsttLog.Printf("new smux stream: conv=%d", conn.GetConv()) + if dnsttLogConnections.Load() { + dnsttLog.Printf("new smux stream: conv=%d", conn.GetConv()) + } // For each new smux stream, hand it off to our SSH handler. go func(s *smux.Stream, conv uint32) { + defer dnsttRecover("smux stream") + defer atomic.AddInt64(&dnsttActiveStreams, -1) + defer atomic.AddUint64(&dnsttStats.SmuxStreamsEnd, 1) defer s.Close() _ = handleDNSTTStream(s, conv) - atomic.AddUint64(&dnsttStats.SmuxStreamsEnd, 1) - dnsttLog.Printf("smux stream closed: conv=%d", conv) + if dnsttLogConnections.Load() { + dnsttLog.Printf("smux stream closed: conv=%d", conv) + } }(stream, conn.GetConv()) } } @@ -496,7 +909,7 @@ func acceptDNSTTStreams(conn *kcp.UDPSession, privkey []byte) error { // acceptDNSTTSessions listens for incoming KCP connections and passes them to // acceptDNSTTStreams. It configures window sizes and MTU on each accepted // session as in the original dnstt-server. -func acceptDNSTTSessions(ln *kcp.Listener, privkey []byte, mtu int) error { +func acceptDNSTTSessions(ln *kcp.Listener, privkey []byte, mtu int, limits dnsttRuntimeLimits) error { for { conn, err := ln.AcceptKCP() if err != nil { @@ -527,10 +940,17 @@ func acceptDNSTTSessions(ln *kcp.Listener, privkey []byte, mtu int) error { mtuSession = m } } - // Log each newly accepted KCP session. Include conversation ID, remote address - // (ClientID), and the chosen MTU. + if limits.MaxSessions > 0 && atomic.LoadInt64(&dnsttActiveSessions) >= int64(limits.MaxSessions) { + rej := atomic.AddUint64(&dnsttStats.SessRejected, 1) + dnsttLogDropEvery(rej, "rejecting DNSTT session: active_sessions=%d max_sessions=%d rejected=%d", atomic.LoadInt64(&dnsttActiveSessions), limits.MaxSessions, rej) + _ = conn.Close() + continue + } + atomic.AddInt64(&dnsttActiveSessions, 1) atomic.AddUint64(&dnsttStats.KCPSessionsNew, 1) - dnsttLog.Printf("new KCP session: conv=%d from=%s mtu=%d limit=%d", conn.GetConv(), from, mtuSession, effectiveLimit) + if dnsttLogConnections.Load() { + dnsttLog.Printf("new KCP session: conv=%d from=%s mtu=%d limit=%d", conn.GetConv(), from, mtuSession, effectiveLimit) + } // Permit coalescing the payloads of consecutive sends. conn.SetStreamMode(true) // Disable the dynamic congestion window (limit only by the maximum of @@ -538,16 +958,23 @@ func acceptDNSTTSessions(ln *kcp.Listener, privkey []byte, mtu int) error { conn.SetNoDelay(0, 0, 0, 1) conn.SetWindowSize(turbotunnel.QueueSize/2, turbotunnel.QueueSize/2) if rc := conn.SetMtu(mtuSession); !rc { - panic(rc) + dnsttLog.Printf("rejecting DNSTT session: invalid mtu=%d conv=%d from=%s", mtuSession, conn.GetConv(), from) + atomic.AddInt64(&dnsttActiveSessions, -1) + _ = conn.Close() + continue } go func(c *kcp.UDPSession, conv uint32, from string) { + defer dnsttRecover("kcp session") + defer atomic.AddInt64(&dnsttActiveSessions, -1) + defer atomic.AddUint64(&dnsttStats.KCPSessionsEnd, 1) defer c.Close() - err := acceptDNSTTStreams(c, privkey) - atomic.AddUint64(&dnsttStats.KCPSessionsEnd, 1) - if err != nil && err != io.ErrClosedPipe { - dnsttLog.Printf("kcp session closed: conv=%d from=%s err=%v", conv, from, err) - } else { - dnsttLog.Printf("kcp session closed: conv=%d from=%s", conv, from) + err := acceptDNSTTStreams(c, privkey, limits) + if dnsttLogConnections.Load() { + if err != nil && err != io.ErrClosedPipe { + dnsttLog.Printf("kcp session closed: conv=%d from=%s err=%v", conv, from, err) + } else { + dnsttLog.Printf("kcp session closed: conv=%d from=%s", conv, from) + } } }(conn, conn.GetConv(), conn.RemoteAddr().String()) } @@ -560,16 +987,134 @@ func acceptDNSTTSessions(ln *kcp.Listener, privkey []byte, mtu int) error { // before sending it. type record struct { Resp *dns.Message + Conn net.PacketConn Addr net.Addr + Listener string ClientID turbotunnel.ClientID // PayloadLimit holds the maximum UDP payload size advertised by the // client via EDNS(0). sendLoop uses this to clamp outgoing DNS // responses so they never exceed what the client claims it will - // accept. A zero value means no per‑client limit and defaults to + // accept. A zero value means no per-client limit and defaults to // maxUDPPayload. PayloadLimit int } +type recordSink interface { + Enqueue(*record) bool + Len() int + Cap() int + ShardCount() int + Shard(int) <-chan *record + Close() +} + +type shardedRecordSink struct { + chans []chan *record +} + +func newShardedRecordSink(totalCapacity, shards int) *shardedRecordSink { + if shards < 1 { + shards = 1 + } + if totalCapacity < shards { + totalCapacity = shards + } + perShard := totalCapacity / shards + if perShard < 1 { + perShard = 1 + } + extra := totalCapacity % shards + s := &shardedRecordSink{chans: make([]chan *record, shards)} + for i := range s.chans { + capForShard := perShard + if i < extra { + capForShard++ + } + s.chans[i] = make(chan *record, capForShard) + } + return s +} + +func (s *shardedRecordSink) Enqueue(rec *record) bool { + if s == nil || len(s.chans) == 0 { + return false + } + ch := s.chans[recordShardIndex(rec, len(s.chans))] + select { + case ch <- rec: + return true + default: + return false + } +} + +func (s *shardedRecordSink) Len() int { + if s == nil { + return 0 + } + n := 0 + for _, ch := range s.chans { + n += len(ch) + } + return n +} + +func (s *shardedRecordSink) Cap() int { + if s == nil { + return 0 + } + n := 0 + for _, ch := range s.chans { + n += cap(ch) + } + return n +} + +func (s *shardedRecordSink) ShardCount() int { + if s == nil { + return 0 + } + return len(s.chans) +} + +func (s *shardedRecordSink) Shard(i int) <-chan *record { + return s.chans[i] +} + +func (s *shardedRecordSink) Close() { + if s == nil { + return + } + for _, ch := range s.chans { + close(ch) + } +} + +func recordShardIndex(rec *record, shards int) int { + if shards <= 1 || rec == nil { + return 0 + } + var h uint32 = 2166136261 + allZeroClient := true + for _, b := range rec.ClientID { + if b != 0 { + allZeroClient = false + } + h ^= uint32(b) + h *= 16777619 + } + // Invalid/error responses often have an all-zero ClientID. Mix in the remote + // address so NXDOMAIN/FORMERR traffic is not forced onto one shard. + if allZeroClient && rec.Addr != nil { + addr := rec.Addr.String() + for i := 0; i < len(addr); i++ { + h ^= uint32(addr[i]) + h *= 16777619 + } + } + return int(h % uint32(shards)) +} + // nextPacket reads the next length‑prefixed packet from r, ignoring padding. // It returns a nil error only when a packet was read successfully. It // returns io.EOF only when there were 0 bytes remaining to read from r. It @@ -683,11 +1228,34 @@ func responseFor(query *dns.Message, domain dns.Name) (*dns.Message, []byte) { return resp, payload } +// responseForDomains is like responseFor, but it accepts a query under any of +// the configured DNSTT root domains. This lets one UDP listener serve public +// and local/test NS domains at the same time while using the same KCP/noise +// session pool and private key. +func responseForDomains(query *dns.Message, domains []dns.Name) (*dns.Message, []byte) { + for i, domain := range domains { + resp, payload := responseFor(query, domain) + if resp == nil { + return nil, nil + } + if resp.Rcode() != dns.RcodeNameError || len(payload) > 0 { + return resp, payload + } + // Try the next configured domain only when this one did not match. Other + // NAMEERROR conditions (bad QTYPE, bad base32 payload, etc.) are terminal + // after the final domain. + if i == len(domains)-1 { + return resp, payload + } + } + return responseFor(query, dns.Name([][]byte{})) +} + // recvLoop repeatedly calls dnsConn.ReadFrom, extracts the packets contained in // the incoming DNS queries, and puts them on ttConn's incoming queue. // Whenever a query calls for a response, constructs a partial response and // passes it to sendLoop over ch. -func recvLoop(domain dns.Name, dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch chan<- *record) error { +func recvLoop(label string, domains []dns.Name, dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, sink recordSink) error { for { var buf [4096]byte n, addr, err := dnsConn.ReadFrom(buf[:]) @@ -702,8 +1270,8 @@ func recvLoop(domain dns.Name, dnsConn net.PacketConn, ttConn *turbotunnel.Queue // Parse DNS query. query, err := dns.MessageFromWireFormat(buf[:n]) if err != nil { - atomic.AddUint64(&dnsttStats.DNSParseErr, 1) - dnsttLog.Printf("cannot parse DNS query: %v", err) + pe := atomic.AddUint64(&dnsttStats.DNSParseErr, 1) + dnsttLogDropEvery(pe, "cannot parse DNS query: %v", err) continue } // Determine an effective UDP payload limit for this query. @@ -758,7 +1326,7 @@ func recvLoop(domain dns.Name, dnsConn net.PacketConn, ttConn *turbotunnel.Queue if payloadLimit == 512 { atomic.AddUint64(&dnsttStats.SmallEDNS, 1) } - resp, payload := responseFor(&query, domain) + resp, payload := responseForDomains(&query, domains) // Extract ClientID var clientID turbotunnel.ClientID n = copy(clientID[:], payload) @@ -785,18 +1353,19 @@ func recvLoop(domain dns.Name, dnsConn net.PacketConn, ttConn *turbotunnel.Queue // Push this record along with the per‑client payload limit to the sender. rec := &record{ Resp: resp, + Conn: dnsConn, Addr: addr, + Listener: label, ClientID: clientID, PayloadLimit: payloadLimit, } - select { - case ch <- rec: + if sink.Enqueue(rec) { atomic.AddUint64(&dnsttStats.RecQueued, 1) - default: + } else { d := atomic.AddUint64(&dnsttStats.RecDropped, 1) // Log occasionally to avoid flooding logs under sustained overload. if d == 1 || d%1000 == 0 { - dnsttLog.Printf("dropping response record: ch_len=%d ch_cap=%d dropped=%d", len(ch), cap(ch), d) + dnsttLog.Printf("dropping response record: ch_len=%d ch_cap=%d dropped=%d", sink.Len(), sink.Cap(), d) } } } @@ -807,16 +1376,20 @@ func recvLoop(domain dns.Name, dnsConn net.PacketConn, ttConn *turbotunnel.Queue // error response are sent immediately. Those that represent a response // capable of carrying data are packed full of as many packets as will fit // while keeping the total size under maxEncodedPayload, then sent. -func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <-chan *record, maxEncodedPayload int) error { +func sendLoop(ttConn *turbotunnel.QueuePacketConn, ch <-chan *record, maxEncodedPayload int, done <-chan struct{}) error { var nextRec *record for { rec := nextRec nextRec = nil if rec == nil { var ok bool - rec, ok = <-ch - if !ok { - break + select { + case <-done: + return nil + case rec, ok = <-ch: + if !ok { + return nil + } } } // Determine the effective per-query UDP payload limit. @@ -877,6 +1450,9 @@ func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <- case p = <-outgoing: case <-timer.C: case nextRec = <-ch: + case <-done: + timer.Stop() + return nil } } } @@ -925,7 +1501,11 @@ func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <- // After promotion, allow this response to be sent. effectivePayloadLimit = promoteTo } - _, err = dnsConn.WriteTo(buf, rec.Addr) + if rec.Conn == nil { + dnsttLog.Printf("cannot send DNS response: missing listener for %s", rec.Listener) + continue + } + _, err = rec.Conn.WriteTo(buf, rec.Addr) if err != nil { if err, ok := err.(net.Error); ok && err.Temporary() { dnsttLog.Printf("WriteTo temporary error: %v", err) @@ -1012,35 +1592,67 @@ func computeMaxEncodedPayload(limit int) int { // MTU based on the configured maxUDPPayload, then accepts KCP sessions and // handles DNS queries. Errors are returned only for fatal conditions. func runDNSTT(privkey []byte, domain dns.Name, udpListen string) error { - dnsConn, err := net.ListenPacket("udp", udpListen) + dnsConn, err := listenDNSTTPacket(udpListen) if err != nil { return fmt.Errorf("dnstt: opening UDP listener on %s: %v", udpListen, err) } - return runDNSTTOnConn(privkey, domain, udpListen, dnsConn) + return runDNSTTOnListeners(privkey, []dnsttListenerSpec{{Label: "dnstt", Listen: udpListen, Conn: dnsConn, Domains: []dns.Name{domain}}}, dnsttLimitsFromConfig(nil)) } -func runDNSTTOnConn(privkey []byte, domain dns.Name, udpListen string, dnsConn net.PacketConn) error { - if udp, ok := dnsConn.(*net.UDPConn); ok { - _ = udp.SetReadBuffer(4 * 1024 * 1024) - _ = udp.SetWriteBuffer(4 * 1024 * 1024) +func runDNSTTOnListeners(privkey []byte, listeners []dnsttListenerSpec, limits dnsttRuntimeLimits) error { + if len(listeners) == 0 { + return fmt.Errorf("dnstt: no UDP listeners configured") + } + done := make(chan struct{}) + defer close(done) + defer closeDNSTTListenerSpecs(listeners) + + registered := make([]net.PacketConn, 0, len(listeners)) + for _, l := range listeners { + if l.Conn == nil { + continue + } + if udp, ok := l.Conn.(*net.UDPConn); ok { + if limits.UDPReadBuffer > 0 { + _ = udp.SetReadBuffer(limits.UDPReadBuffer) + } + if limits.UDPWriteBuffer > 0 { + _ = udp.SetWriteBuffer(limits.UDPWriteBuffer) + } + } + registered = append(registered, l.Conn) } - // Register so stopDNSTT() can close this socket and unblock the read loop. + // Register so stopDNSTT() can close every socket and unblock all read loops. dnsttConnMu.Lock() - if dnsttConn != nil && dnsttConn != dnsConn { - _ = dnsttConn.Close() + for _, c := range dnsttConns { + if c != nil { + _ = c.Close() + } } - dnsttConn = dnsConn + dnsttConns = registered dnsttConnMu.Unlock() defer func() { dnsttConnMu.Lock() - if dnsttConn == dnsConn { - dnsttConn = nil + // Only clear the global list if it still points at this generation. + if len(dnsttConns) == len(registered) { + same := true + for i := range registered { + if dnsttConns[i] != registered[i] { + same = false + break + } + } + if same { + dnsttConns = nil + } } dnsttConnMu.Unlock() }() - // Log readiness of the UDP listener. - dnsttLog.Printf("udp listener ready on %s", udpListen) + + for _, l := range listeners { + dnsttLog.Printf("udp listener ready on %s (%s, domains=%q)", l.Listen, l.Label, dnsNamesForLog(l.Domains)) + } // compute maximum encoded payload and resulting MTU maxEncodedPayload := computeMaxEncodedPayload(maxUDPPayload) mtu := maxEncodedPayload - 2 @@ -1056,16 +1668,22 @@ func runDNSTTOnConn(privkey []byte, domain dns.Name, udpListen string, dnsConn n if err != nil { return fmt.Errorf("dnstt: opening KCP listener: %v", err) } + defer ln.Close() go func() { - if err := acceptDNSTTSessions(ln, privkey, mtu); err != nil { + defer dnsttRecover("acceptDNSTTSessions") + if err := acceptDNSTTSessions(ln, privkey, mtu, limits); err != nil { dnsttLog.Printf("acceptSessions error: %v", err) } }() // NOTE: This channel buffers pending DNS response records. Keeping this // extremely large can look like a "memory leak" under bursty load because // records (and their associated allocations) are retained until drained. - // A moderate size provides smoothing while still applying backpressure. - ch := make(chan *record, 20000) + // Keep it bounded so overload drops DNS responses instead of exhausting RAM. + pendingResponses := limits.PendingResponses + if pendingResponses <= 0 { + pendingResponses = defaultDNSTTPendingResponses + } + sink := newShardedRecordSink(pendingResponses, limits.DNSResponseWorkers) // Periodically aggregate DNSTT counters. This goroutine runs every 5 seconds, // resetting the atomic counters, storing them in lastDnsttStats and optionally // emitting a log line. Even when dnsttPrintStats is false, statistics will @@ -1073,7 +1691,13 @@ func runDNSTTOnConn(privkey []byte, domain dns.Name, udpListen string, dnsConn n go func() { t := time.NewTicker(5 * time.Second) defer t.Stop() - for range t.C { + for { + select { + case <-done: + return + case <-t.C: + } + dnsRx := atomic.SwapUint64(&dnsttStats.DNSRx, 0) parseErr := atomic.SwapUint64(&dnsttStats.DNSParseErr, 0) noEDNS := atomic.SwapUint64(&dnsttStats.NoEDNS, 0) @@ -1089,43 +1713,102 @@ func runDNSTTOnConn(privkey []byte, domain dns.Name, udpListen string, dnsConn n kcpEnd := atomic.SwapUint64(&dnsttStats.KCPSessionsEnd, 0) smuxNew := atomic.SwapUint64(&dnsttStats.SmuxStreamsNew, 0) smuxEnd := atomic.SwapUint64(&dnsttStats.SmuxStreamsEnd, 0) + sessRejected := atomic.SwapUint64(&dnsttStats.SessRejected, 0) + streamRejected := atomic.SwapUint64(&dnsttStats.StreamRejected, 0) + panicRecovered := atomic.SwapUint64(&dnsttStats.PanicRecovered, 0) // Update the snapshot dnsttStatsMu.Lock() lastDnsttStats = DnsttStatsSnapshot{ - Timestamp: time.Now(), - DNSRx: dnsRx, - ParseErr: parseErr, - NoEDNS: noEDNS, - Limit512: limit512, - RecQueued: queued, - RecDropped: dropped, - RespSent: respSent, - RespBytes: respBytes, - RespEmpty: respEmpty, - RespData: respData, - RespOversize: over, - KCPNew: kcpNew, - KCPEnd: kcpEnd, - SmuxNew: smuxNew, - SmuxEnd: smuxEnd, - ChLen: len(ch), + Timestamp: time.Now(), + Running: dnsttRunning(), + DNSRx: dnsRx, + ParseErr: parseErr, + NoEDNS: noEDNS, + Limit512: limit512, + RecQueued: queued, + RecDropped: dropped, + RespSent: respSent, + RespBytes: respBytes, + RespEmpty: respEmpty, + RespData: respData, + RespOversize: over, + KCPNew: kcpNew, + KCPEnd: kcpEnd, + SmuxNew: smuxNew, + SmuxEnd: smuxEnd, + SessRejected: sessRejected, + StreamRejected: streamRejected, + PanicRecovered: panicRecovered, + ActiveSessions: atomic.LoadInt64(&dnsttActiveSessions), + ActiveStreams: atomic.LoadInt64(&dnsttActiveStreams), + ChLen: sink.Len(), + FakeDNSWorkers: limits.FakeDNSWorkers, + DNSResponseWorkers: limits.DNSResponseWorkers, } dnsttStatsMu.Unlock() // Optionally log the snapshot to stderr if dnsttPrintStats { dnsttLog.Printf( - "stats 5s: dns_rx=%d parse_err=%d no_edns=%d limit512=%d rec_queued=%d rec_dropped=%d resp_sent=%d resp_bytes=%d resp_empty=%d resp_data=%d resp_oversize=%d kcp_new=%d kcp_end=%d smux_new=%d smux_end=%d ch_len=%d", - dnsRx, parseErr, noEDNS, limit512, queued, dropped, respSent, respBytes, respEmpty, respData, over, kcpNew, kcpEnd, smuxNew, smuxEnd, len(ch), + "stats 5s: dns_rx=%d parse_err=%d no_edns=%d limit512=%d rec_queued=%d rec_dropped=%d resp_sent=%d resp_bytes=%d resp_empty=%d resp_data=%d resp_oversize=%d kcp_new=%d kcp_end=%d smux_new=%d smux_end=%d sess_rejected=%d stream_rejected=%d panic_recovered=%d active_sessions=%d active_streams=%d ch_len=%d fake_dns_workers=%d response_workers=%d", + dnsRx, parseErr, noEDNS, limit512, queued, dropped, respSent, respBytes, respEmpty, respData, over, kcpNew, kcpEnd, smuxNew, smuxEnd, sessRejected, streamRejected, panicRecovered, atomic.LoadInt64(&dnsttActiveSessions), atomic.LoadInt64(&dnsttActiveStreams), sink.Len(), limits.FakeDNSWorkers, limits.DNSResponseWorkers, ) } } }() - go func() { - if err := sendLoop(dnsConn, ttConn, ch, maxEncodedPayload); err != nil { - dnsttLog.Printf("sendLoop error: %v", err) + for i := 0; i < sink.ShardCount(); i++ { + shard := i + go func() { + defer dnsttRecover(fmt.Sprintf("sendLoop-%d", shard+1)) + if err := sendLoop(ttConn, sink.Shard(shard), maxEncodedPayload, done); err != nil { + dnsttLog.Printf("sendLoop-%d error: %v", shard+1, err) + closeDNSTTListenerSpecs(listeners) + } + }() + } + + readWorkers := 0 + for _, l := range listeners { + if l.Label == "local-dns" && limits.FakeDNSWorkers > 1 { + readWorkers += limits.FakeDNSWorkers + } else { + readWorkers++ } - }() - return recvLoop(domain, dnsConn, ttConn, ch) + } + errCh := make(chan error, readWorkers) + for _, l := range listeners { + listener := l + workers := 1 + if listener.Label == "local-dns" && limits.FakeDNSWorkers > 1 { + workers = limits.FakeDNSWorkers + } + for worker := 0; worker < workers; worker++ { + workerLabel := listener.Label + if workers > 1 { + workerLabel = fmt.Sprintf("%s-%d", listener.Label, worker+1) + } + domains := listener.Domains + conn := listener.Conn + go func(label string, domains []dns.Name, conn net.PacketConn) { + defer dnsttRecover("recvLoop " + label) + errCh <- recvLoop(label, domains, conn, ttConn, sink) + }(workerLabel, domains, conn) + } + } + err = <-errCh + closeDNSTTListenerSpecs(listeners) + return err +} + +func dnsNamesForLog(domains []dns.Name) []string { + out := make([]string, 0, len(domains)) + for _, d := range domains { + parts := make([]string, 0, len(d)) + for _, label := range d { + parts = append(parts, string(label)) + } + out = append(out, strings.Join(parts, ".")) + } + return out } // ---- Key management API handlers ---- diff --git a/hotreload.go b/hotreload.go index 97d730c..12e94be 100644 --- a/hotreload.go +++ b/hotreload.go @@ -98,6 +98,26 @@ func (p *listenerPool) Has(addr string) bool { return ok } +// StopAll closes every listener in the pool. Active SSH sessions are not owned +// by this pool; callers that want a hard restart should also close tracked SSH +// server connections through userMgr.DisconnectAll(). +func (p *listenerPool) StopAll(reason string) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + for addr, ln := range p.entries { + _ = ln.Close() + delete(p.entries, addr) + if reason != "" { + log.Printf("hotreload: stopped %s (%s)", addr, reason) + } else { + log.Printf("hotreload: stopped %s", addr) + } + } +} + func (p *listenerPool) HasAll(addrs []string) bool { if p == nil { return false @@ -179,6 +199,23 @@ func (p *tlsListenerPool) Has(addr string) bool { return ok } +func (p *tlsListenerPool) StopAll(reason string) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + for addr, ln := range p.entries { + _ = ln.Close() + delete(p.entries, addr) + if reason != "" { + log.Printf("hotreload: stopped TLS %s (%s)", addr, reason) + } else { + log.Printf("hotreload: stopped TLS %s", addr) + } + } +} + func (p *tlsListenerPool) HasAll(forwarders []TLSForwarderConfig) bool { if p == nil { return false @@ -283,6 +320,7 @@ func joinAddrs(addrs []string) string { func applyFullConfigReload(newCfg *Config) ConfigReloadReport { report := newReloadReport() + stopProxyAutoRestart() // Banner bt := newCfg.Banner if bt == "" && newCfg.BannerFile != "" { @@ -372,9 +410,13 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport { // Xray — update stored config then restart/stop as needed. if newCfg.Xray != nil { + newCfg.Xray.NormalizeDefaults() xrayMgr.mu.Lock() xrayMgr.cfg = newCfg.Xray xrayMgr.mu.Unlock() + if !newCfg.Xray.UseNative() { + xrayMgr.startStatsPoller() + } if newCfg.Xray.Enabled { if err := xrayMgr.Restart(); err != nil { report.warnf("Xray failed to restart: %v", err) @@ -395,6 +437,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport { } setGlobalCfg(newCfg) + startProxyAutoRestart(newCfg) return report } diff --git a/install.sh b/install.sh index 330158e..47956e0 100644 --- a/install.sh +++ b/install.sh @@ -455,8 +455,11 @@ cat > "$INSTALL_DIR/config.json" </dev/null \ || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null \ || echo "11111111-2222-3333-4444-555555555555") -# xray_config.json (default VLESS + SOCKS inbounds — no geoip routing needed) -cat > "$INSTALL_DIR/xray_config.json" < "$INSTALL_DIR/xray_native_config.json" < "$INSTALL_DIR/xray_config.json" < 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 + } +} diff --git a/server_config_api.go b/server_config_api.go index 39e4667..15c3de8 100644 --- a/server_config_api.go +++ b/server_config_api.go @@ -92,6 +92,9 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) { http.Error(w, "listen address required", http.StatusBadRequest) return } + if newCfg.Xray != nil { + newCfg.Xray.NormalizeDefaults() + } // Preserve file-based users array (not editable through the UI). globalCfgMu.RLock() diff --git a/udpgw_integration.go b/udpgw_integration.go index a6541c9..f5e0778 100644 --- a/udpgw_integration.go +++ b/udpgw_integration.go @@ -20,24 +20,65 @@ import ( "io" "log" "net" + "strings" "sync" "time" ) var ( - udpgwMu sync.Mutex - udpgwLn net.Listener + udpgwMu sync.Mutex + udpgwLn net.Listener + udpgwClients = make(map[net.Conn]struct{}) + + udpgwAutoMu sync.Mutex + udpgwAutoCancel context.CancelFunc ) -// stopUDPGW closes the active UDPGW listener, causing the accept loop to exit. -// It is a no-op if UDPGW is not running. +// stopUDPGW closes the active UDPGW listener, all active UDPGW client TCP +// sockets, and the optional auto-restart watchdog. It is a no-op if UDPGW is +// not running. func stopUDPGW() { + stopUDPGWAutoRestart() + stopUDPGWInstance() +} + +func stopUDPGWInstance() { udpgwMu.Lock() defer udpgwMu.Unlock() if udpgwLn != nil { _ = udpgwLn.Close() udpgwLn = nil } + for conn := range udpgwClients { + _ = conn.Close() + delete(udpgwClients, conn) + } +} + +func stopUDPGWAutoRestart() { + udpgwAutoMu.Lock() + defer udpgwAutoMu.Unlock() + if udpgwAutoCancel != nil { + udpgwAutoCancel() + udpgwAutoCancel = nil + } +} + +func registerUDPGWClient(conn net.Conn) bool { + udpgwMu.Lock() + defer udpgwMu.Unlock() + if udpgwLn == nil { + _ = conn.Close() + return false + } + udpgwClients[conn] = struct{}{} + return true +} + +func unregisterUDPGWClient(conn net.Conn) { + udpgwMu.Lock() + delete(udpgwClients, conn) + udpgwMu.Unlock() } func udpgwRunning() bool { @@ -53,6 +94,18 @@ func udpgwRunning() bool { // prevent the gateway from starting, but do not terminate the main // process. func startUDPGW(cfg *UDPGWConfig) error { + if cfg == nil { + return nil + } + stopUDPGWAutoRestart() + if err := startUDPGWInstance(cfg); err != nil { + return err + } + startUDPGWAutoRestart(cfg) + return nil +} + +func startUDPGWInstance(cfg *UDPGWConfig) error { if cfg == nil { return nil } @@ -166,12 +219,99 @@ func startUDPGW(cfg *UDPGWConfig) error { log.Printf("udpgw: accept error: %v", err) continue } - go handleUDPGWClient(conn, c) + if !registerUDPGWClient(conn) { + continue + } + go func(client net.Conn) { + defer unregisterUDPGWClient(client) + handleUDPGWClient(client, c) + }(conn) } }() return nil } +func startUDPGWAutoRestart(cfg *UDPGWConfig) { + interval := udpgwAutoRestartInterval(cfg) + if interval <= 0 { + return + } + grace := udpgwAutoRestartGrace(cfg) + cfgCopy := *cfg + ctx, cancel := context.WithCancel(context.Background()) + + udpgwAutoMu.Lock() + if udpgwAutoCancel != nil { + udpgwAutoCancel() + } + udpgwAutoCancel = cancel + udpgwAutoMu.Unlock() + + go func() { + t := time.NewTicker(interval) + defer t.Stop() + log.Printf("udpgw: auto restart enabled: interval=%s grace=%s mode=hard", interval, grace) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + log.Printf("udpgw: auto restart: stopping listener and connected clients") + stopUDPGWInstance() + if !sleepOrContextDone(ctx, grace) { + return + } + for attempt := 1; ; attempt++ { + if err := startUDPGWInstance(&cfgCopy); err != nil { + log.Printf("udpgw: auto restart: start attempt %d failed: %v", attempt, err) + if !sleepOrContextDone(ctx, 10*time.Second) { + return + } + continue + } + log.Printf("udpgw: auto restart: listener and client handler restarted") + break + } + } + } + }() +} + +func udpgwAutoRestartInterval(cfg *UDPGWConfig) time.Duration { + if cfg == nil { + return 0 + } + raw := strings.TrimSpace(cfg.AutoRestartInterval) + if raw == "" || raw == "0" || raw == "0s" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") { + return 0 + } + d, err := time.ParseDuration(raw) + if err != nil { + log.Printf("udpgw: auto restart disabled: invalid interval %q: %v", raw, err) + return 0 + } + if d < time.Minute { + log.Printf("udpgw: auto restart disabled: interval %q is below minimum 1m", raw) + return 0 + } + return d +} + +func udpgwAutoRestartGrace(cfg *UDPGWConfig) time.Duration { + if cfg == nil || strings.TrimSpace(cfg.AutoRestartGrace) == "" { + return 2 * time.Second + } + d, err := time.ParseDuration(strings.TrimSpace(cfg.AutoRestartGrace)) + if err != nil || d < 0 { + log.Printf("udpgw: auto restart: invalid grace %q, using 2s", cfg.AutoRestartGrace) + return 2 * time.Second + } + if d > time.Minute { + return time.Minute + } + return d +} + // internalUDPGWConfig mirrors the exported UDPGWConfig but with // time.Duration fields for TTL and reaper intervals. It does not // embed JSON tags because it is not exposed to the user. diff --git a/update.sh b/update.sh index 2dc2413..e3fded3 100644 --- a/update.sh +++ b/update.sh @@ -356,6 +356,13 @@ patch_configs() { cfg="$INSTALL_DIR/config.json" xcfg="$INSTALL_DIR/xray_config.json" + native_xcfg="$INSTALL_DIR/xray_native_config.json" + if [[ ! -f "$native_xcfg" && -f "$xcfg" ]]; then + cp -f "$xcfg" "$native_xcfg" + chmod 600 "$native_xcfg" || true + info " Created independent native Xray config: $native_xcfg" + fi + if [[ -f "$cfg" ]]; then python3 - "$cfg" <<'PYEOF' import json, sys @@ -373,6 +380,22 @@ if 'banner_file' not in d: if 'local_ssh_listen' in d: d.pop('local_ssh_listen', None) changed = True +x = d.get('xray') +if isinstance(x, dict): + mode = str(x.get('mode') or '').strip().lower() + if mode not in ('native', 'external'): + x['mode'] = 'native' + x['native'] = True + changed = True + elif mode == 'native' and x.get('native') is not True: + x['native'] = True + changed = True + elif mode == 'external' and x.get('native') is not False: + x['native'] = False + changed = True + x.setdefault('bin_path', '/opt/sshpanel/xray') + x.setdefault('config_file', '/opt/sshpanel/xray_config.json') + x.setdefault('native_config_file', '/opt/sshpanel/xray_native_config.json') if changed: with open(path, 'w') as f: json.dump(d, f, indent=2) diff --git a/xray_clients.go b/xray_clients.go index 3cae163..d5a436b 100644 --- a/xray_clients.go +++ b/xray_clients.go @@ -11,29 +11,41 @@ import ( // Xray's own config only stores uuid/email/level; expiry, display name, // reseller owner, and connection policy live here. type XrayClientMeta struct { - UUID string - Name string - Email string - InboundTag string - OwnerUsername string - ExpiresAt *time.Time - MaxConns int - CreatedAt time.Time + UUID string + Name string + Email string + InboundTag string + OwnerUsername string + ExpiresAt *time.Time + MaxConns int + CreatedAt time.Time + TotalUplinkBytes int64 + TotalDownlinkBytes int64 + LastActive *time.Time + ActiveConnections int } func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS xray_clients ( - uuid TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - email TEXT NOT NULL DEFAULT '', - inbound_tag TEXT NOT NULL DEFAULT '', - owner_username TEXT NOT NULL DEFAULT '', - expires_at TIMESTAMPTZ, - max_conns INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + inbound_tag TEXT NOT NULL DEFAULT '', + owner_username TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ, + max_conns INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + total_uplink_bytes BIGINT NOT NULL DEFAULT 0, + total_downlink_bytes BIGINT NOT NULL DEFAULT 0, + last_active TIMESTAMPTZ, + active_connections INT NOT NULL DEFAULT 0 )`, `ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_uplink_bytes BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS last_active TIMESTAMPTZ`, + `ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS active_connections INT NOT NULL DEFAULT 0`, } for _, stmt := range stmts { if _, err := s.db.ExecContext(ctx, stmt); err != nil { @@ -65,16 +77,21 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClientMeta, error) { m := &XrayClientMeta{} var expiresAt sql.NullTime + var lastActive sql.NullTime err := s.db.QueryRowContext(ctx, ` - SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, + COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) FROM xray_clients WHERE uuid = $1`, uuid). - Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt) + Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections) if err != nil { return nil, err } if expiresAt.Valid { m.ExpiresAt = &expiresAt.Time } + if lastActive.Valid { + m.LastActive = &lastActive.Time + } return m, nil } @@ -85,7 +102,8 @@ func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error { func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, + COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) FROM xray_clients ORDER BY created_at DESC`) if err != nil { return nil, err @@ -96,7 +114,8 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, + COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername) if err != nil { return nil, err @@ -113,7 +132,8 @@ func (s *Store) CountXrayClientsByOwner(ctx context.Context, ownerUsername strin func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at + SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, + COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`) if err != nil { return nil, err @@ -127,17 +147,78 @@ func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) { for rows.Next() { m := &XrayClientMeta{} var expiresAt sql.NullTime - if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil { + var lastActive sql.NullTime + if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections); err != nil { return nil, err } if expiresAt.Valid { m.ExpiresAt = &expiresAt.Time } + if lastActive.Valid { + m.LastActive = &lastActive.Time + } out = append(out, m) } return out, rows.Err() } +// ResetXrayActiveConnections clears stale online counters after the panel starts. +// Native mode then increments/decrements active_connections for real live streams. +func (s *Store) ResetXrayActiveConnections(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `UPDATE xray_clients SET active_connections = 0`) + return err +} + +// AddXrayClientTrafficBatch persists native-emulator traffic deltas. It keeps +// totals in PostgreSQL so bandwidth remains visible after panel restarts. +func (s *Store) AddXrayClientTrafficBatch(ctx context.Context, deltas map[string]xrayPendingTraffic) error { + if len(deltas) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + stmt, err := tx.PrepareContext(ctx, ` + UPDATE xray_clients SET + email = CASE WHEN email = '' AND $2 <> '' THEN $2 ELSE email END, + name = CASE WHEN name = '' AND $2 <> '' THEN $2 ELSE name END, + total_uplink_bytes = GREATEST(total_uplink_bytes + GREATEST($3::BIGINT, 0), 0), + total_downlink_bytes = GREATEST(total_downlink_bytes + GREATEST($4::BIGINT, 0), 0), + last_active = NOW() + WHERE uuid = $1`) + if err != nil { + _ = tx.Rollback() + return err + } + defer stmt.Close() + for uuid, d := range deltas { + if uuid == "" || (d.Uplink == 0 && d.Downlink == 0) { + continue + } + if _, err := stmt.ExecContext(ctx, uuid, d.Email, d.Uplink, d.Downlink); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() +} + +// UpdateXrayClientActive adjusts the native online connection counter. +func (s *Store) UpdateXrayClientActive(ctx context.Context, uuid, email string, delta int) error { + if uuid == "" || delta == 0 { + return nil + } + _, err := s.db.ExecContext(ctx, ` + UPDATE xray_clients SET + email = CASE WHEN email = '' AND $2 <> '' THEN $2 ELSE email END, + name = CASE WHEN name = '' AND $2 <> '' THEN $2 ELSE name END, + last_active = CASE WHEN $3::INT > 0 THEN NOW() ELSE last_active END, + active_connections = GREATEST(active_connections + $3::INT, 0) + WHERE uuid = $1`, uuid, email, delta) + return err +} + func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int { if store == nil || ownerUsername == "" { return 0 @@ -177,9 +258,7 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str } } if needRestart { - if err := xrayMgr.Restart(); err != nil { - log.Printf("xray owner cleanup: restart: %v", err) - } + xrayMgr.restartIfExternalRunning() } } @@ -220,9 +299,7 @@ func startXrayClientExpiryChecker(store *Store) { log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag) } if needRestart { - if err := xrayMgr.Restart(); err != nil { - log.Printf("xray expiry: restart error: %v", err) - } + xrayMgr.restartIfExternalRunning() } } }() diff --git a/xray_config_store.go b/xray_config_store.go new file mode 100644 index 0000000..e7e0043 --- /dev/null +++ b/xray_config_store.go @@ -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 +} diff --git a/xray_integration.go b/xray_integration.go index 289d8fc..fc10c6b 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "encoding/json" "fmt" "io" @@ -20,9 +21,21 @@ import ( // XrayConfig holds Xray process management settings embedded in the main Config. type XrayConfig struct { - Enabled bool `json:"enabled"` - BinPath string `json:"bin_path"` // e.g. /opt/sshpanel/xray - ConfigFile string `json:"config_file"` // e.g. /opt/sshpanel/xray_config.json + Enabled bool `json:"enabled"` + BinPath string `json:"bin_path"` // external mode only, e.g. /opt/sshpanel/xray + ConfigFile string `json:"config_file"` // external runtime config, e.g. /opt/sshpanel/xray_config.json + NativeConfigFile string `json:"native_config_file,omitempty"` // native emulator config, independent from external xray + + // Mode selects the runtime implementation. Supported values: + // native = run the in-process emulator implemented in this binary + // external = spawn the external /opt/sshpanel/xray binary + // Empty/unknown is normalized to native so older configs do not silently + // keep using the external binary after this build is installed. + Mode string `json:"mode,omitempty"` + + // Native is kept for backward compatibility with older configs/UI payloads. + // New writes should set both Mode and Native. Runtime decisions use UseNative(). + Native bool `json:"native,omitempty"` // Optional Xray API endpoint used for online client counters. If empty, // the panel auto-detects a local inbound tagged "api" from the Xray config. @@ -30,6 +43,109 @@ type XrayConfig struct { // A client is considered online when its Xray stats traffic changed recently. OnlineWindowSeconds int `json:"online_window_seconds,omitempty"` // default 90 StatsPollSeconds int `json:"stats_poll_seconds,omitempty"` // default 15 + + // NativeIPStrategy controls native outbound address selection. The native + // runtime defaults to auto dual-stack behavior so IPv6 destinations from + // clients are preserved when the server has IPv6 connectivity. force_ipv4 is + // only kept as an explicit legacy override. + NativeIPStrategy string `json:"native_ip_strategy,omitempty"` // auto | force_ipv4 +} + +const ( + xrayModeNative = "native" + xrayModeExternal = "external" + defaultXrayBinPath = "/opt/sshpanel/xray" + defaultXrayConfigFile = "/opt/sshpanel/xray_config.json" + defaultXrayNativeConfigFile = "/opt/sshpanel/xray_native_config.json" + xrayNativeIPStrategyAuto = "auto" + xrayNativeIPStrategyForce4 = "force_ipv4" +) + +func (c *XrayConfig) NormalizeDefaults() { + if c == nil { + return + } + mode := strings.ToLower(strings.TrimSpace(c.Mode)) + switch mode { + case xrayModeExternal: + c.Mode = xrayModeExternal + c.Native = false + case xrayModeNative: + c.Mode = xrayModeNative + c.Native = true + default: + // Old configs did not have mode/native and used the external binary by + // accident. For the emulator build, make internal/native the safe default. + c.Mode = xrayModeNative + c.Native = true + } + if strings.TrimSpace(c.ConfigFile) == "" { + c.ConfigFile = defaultXrayConfigFile + } + if strings.TrimSpace(c.NativeConfigFile) == "" { + c.NativeConfigFile = defaultXrayNativeConfigFile + } + if strings.TrimSpace(c.BinPath) == "" { + c.BinPath = defaultXrayBinPath + } + switch strings.ToLower(strings.TrimSpace(c.NativeIPStrategy)) { + case "ipv4", "useipv4", "use_ipv4", xrayNativeIPStrategyForce4: + c.NativeIPStrategy = xrayNativeIPStrategyForce4 + default: + c.NativeIPStrategy = xrayNativeIPStrategyAuto + } +} + +func (c *XrayConfig) NativeForceIPv4() bool { + if c == nil { + return false + } + mode := strings.ToLower(strings.TrimSpace(c.NativeIPStrategy)) + return mode == xrayNativeIPStrategyForce4 || mode == "ipv4" || mode == "useipv4" || mode == "use_ipv4" +} + +func (c *XrayConfig) ModeName() string { + if c == nil { + return "disabled" + } + mode := strings.ToLower(strings.TrimSpace(c.Mode)) + if mode == xrayModeExternal { + return xrayModeExternal + } + if mode == xrayModeNative || c.Native { + return xrayModeNative + } + return xrayModeNative +} + +func (c *XrayConfig) UseNative() bool { + return c != nil && c.ModeName() != xrayModeExternal +} + +func (c *XrayConfig) ActiveConfigFile() string { + if c == nil { + return "" + } + if c.UseNative() { + if strings.TrimSpace(c.NativeConfigFile) != "" { + return strings.TrimSpace(c.NativeConfigFile) + } + return defaultXrayNativeConfigFile + } + if strings.TrimSpace(c.ConfigFile) != "" { + return strings.TrimSpace(c.ConfigFile) + } + return defaultXrayConfigFile +} + +func (c *XrayConfig) ExternalConfigFile() string { + if c == nil { + return "" + } + if strings.TrimSpace(c.ConfigFile) != "" { + return strings.TrimSpace(c.ConfigFile) + } + return defaultXrayConfigFile } // xrayLogRing is a fixed-capacity circular buffer for captured log lines. @@ -98,6 +214,10 @@ type XrayManager struct { lastStatsErr string lastStatsPoll time.Time pollStarted bool + + nativeDBMu sync.Mutex + nativeTrafficPending map[string]xrayPendingTraffic + nativeStatsFlushStarted bool } type xrayTrafficCounters struct { @@ -106,10 +226,17 @@ type xrayTrafficCounters struct { } type xrayRuntimeStat struct { - Email string - Uplink int64 - Downlink int64 - LastActive time.Time + Email string + Uplink int64 + Downlink int64 + LastActive time.Time + ActiveConnections int +} + +type xrayPendingTraffic struct { + Email string + Uplink int64 + Downlink int64 } var xrayMgr = &XrayManager{} @@ -121,8 +248,18 @@ func initXrayManager(cfg *XrayConfig) { } xrayMgr.mu.Lock() xrayMgr.cfg = cfg + if err := xrayMgr.bootstrapConfigStoreLocked(); err != nil { + log.Printf("xray: database config bootstrap failed: %v", err) + } xrayMgr.mu.Unlock() - xrayMgr.startStatsPoller() + + // In native mode the in-process emulator records traffic directly, so the + // external `xray api statsquery` poller is not started (it would overwrite + // the native counters with errors from a non-existent CLI endpoint). + xrayMgr.startNativeStatsFlusher() + if !cfg.UseNative() { + xrayMgr.startStatsPoller() + } if cfg.Enabled { if err := xrayMgr.Start(); err != nil { @@ -150,12 +287,32 @@ func (m *XrayManager) Start() error { m.mu.Lock() defer m.mu.Unlock() - if m.isRunning() { - return fmt.Errorf("xray already running (pid %d)", m.cmd.Process.Pid) - } if m.cfg == nil { return fmt.Errorf("xray not configured") } + if err := m.syncConfigFileFromStoreLocked(); err != nil { + m.lastErr = err.Error() + return err + } + configFile := m.activeConfigFileLocked() + + // Native mode: run the in-process emulator instead of the subprocess. + if m.cfg.UseNative() { + if nativeXray.nativeRunning() { + return fmt.Errorf("native xray already running") + } + if err := nativeXray.start(configFile); err != nil { + m.lastErr = err.Error() + return err + } + m.startTime = time.Now() + m.lastErr = "" + return nil + } + + if m.isRunning() { + return fmt.Errorf("xray already running (pid %d)", m.cmd.Process.Pid) + } if _, err := os.Stat(m.cfg.BinPath); err != nil { return fmt.Errorf("xray binary not found at %s", m.cfg.BinPath) } @@ -166,8 +323,8 @@ func (m *XrayManager) Start() error { } args := []string{"run"} - if m.cfg.ConfigFile != "" { - args = append(args, "-c", m.cfg.ConfigFile) + if configFile != "" { + args = append(args, "-c", configFile) } cmd := exec.Command(m.cfg.BinPath, args...) @@ -202,6 +359,13 @@ func (m *XrayManager) Start() error { // Stop sends SIGTERM and waits up to 5 s before forcing SIGKILL. func (m *XrayManager) Stop() error { + // Stop both implementations defensively. Mode can change at runtime; relying + // only on the new cfg.Native value can leak the old external subprocess when + // switching external -> native, or leave native listeners bound when switching + // native -> external. + nativeXray.stop() + m.flushNativeStatsToDB() + m.mu.Lock() if !m.isRunning() { m.mu.Unlock() @@ -234,10 +398,167 @@ func (m *XrayManager) Restart() error { return m.Start() } +// recordNativeConnect marks a native client stream as online immediately. This +// is more accurate than external Xray's Stats API polling because it knows when +// the decoded VMess/VLESS stream is authenticated and opened. +func (m *XrayManager) recordNativeConnect(uuid, email string) { + uuid = strings.TrimSpace(uuid) + email = strings.TrimSpace(email) + if email == "" { + email = uuid + } + if uuid == "" && email == "" { + return + } + now := time.Now() + m.statsMu.Lock() + if m.statsByEmail == nil { + m.statsByEmail = make(map[string]xrayRuntimeStat) + } + st := m.statsByEmail[email] + st.Email = email + st.LastActive = now + st.ActiveConnections++ + m.statsByEmail[email] = st + m.statsMu.Unlock() + + if statsStore != nil && uuid != "" { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := statsStore.UpdateXrayClientActive(ctx, uuid, email, 1); err != nil { + log.Printf("xray native stats: active +1 for %s failed: %v", uuid, err) + } + }() + } +} + +func (m *XrayManager) recordNativeDisconnect(uuid, email string) { + uuid = strings.TrimSpace(uuid) + email = strings.TrimSpace(email) + if email == "" { + email = uuid + } + if uuid == "" && email == "" { + return + } + m.statsMu.Lock() + if m.statsByEmail != nil { + st := m.statsByEmail[email] + if st.ActiveConnections > 0 { + st.ActiveConnections-- + } + m.statsByEmail[email] = st + } + m.statsMu.Unlock() + + if statsStore != nil && uuid != "" { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := statsStore.UpdateXrayClientActive(ctx, uuid, email, -1); err != nil { + log.Printf("xray native stats: active -1 for %s failed: %v", uuid, err) + } + }() + } +} + +// recordNativeTraffic accumulates in-process byte counters for a client and +// queues DB persistence. Used by the native emulator instead of external +// `xray api statsquery` polling. +func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) { + uuid = strings.TrimSpace(uuid) + email = strings.TrimSpace(email) + if email == "" { + email = uuid + } + if email == "" || (up == 0 && down == 0) { + return + } + now := time.Now() + m.statsMu.Lock() + if m.statsByEmail == nil { + m.statsByEmail = make(map[string]xrayRuntimeStat) + } + st := m.statsByEmail[email] + st.Email = email + st.Uplink += up + st.Downlink += down + st.LastActive = now + m.statsByEmail[email] = st + m.statsMu.Unlock() + + if statsStore != nil && uuid != "" { + m.nativeDBMu.Lock() + if m.nativeTrafficPending == nil { + m.nativeTrafficPending = make(map[string]xrayPendingTraffic) + } + p := m.nativeTrafficPending[uuid] + p.Email = email + p.Uplink += up + p.Downlink += down + m.nativeTrafficPending[uuid] = p + m.nativeDBMu.Unlock() + } +} + +func (m *XrayManager) startNativeStatsFlusher() { + m.nativeDBMu.Lock() + if m.nativeStatsFlushStarted { + m.nativeDBMu.Unlock() + return + } + m.nativeStatsFlushStarted = true + m.nativeDBMu.Unlock() + + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + m.flushNativeStatsToDB() + } + }() +} + +func (m *XrayManager) flushNativeStatsToDB() { + if statsStore == nil { + return + } + m.nativeDBMu.Lock() + pending := m.nativeTrafficPending + m.nativeTrafficPending = nil + m.nativeDBMu.Unlock() + if len(pending) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := statsStore.AddXrayClientTrafficBatch(ctx, pending); err != nil { + log.Printf("xray native stats: db traffic flush failed: %v", err) + // Put deltas back so a transient DB failure does not lose accounting. + m.nativeDBMu.Lock() + if m.nativeTrafficPending == nil { + m.nativeTrafficPending = make(map[string]xrayPendingTraffic) + } + for uuid, d := range pending { + p := m.nativeTrafficPending[uuid] + if p.Email == "" { + p.Email = d.Email + } + p.Uplink += d.Uplink + p.Downlink += d.Downlink + m.nativeTrafficPending[uuid] = p + } + m.nativeDBMu.Unlock() + } +} + // XrayStatusDTO is returned by /api/xray/status. type XrayStatusDTO struct { Enabled bool `json:"enabled"` Running bool `json:"running"` + Mode string `json:"mode"` + Native bool `json:"native"` PID int `json:"pid,omitempty"` Uptime string `json:"uptime,omitempty"` Error string `json:"error,omitempty"` @@ -252,11 +573,36 @@ type XrayStatusDTO struct { // Status returns a snapshot of the current xray process state. func (m *XrayManager) Status() XrayStatusDTO { + m.mu.Lock() + native := m.cfg != nil && m.cfg.UseNative() + m.mu.Unlock() + + // Native mode: report the in-process emulator's state. Traffic counters are + // recorded live so there is no CLI poll and no Stats API config to check. + if native { + m.mu.Lock() + s := XrayStatusDTO{Enabled: m.cfg.Enabled, Mode: m.cfg.ModeName(), Native: m.cfg.UseNative()} + if nativeXray.nativeRunning() { + s.Running = true + s.Uptime = time.Since(m.startTime).Round(time.Second).String() + } + if m.lastErr != "" { + s.Error = m.lastErr + } + m.mu.Unlock() + s.OnlineUsers = m.CountOnlineUsers() + s.OnlineWindowSec = int(m.onlineWindow().Seconds()) + s.StatsConfigured = true // in-process metering is always available + return s + } + m.refreshRuntimeStatsIfStale(3 * time.Second) m.mu.Lock() s := XrayStatusDTO{} if m.cfg != nil { s.Enabled = m.cfg.Enabled + s.Mode = m.cfg.ModeName() + s.Native = m.cfg.UseNative() } if m.isRunning() && m.cmd != nil && m.cmd.Process != nil { s.Running = true @@ -336,6 +682,12 @@ func (m *XrayManager) startStatsPoller() { } func (m *XrayManager) isRunningSnapshot() bool { + m.mu.Lock() + native := m.cfg != nil && m.cfg.UseNative() + m.mu.Unlock() + if native { + return nativeXray.nativeRunning() + } m.mu.Lock() defer m.mu.Unlock() return m.isRunning() @@ -344,7 +696,7 @@ func (m *XrayManager) isRunningSnapshot() bool { func (m *XrayManager) apiCommandConfig() (binPath, apiServer string, ok bool) { m.mu.Lock() defer m.mu.Unlock() - if m.cfg == nil || m.cfg.BinPath == "" || m.cfg.ConfigFile == "" { + if m.cfg == nil || m.cfg.BinPath == "" || m.cfg.ExternalConfigFile() == "" { return "", "", false } binPath = m.cfg.BinPath @@ -356,10 +708,10 @@ func (m *XrayManager) apiCommandConfig() (binPath, apiServer string, ok bool) { } func (m *XrayManager) discoverAPIServerLocked() string { - if m.cfg == nil || m.cfg.ConfigFile == "" { + if m.cfg == nil || m.cfg.ExternalConfigFile() == "" { return "" } - data, err := os.ReadFile(m.cfg.ConfigFile) + data, err := os.ReadFile(m.cfg.ExternalConfigFile()) if err != nil { return "" } @@ -412,7 +764,7 @@ func (m *XrayManager) refreshRuntimeStats() { for email, counters := range traffic { seen[email] = true prev := m.statsByEmail[email] - st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive} + st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive, ActiveConnections: prev.ActiveConnections} changed := counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink // First successful poll with non-zero traffic means the client has been // active since Xray started. Later polls refresh LastActive only when bytes @@ -578,30 +930,237 @@ func (m *XrayManager) CountOnlineUsers() int { defer m.statsMu.RUnlock() n := 0 for _, st := range m.statsByEmail { - if !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window { + if st.ActiveConnections > 0 || (!st.LastActive.IsZero() && now.Sub(st.LastActive) <= window) { n++ } } return n } -// GetConfig reads the current xray JSON config file. +func (m *XrayManager) activeConfigFileLocked() string { + if m.cfg == nil { + return "" + } + return m.cfg.ActiveConfigFile() +} + +func (m *XrayManager) configStoreKeyLocked() string { + if m.cfg == nil { + return "default" + } + mode := m.cfg.ModeName() + path := strings.TrimSpace(m.activeConfigFileLocked()) + if path == "" { + path = "default" + } + return mode + ":" + path +} + +func normalizeJSONIndent(data []byte) ([]byte, error) { + var raw interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return json.MarshalIndent(raw, "", " ") +} + +func (m *XrayManager) readConfigLocked() ([]byte, error) { + configFile := m.activeConfigFileLocked() + if m.cfg == nil || configFile == "" { + return nil, fmt.Errorf("xray config file not configured") + } + if statsStore != nil { + if data, ok, err := statsStore.GetXrayConfig(context.Background(), m.configStoreKeyLocked()); err != nil { + log.Printf("xray: database config read failed, falling back to file: %v", err) + } else if ok { + pretty, err := normalizeJSONIndent(data) + if err != nil { + return nil, err + } + _ = os.WriteFile(configFile, pretty, 0o600) + return pretty, nil + } + } + return os.ReadFile(configFile) +} + +func (m *XrayManager) writeConfigLocked(data []byte) error { + configFile := m.activeConfigFileLocked() + if m.cfg == nil || configFile == "" { + return fmt.Errorf("xray config file not configured") + } + pretty, err := normalizeJSONIndent(data) + if err != nil { + return err + } + if statsStore != nil { + if err := statsStore.UpsertXrayConfig(context.Background(), m.configStoreKeyLocked(), pretty); err != nil { + return fmt.Errorf("save Xray config to database: %w", err) + } + } + if err := os.WriteFile(configFile, pretty, 0o600); err != nil { + return err + } + m.importConfigClientsLocked(pretty, "saved xray config") + return nil +} + +func (m *XrayManager) importConfigClientsLocked(data []byte, source string) { + if statsStore == nil || len(data) == 0 { + return + } + n, err := statsStore.ImportXrayClientsFromConfig(context.Background(), data) + if err != nil { + log.Printf("xray: import clients from %s failed: %v", source, err) + return + } + if n > 0 { + log.Printf("xray: imported/synced %d client UUIDs from %s into database", n, source) + } +} + +func (m *XrayManager) importRuntimeConfigFileClientsLocked(source string) { + configFile := m.activeConfigFileLocked() + if m.cfg == nil || strings.TrimSpace(configFile) == "" || statsStore == nil { + return + } + data, err := os.ReadFile(configFile) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("xray: read %s for client import failed: %v", configFile, err) + } + return + } + pretty, err := normalizeJSONIndent(data) + if err != nil { + log.Printf("xray: cannot import clients from %s: invalid JSON: %v", configFile, err) + return + } + m.importConfigClientsLocked(pretty, source) +} + +func (m *XrayManager) restartIfExternalRunning() { + m.mu.Lock() + external := m.cfg != nil && !m.cfg.UseNative() + running := external && m.isRunning() + m.mu.Unlock() + if !running { + return + } + if err := m.Restart(); err != nil { + log.Printf("xray: external restart after client/config change failed: %v", err) + } +} + +func (m *XrayManager) useNativeMode() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.cfg != nil && m.cfg.UseNative() +} + +func (m *XrayManager) bootstrapConfigStoreLocked() error { + configFile := m.activeConfigFileLocked() + if m.cfg == nil || configFile == "" || statsStore == nil { + return nil + } + ctx := context.Background() + key := m.configStoreKeyLocked() + + if data, ok, err := statsStore.GetXrayConfig(ctx, key); err != nil { + return err + } else if ok { + pretty, err := normalizeJSONIndent(data) + if err != nil { + return err + } + m.importConfigClientsLocked(pretty, "database xray config") + return os.WriteFile(configFile, pretty, 0o600) + } + + data, err := os.ReadFile(configFile) + if err != nil { + if os.IsNotExist(err) && m.cfg.UseNative() { + // One-time migration only: if this node already has an old external + // config and no native config exists yet, clone it into the native path. + // After this point native mode reads/writes only its own DB row/file. + if migrated, migErr := m.seedNativeConfigFromExternalLocked(configFile); migErr != nil { + return migErr + } else if len(migrated) > 0 { + pretty, err := normalizeJSONIndent(migrated) + if err != nil { + return err + } + m.importConfigClientsLocked(pretty, "one-time external-to-native migration") + if err := statsStore.UpsertXrayConfig(ctx, key, pretty); err != nil { + return err + } + return os.WriteFile(configFile, pretty, 0o600) + } + } + if os.IsNotExist(err) { + return nil + } + return err + } + pretty, err := normalizeJSONIndent(data) + if err != nil { + return err + } + m.importConfigClientsLocked(pretty, "runtime xray config") + return statsStore.UpsertXrayConfig(ctx, key, pretty) +} + +func (m *XrayManager) seedNativeConfigFromExternalLocked(nativeConfigFile string) ([]byte, error) { + if m.cfg == nil || !m.cfg.UseNative() { + return nil, nil + } + ext := strings.TrimSpace(m.cfg.ExternalConfigFile()) + if ext == "" || ext == nativeConfigFile { + return nil, nil + } + data, err := os.ReadFile(ext) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if _, err := normalizeJSONIndent(data); err != nil { + return nil, err + } + log.Printf("xray native: cloned %s to independent native config %s once", ext, nativeConfigFile) + return data, nil +} + +func (m *XrayManager) syncConfigFileFromStoreLocked() error { + configFile := m.activeConfigFileLocked() + if m.cfg == nil || configFile == "" || statsStore == nil { + return nil + } + data, ok, err := statsStore.GetXrayConfig(context.Background(), m.configStoreKeyLocked()) + if err != nil || !ok { + return err + } + pretty, err := normalizeJSONIndent(data) + if err != nil { + return err + } + m.importConfigClientsLocked(pretty, "database xray config") + return os.WriteFile(configFile, pretty, 0o600) +} + +// GetConfig reads the current Xray JSON config. With PostgreSQL enabled, the +// database row is canonical and the file is only a synchronized runtime copy. func (m *XrayManager) GetConfig() ([]byte, error) { m.mu.Lock() defer m.mu.Unlock() - if m.cfg == nil || m.cfg.ConfigFile == "" { - return nil, fmt.Errorf("xray config file not configured") - } - return os.ReadFile(m.cfg.ConfigFile) + return m.readConfigLocked() } -// SetConfig validates and atomically writes a new xray JSON config file. +// SetConfig validates and writes a new Xray JSON config to DB and to the runtime file. func (m *XrayManager) SetConfig(data []byte) error { m.mu.Lock() defer m.mu.Unlock() - if m.cfg == nil || m.cfg.ConfigFile == "" { - return fmt.Errorf("xray config file not configured") - } patched, changed, err := patchXrayStatsAPIBytes(data) if err != nil { return err @@ -609,7 +1168,7 @@ func (m *XrayManager) SetConfig(data []byte) error { if changed { log.Printf("xray: added/repaired Stats API support while saving config") } - return os.WriteFile(m.cfg.ConfigFile, patched, 0o600) + return m.writeConfigLocked(patched) } type xrayStatsConfigCheck struct { @@ -625,10 +1184,7 @@ func (m *XrayManager) CheckStatsAPIConfig() (xrayStatsConfigCheck, error) { } func (m *XrayManager) checkStatsAPIConfigLocked() (xrayStatsConfigCheck, error) { - if m.cfg == nil || m.cfg.ConfigFile == "" { - return xrayStatsConfigCheck{}, fmt.Errorf("xray config file not configured") - } - data, err := os.ReadFile(m.cfg.ConfigFile) + data, err := m.readConfigLocked() if err != nil { return xrayStatsConfigCheck{}, err } @@ -653,10 +1209,7 @@ func (m *XrayManager) EnsureStatsAPIConfig() (bool, error) { } func (m *XrayManager) ensureStatsAPIConfigLocked() (bool, error) { - if m.cfg == nil || m.cfg.ConfigFile == "" { - return false, fmt.Errorf("xray config file not configured") - } - data, err := os.ReadFile(m.cfg.ConfigFile) + data, err := m.readConfigLocked() if err != nil { if os.IsNotExist(err) { return false, nil @@ -670,7 +1223,7 @@ func (m *XrayManager) ensureStatsAPIConfigLocked() (bool, error) { if !changed { return false, nil } - return true, os.WriteFile(m.cfg.ConfigFile, patched, 0o600) + return true, m.writeConfigLocked(patched) } func patchXrayStatsAPIBytes(data []byte) ([]byte, bool, error) { @@ -1108,11 +1661,13 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) { // pieces or missing per-client stat labels, repair once automatically. This // avoids a dashboard that says "OK" but continues to show zero Xray online // users until a technical admin manually edits JSON. - if check, err := xrayMgr.CheckStatsAPIConfig(); err == nil && !check.Configured { - wasRunning := xrayMgr.isRunningSnapshot() - if changed, err := xrayMgr.EnsureStatsAPIConfig(); err == nil && changed && wasRunning { - if err := xrayMgr.Restart(); err != nil { - log.Printf("xray: auto stats repair restart failed: %v", err) + if !xrayMgr.useNativeMode() { + if check, err := xrayMgr.CheckStatsAPIConfig(); err == nil && !check.Configured { + wasRunning := xrayMgr.isRunningSnapshot() + if changed, err := xrayMgr.EnsureStatsAPIConfig(); err == nil && changed && wasRunning { + if err := xrayMgr.Restart(); err != nil { + log.Printf("xray: auto stats repair restart failed: %v", err) + } } } } @@ -1124,8 +1679,11 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) { window := xrayMgr.onlineWindow() now := time.Now() for _, m := range metas { - st, ok := xrayMgr.RuntimeStatsForKeys(m.Email, m.UUID, m.Name) - if ok && !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window { + online := m.ActiveConnections > 0 || (m.LastActive != nil && now.Sub(*m.LastActive) <= window) + if st, ok := xrayMgr.RuntimeStatsForKeys(m.Email, m.UUID, m.Name); ok { + online = online || st.ActiveConnections > 0 || (!st.LastActive.IsZero() && now.Sub(st.LastActive) <= window) + } + if online { status.OnlineUsers++ } } @@ -1281,11 +1839,12 @@ type XrayClientInfo struct { Level int `json:"level,omitempty"` // Runtime counters from the Xray stats API. Online means this user's // traffic counters changed inside the configured online window. - Online bool `json:"online"` - LastActive *time.Time `json:"last_active,omitempty"` - UplinkBytes int64 `json:"uplink_bytes,omitempty"` - DownlinkBytes int64 `json:"downlink_bytes,omitempty"` - TotalBytes int64 `json:"total_bytes,omitempty"` + Online bool `json:"online"` + LastActive *time.Time `json:"last_active,omitempty"` + UplinkBytes int64 `json:"uplink_bytes,omitempty"` + DownlinkBytes int64 `json:"downlink_bytes,omitempty"` + TotalBytes int64 `json:"total_bytes,omitempty"` + ActiveConnections int `json:"active_connections,omitempty"` // Metadata from PostgreSQL (enriched by handleXrayInbounds) Name string `json:"name,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` @@ -1313,10 +1872,7 @@ var xrayClientProtos = map[string]bool{ func (m *XrayManager) ListInbounds() ([]XrayInboundInfo, error) { m.mu.Lock() defer m.mu.Unlock() - if m.cfg == nil || m.cfg.ConfigFile == "" { - return nil, fmt.Errorf("xray config file not configured") - } - data, err := os.ReadFile(m.cfg.ConfigFile) + data, err := m.readConfigLocked() if err != nil { return nil, err } @@ -1369,10 +1925,7 @@ func (m *XrayManager) ListInbounds() ([]XrayInboundInfo, error) { // modifyRawConfig reads the config as a generic map, calls fn to mutate it, then writes it back. // Caller must hold m.mu. func (m *XrayManager) modifyRawConfig(fn func(cfg map[string]interface{}) error) error { - if m.cfg == nil || m.cfg.ConfigFile == "" { - return fmt.Errorf("xray config file not configured") - } - data, err := os.ReadFile(m.cfg.ConfigFile) + data, err := m.readConfigLocked() if err != nil { return err } @@ -1390,14 +1943,14 @@ func (m *XrayManager) modifyRawConfig(fn func(cfg map[string]interface{}) error) if err != nil { return err } - return os.WriteFile(m.cfg.ConfigFile, out, 0o600) + return m.writeConfigLocked(out) } // AddXrayClient adds a client to the named inbound and saves the config. func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error { m.mu.Lock() defer m.mu.Unlock() - return m.modifyRawConfig(func(raw map[string]interface{}) error { + err := m.modifyRawConfig(func(raw map[string]interface{}) error { _, _ = ensureXrayStatsAPIConfig(raw) inbounds, _ := raw["inbounds"].([]interface{}) for _, ib := range inbounds { @@ -1416,7 +1969,11 @@ func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error { clients, _ := settings["clients"].([]interface{}) for _, c := range clients { if cm, ok := c.(map[string]interface{}); ok { - if id, _ := cm["id"].(string); id == uuid { + id, _ := cm["id"].(string) + if id == "" { + id, _ = cm["password"].(string) + } + if id == uuid { return fmt.Errorf("UUID %s already exists in inbound %s", uuid, inboundTag) } } @@ -1433,13 +1990,19 @@ func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error { } return fmt.Errorf("inbound %q not found", inboundTag) }) + if err == nil && m.cfg != nil && m.cfg.UseNative() { + if hotErr := nativeXray.addClient(inboundTag, uuid, email); hotErr != nil { + log.Printf("native xray: hot-add client %s to %s failed: %v", uuid, inboundTag, hotErr) + } + } + return err } // RemoveXrayClient removes a client by UUID from the named inbound and saves the config. func (m *XrayManager) RemoveXrayClient(inboundTag, uuid string) error { m.mu.Lock() defer m.mu.Unlock() - return m.modifyRawConfig(func(raw map[string]interface{}) error { + err := m.modifyRawConfig(func(raw map[string]interface{}) error { inbounds, _ := raw["inbounds"].([]interface{}) for _, ib := range inbounds { ibMap, ok := ib.(map[string]interface{}) @@ -1458,7 +2021,11 @@ func (m *XrayManager) RemoveXrayClient(inboundTag, uuid string) error { removed := false for _, c := range clients { if cm, ok := c.(map[string]interface{}); ok { - if id, _ := cm["id"].(string); id == uuid { + id, _ := cm["id"].(string) + if id == "" { + id, _ = cm["password"].(string) + } + if id == uuid { removed = true continue } @@ -1466,13 +2033,67 @@ func (m *XrayManager) RemoveXrayClient(inboundTag, uuid string) error { kept = append(kept, c) } if !removed { - return fmt.Errorf("UUID %s not found in inbound %s", uuid, inboundTag) + // DB-backed native users may exist only in xray_clients. The inbound + // exists, so let the caller delete the database row too. + return nil } settings["clients"] = kept return nil } return fmt.Errorf("inbound %q not found", inboundTag) }) + if err == nil && m.cfg != nil && m.cfg.UseNative() { + if hotErr := nativeXray.removeClient(inboundTag, uuid); hotErr != nil { + log.Printf("native xray: hot-remove client %s from %s failed: %v", uuid, inboundTag, hotErr) + } + } + return err +} + +// UpdateXrayClientEmail updates the client's email/stats label inside the Xray JSON config. +// UUID remains immutable; metadata such as display name/expiry stays in PostgreSQL. +func (m *XrayManager) UpdateXrayClientEmail(uuid, email string) error { + if strings.TrimSpace(uuid) == "" || strings.TrimSpace(email) == "" { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + err := m.modifyRawConfig(func(raw map[string]interface{}) error { + changed := false + inbounds, _ := raw["inbounds"].([]interface{}) + for _, ib := range inbounds { + ibMap, ok := ib.(map[string]interface{}) + if !ok { + continue + } + settings, _ := ibMap["settings"].(map[string]interface{}) + clients, _ := settings["clients"].([]interface{}) + for _, c := range clients { + cm, ok := c.(map[string]interface{}) + if !ok { + continue + } + id, _ := cm["id"].(string) + if id == "" { + id, _ = cm["password"].(string) + } + if id == uuid { + cm["email"] = email + changed = true + } + } + } + if !changed { + return nil + } + return nil + }) + if err == nil && m.cfg != nil && m.cfg.UseNative() { + if hotErr := nativeXray.updateClientEmail(uuid, email); hotErr != nil { + log.Printf("native xray: hot-update client %s email failed: %v", uuid, hotErr) + } + } + return err } // ---- HTTP handlers for inbound/client management ---- @@ -1517,28 +2138,40 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) { } } else { metaMap := make(map[string]*XrayClientMeta, len(metas)) + metasByInbound := make(map[string][]*XrayClientMeta) for _, m := range metas { metaMap[m.UUID] = m + metasByInbound[m.InboundTag] = append(metasByInbound[m.InboundTag], m) } now := time.Now() for i := range inbounds { - filtered := make([]XrayClientInfo, 0, len(inbounds[i].Clients)) - for j := range inbounds[i].Clients { - c := inbounds[i].Clients[j] - applyXrayRuntimeStats(&c) - m, ok := metaMap[c.UUID] - if isReseller && (!ok || m.OwnerUsername != sess.Username) { - continue + seen := make(map[string]bool) + filtered := make([]XrayClientInfo, 0, len(inbounds[i].Clients)+len(metasByInbound[inbounds[i].Tag])) + appendMetaClient := func(m *XrayClientMeta) { + if m == nil || m.UUID == "" || seen[m.UUID] { + return } - if !ok { - c.ExpirationDays = -1 - filtered = append(filtered, c) - continue + c := XrayClientInfo{ + UUID: m.UUID, + Email: m.Email, + Name: m.Name, + ExpiresAt: m.ExpiresAt, + MaxConns: m.MaxConns, + OwnerUsername: m.OwnerUsername, + UplinkBytes: m.TotalUplinkBytes, + DownlinkBytes: m.TotalDownlinkBytes, + TotalBytes: m.TotalUplinkBytes + m.TotalDownlinkBytes, + LastActive: m.LastActive, + ActiveConnections: m.ActiveConnections, + } + if c.Email == "" { + c.Email = m.UUID + } + if c.ActiveConnections > 0 { + c.Online = true + } else if c.LastActive != nil && now.Sub(*c.LastActive) <= xrayMgr.onlineWindow() { + c.Online = true } - c.Name = m.Name - c.ExpiresAt = m.ExpiresAt - c.MaxConns = m.MaxConns - c.OwnerUsername = m.OwnerUsername applyXrayRuntimeStats(&c) if m.ExpiresAt == nil { c.ExpirationDays = -1 @@ -1548,8 +2181,30 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) { } else { c.ExpirationDays = int(m.ExpiresAt.Sub(now).Hours() / 24) } + seen[m.UUID] = true filtered = append(filtered, c) } + for j := range inbounds[i].Clients { + c := inbounds[i].Clients[j] + applyXrayRuntimeStats(&c) + m, ok := metaMap[c.UUID] + if isReseller && (!ok || m.OwnerUsername != sess.Username) { + continue + } + if !ok { + c.ExpirationDays = -1 + seen[c.UUID] = true + filtered = append(filtered, c) + continue + } + appendMetaClient(m) + } + for _, m := range metasByInbound[inbounds[i].Tag] { + if isReseller && m.OwnerUsername != sess.Username { + continue + } + appendMetaClient(m) + } inbounds[i].Clients = filtered } } @@ -1578,14 +2233,21 @@ func applyXrayRuntimeStats(c *XrayClientInfo) { } window := xrayMgr.onlineWindow() now := time.Now() - c.UplinkBytes = st.Uplink - c.DownlinkBytes = st.Downlink - c.TotalBytes = st.Uplink + st.Downlink + if st.Uplink > c.UplinkBytes { + c.UplinkBytes = st.Uplink + } + if st.Downlink > c.DownlinkBytes { + c.DownlinkBytes = st.Downlink + } + c.TotalBytes = c.UplinkBytes + c.DownlinkBytes + if st.ActiveConnections > c.ActiveConnections { + c.ActiveConnections = st.ActiveConnections + } if !st.LastActive.IsZero() { t := st.LastActive c.LastActive = &t - c.Online = now.Sub(st.LastActive) <= window } + c.Online = c.ActiveConnections > 0 || (c.LastActive != nil && now.Sub(*c.LastActive) <= window) } func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { @@ -1661,6 +2323,15 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { ownerUsername = strings.TrimSpace(req.OwnerUsername) } + if statsStore != nil { + if _, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID); err == nil { + http.Error(w, "UUID already exists in database", http.StatusBadRequest) + return + } else if err != sql.ErrNoRows { + http.Error(w, "database error: "+err.Error(), http.StatusInternalServerError) + return + } + } if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -1691,12 +2362,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { log.Printf("xray: save meta for %s: %v", req.UUID, err) } } - _ = xrayMgr.Restart() + xrayMgr.restartIfExternalRunning() w.WriteHeader(http.StatusCreated) } -// handleXrayClientUpdate updates the metadata (name, email, expiry, max_conns) -// of an existing Xray client in PostgreSQL without touching the config file. +// handleXrayClientUpdate updates DB metadata and mirrors the email/stats label +// into the Xray JSON config when the client also exists there. func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) @@ -1772,6 +2443,13 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) { http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError) return } + if req.Email != "" { + if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil { + log.Printf("xray: update config email for %s: %v", req.UUID, err) + } else { + xrayMgr.restartIfExternalRunning() + } + } w.WriteHeader(http.StatusOK) } @@ -1827,6 +2505,6 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) { if statsStore != nil { _ = statsStore.DeleteXrayClientMeta(r.Context(), uuid) } - _ = xrayMgr.Restart() + xrayMgr.restartIfExternalRunning() w.WriteHeader(http.StatusNoContent) } diff --git a/xray_native.go b/xray_native.go new file mode 100644 index 0000000..c95ce95 --- /dev/null +++ b/xray_native.go @@ -0,0 +1,1178 @@ +package main + +// Pure-Go, in-process Xray emulator. +// +// This is the Xray equivalent of the in-process SSH server in main.go: instead +// of shelling out to an external `xray run -c config.json` subprocess, the +// supported protocols are spoken directly in Go and every accepted stream is +// tunnelled with the same copyWithRateLimit machinery the SSH side uses. +// +// Native emulator scope: +// - Protocols : VLESS and VMess AEAD (TCP + UDP commands) +// - Transports: raw TCP, WebSocket (RFC 6455), XHTTP/SplitHTTP +// - Security : TLS, none +// +// Mux, REALITY, gRPC and HTTPUpgrade are still deferred; unsupported commands +// are rejected explicitly instead of silently falling back. +// +// Native mode has its own DB-backed config/runtime path. It does not spawn or +// query the external xray binary and does not require /opt/sshpanel/xray to be +// installed. The JSON shape remains Xray-compatible so the same panel wizard can +// generate native and external configs. + +import ( + "bufio" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// ---------- parsed config model ---------- + +// nativeXrayClient is one authorized user parsed from an inbound's clients array. +type nativeXrayClient struct { + id [16]byte // parsed UUID bytes, used as the fast lookup key + uuid string // canonical string form (for logs) + email string // stats label + + // VMess-only material, precomputed at parse time (nil/zero for VLESS). + cmdKey [16]byte // MD5(uuid || vmess magic) + authIDCipher cipher.Block // AES-128 over KDF16(cmdKey, "AES Auth ID Encryption") +} + +// nativeInbound is a single listener built from one JSON inbound entry. +type nativeInbound struct { + tag string + protocol string // "vless" | "vmess" + listen string // bind host, default 0.0.0.0 + port int + transport string // "tcp" | "ws" | "xhttp" | ... + path string // ws/xhttp request path (default "/") + security string // "tls" | "" (none) + + // XHTTP/SplitHTTP transport options. Only the fields that affect the wire + // format are mirrored here; unsupported obfuscation/padding knobs are ignored + // leniently so existing panel configs keep working. + xhttpHost string + xhttpMode string + xhttpSessionPlacement string + xhttpSessionKey string + xhttpSeqPlacement string + xhttpSeqKey string + xhttpUplinkDataPlacement string + xhttpUplinkDataKey string + xhttpMaxEachPostBytes int64 + xhttpMaxBufferedPosts int + xhttpMaxHeaderBytes int + xhttpNoSSEHeader bool + xhttpSessions map[string]*nativeXHTTPSession + xhttpMu sync.Mutex + + tlsConfig *tls.Config // built when security == "tls" + + clientMu sync.RWMutex + clientsByID map[[16]byte]*nativeXrayClient + + // Per-connection bandwidth ceilings in bytes/sec (0 = unlimited). Sourced + // from the panel's default limits, matching the SSH tunnel behaviour. + upBytesPerSec int + downBytesPerSec int +} + +// ---------- listener manager ---------- + +type nativeXrayServer struct { + mu sync.Mutex + listeners []net.Listener + inboundsByTag map[string]*nativeInbound + running bool + startTime time.Time +} + +var nativeXray = &nativeXrayServer{} + +// nativeRunning reports whether the in-process Xray listeners are up. +func (s *nativeXrayServer) nativeRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} + +// start parses the config file and opens a listener for every supported +// inbound. It is idempotent-ish: callers (XrayManager) guard against double +// start, but start() will refuse if already running. +func (s *nativeXrayServer) start(configFile string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.running { + return fmt.Errorf("native xray already running") + } + if configFile == "" { + return fmt.Errorf("native xray: no config file configured") + } + + inbounds, err := parseNativeInbounds(configFile) + if err != nil { + return err + } + if len(inbounds) == 0 { + return fmt.Errorf("native xray: no servable inbounds in %s", configFile) + } + + var opened []net.Listener + active := make(map[string]*nativeInbound, len(inbounds)) + for _, ib := range inbounds { + addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port)) + ln, err := net.Listen("tcp", addr) + if err != nil { + // Roll back anything already opened so we don't leak listeners. + for _, l := range opened { + _ = l.Close() + } + return fmt.Errorf("native xray: listen %s (inbound %q): %w", addr, ib.tag, err) + } + + serveLn := ln + if ib.isXHTTP() { + // HTTP/XHTTP needs a real http.Server because one logical XHTTP + // session can span several HTTP requests/connections. TLS is therefore + // wrapped at listener level instead of inside serve(). + if ib.security == "tls" { + serveLn = tls.NewListener(ln, ib.tlsConfig) + } + opened = append(opened, serveLn) + go ib.serveXHTTPListener(serveLn) + } else { + opened = append(opened, serveLn) + go ib.acceptLoop(serveLn) + } + active[ib.tag] = ib + log.Printf("native xray: serving %s/%s on %s (inbound %q, security=%s, %d clients)", + ib.protocol, ib.transport, addr, ib.tag, orNone(ib.security), ib.clientCount()) + } + + s.listeners = opened + s.inboundsByTag = active + s.running = true + s.startTime = time.Now() + return nil +} + +func (s *nativeXrayServer) stop() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.running && len(s.listeners) == 0 { + return + } + for _, l := range s.listeners { + _ = l.Close() + } + s.listeners = nil + s.inboundsByTag = nil + s.running = false + log.Printf("native xray: stopped") +} + +func (ib *nativeInbound) acceptLoop(ln net.Listener) { + for { + c, err := ln.Accept() + if err != nil { + if isListenerClosed(err) { + return + } + log.Printf("native xray: accept error on %s: %v", ln.Addr(), err) + continue + } + go ib.serve(c) + } +} + +// serve terminates TLS + transport, then dispatches on protocol. +func (ib *nativeInbound) serve(raw net.Conn) { + defer raw.Close() + + if tc, ok := raw.(*net.TCPConn); ok { + _ = tc.SetKeepAlive(true) + _ = tc.SetKeepAlivePeriod(30 * time.Second) + _ = tc.SetNoDelay(true) + } + + // --- security layer --- + var conn net.Conn = raw + if ib.security == "tls" { + tconn := tls.Server(raw, ib.tlsConfig) + _ = tconn.SetDeadline(time.Now().Add(tlsHandshakeTimeout)) + if err := tconn.Handshake(); err != nil { + log.Printf("native xray: tls handshake from %s failed: %v", raw.RemoteAddr(), err) + return + } + _ = tconn.SetDeadline(time.Time{}) + conn = tconn + } + + // --- transport layer --- + var stream net.Conn = conn + switch ib.transport { + case "tcp", "raw", "": + // stream is already the protocol stream + case "ws", "websocket": + ws, err := wsServerHandshake(conn, ib.path) + if err != nil { + log.Printf("native xray: ws handshake from %s failed: %v", raw.RemoteAddr(), err) + return + } + stream = ws + case "xhttp", "splithttp": + log.Printf("native xray: inbound %q got raw connection for XHTTP; this transport is served by http.Server", ib.tag) + return + default: + log.Printf("native xray: inbound %q transport %q not supported yet; dropping conn from %s", + ib.tag, ib.transport, raw.RemoteAddr()) + return + } + + // --- protocol layer --- + switch ib.protocol { + case "vless": + ib.handleVLESS(stream, raw.RemoteAddr()) + case "vmess": + ib.handleVMess(stream, raw.RemoteAddr()) + default: + log.Printf("native xray: inbound %q protocol %q not supported yet; dropping conn from %s", + ib.tag, ib.protocol, raw.RemoteAddr()) + } +} + +// ---------- VLESS ---------- +// +// VLESS request header (client -> server): +// 1 byte version (0) +// 16 bytes UUID +// 1 byte addon length M +// M bytes addons (flow etc.) — skipped +// 1 byte command (1=TCP, 2=UDP, 3=Mux) +// 2 bytes port (big endian) +// 1 byte address type (1=IPv4, 2=domain, 3=IPv6) +// ... address +// ... payload +// Response (server -> client): 1 byte version echo, 1 byte addon length (0). + +const ( + vlessCmdTCP = 1 + vlessCmdUDP = 2 + vlessCmdMux = 3 + + atypIPv4 = 1 + atypDomain = 2 + atypIPv6 = 3 +) + +func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) { + log.Printf("native xray: vless handshake start inbound=%q transport=%s remote=%s", ib.tag, ib.transport, remote) + _ = stream.SetReadDeadline(time.Now().Add(30 * time.Second)) + + head := make([]byte, 1+16+1) // version + uuid + addonLen + if _, err := io.ReadFull(stream, head); err != nil { + log.Printf("native xray: vless handshake failed inbound=%q transport=%s remote=%s: %v", ib.tag, ib.transport, remote, err) + return + } + version := head[0] + var id [16]byte + copy(id[:], head[1:17]) + + client := ib.getNativeClient(id) + if client == nil { + log.Printf("native xray: inbound %q rejected unknown VLESS uuid from %s", ib.tag, remote) + return + } + + if addonLen := int(head[17]); addonLen > 0 { + if _, err := io.CopyN(io.Discard, stream, int64(addonLen)); err != nil { + log.Printf("native xray: vless addon read failed inbound=%q user=%s: %v", ib.tag, client.email, err) + return + } + } + + var cmd [1]byte + if _, err := io.ReadFull(stream, cmd[:]); err != nil { + log.Printf("native xray: vless command read failed inbound=%q user=%s: %v", ib.tag, client.email, err) + return + } + var portBuf [2]byte + if _, err := io.ReadFull(stream, portBuf[:]); err != nil { + log.Printf("native xray: vless port read failed inbound=%q user=%s: %v", ib.tag, client.email, err) + return + } + port := binary.BigEndian.Uint16(portBuf[:]) + + host, err := readProxyAddress(stream) + if err != nil { + log.Printf("native xray: inbound %q VLESS bad address from %s: %v", ib.tag, remote, err) + return + } + _ = stream.SetReadDeadline(time.Time{}) + + // VLESS response header must be sent before relaying payload. + if _, err := stream.Write([]byte{version, 0}); err != nil { + log.Printf("native xray: vless response write failed inbound=%q user=%s: %v", ib.tag, client.email, err) + return + } + + switch cmd[0] { + case vlessCmdTCP: + backend, target, err := ib.nativeDialTCP(host, port) + if err != nil { + log.Printf("native xray: inbound %q VLESS TCP dial %s failed: %v", ib.tag, target, err) + return + } + log.Printf("native xray: vless/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag) + nativeTunnel(stream, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter()) + case vlessCmdUDP: + backend, target, err := ib.nativeDialUDP(host, port) + if err != nil { + log.Printf("native xray: inbound %q VLESS UDP dial %s failed: %v", ib.tag, target, err) + return + } + log.Printf("native xray: vless/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag) + nativeVLESSUDPTunnel(stream, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter()) + default: + log.Printf("native xray: inbound %q VLESS command %d not supported yet", ib.tag, cmd[0]) + } +} + +// readProxyAddress reads a VMess/VLESS-style address (type byte + address). +func readProxyAddress(r io.Reader) (string, error) { + var t [1]byte + if _, err := io.ReadFull(r, t[:]); err != nil { + return "", err + } + switch t[0] { + case atypIPv4: + b := make([]byte, 4) + if _, err := io.ReadFull(r, b); err != nil { + return "", err + } + return net.IP(b).String(), nil + case atypDomain: + var l [1]byte + if _, err := io.ReadFull(r, l[:]); err != nil { + return "", err + } + d := make([]byte, int(l[0])) + if _, err := io.ReadFull(r, d); err != nil { + return "", err + } + return string(d), nil + case atypIPv6: + b := make([]byte, 16) + if _, err := io.ReadFull(r, b); err != nil { + return "", err + } + return net.IP(b).String(), nil + default: + return "", fmt.Errorf("unknown address type %d", t[0]) + } +} + +func (ib *nativeInbound) nativeDialTCP(host string, port uint16) (net.Conn, string, error) { + return ib.nativeDialTarget("tcp", host, port) +} + +func (ib *nativeInbound) nativeDialUDP(host string, port uint16) (net.Conn, string, error) { + return ib.nativeDialTarget("udp", host, port) +} + +func nativeDialTCP(host string, port uint16) (net.Conn, string, error) { + return nativeDialTarget("tcp", host, port) +} + +func nativeDialUDP(host string, port uint16) (net.Conn, string, error) { + return nativeDialTarget("udp", host, port) +} + +func nativeDialTarget(network, host string, port uint16) (net.Conn, string, error) { + return nativeDialTargetWithSource(network, host, port, "") +} + +func (ib *nativeInbound) nativeDialTarget(network, host string, port uint16) (net.Conn, string, error) { + return nativeDialTargetWithSource(network, host, port, ib.listen) +} + +func nativeDialTargetWithSource(network, host string, port uint16, sourceHost string) (net.Conn, string, error) { + targetHost := normalizeNativeTargetHost(host) + target := net.JoinHostPort(targetHost, strconv.Itoa(int(port))) + dialNetwork := nativeDialNetwork(network, targetHost) + + // IPv6 tunnel traffic must remain IPv6, but binding to the inbound/listen + // address is not always valid on providers with routed /128s, policy routing, + // or multiple IPv6 addresses. Try the source-bound dial first when it makes + // sense, then fall back to the kernel's normal source selection before giving + // up. This mirrors external Xray/freedom behavior more closely and prevents + // client-side ERR_CONNECTION_CLOSED when the first IPv6 source choice fails. + var attempts []net.Addr + if local := nativeLocalAddrForDial(dialNetwork, targetHost, sourceHost); local != nil { + attempts = append(attempts, local) + } + attempts = append(attempts, nil) + + var lastErr error + for i, local := range attempts { + ctx, cancel := context.WithTimeout(context.Background(), directTCPIPDialTimeout) + d := &net.Dialer{Timeout: directTCPIPDialTimeout, KeepAlive: 30 * time.Second} + if local != nil { + d.LocalAddr = local + } + conn, err := d.DialContext(ctx, dialNetwork, target) + cancel() + if err == nil { + if i > 0 && len(attempts) > 1 { + log.Printf("native xray: outbound dial recovered target=%s network=%s using auto source after bound source failed", target, dialNetwork) + } + return conn, target, nil + } + lastErr = err + if local != nil { + log.Printf("native xray: outbound dial target=%s network=%s source=%s failed, retrying auto source: %v", target, dialNetwork, local.String(), err) + } + } + return nil, target, lastErr +} + +func nativeDialNetwork(base, host string) string { + if base != "tcp" && base != "udp" { + return base + } + ip := net.ParseIP(normalizeNativeTargetHost(host)) + if ip == nil { + // Domain targets must remain dual-stack. Let Go's dialer use the server's + // resolver and Happy Eyeballs instead of forcing IPv4. This matches the + // expected behavior when the Android client has IPv6 route enabled. + return base + } + if ip.To4() != nil { + return base + "4" + } + return base + "6" +} + +func nativeLocalAddrForDial(network, targetHost, sourceHost string) net.Addr { + base := network + if strings.HasSuffix(base, "4") || strings.HasSuffix(base, "6") { + base = base[:len(base)-1] + } + if base != "tcp" && base != "udp" { + return nil + } + targetIP := net.ParseIP(normalizeNativeTargetHost(targetHost)) + if targetIP == nil { + return nil + } + sourceIP := net.ParseIP(normalizeNativeListenHost(sourceHost)) + if sourceIP == nil || sourceIP.IsUnspecified() { + return nil + } + // The tunnel must preserve the target address family. When the client sends + // an IPv6 destination, bind the outbound socket to the inbound/listen IPv6 + // address so Linux does not select a different or unrouted IPv6 source. This + // fixes the case where the Android client has IPv6 enabled and sends AAAA + // destinations through VLESS/VMess. Do not bind an IPv4 source for an IPv6 + // target, or the dial will fail before leaving the server. + if targetIP.To4() == nil { + if sourceIP.To4() != nil { + return nil + } + if base == "tcp" { + return &net.TCPAddr{IP: sourceIP} + } + return &net.UDPAddr{IP: sourceIP} + } + if sourceIP.To4() == nil { + return nil + } + if base == "tcp" { + return &net.TCPAddr{IP: sourceIP} + } + return &net.UDPAddr{IP: sourceIP} +} + +func normalizeNativeTargetHost(raw string) string { + v := strings.TrimSpace(raw) + if v == "" { + return v + } + if h, _, err := net.SplitHostPort(v); err == nil { + v = strings.TrimSpace(h) + } + for len(v) >= 2 && strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { + v = strings.TrimSpace(v[1 : len(v)-1]) + } + return v +} + +// ---------- bidirectional tunnel + traffic metering ---------- + +// nativeTunnel pipes bytes between the decoded client stream and the dialed +// backend, applying per-direction rate limits and accounting traffic against +// the client's email so the panel's online detection keeps working. It mirrors +// handleDirectTCPIP in main.go. +func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, up, down *rate.Limiter) { + xrayMgr.recordNativeConnect(uuid, email) + defer xrayMgr.recordNativeDisconnect(uuid, email) + 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() { // client -> backend (uplink) + defer wg.Done() + _, _ = copyWithRateLimit(meteredWriter{w: backend, meter: upMeter}, client, up) + if cw, ok := backend.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + closeAll() + }() + + wg.Add(1) + go func() { // backend -> client (downlink) + defer wg.Done() + _, _ = copyWithRateLimit(meteredWriter{w: client, meter: downMeter}, backend, down) + closeAll() + }() + + wg.Wait() + upMeter.flush() + downMeter.flush() + closeAll() +} + +// trafficMeter accumulates bytes for one direction and flushes them to the +// stats manager in batches to avoid locking on every write. +type trafficMeter struct { + uuid string + email string + uplink bool + n int64 +} + +const trafficFlushThreshold = 64 * 1024 + +func (t *trafficMeter) add(n int) { + t.n += int64(n) + if t.n >= trafficFlushThreshold { + t.flush() + } +} + +func (t *trafficMeter) flush() { + if t.n == 0 || t.email == "" { + return + } + if t.uplink { + xrayMgr.recordNativeTraffic(t.uuid, t.email, t.n, 0) + } else { + xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n) + } + t.n = 0 +} + +// meteredWriter counts bytes as they are written through to the wrapped writer. +type meteredWriter struct { + w io.Writer + meter *trafficMeter +} + +func (mw meteredWriter) Write(p []byte) (int, error) { + n, err := mw.w.Write(p) + if n > 0 { + mw.meter.add(n) + } + return n, err +} + +func (ib *nativeInbound) upLimiter() *rate.Limiter { return newByteLimiter(ib.upBytesPerSec) } +func (ib *nativeInbound) downLimiter() *rate.Limiter { return newByteLimiter(ib.downBytesPerSec) } + +func newByteLimiter(bytesPerSec int) *rate.Limiter { + if bytesPerSec <= 0 { + return nil + } + return rate.NewLimiter(rate.Limit(bytesPerSec), bytesPerSec) +} + +// ---------- WebSocket transport (RFC 6455, server side) ---------- + +const wsMagicGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +const wsMaxFrame = 16 * 1024 * 1024 + +// wsServerHandshake performs the server side of the WebSocket upgrade over an +// already-established (optionally TLS) connection, then returns a net.Conn whose +// Read/Write speak binary WebSocket frames. +func wsServerHandshake(conn net.Conn, wantPath string) (*websocketConn, error) { + br := bufio.NewReader(conn) + req, err := http.ReadRequest(br) + if err != nil { + return nil, err + } + if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") { + return nil, errors.New("missing websocket upgrade") + } + key := req.Header.Get("Sec-WebSocket-Key") + if key == "" { + return nil, errors.New("missing Sec-WebSocket-Key") + } + if wantPath != "" && wantPath != "/" && req.URL.Path != wantPath { + return nil, fmt.Errorf("ws path mismatch: got %q want %q", req.URL.Path, wantPath) + } + + sum := sha1.Sum([]byte(key + wsMagicGUID)) + accept := base64.StdEncoding.EncodeToString(sum[:]) + resp := "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n" + if _, err := conn.Write([]byte(resp)); err != nil { + return nil, err + } + return &websocketConn{Conn: conn, r: br}, nil +} + +// websocketConn adapts a WebSocket data stream to a net.Conn. Client frames are +// masked; server frames are written unmasked as binary frames. +type websocketConn struct { + net.Conn + r *bufio.Reader + readBuf []byte // decoded payload not yet consumed by Read + wmu sync.Mutex +} + +func (c *websocketConn) Read(p []byte) (int, error) { + for len(c.readBuf) == 0 { + payload, opcode, err := c.readFrame() + if err != nil { + return 0, err + } + switch opcode { + case 0x8: // close + return 0, io.EOF + case 0x9: // ping -> pong + _ = c.writeFrame(0xA, payload) + continue + case 0xA: // pong -> ignore + continue + default: // 0x0 continuation, 0x1 text, 0x2 binary -> treat as data + c.readBuf = payload + } + } + n := copy(p, c.readBuf) + c.readBuf = c.readBuf[n:] + return n, nil +} + +func (c *websocketConn) readFrame() (payload []byte, opcode byte, err error) { + var h [2]byte + if _, err = io.ReadFull(c.r, h[:]); err != nil { + return nil, 0, err + } + opcode = h[0] & 0x0f + masked := h[1]&0x80 != 0 + length := int64(h[1] & 0x7f) + switch length { + case 126: + var ext [2]byte + if _, err = io.ReadFull(c.r, ext[:]); err != nil { + return nil, 0, err + } + length = int64(binary.BigEndian.Uint16(ext[:])) + case 127: + var ext [8]byte + if _, err = io.ReadFull(c.r, ext[:]); err != nil { + return nil, 0, err + } + length = int64(binary.BigEndian.Uint64(ext[:])) + } + if length < 0 || length > wsMaxFrame { + return nil, 0, fmt.Errorf("ws frame too large: %d", length) + } + var mask [4]byte + if masked { + if _, err = io.ReadFull(c.r, mask[:]); err != nil { + return nil, 0, err + } + } + payload = make([]byte, length) + if _, err = io.ReadFull(c.r, payload); err != nil { + return nil, 0, err + } + if masked { + for i := range payload { + payload[i] ^= mask[i&3] + } + } + return payload, opcode, nil +} + +func (c *websocketConn) Write(p []byte) (int, error) { + if err := c.writeFrame(0x2, p); err != nil { + return 0, err + } + return len(p), nil +} + +func (c *websocketConn) writeFrame(opcode byte, payload []byte) error { + c.wmu.Lock() + defer c.wmu.Unlock() + + n := len(payload) + var header []byte + b0 := byte(0x80) | opcode // FIN + opcode + switch { + case n < 126: + header = []byte{b0, byte(n)} + case n <= 0xffff: + header = []byte{b0, 126, byte(n >> 8), byte(n)} + default: + header = make([]byte, 10) + header[0] = b0 + header[1] = 127 + binary.BigEndian.PutUint64(header[2:], uint64(n)) + } + frame := make([]byte, len(header)+n) + copy(frame, header) + copy(frame[len(header):], payload) + _, err := c.Conn.Write(frame) + return err +} + +// ---------- config parsing ---------- + +// nativeXrayConfigFile mirrors the subset of the Xray JSON config the native +// server understands. +type nativeXrayConfigFile struct { + Inbounds []nativeInboundJSON `json:"inbounds"` +} + +type nativeXHTTPSettingsJSON struct { + Host string `json:"host"` + Path string `json:"path"` + Mode string `json:"mode"` + NoSSEHeader bool `json:"noSSEHeader"` + SessionIDPlacement string `json:"sessionIDPlacement"` + SessionIDKey string `json:"sessionIDKey"` + SeqPlacement string `json:"seqPlacement"` + SeqKey string `json:"seqKey"` + UplinkDataPlacement string `json:"uplinkDataPlacement"` + UplinkDataKey string `json:"uplinkDataKey"` + ScMaxEachPostBytes *nativeRangeJSON `json:"scMaxEachPostBytes"` + ScMaxBufferedPosts int `json:"scMaxBufferedPosts"` + ServerMaxHeaderBytes int `json:"serverMaxHeaderBytes"` +} + +type nativeRangeJSON struct { + From int64 `json:"from"` + To int64 `json:"to"` +} + +type nativeInboundJSON struct { + Tag string `json:"tag"` + Protocol string `json:"protocol"` + Listen string `json:"listen"` + Port json.RawMessage `json:"port"` + 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"` + StreamSettings struct { + Network string `json:"network"` + Security string `json:"security"` + TLSSettings struct { + Certificates []struct { + CertificateFile string `json:"certificateFile"` + KeyFile string `json:"keyFile"` + } `json:"certificates"` + } `json:"tlsSettings"` + WSSettings struct { + Path string `json:"path"` + } `json:"wsSettings"` + XHTTPSettings nativeXHTTPSettingsJSON `json:"xhttpSettings"` + SplitHTTPSettings nativeXHTTPSettingsJSON `json:"splithttpSettings"` + } `json:"streamSettings"` +} + +// parseNativeInbounds reads the Xray config file and returns one nativeInbound +// per servable client-bearing inbound. Unsupported inbounds (api dokodemo-door, +// freedom, etc.) are silently skipped. +func parseNativeInbounds(configFile string) ([]*nativeInbound, error) { + data, err := os.ReadFile(configFile) + if err != nil { + return nil, err + } + var cf nativeXrayConfigFile + if err := json.Unmarshal(data, &cf); err != nil { + return nil, fmt.Errorf("native xray: parse %s: %w", configFile, err) + } + + upBps, downBps := defaultNativeLimits() + + var out []*nativeInbound + for _, in := range cf.Inbounds { + proto := strings.ToLower(strings.TrimSpace(in.Protocol)) + if !xrayClientProtos[proto] { + continue // only vless/vmess/trojan carry clients; skip api/freedom/etc. + } + port, ok := parseSinglePort(in.Port) + if !ok { + log.Printf("native xray: inbound %q has unsupported port form; skipping", in.Tag) + continue + } + + ib := &nativeInbound{ + tag: in.Tag, + protocol: proto, + listen: normalizeNativeListenHost(firstNonEmpty(in.Listen, "0.0.0.0")), + port: port, + transport: strings.ToLower(firstNonEmpty(in.StreamSettings.Network, "tcp")), + security: strings.ToLower(strings.TrimSpace(in.StreamSettings.Security)), + clientsByID: make(map[[16]byte]*nativeXrayClient), + upBytesPerSec: upBps, + downBytesPerSec: downBps, + } + if ib.security == "none" { + ib.security = "" + } + + switch ib.transport { + case "ws", "websocket": + ib.path = firstNonEmpty(in.StreamSettings.WSSettings.Path, "/") + case "xhttp", "splithttp": + xh := mergeNativeXHTTPSettings(in.StreamSettings.XHTTPSettings, in.StreamSettings.SplitHTTPSettings) + ib.path = normalizeXHTTPPath(firstNonEmpty(xh.Path, "/xhttp")) + ib.xhttpHost = strings.TrimSpace(xh.Host) + ib.xhttpMode = strings.ToLower(strings.TrimSpace(xh.Mode)) + ib.xhttpSessionPlacement = strings.ToLower(strings.TrimSpace(xh.SessionIDPlacement)) + ib.xhttpSessionKey = strings.TrimSpace(xh.SessionIDKey) + ib.xhttpSeqPlacement = strings.ToLower(strings.TrimSpace(xh.SeqPlacement)) + ib.xhttpSeqKey = strings.TrimSpace(xh.SeqKey) + ib.xhttpUplinkDataPlacement = strings.ToLower(strings.TrimSpace(xh.UplinkDataPlacement)) + ib.xhttpUplinkDataKey = strings.TrimSpace(xh.UplinkDataKey) + ib.xhttpNoSSEHeader = xh.NoSSEHeader + ib.xhttpMaxHeaderBytes = xh.ServerMaxHeaderBytes + ib.xhttpMaxEachPostBytes = 1_000_000 + if xh.ScMaxEachPostBytes != nil && xh.ScMaxEachPostBytes.To > 0 { + ib.xhttpMaxEachPostBytes = xh.ScMaxEachPostBytes.To + } + ib.xhttpMaxBufferedPosts = xh.ScMaxBufferedPosts + if ib.xhttpMaxBufferedPosts <= 0 { + ib.xhttpMaxBufferedPosts = 30 + } + ib.xhttpSessions = make(map[string]*nativeXHTTPSession) + } + + // TLS certificate: prefer the inbound's own tlsSettings, else fall back + // to the panel's top-level cert/key. + if ib.security == "tls" { + tc, err := buildInboundTLS(in) + if err != nil { + log.Printf("native xray: inbound %q TLS disabled: %v; skipping", in.Tag, err) + continue + } + ib.tlsConfig = tc + } + + configClients := in.Settings.Clients + if len(in.Settings.Users) > 0 { + configClients = append(configClients, in.Settings.Users...) + } + for _, c := range configClients { + raw := c.ID + if raw == "" { + raw = c.Password // some protocols reuse password as id + } + if err := ib.addNativeClient(proto, raw, c.Email); err != nil { + log.Printf("native xray: inbound %q skipping client %q: %v", in.Tag, raw, err) + } + } + if statsStore != nil && in.Tag != "" { + metas, err := statsStore.ListXrayClientsByInbound(context.Background(), in.Tag) + if err != nil { + log.Printf("native xray: inbound %q database clients unavailable: %v", in.Tag, err) + } else { + for _, m := range metas { + if err := ib.addNativeClient(proto, m.UUID, firstNonEmpty(m.Email, m.Name, m.UUID)); err != nil { + log.Printf("native xray: inbound %q skipping DB client %q: %v", in.Tag, m.UUID, err) + } + } + } + } + + if ib.clientCount() == 0 { + log.Printf("native xray: inbound %q has no valid clients; skipping", in.Tag) + continue + } + out = append(out, ib) + } + return out, nil +} + +func (ib *nativeInbound) makeNativeClient(proto, raw, email string) (*nativeXrayClient, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty uuid") + } + id, err := parseUUID(raw) + if err != nil { + return nil, err + } + if email == "" { + email = raw + } + nc := &nativeXrayClient{id: id, uuid: raw, email: email} + if proto == "vmess" { + nc.cmdKey = vmessCmdKey(id) + block, err := aes.NewCipher(vmessKDF16(nc.cmdKey[:], kdfLabelAuthIDEncryptionKey)) + if err != nil { + return nil, fmt.Errorf("vmess cipher init: %w", err) + } + nc.authIDCipher = block + } + return nc, nil +} + +func (ib *nativeInbound) addNativeClient(proto, raw, email string) error { + nc, err := ib.makeNativeClient(proto, raw, email) + if err != nil { + return err + } + ib.clientMu.Lock() + if ib.clientsByID == nil { + ib.clientsByID = make(map[[16]byte]*nativeXrayClient) + } + ib.clientsByID[nc.id] = nc + ib.clientMu.Unlock() + return nil +} + +func (ib *nativeInbound) getNativeClient(id [16]byte) *nativeXrayClient { + ib.clientMu.RLock() + defer ib.clientMu.RUnlock() + return ib.clientsByID[id] +} + +func (ib *nativeInbound) removeNativeClient(uuid string) bool { + id, err := parseUUID(uuid) + if err != nil { + return false + } + ib.clientMu.Lock() + _, existed := ib.clientsByID[id] + delete(ib.clientsByID, id) + ib.clientMu.Unlock() + return existed +} + +func (ib *nativeInbound) updateNativeClientEmail(uuid, email string) bool { + id, err := parseUUID(uuid) + if err != nil || strings.TrimSpace(email) == "" { + return false + } + ib.clientMu.Lock() + defer ib.clientMu.Unlock() + old := ib.clientsByID[id] + if old == nil { + return false + } + cp := *old + cp.email = email + ib.clientsByID[id] = &cp + return true +} + +func (ib *nativeInbound) clientCount() int { + ib.clientMu.RLock() + defer ib.clientMu.RUnlock() + return len(ib.clientsByID) +} + +func (s *nativeXrayServer) addClient(inboundTag, uuid, email string) error { + s.mu.Lock() + ib := s.inboundsByTag[inboundTag] + running := s.running + s.mu.Unlock() + if !running || ib == nil { + return nil + } + return ib.addNativeClient(ib.protocol, uuid, email) +} + +func (s *nativeXrayServer) removeClient(inboundTag, uuid string) error { + s.mu.Lock() + ib := s.inboundsByTag[inboundTag] + running := s.running + s.mu.Unlock() + if !running || ib == nil { + return nil + } + ib.removeNativeClient(uuid) + return nil +} + +func (s *nativeXrayServer) updateClientEmail(uuid, email string) error { + s.mu.Lock() + inbounds := make([]*nativeInbound, 0, len(s.inboundsByTag)) + for _, ib := range s.inboundsByTag { + inbounds = append(inbounds, ib) + } + running := s.running + s.mu.Unlock() + if !running { + return nil + } + for _, ib := range inbounds { + ib.updateNativeClientEmail(uuid, email) + } + return nil +} + +func buildInboundTLS(in nativeInboundJSON) (*tls.Config, error) { + var certFile, keyFile string + if certs := in.StreamSettings.TLSSettings.Certificates; len(certs) > 0 { + certFile = certs[0].CertificateFile + keyFile = certs[0].KeyFile + } + if certFile == "" || keyFile == "" { + // Fall back to the panel's first TLS forwarder cert/key, which is the + // main TLS material an operator already configured for TLS-over-SSH. + if gc := getGlobalCfg(); gc != nil && len(gc.TLSForwarders) > 0 { + if certFile == "" { + certFile = gc.TLSForwarders[0].CertFile + } + if keyFile == "" { + keyFile = gc.TLSForwarders[0].KeyFile + } + } + } + if certFile == "" || keyFile == "" { + return nil, errors.New("no certificate/key configured") + } + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"h2", "http/1.1"}, + }, nil +} + +// defaultNativeLimits converts the panel's default Mbps limits to bytes/sec. +func defaultNativeLimits() (up, down int) { + gc := getGlobalCfg() + if gc == nil { + return 0, 0 + } + return gc.DefaultLimitMbpsUp * 125000, gc.DefaultLimitMbpsDown * 125000 +} + +// parseSinglePort accepts an Xray port field that is a plain integer (the only +// form the panel generates) and returns it. Ranges/strings are rejected. +func parseSinglePort(raw json.RawMessage) (int, bool) { + if len(raw) == 0 { + return 0, false + } + var n int + if err := json.Unmarshal(raw, &n); err == nil && n > 0 && n < 65536 { + return n, true + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + if p, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && p > 0 && p < 65536 { + return p, true + } + } + return 0, false +} + +// parseUUID parses a canonical 36-char UUID string into 16 bytes. +func parseUUID(s string) ([16]byte, error) { + var out [16]byte + clean := strings.ReplaceAll(strings.TrimSpace(s), "-", "") + if len(clean) != 32 { + return out, fmt.Errorf("expected 32 hex chars, got %d", len(clean)) + } + b, err := hex.DecodeString(clean) + if err != nil { + return out, err + } + copy(out[:], b) + return out, nil +} + +// normalizeNativeListenHost returns the host-only value expected by net.JoinHostPort. +// Xray's listen field is host-only, but panel/manual configs often store IPv6 in +// bracket form ("[2001:db8::1]") or accidentally store a full socket address +// ("[2001:db8::1]:443"). Passing a bracketed host to net.JoinHostPort creates +// invalid addresses like "[[2001:db8::1]]:443". Strip those forms here. +func normalizeNativeListenHost(raw string) string { + v := strings.TrimSpace(raw) + if v == "" { + return "0.0.0.0" + } + if h, _, err := net.SplitHostPort(v); err == nil { + v = strings.TrimSpace(h) + } + for len(v) >= 2 && strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { + v = strings.TrimSpace(v[1 : len(v)-1]) + } + if v == "" { + return "0.0.0.0" + } + return v +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func orNone(s string) string { + if s == "" { + return "none" + } + return s +} diff --git a/xray_native_test.go b/xray_native_test.go new file mode 100644 index 0000000..f25f2c4 --- /dev/null +++ b/xray_native_test.go @@ -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) + } +} diff --git a/xray_native_udp.go b/xray_native_udp.go new file mode 100644 index 0000000..3371f9b --- /dev/null +++ b/xray_native_udp.go @@ -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) +} diff --git a/xray_vmess.go b/xray_vmess.go new file mode 100644 index 0000000..537d12d --- /dev/null +++ b/xray_vmess.go @@ -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()) + } +} diff --git a/xray_xhttp.go b/xray_xhttp.go new file mode 100644 index 0000000..bc6a6cb --- /dev/null +++ b/xray_xhttp.go @@ -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 +}