Native Xray
This commit is contained in:
-27
@@ -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/
|
||||
@@ -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**.
|
||||
|
||||
@@ -627,3 +627,14 @@ select:disabled {
|
||||
color:#94a3b8 !important;
|
||||
background:#070b12 !important;
|
||||
}
|
||||
|
||||
/* Xray runtime mode selector */
|
||||
.input-sm{
|
||||
min-height:30px;
|
||||
padding:4px 8px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(34,211,238,.26);
|
||||
background:#070b12;
|
||||
color:#f3f7ff;
|
||||
font-size:.78rem;
|
||||
}
|
||||
|
||||
+260
-18
@@ -42,7 +42,7 @@ const I18N_TEXT = {
|
||||
"Server Load":"Server Load","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Updated: {time}","Error loading stats.":"Error loading stats.","Normal load":"Normal load","Moderate load":"Moderate load","High load":"High load","Cleaning interface totals…":"Cleaning interface totals…","Interface totals cleaned. Auto-clean remains every 30 days.":"Interface totals cleaned. Auto-clean remains every 30 days.","Error cleaning totals: {error}":"Error cleaning totals: {error}",
|
||||
"VnStat Usage":"VnStat Usage","Today total":"Today total","This month total":"This month total","Interfaces tracked":"Interfaces tracked","daily / monthly":"daily / monthly","Daily usage":"Daily usage","Monthly usage":"Monthly usage","Day":"Day","Month":"Month","Clean usage":"Clean usage","Clean VnStat history":"Clean VnStat history","VnStat history does not auto-clean. Use the button when you want to reset it.":"VnStat history does not auto-clean. Use the button when you want to reset it.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.","Loading VnStat usage…":"Loading VnStat usage…","VnStat history cleaned.":"VnStat history cleaned.","Error loading VnStat usage: {error}":"Error loading VnStat usage: {error}","Error cleaning VnStat history: {error}":"Error cleaning VnStat history: {error}",
|
||||
"Panel / system":"Panel / system","Select a log source and click Refresh.":"Select a log source and click Refresh.","Clean panel log":"Clean panel log","No log lines yet.":"No log lines yet.","Panel log cleaned · {path} · max {max}":"Panel log cleaned · {path} · max {max}","Cleaning panel log…":"Cleaning panel log…",
|
||||
"Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes"
|
||||
"Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Auto Restart Interval":"Auto Restart Interval","Restart Grace Delay":"Restart Grace Delay","0s/off disables":"0s/off disables","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes"
|
||||
},
|
||||
"pt-BR": {
|
||||
"Dashboard":"Painel","Overview":"Visão geral","Accounts":"Contas","Administration":"Administração","Server":"Servidor","System":"Sistema","Settings":"Configurações","Traffic":"Tráfego","Monitoring":"Monitoramento",
|
||||
@@ -59,7 +59,7 @@ const I18N_TEXT = {
|
||||
"Server Load":"Carga do servidor","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Atualizado: {time}","Error loading stats.":"Erro ao carregar stats.","Normal load":"Carga normal","Moderate load":"Carga moderada","High load":"Carga alta","Cleaning interface totals…":"Limpando totais das interfaces…","Interface totals cleaned. Auto-clean remains every 30 days.":"Totais das interfaces limpos. A limpeza automática continua a cada 30 dias.","Error cleaning totals: {error}":"Erro ao limpar totais: {error}",
|
||||
"VnStat Usage":"Uso do VnStat","Today total":"Total hoje","This month total":"Total este mês","Interfaces tracked":"Interfaces monitoradas","daily / monthly":"diário / mensal","Daily usage":"Uso diário","Monthly usage":"Uso mensal","Day":"Dia","Month":"Mês","Clean usage":"Limpar uso","Clean VnStat history":"Limpar histórico VnStat","VnStat history does not auto-clean. Use the button when you want to reset it.":"O histórico VnStat não é limpo automaticamente. Use o botão quando quiser resetar.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Os totais podem ser limpos aqui e têm limpeza automática a cada 30 dias. O histórico VnStat é separado.","Loading VnStat usage…":"Carregando uso do VnStat…","VnStat history cleaned.":"Histórico VnStat limpo.","Error loading VnStat usage: {error}":"Erro ao carregar uso do VnStat: {error}","Error cleaning VnStat history: {error}":"Erro ao limpar histórico VnStat: {error}",
|
||||
"Panel / system":"Painel / sistema","Select a log source and click Refresh.":"Selecione uma fonte de log e clique em Atualizar.","Clean panel log":"Limpar log do painel","No log lines yet.":"Ainda não há linhas de log.","Panel log cleaned · {path} · max {max}":"Log do painel limpo · {path} · máx {max}","Cleaning panel log…":"Limpando log do painel…",
|
||||
"Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações"
|
||||
"Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","Proxy Auto Restart":"Reinício automático do proxy","Proxy Auto Restart Interval":"Intervalo de reinício automático do proxy","Proxy Restart Grace Delay":"Atraso para reiniciar proxy","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Auto Restart Interval":"Intervalo de reinício automático","Restart Grace Delay":"Atraso para reiniciar","0s/off disables":"0s/off desativa","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações"
|
||||
}
|
||||
};
|
||||
const I18N_ALIASES = {
|
||||
@@ -69,14 +69,14 @@ const I18N_ALIASES = {
|
||||
"Minha conta":"My Account","Usuários":"Users","Usuário":"User","Autenticação":"Auth","Conexões":"Conn","Máximo":"Max","Dono":"Owner","Ações":"Actions","Criar / atualizar usuário":"Create / update user","Mostrar formulário":"Show form","Ocultar formulário":"Hide form","Salvar usuário":"Save user","Cancelar":"Cancel","Gerar":"Gen","Copiar":"Copy","Editar":"Edit","Excluir":"Del","Recarregar":"Reload","Atualizar":"Refresh",
|
||||
"Rodando":"Running","Parado":"Stopped","rodando":"running","parado":"stopped","desativado":"disabled","API de contadores":"Counters API","Reparar contadores":"Repair counters","Iniciar":"Start","Parar":"Stop","Reiniciar":"Restart","Inbounds e clientes":"Inbounds & Clients","Configuração Xray":"Xray Config","Editor de configuração":"Config editor","Carregar JSON":"Load JSON","Salvar e reiniciar":"Save & Restart","Logs do sistema":"System Logs","últimas 200 linhas":"last 200 lines","Clientes Xray":"Xray clients","Núcleo Xray":"Xray Core","Ativado":"Enabled","Tempo ativo":"Uptime","Precisa de reparo":"Needs repair",
|
||||
"Nome":"Name","Nome de exibição":"Display Name","Data de vencimento":"Expiry Date","Máx. conexões":"Max Connections","Ilimitado":"Unlimited","Ativo":"Active","Suspenso":"Suspended","Expirado":"Expired","Sem vencimento":"No expiration","ocioso":"idle","Nenhum cliente.":"No clients.","Adicionar cliente":"Add Client","Novo usuário.":"New user.","Carregado.":"Loaded.","Salvo.":"Saved.","Salvando…":"Saving…","Erro ao carregar usuários.":"Error loading users.","Erro ao excluir.":"Error deleting.","Credenciais inválidas.":"Invalid credentials.","Conta suspensa ou expirada.":"Account suspended or expired.","Falha no login.":"Login failed.","Erro de rede.":"Network error.","Sessão expirada — faça login novamente.":"Session expired — please sign in again.",
|
||||
"Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live."
|
||||
"Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","Reinício automático do proxy":"Proxy Auto Restart","Intervalo de reinício automático do proxy":"Proxy Auto Restart Interval","Atraso para reiniciar proxy":"Proxy Restart Grace Delay","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live."
|
||||
};
|
||||
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Servers":"Servers","Reseller area":"Reseller area","shared quota":"shared quota","available":"available","used":"used","breakdown":"breakdown",
|
||||
"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.",
|
||||
"Loading inbounds…":"Loading inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"active ·","expired":"expired",
|
||||
"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
|
||||
"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
|
||||
"Public Key — share with dnstt clients":"Public Key — share with DNSTT clients","auto-saved to /opt/sshpanel/dnstt.key":"auto-saved to /opt/sshpanel/dnstt.key",
|
||||
"Max UDP Sessions Per Client":"Max UDP Sessions Per Client","(not total server users)":"(not total server users)","Service Name":"Service Name","Mode":"Mode","Protocol":"Protocol","Port":"Port","Tag":"Tag","Listen IP":"Listen IP","Method":"Method","Host":"Host","Path":"Path","Dest":"Dest","Short ID":"Short ID","Server Name":"Server Name","Cert File Path":"Cert File Path","Key File Path":"Key File Path","Certificate source:":"Certificate source:","Self-Signed":"Self-Signed","Paste PEM":"Paste PEM","File Path":"File Path","Save PEM":"Save PEM","Generate":"Generate","Public Key":"Public Key","Debug Logging":"Debug Logging","Name":"Name","Private Key PEM":"Private Key PEM","Certificate PEM":"Certificate PEM","Domain Name":"Domain Name"
|
||||
});
|
||||
@@ -84,7 +84,7 @@ Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Servers":"Servidores","Reseller area":"Área do revendedor","shared quota":"cota única","available":"disponíveis","used":"usadas","breakdown":"divisão",
|
||||
"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.",
|
||||
"Loading inbounds…":"Carregando inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"ativas ·","expired":"expiradas",
|
||||
"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085",
|
||||
"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085",
|
||||
"Public Key — share with dnstt clients":"Chave pública — compartilhe com clientes DNSTT","auto-saved to /opt/sshpanel/dnstt.key":"salva automaticamente em /opt/sshpanel/dnstt.key",
|
||||
"Max UDP Sessions Per Client":"Máx. sessões UDP por cliente","(not total server users)":"(não é o total de usuários do servidor)","Service Name":"Nome do serviço","Mode":"Modo","Protocol":"Protocolo","Port":"Porta","Tag":"Tag","Listen IP":"IP de listen","Method":"Método","Host":"Host","Path":"Caminho","Dest":"Destino","Short ID":"ID curto","Server Name":"Nome do servidor","Cert File Path":"Caminho do arquivo cert","Key File Path":"Caminho do arquivo key","Certificate source:":"Fonte do certificado:","Self-Signed":"Autoassinado","Paste PEM":"Colar PEM","File Path":"Caminho do arquivo","Save PEM":"Salvar PEM","Generate":"Gerar","Public Key":"Chave pública","Debug Logging":"Log de debug","Name":"Nome","Private Key PEM":"Chave privada PEM","Certificate PEM":"Certificado PEM","Domain Name":"Nome do domínio"
|
||||
});
|
||||
@@ -92,9 +92,9 @@ Object.assign(I18N_ALIASES, {
|
||||
"Servidores":"Servers","Área do revendedor":"Reseller area","cota única":"shared quota","disponíveis":"available","usadas":"used","divisão":"breakdown",
|
||||
"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.",
|
||||
"Carregando inbounds…":"Loading inbounds…","Loading inbounds…":"Loading inbounds…","ativas ·":"active ·","expiradas":"expired",
|
||||
"Binário: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
|
||||
"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
|
||||
"Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients",
|
||||
"Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Chave pública":"Public Key","Nome do domínio":"Domain Name"
|
||||
"Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name"
|
||||
});
|
||||
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
|
||||
let currentLang = detectInitialLanguage();
|
||||
@@ -269,6 +269,8 @@ const xPID = document.getElementById("xPID");
|
||||
const xUptime = document.getElementById("xUptime");
|
||||
const xStatus = document.getElementById("xStatus");
|
||||
const xOnlineUsers = document.getElementById("xOnlineUsers");
|
||||
const xCoreMode = document.getElementById("xCoreMode");
|
||||
const xSaveModeBtn = document.getElementById("xSaveModeBtn");
|
||||
const xCfgEditor = document.getElementById("xCfgEditor");
|
||||
const xCfgStatus = document.getElementById("xCfgStatus");
|
||||
const xLogsBox = document.getElementById("xLogsBox");
|
||||
@@ -326,6 +328,14 @@ const ifaceBody = document.getElementById("ifaceBody");
|
||||
const ifaceSummary = document.getElementById("ifaceSummary");
|
||||
const statsUpdated = document.getElementById("statsUpdated");
|
||||
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
|
||||
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
|
||||
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
|
||||
const dnsttActiveSessions = document.getElementById("dnsttActiveSessions");
|
||||
const dnsttActiveStreams = document.getElementById("dnsttActiveStreams");
|
||||
const dnsttDNSRx = document.getElementById("dnsttDNSRx");
|
||||
const dnsttQueueLen = document.getElementById("dnsttQueueLen");
|
||||
const dnsttHealthBody = document.getElementById("dnsttHealthBody");
|
||||
const dnsttHealthSummary = document.getElementById("dnsttHealthSummary");
|
||||
|
||||
// VnStat
|
||||
const vnstatDailyBody = document.getElementById("vnstatDailyBody");
|
||||
@@ -362,6 +372,25 @@ function selectedXrayServerLabel() {
|
||||
if (srv) return srv.name || srv.base_url || id;
|
||||
return id === "local" ? "Master node" : id;
|
||||
}
|
||||
function xrayModeFromConfig(x) {
|
||||
const mode = String(x?.mode || "").toLowerCase();
|
||||
return mode === "external" ? "external" : "native";
|
||||
}
|
||||
|
||||
function applyXrayModeToConfig(cfg, mode) {
|
||||
mode = mode === "external" ? "external" : "native";
|
||||
cfg.xray = cfg.xray && typeof cfg.xray === "object" ? cfg.xray : {};
|
||||
cfg.xray.mode = mode;
|
||||
cfg.xray.native = mode === "native";
|
||||
cfg.xray.bin_path = cfg.xray.bin_path || "/opt/sshpanel/xray";
|
||||
cfg.xray.config_file = cfg.xray.config_file || "/opt/sshpanel/xray_config.json";
|
||||
cfg.xray.native_config_file = cfg.xray.native_config_file || "/opt/sshpanel/xray_native_config.json";
|
||||
cfg.xray.api_server = cfg.xray.api_server || "127.0.0.1:10085";
|
||||
cfg.xray.online_window_seconds = cfg.xray.online_window_seconds || 90;
|
||||
cfg.xray.stats_poll_seconds = cfg.xray.stats_poll_seconds || 15;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function reloadXrayConfigForSelectedServer() {
|
||||
const wizPane = document.getElementById("xrayWizardPane");
|
||||
const jsonPane = document.getElementById("xrayCfgPaneJson");
|
||||
@@ -379,6 +408,16 @@ function fmtBytes(n) {
|
||||
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
|
||||
return (m/1024).toFixed(1)+" GiB";
|
||||
}
|
||||
function fmtInt(n) {
|
||||
const v = Number(n);
|
||||
return Number.isFinite(v) ? v.toLocaleString() : "--";
|
||||
}
|
||||
function fmtDnsttTimestamp(ts) {
|
||||
if (!ts) return "Waiting for DNSTT stats…";
|
||||
const d = new Date(ts);
|
||||
if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…";
|
||||
return "Updated: " + d.toLocaleTimeString();
|
||||
}
|
||||
function localDateKey(d = new Date()) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
@@ -461,6 +500,13 @@ function clientOnlineHTML(c) {
|
||||
return `${c.online ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("offline")}</span>`}<div class="hint">${escapeHTML(formatLastActive(c.last_active))}</div>`;
|
||||
}
|
||||
|
||||
function clientTrafficHTML(c) {
|
||||
const up = Number(c.uplink_bytes || 0);
|
||||
const down = Number(c.downlink_bytes || 0);
|
||||
const total = Number(c.total_bytes || (up + down) || 0);
|
||||
return `${escapeHTML(formatBytes(total))}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
|
||||
}
|
||||
|
||||
function updateCell(row, name, html) {
|
||||
const cell = row?.querySelector?.(`[data-cell="${name}"]`);
|
||||
if (cell && cell.innerHTML !== html) cell.innerHTML = html;
|
||||
@@ -496,7 +542,7 @@ function patchRenderedInbounds(inbounds) {
|
||||
updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
|
||||
updateCell(row, "status", clientStatusHTML(c));
|
||||
updateCell(row, "online", clientOnlineHTML(c));
|
||||
updateCell(row, "traffic", escapeHTML(formatBytes(c.total_bytes)));
|
||||
updateCell(row, "traffic", clientTrafficHTML(c));
|
||||
updateCell(row, "max", escapeHTML(c.max_conns || "∞"));
|
||||
}
|
||||
}
|
||||
@@ -550,7 +596,6 @@ document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click
|
||||
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
|
||||
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
|
||||
languageSelect?.addEventListener("change", () => { applyLanguage(languageSelect.value); renderDashboardCounters(); });
|
||||
document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump)));
|
||||
applyLanguage(currentLang, { persist: false });
|
||||
startI18nObserver();
|
||||
|
||||
@@ -956,6 +1001,7 @@ document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("s
|
||||
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
|
||||
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
|
||||
document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
|
||||
xSaveModeBtn?.addEventListener("click", saveXrayCoreMode);
|
||||
document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); });
|
||||
document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true }));
|
||||
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
|
||||
@@ -972,8 +1018,9 @@ async function loadXrayStatus() {
|
||||
xrayChip.className = "chip " + (run ? "green" : "red");
|
||||
xRunning.textContent = run ? t("Running") : t("Stopped");
|
||||
xRunning.style.color = run ? "var(--success)" : "var(--danger)";
|
||||
xPID.textContent = s.pid || "--";
|
||||
xPID.textContent = s.pid || (s.native ? "internal" : "--");
|
||||
xUptime.textContent = s.uptime || "--";
|
||||
if (xCoreMode) xCoreMode.value = String(s.mode || (s.native ? "native" : "external")).toLowerCase() === "external" ? "external" : "native";
|
||||
const statsCfgEl = document.getElementById("xStatsConfig");
|
||||
const repairBtn = document.getElementById("xRepairStatsBtn");
|
||||
if (statsCfgEl) {
|
||||
@@ -997,6 +1044,29 @@ async function loadXrayStatus() {
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
}
|
||||
|
||||
async function saveXrayCoreMode() {
|
||||
const mode = xCoreMode?.value === "external" ? "external" : "native";
|
||||
const target = selectedXrayServerLabel();
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (xStatus) xStatus.textContent = `Saving Xray mode on ${target}...`;
|
||||
try {
|
||||
const getRes = await api(withServerParam("/api/servers/config", selectedID));
|
||||
if (!getRes.ok) throw new Error(await getRes.text());
|
||||
const cfg = await getRes.json();
|
||||
applyXrayModeToConfig(cfg, mode);
|
||||
const postRes = await api(withServerParam("/api/servers/config", selectedID), { method:"POST", body: JSON.stringify(cfg) });
|
||||
if (!postRes.ok) throw new Error(await postRes.text());
|
||||
if (xStatus) xStatus.textContent = mode === "native"
|
||||
? `Saved on ${target}: using internal native emulator.`
|
||||
: `Saved on ${target}: using external Xray binary.`;
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
setTimeout(() => loadInbounds({ force: true }), 1200);
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (xStatus) xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
}
|
||||
}
|
||||
|
||||
async function repairXrayStats() {
|
||||
const btn = document.getElementById("xRepairStatsBtn");
|
||||
if (btn) btn.disabled = true;
|
||||
@@ -1155,7 +1225,7 @@ function renderInbounds(inbounds, options = {}) {
|
||||
<td data-cell="expiry" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
|
||||
<td data-cell="status">${clientStatusHTML(c)}</td>
|
||||
<td data-cell="online">${clientOnlineHTML(c)}</td>
|
||||
<td data-cell="traffic" style="font-size:.7rem;">${escapeHTML(formatBytes(c.total_bytes))}</td>
|
||||
<td data-cell="traffic" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
|
||||
<td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
|
||||
const actTd = document.createElement("td");
|
||||
actTd.style.whiteSpace = "nowrap";
|
||||
@@ -1215,7 +1285,7 @@ async function addClient(tag) {
|
||||
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns, server_id: selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client {id}… added. Restarting Xray…", {id: uuid.slice(0,8)});
|
||||
xStatus.textContent = t("Client {id}… added. Native mode hot-reloads without restart.", {id: uuid.slice(0,8)});
|
||||
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
@@ -1228,7 +1298,7 @@ async function removeClient(tag, uuid) {
|
||||
try {
|
||||
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client removed. Restarting Xray…");
|
||||
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart.");
|
||||
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
@@ -1764,6 +1834,8 @@ async function loadManagedServerConfig(id) {
|
||||
|
||||
document.getElementById("managedCfgListen").value = c.listen || "";
|
||||
document.getElementById("managedCfgExtraListen").value = (c.extra_listen || []).join("\n");
|
||||
document.getElementById("managedCfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
|
||||
document.getElementById("managedCfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
|
||||
|
||||
document.getElementById("managedCfgLimitUp").value = c.default_limit_mbps_up || 0;
|
||||
document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0;
|
||||
@@ -1775,11 +1847,25 @@ async function loadManagedServerConfig(id) {
|
||||
document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt;
|
||||
toggleManagedDnsttFields(hasDnstt);
|
||||
const d = c.dnstt || {};
|
||||
document.getElementById("managedCfgDnsttDomain").value = d.domain || "";
|
||||
document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d);
|
||||
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "";
|
||||
document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||
document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||
document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||
document.getElementById("managedCfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
|
||||
document.getElementById("managedCfgDnsttRespWorkers").value = d.dns_response_workers || 0;
|
||||
document.getElementById("managedCfgDnsttAutoRestart").value = d.auto_restart_interval || "";
|
||||
document.getElementById("managedCfgDnsttRestartGrace").value = d.auto_restart_grace || "";
|
||||
document.getElementById("managedCfgDnsttMaxSessions").value = d.max_sessions || 0;
|
||||
document.getElementById("managedCfgDnsttMaxStreams").value = d.max_streams || 0;
|
||||
document.getElementById("managedCfgDnsttPendingResponses").value = d.pending_responses || 0;
|
||||
document.getElementById("managedCfgDnsttStreamBuffer").value = d.stream_buffer || 0;
|
||||
document.getElementById("managedCfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
|
||||
document.getElementById("managedCfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
|
||||
document.getElementById("managedCfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
|
||||
document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log;
|
||||
document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log;
|
||||
document.getElementById("managedCfgDnsttLogConnections").checked = !!d.log_connections;
|
||||
|
||||
const hasUdpgw = !!c.udpgw;
|
||||
document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw;
|
||||
@@ -1789,6 +1875,8 @@ async function loadManagedServerConfig(id) {
|
||||
document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0;
|
||||
document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || "";
|
||||
document.getElementById("managedCfgUdpgwMapTTL").value = u.map_ttl || "";
|
||||
document.getElementById("managedCfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
|
||||
document.getElementById("managedCfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
|
||||
document.getElementById("managedCfgUdpgwDebug").checked = !!u.debug;
|
||||
|
||||
managedTlsForwardersState = c.tls_forwarders || [];
|
||||
@@ -1796,6 +1884,7 @@ async function loadManagedServerConfig(id) {
|
||||
|
||||
const x = c.xray || {};
|
||||
document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled;
|
||||
document.getElementById("managedCfgXrayMode").value = xrayModeFromConfig(x);
|
||||
|
||||
document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden");
|
||||
if (st) st.textContent = "Config loaded.";
|
||||
@@ -1808,9 +1897,12 @@ async function loadManagedServerConfig(id) {
|
||||
function managedConfigFromForm() {
|
||||
const extraLines = document.getElementById("managedCfgExtraListen").value
|
||||
.split("\n").map(s => s.trim()).filter(Boolean);
|
||||
const dnsttDomains = readDnsttDomains("managedCfgDnsttDomains");
|
||||
return {
|
||||
listen: document.getElementById("managedCfgListen").value.trim(),
|
||||
extra_listen: extraLines,
|
||||
proxy_auto_restart_interval: document.getElementById("managedCfgProxyAutoRestart").value.trim(),
|
||||
proxy_auto_restart_grace: document.getElementById("managedCfgProxyRestartGrace").value.trim(),
|
||||
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
|
||||
admin_dir: "/opt/sshpanel/admin",
|
||||
default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10),
|
||||
@@ -1820,24 +1912,44 @@ function managedConfigFromForm() {
|
||||
banner: document.getElementById("managedCfgBanner").value,
|
||||
banner_file: "/opt/sshpanel/banner.txt",
|
||||
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
|
||||
domain: document.getElementById("managedCfgDnsttDomain").value.trim(),
|
||||
domain: dnsttDomains[0] || "",
|
||||
domains: dnsttDomains,
|
||||
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(),
|
||||
fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked,
|
||||
fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(),
|
||||
fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(),
|
||||
fake_dns_workers: parseInt(document.getElementById("managedCfgDnsttFakeWorkers").value || "0", 10),
|
||||
dns_response_workers: parseInt(document.getElementById("managedCfgDnsttRespWorkers").value || "0", 10),
|
||||
auto_restart_interval: document.getElementById("managedCfgDnsttAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("managedCfgDnsttRestartGrace").value.trim(),
|
||||
max_sessions: parseInt(document.getElementById("managedCfgDnsttMaxSessions").value || "0", 10),
|
||||
max_streams: parseInt(document.getElementById("managedCfgDnsttMaxStreams").value || "0", 10),
|
||||
pending_responses: parseInt(document.getElementById("managedCfgDnsttPendingResponses").value || "0", 10),
|
||||
stream_buffer: parseInt(document.getElementById("managedCfgDnsttStreamBuffer").value || "0", 10),
|
||||
udp_read_buffer: parseInt(document.getElementById("managedCfgDnsttUDPReadBuffer").value || "0", 10),
|
||||
udp_write_buffer: parseInt(document.getElementById("managedCfgDnsttUDPWriteBuffer").value || "0", 10),
|
||||
privkey_file: document.getElementById("managedCfgDnsttKey").value.trim(),
|
||||
disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked,
|
||||
disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked,
|
||||
log_connections: document.getElementById("managedCfgDnsttLogConnections").checked,
|
||||
} : null,
|
||||
udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? {
|
||||
listen: document.getElementById("managedCfgUdpgwListen").value.trim(),
|
||||
max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10),
|
||||
idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(),
|
||||
map_ttl: document.getElementById("managedCfgUdpgwMapTTL").value.trim(),
|
||||
auto_restart_interval: document.getElementById("managedCfgUdpgwAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("managedCfgUdpgwRestartGrace").value.trim(),
|
||||
debug: document.getElementById("managedCfgUdpgwDebug").checked,
|
||||
} : null,
|
||||
tls_forwarders: managedTlsForwardersState,
|
||||
xray: {
|
||||
enabled: document.getElementById("managedCfgXrayEnabled").checked,
|
||||
mode: document.getElementById("managedCfgXrayMode").value === "external" ? "external" : "native",
|
||||
native: document.getElementById("managedCfgXrayMode").value !== "external",
|
||||
bin_path: "/opt/sshpanel/xray",
|
||||
config_file: "/opt/sshpanel/xray_config.json",
|
||||
native_config_file: "/opt/sshpanel/xray_native_config.json",
|
||||
api_server: "127.0.0.1:10085",
|
||||
online_window_seconds: 90,
|
||||
stats_poll_seconds: 15,
|
||||
@@ -2025,6 +2137,7 @@ async function loadDashboardStats() {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const s = await res.json();
|
||||
updateDashboardStats(s);
|
||||
await loadDnsttHealth();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
@@ -2061,6 +2174,76 @@ function updateDashboardStats(s) {
|
||||
if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`;
|
||||
}
|
||||
|
||||
async function loadDnsttHealth() {
|
||||
if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return;
|
||||
if (currentRole !== "superadmin") {
|
||||
dnsttDashboardCard?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/dnstt");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const d = await res.json();
|
||||
const enabled = d.enabled !== false;
|
||||
if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled);
|
||||
if (!enabled) return;
|
||||
|
||||
if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions);
|
||||
if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams);
|
||||
if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx);
|
||||
if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len);
|
||||
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp);
|
||||
|
||||
const rows = [
|
||||
["Session rejected", d.sess_rejected],
|
||||
["Stream rejected", d.stream_rejected],
|
||||
["DNS parse errors", d.parse_err],
|
||||
["No EDNS", d.no_edns],
|
||||
["EDNS limit 512", d.limit512],
|
||||
["Local DNS workers", d.fake_dns_workers],
|
||||
["Response workers", d.dns_response_workers],
|
||||
["Responses queued", d.rec_queued],
|
||||
["Responses dropped", d.rec_dropped],
|
||||
["Responses sent", d.resp_sent],
|
||||
["Response bytes", d.resp_bytes],
|
||||
["Empty responses", d.resp_empty],
|
||||
["Data responses", d.resp_data],
|
||||
["Oversize responses", d.resp_oversize],
|
||||
["KCP sessions new", d.kcp_new],
|
||||
["KCP sessions ended", d.kcp_end],
|
||||
["SMUX streams new", d.smux_new],
|
||||
["SMUX streams ended", d.smux_end],
|
||||
["Panic recovered", d.panic_recovered],
|
||||
];
|
||||
if (dnsttHealthBody) {
|
||||
dnsttHealthBody.innerHTML = "";
|
||||
for (let i = 0; i < rows.length; i += 2) {
|
||||
const a = rows[i];
|
||||
const b = rows[i + 1] || ["", ""];
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${a[0]}</td><td>${fmtInt(a[1])}</td><td>${b[0]}</td><td>${b[0] ? fmtInt(b[1]) : ""}</td>`;
|
||||
dnsttHealthBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0);
|
||||
if (dnsttHealthSummary) {
|
||||
if (d.running === false) {
|
||||
dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs.";
|
||||
} else {
|
||||
dnsttHealthSummary.textContent = bad > 0
|
||||
? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.`
|
||||
: "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window.";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === "auth") throw e;
|
||||
dnsttDashboardCard?.classList.add("hidden");
|
||||
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats.";
|
||||
if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await api("/api/stats");
|
||||
@@ -2087,6 +2270,7 @@ async function loadStats() {
|
||||
});
|
||||
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
|
||||
if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
|
||||
await loadDnsttHealth();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats.";
|
||||
@@ -2223,6 +2407,22 @@ async function clearPanelLog() {
|
||||
// ─── Server Config ────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
|
||||
|
||||
|
||||
function dnsttDomainsText(d) {
|
||||
const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []);
|
||||
return domains.join("\n");
|
||||
}
|
||||
function readDnsttDomains(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
const seen = new Set();
|
||||
return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => {
|
||||
if (seen.has(s)) return false;
|
||||
seen.add(s);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDnsttFields(on) {
|
||||
const el = document.getElementById("dnsttFields");
|
||||
el.style.opacity = on ? "1" : ".4";
|
||||
@@ -2245,6 +2445,8 @@ async function loadServerConfig() {
|
||||
// Network
|
||||
document.getElementById("cfgListen").value = c.listen || "";
|
||||
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
|
||||
document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
|
||||
document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
|
||||
|
||||
// SSH / general
|
||||
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
|
||||
@@ -2260,11 +2462,25 @@ async function loadServerConfig() {
|
||||
document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
|
||||
toggleDnsttFields(hasDnstt);
|
||||
const d = c.dnstt || {};
|
||||
document.getElementById("cfgDnsttDomain").value = d.domain || "";
|
||||
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
|
||||
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
|
||||
document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||
document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||
document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||
document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
|
||||
document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0;
|
||||
document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || "";
|
||||
document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || "";
|
||||
document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0;
|
||||
document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0;
|
||||
document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0;
|
||||
document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0;
|
||||
document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
|
||||
document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
|
||||
document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
|
||||
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
|
||||
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
|
||||
document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections;
|
||||
|
||||
// UDPGW
|
||||
const hasUdpgw = !!c.udpgw;
|
||||
@@ -2275,6 +2491,8 @@ async function loadServerConfig() {
|
||||
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
|
||||
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
|
||||
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
|
||||
document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
|
||||
document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
|
||||
document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
|
||||
|
||||
// TLS forwarders
|
||||
@@ -2284,6 +2502,7 @@ async function loadServerConfig() {
|
||||
// Xray
|
||||
const x = c.xray || {};
|
||||
document.getElementById("cfgXrayEnabled").checked = !!x.enabled;
|
||||
document.getElementById("cfgXrayMode").value = xrayModeFromConfig(x);
|
||||
|
||||
st.textContent = "Config loaded.";
|
||||
} catch (e) {
|
||||
@@ -2300,10 +2519,13 @@ async function saveServerConfig() {
|
||||
|
||||
const extraLines = document.getElementById("cfgExtraListen").value
|
||||
.split("\n").map(s => s.trim()).filter(Boolean);
|
||||
const dnsttDomains = readDnsttDomains("cfgDnsttDomains");
|
||||
|
||||
const cfg = {
|
||||
listen: document.getElementById("cfgListen").value.trim(),
|
||||
extra_listen: extraLines,
|
||||
proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart").value.trim(),
|
||||
proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace").value.trim(),
|
||||
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
|
||||
admin_dir: "/opt/sshpanel/admin",
|
||||
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
|
||||
@@ -2313,24 +2535,44 @@ async function saveServerConfig() {
|
||||
banner: document.getElementById("cfgBanner").value,
|
||||
banner_file: "/opt/sshpanel/banner.txt",
|
||||
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
|
||||
domain: document.getElementById("cfgDnsttDomain").value.trim(),
|
||||
domain: dnsttDomains[0] || "",
|
||||
domains: dnsttDomains,
|
||||
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
|
||||
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked,
|
||||
fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(),
|
||||
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(),
|
||||
fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers").value || "0", 10),
|
||||
dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers").value || "0", 10),
|
||||
auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(),
|
||||
max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions").value || "0", 10),
|
||||
max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams").value || "0", 10),
|
||||
pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses").value || "0", 10),
|
||||
stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer").value || "0", 10),
|
||||
udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer").value || "0", 10),
|
||||
udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer").value || "0", 10),
|
||||
privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
|
||||
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
|
||||
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
|
||||
log_connections: document.getElementById("cfgDnsttLogConnections").checked,
|
||||
} : null,
|
||||
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
|
||||
listen: document.getElementById("cfgUdpgwListen").value.trim(),
|
||||
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
|
||||
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
|
||||
map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
|
||||
auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace").value.trim(),
|
||||
debug: document.getElementById("cfgUdpgwDebug").checked,
|
||||
} : null,
|
||||
tls_forwarders: tlsArr,
|
||||
xray: {
|
||||
enabled: document.getElementById("cfgXrayEnabled").checked,
|
||||
mode: document.getElementById("cfgXrayMode").value === "external" ? "external" : "native",
|
||||
native: document.getElementById("cfgXrayMode").value !== "external",
|
||||
bin_path: "/opt/sshpanel/xray",
|
||||
config_file: "/opt/sshpanel/xray_config.json",
|
||||
native_config_file: "/opt/sshpanel/xray_native_config.json",
|
||||
api_server: "127.0.0.1:10085",
|
||||
online_window_seconds: 90,
|
||||
stats_poll_seconds: 15,
|
||||
@@ -2779,7 +3021,7 @@ function wzSaveInbound() {
|
||||
break;
|
||||
case "xhttp":
|
||||
ib.streamSettings.xhttpSettings = {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/",
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
};
|
||||
|
||||
+112
-16
@@ -159,17 +159,24 @@
|
||||
</div>
|
||||
|
||||
<div class="grid2 dashboard-lower">
|
||||
<div class="card">
|
||||
<div class="card hidden" id="dnsttDashboardCard">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Ações rápidas</div>
|
||||
<span class="chip green">simples</span>
|
||||
<div class="card-title">DNSTT Health <span class="chip">5s window</span></div>
|
||||
<span class="hint" id="dnsttHealthUpdated">--</span>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button class="quick-action" type="button" data-jump="ssh"><strong>Criar SSH</strong><span>Usuário, senha, validade e limite.</span></button>
|
||||
<button class="quick-action" type="button" data-jump="xray"><strong>Criar Xray</strong><span>UUID, label, validade e conexões.</span></button>
|
||||
<button class="quick-action superadmin-only hidden" type="button" data-jump="resellers"><strong>Novo revendedor</strong><span>Plano, validade e limite de contas.</span></button>
|
||||
<button class="quick-action superadmin-only hidden" type="button" data-jump="server"><strong>Configurar serviços</strong><span>Portas, DNSTT, UDPGW e TLS.</span></button>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="m-label">Active Sessions</div><div class="m-val" id="dnsttActiveSessions">--</div></div>
|
||||
<div class="metric"><div class="m-label">Active Streams</div><div class="m-val" id="dnsttActiveStreams">--</div></div>
|
||||
<div class="metric"><div class="m-label">DNS RX / 5s</div><div class="m-val" id="dnsttDNSRx">--</div></div>
|
||||
<div class="metric"><div class="m-label">Queue</div><div class="m-val" id="dnsttQueueLen">--</div></div>
|
||||
</div>
|
||||
<div class="tbl-wrap" style="margin-top:10px;">
|
||||
<table>
|
||||
<thead><tr><th>Counter</th><th>Value</th><th>Counter</th><th>Value</th></tr></thead>
|
||||
<tbody id="dnsttHealthBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="dnsttHealthSummary">DNSTT counters are loaded from /api/dnstt.</span><span class="hint">Reject/drop/panic counters should normally stay at 0.</span></div>
|
||||
</div>
|
||||
<div class="card" id="dashboardQuotaCard">
|
||||
<div class="card-hdr"><div class="card-title">Minha cota</div><span class="chip" id="dashQuotaChip">--</span></div>
|
||||
@@ -321,6 +328,11 @@
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
|
||||
<div class="card-actions xray-admin-only">
|
||||
<select id="xCoreMode" class="input-sm" title="Xray runtime mode">
|
||||
<option value="native">Internal native emulator</option>
|
||||
<option value="external">External xray binary</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost btn-sm" id="xSaveModeBtn">Save mode</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xStartBtn">Start</button>
|
||||
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
|
||||
@@ -410,7 +422,7 @@
|
||||
<!-- WebSocket -->
|
||||
<div class="field" id="wzWSPathField" style="display:none;"><label>Path</label><input type="text" id="wzWSPath" placeholder="/ws"/></div>
|
||||
<!-- XHTTP -->
|
||||
<div class="field" id="wzXHTTPPathField" style="display:none;"><label>Path</label><input type="text" id="wzXHTTPPath" placeholder="/xhttp"/></div>
|
||||
<div class="field" id="wzXHTTPPathField" style="display:none;"><label>Path</label><input type="text" id="wzXHTTPPath" placeholder="/xhttp" value="/xhttp"/></div>
|
||||
<div class="field" id="wzXHTTPHostField" style="display:none;"><label>Host <span class="hint">(SNI)</span></label><input type="text" id="wzXHTTPHost" placeholder="example.com"/></div>
|
||||
<div class="field" id="wzXHTTPModeField" style="display:none;">
|
||||
<label>Mode</label>
|
||||
@@ -645,6 +657,11 @@
|
||||
<label>Extra Listen Addresses <span class="hint">(one per line, e.g. 0.0.0.0:8080)</span></label>
|
||||
<textarea id="managedCfgExtraListen" rows="4" style="width:100%;box-sizing:border-box;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:inherit;padding:10px;resize:vertical;"></textarea>
|
||||
</div>
|
||||
<div class="form-grid" style="margin-top:8px;">
|
||||
<div class="field"><label>Proxy Auto Restart Interval <span class="hint">0s/off disables</span></label><input type="text" id="managedCfgProxyAutoRestart" placeholder="24h"/></div>
|
||||
<div class="field"><label>Proxy Restart Grace Delay</label><input type="text" id="managedCfgProxyRestartGrace" placeholder="2s"/></div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes public proxy listeners and active SSH sessions, then starts the listeners again. Use duration values like 6h, 12h, or 24h.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:12px;">
|
||||
@@ -677,8 +694,23 @@
|
||||
</label>
|
||||
</div>
|
||||
<div id="managedDnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
|
||||
<div class="field"><label>Domain</label><input type="text" id="managedCfgDnsttDomain" placeholder="t.example.com"/></div>
|
||||
<div class="field" style="grid-column:1/-1"><label>NS / Root Domains <span class="hint">one per line</span></label><textarea id="managedCfgDnsttDomains" rows="3" placeholder="t.example.com t.local.lan"></textarea></div>
|
||||
<div class="field"><label>UDP Listen</label><input type="text" id="managedCfgDnsttUDP" placeholder="[::]:5300"/></div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS</label>
|
||||
<div class="field"><label>Local DNS Listen <span class="hint">IPv6 ok</span></label><input type="text" id="managedCfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/></div>
|
||||
<div class="field"><label>Local DNS Domain</label><input type="text" id="managedCfgDnsttFakeDomain" placeholder="t.local.lan"/></div>
|
||||
<div class="field"><label>Local DNS Workers <span class="hint">0=auto</span></label><input type="number" id="managedCfgDnsttFakeWorkers" min="0" placeholder="4"/></div>
|
||||
<div class="field"><label>Response Workers <span class="hint">0=safe</span></label><input type="number" id="managedCfgDnsttRespWorkers" min="0" placeholder="1"/></div>
|
||||
<div class="field"><label>Auto Restart Interval <span class="hint">0s/off disables</span></label><input type="text" id="managedCfgDnsttAutoRestart" placeholder="6h"/></div>
|
||||
<div class="field"><label>Restart Grace Delay</label><input type="text" id="managedCfgDnsttRestartGrace" placeholder="2s"/></div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Restarts only the DNSTT UDP listener, not the VPS or the full panel. Use duration values like 30m, 2h, or 6h.</div>
|
||||
<div class="field"><label>Max Sessions <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="managedCfgDnsttMaxSessions" placeholder="10000"/></div>
|
||||
<div class="field"><label>Max Streams <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="managedCfgDnsttMaxStreams" placeholder="15000"/></div>
|
||||
<div class="field"><label>Pending DNS Responses</label><input type="number" id="managedCfgDnsttPendingResponses" placeholder="20000"/></div>
|
||||
<div class="field"><label>Stream Buffer Bytes</label><input type="number" id="managedCfgDnsttStreamBuffer" placeholder="262144"/></div>
|
||||
<div class="field"><label>UDP Read Buffer Bytes</label><input type="number" id="managedCfgDnsttUDPReadBuffer" placeholder="16777216"/></div>
|
||||
<div class="field"><label>UDP Write Buffer Bytes</label><input type="number" id="managedCfgDnsttUDPWriteBuffer" placeholder="16777216"/></div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Scale guard: bounded sessions/streams stop RAM explosions under thousands of DNSTT users. Keep verbose connection logs disabled on busy servers.</div>
|
||||
<div class="field" style="grid-column:1/-1">
|
||||
<label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label>
|
||||
<div class="field-row">
|
||||
@@ -697,6 +729,7 @@
|
||||
</div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttNoStats"/> Disable Stats Log</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttNoConsole"/> Disable Console Log</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgDnsttLogConnections"/> Verbose Connection Logs</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -713,6 +746,9 @@
|
||||
<div class="field"><label>Max UDP Sessions Per Client <span class="hint">(not total server users)</span></label><input type="number" id="managedCfgUdpgwMaxConns" min="0" placeholder="10"/></div>
|
||||
<div class="field"><label>Idle Timeout</label><input type="text" id="managedCfgUdpgwIdle" placeholder="2m"/></div>
|
||||
<div class="field"><label>Map TTL</label><input type="text" id="managedCfgUdpgwMapTTL" placeholder="90s"/></div>
|
||||
<div class="field"><label>Auto Restart Interval <span class="hint">0s/off disables</span></label><input type="text" id="managedCfgUdpgwAutoRestart" placeholder="24h"/></div>
|
||||
<div class="field"><label>Restart Grace Delay</label><input type="text" id="managedCfgUdpgwRestartGrace" placeholder="2s"/></div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes the UDPGW listener and every connected UDPGW client, then starts UDPGW again.</div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="managedCfgUdpgwDebug"/> Debug Logging</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -764,7 +800,8 @@
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr"><div class="card-title">Xray Core</div><span class="chip green">live</span></div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-top:4px;"><input type="checkbox" id="managedCfgXrayEnabled"/> Enabled</label>
|
||||
<div class="hint" style="margin-top:6px;color:var(--muted);">Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085</div>
|
||||
<div class="field" style="margin-top:8px;"><label>Runtime mode</label><select id="managedCfgXrayMode"><option value="native">Internal native emulator</option><option value="external">External xray binary</option></select></div>
|
||||
<div class="hint" style="margin-top:6px;color:var(--muted);">Native runs inside DragonCoreSSH. External uses /opt/sshpanel/xray with DB-backed /opt/sshpanel/xray_config.json and Stats API on 127.0.0.1:10085.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -912,6 +949,17 @@
|
||||
<label>Extra Listen Addresses <span class="hint">(one per line, e.g. 0.0.0.0:8080)</span></label>
|
||||
<textarea id="cfgExtraListen" rows="3" style="resize:vertical"></textarea>
|
||||
</div>
|
||||
<div class="form-grid" style="margin-top:8px;">
|
||||
<div class="field">
|
||||
<label>Proxy Auto Restart Interval <span class="hint">0s/off disables</span></label>
|
||||
<input type="text" id="cfgProxyAutoRestart" placeholder="24h"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Proxy Restart Grace Delay</label>
|
||||
<input type="text" id="cfgProxyRestartGrace" placeholder="2s"/>
|
||||
</div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes public proxy listeners and active SSH sessions, then starts the listeners again. Use duration values like 6h, 12h, or 24h.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH & general -->
|
||||
@@ -967,14 +1015,49 @@
|
||||
</label>
|
||||
</div>
|
||||
<div id="dnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
|
||||
<div class="field">
|
||||
<label>Domain</label>
|
||||
<input type="text" id="cfgDnsttDomain" placeholder="t.example.com"/>
|
||||
<div class="field" style="grid-column:1/-1">
|
||||
<label>NS / Root Domains <span class="hint">one per line</span></label>
|
||||
<textarea id="cfgDnsttDomains" rows="3" placeholder="t.example.com t.local.lan"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>UDP Listen</label>
|
||||
<input type="text" id="cfgDnsttUDP" placeholder="[::]:5300"/>
|
||||
</div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
||||
<input type="checkbox" id="cfgDnsttFakeEnabled"/> Built-in Local DNS / Fake DNS
|
||||
</label>
|
||||
<div class="field">
|
||||
<label>Local DNS Listen <span class="hint">IPv6 ok</span></label>
|
||||
<input type="text" id="cfgDnsttFakeListen" placeholder="[2001:db8::1234]:53"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Local DNS Domain</label>
|
||||
<input type="text" id="cfgDnsttFakeDomain" placeholder="t.local.lan"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Local DNS Workers <span class="hint">0=auto</span></label>
|
||||
<input type="number" id="cfgDnsttFakeWorkers" min="0" placeholder="4"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Response Workers <span class="hint">0=safe</span></label>
|
||||
<input type="number" id="cfgDnsttRespWorkers" min="0" placeholder="1"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Auto Restart Interval <span class="hint">0s/off disables</span></label>
|
||||
<input type="text" id="cfgDnsttAutoRestart" placeholder="6h"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Restart Grace Delay</label>
|
||||
<input type="text" id="cfgDnsttRestartGrace" placeholder="2s"/>
|
||||
</div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Restarts only the DNSTT UDP listener, not the VPS or the full panel. Use duration values like 30m, 2h, or 6h.</div>
|
||||
<div class="field"><label>Max Sessions <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="cfgDnsttMaxSessions" placeholder="10000"/></div>
|
||||
<div class="field"><label>Max Streams <span class="hint">0=default, -1=unlimited</span></label><input type="number" id="cfgDnsttMaxStreams" placeholder="15000"/></div>
|
||||
<div class="field"><label>Pending DNS Responses</label><input type="number" id="cfgDnsttPendingResponses" placeholder="20000"/></div>
|
||||
<div class="field"><label>Stream Buffer Bytes</label><input type="number" id="cfgDnsttStreamBuffer" placeholder="262144"/></div>
|
||||
<div class="field"><label>UDP Read Buffer Bytes</label><input type="number" id="cfgDnsttUDPReadBuffer" placeholder="16777216"/></div>
|
||||
<div class="field"><label>UDP Write Buffer Bytes</label><input type="number" id="cfgDnsttUDPWriteBuffer" placeholder="16777216"/></div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Scale guard: bounded sessions/streams stop RAM explosions under thousands of DNSTT users. Keep verbose connection logs disabled on busy servers.</div>
|
||||
<div class="field" style="grid-column:1/-1">
|
||||
<label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label>
|
||||
<div class="field-row">
|
||||
@@ -997,6 +1080,9 @@
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
||||
<input type="checkbox" id="cfgDnsttNoConsole"/> Disable Console Log
|
||||
</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
||||
<input type="checkbox" id="cfgDnsttLogConnections"/> Verbose Connection Logs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1026,6 +1112,15 @@
|
||||
<label>Map TTL</label>
|
||||
<input type="text" id="cfgUdpgwMapTTL" placeholder="90s"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Auto Restart Interval <span class="hint">0s/off disables</span></label>
|
||||
<input type="text" id="cfgUdpgwAutoRestart" placeholder="24h"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Restart Grace Delay</label>
|
||||
<input type="text" id="cfgUdpgwRestartGrace" placeholder="2s"/>
|
||||
</div>
|
||||
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Hard restart: closes the UDPGW listener and every connected UDPGW client, then starts UDPGW again.</div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
||||
<input type="checkbox" id="cfgUdpgwDebug"/> Debug Logging
|
||||
</label>
|
||||
@@ -1091,7 +1186,8 @@
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-top:4px;">
|
||||
<input type="checkbox" id="cfgXrayEnabled"/> Enabled
|
||||
</label>
|
||||
<div class="hint" style="margin-top:6px;color:var(--muted);">Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085</div>
|
||||
<div class="field" style="margin-top:8px;"><label>Runtime mode</label><select id="cfgXrayMode"><option value="native">Internal native emulator</option><option value="external">External xray binary</option></select></div>
|
||||
<div class="hint" style="margin-top:6px;color:var(--muted);">Native runs inside DragonCoreSSH. External uses /opt/sshpanel/xray with DB-backed /opt/sshpanel/xray_config.json and Stats API on 127.0.0.1:10085.</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /right -->
|
||||
@@ -1115,6 +1211,6 @@
|
||||
</div><!-- /shell -->
|
||||
</div><!-- /app -->
|
||||
|
||||
<script defer src="assets/app.js?v=20260511visualsafesave1"></script>
|
||||
<script defer src="assets/app.js?v=20260622dnsttfakednsfast"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+145
-3
@@ -103,6 +103,14 @@ const ifaceBody = document.getElementById("ifaceBody");
|
||||
const ifaceSummary = document.getElementById("ifaceSummary");
|
||||
const statsUpdated = document.getElementById("statsUpdated");
|
||||
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
|
||||
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
|
||||
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
|
||||
const dnsttActiveSessions = document.getElementById("dnsttActiveSessions");
|
||||
const dnsttActiveStreams = document.getElementById("dnsttActiveStreams");
|
||||
const dnsttDNSRx = document.getElementById("dnsttDNSRx");
|
||||
const dnsttQueueLen = document.getElementById("dnsttQueueLen");
|
||||
const dnsttHealthBody = document.getElementById("dnsttHealthBody");
|
||||
const dnsttHealthSummary = document.getElementById("dnsttHealthSummary");
|
||||
|
||||
// VnStat
|
||||
const vnstatDailyBody = document.getElementById("vnstatDailyBody");
|
||||
@@ -136,6 +144,16 @@ function fmtBytes(n) {
|
||||
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
|
||||
return (m/1024).toFixed(1)+" GiB";
|
||||
}
|
||||
function fmtInt(n) {
|
||||
const v = Number(n);
|
||||
return Number.isFinite(v) ? v.toLocaleString() : "--";
|
||||
}
|
||||
function fmtDnsttTimestamp(ts) {
|
||||
if (!ts) return "Waiting for DNSTT stats…";
|
||||
const d = new Date(ts);
|
||||
if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…";
|
||||
return "Updated: " + d.toLocaleTimeString();
|
||||
}
|
||||
function localDateKey(d = new Date()) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
@@ -213,7 +231,6 @@ document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click
|
||||
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
|
||||
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
|
||||
themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode"));
|
||||
document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump)));
|
||||
document.getElementById("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); });
|
||||
document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray"));
|
||||
|
||||
@@ -872,6 +889,76 @@ async function deleteReseller(username) {
|
||||
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
|
||||
|
||||
async function loadDnsttHealth() {
|
||||
if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return;
|
||||
if (currentRole !== "superadmin") {
|
||||
dnsttDashboardCard?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/dnstt");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const d = await res.json();
|
||||
const enabled = d.enabled !== false;
|
||||
if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled);
|
||||
if (!enabled) return;
|
||||
|
||||
if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions);
|
||||
if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams);
|
||||
if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx);
|
||||
if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len);
|
||||
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp);
|
||||
|
||||
const rows = [
|
||||
["Session rejected", d.sess_rejected],
|
||||
["Stream rejected", d.stream_rejected],
|
||||
["DNS parse errors", d.parse_err],
|
||||
["No EDNS", d.no_edns],
|
||||
["EDNS limit 512", d.limit512],
|
||||
["Local DNS workers", d.fake_dns_workers],
|
||||
["Response workers", d.dns_response_workers],
|
||||
["Responses queued", d.rec_queued],
|
||||
["Responses dropped", d.rec_dropped],
|
||||
["Responses sent", d.resp_sent],
|
||||
["Response bytes", d.resp_bytes],
|
||||
["Empty responses", d.resp_empty],
|
||||
["Data responses", d.resp_data],
|
||||
["Oversize responses", d.resp_oversize],
|
||||
["KCP sessions new", d.kcp_new],
|
||||
["KCP sessions ended", d.kcp_end],
|
||||
["SMUX streams new", d.smux_new],
|
||||
["SMUX streams ended", d.smux_end],
|
||||
["Panic recovered", d.panic_recovered],
|
||||
];
|
||||
if (dnsttHealthBody) {
|
||||
dnsttHealthBody.innerHTML = "";
|
||||
for (let i = 0; i < rows.length; i += 2) {
|
||||
const a = rows[i];
|
||||
const b = rows[i + 1] || ["", ""];
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${a[0]}</td><td>${fmtInt(a[1])}</td><td>${b[0]}</td><td>${b[0] ? fmtInt(b[1]) : ""}</td>`;
|
||||
dnsttHealthBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0);
|
||||
if (dnsttHealthSummary) {
|
||||
if (d.running === false) {
|
||||
dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs.";
|
||||
} else {
|
||||
dnsttHealthSummary.textContent = bad > 0
|
||||
? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.`
|
||||
: "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window.";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === "auth") throw e;
|
||||
dnsttDashboardCard?.classList.add("hidden");
|
||||
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats.";
|
||||
if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await api("/api/stats");
|
||||
@@ -895,6 +982,7 @@ async function loadStats() {
|
||||
});
|
||||
ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
|
||||
statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
|
||||
await loadDnsttHealth();
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
}
|
||||
|
||||
@@ -1028,6 +1116,22 @@ async function clearPanelLog() {
|
||||
// ─── Server Config ────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
|
||||
|
||||
|
||||
function dnsttDomainsText(d) {
|
||||
const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []);
|
||||
return domains.join("\n");
|
||||
}
|
||||
function readDnsttDomains(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
const seen = new Set();
|
||||
return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => {
|
||||
if (seen.has(s)) return false;
|
||||
seen.add(s);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDnsttFields(on) {
|
||||
const el = document.getElementById("dnsttFields");
|
||||
el.style.opacity = on ? "1" : ".4";
|
||||
@@ -1050,6 +1154,8 @@ async function loadServerConfig() {
|
||||
// Network
|
||||
document.getElementById("cfgListen").value = c.listen || "";
|
||||
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
|
||||
if (document.getElementById("cfgProxyAutoRestart")) document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
|
||||
if (document.getElementById("cfgProxyRestartGrace")) document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
|
||||
|
||||
// SSH / general
|
||||
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
|
||||
@@ -1065,11 +1171,25 @@ async function loadServerConfig() {
|
||||
document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
|
||||
toggleDnsttFields(hasDnstt);
|
||||
const d = c.dnstt || {};
|
||||
document.getElementById("cfgDnsttDomain").value = d.domain || "";
|
||||
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
|
||||
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
|
||||
if (document.getElementById("cfgDnsttFakeEnabled")) document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||
if (document.getElementById("cfgDnsttFakeListen")) document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||
if (document.getElementById("cfgDnsttFakeDomain")) document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||
if (document.getElementById("cfgDnsttFakeWorkers")) document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
|
||||
if (document.getElementById("cfgDnsttRespWorkers")) document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0;
|
||||
document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || "";
|
||||
document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || "";
|
||||
if (document.getElementById("cfgDnsttMaxSessions")) document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0;
|
||||
if (document.getElementById("cfgDnsttMaxStreams")) document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0;
|
||||
if (document.getElementById("cfgDnsttPendingResponses")) document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0;
|
||||
if (document.getElementById("cfgDnsttStreamBuffer")) document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0;
|
||||
if (document.getElementById("cfgDnsttUDPReadBuffer")) document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
|
||||
if (document.getElementById("cfgDnsttUDPWriteBuffer")) document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
|
||||
document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
|
||||
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
|
||||
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
|
||||
if (document.getElementById("cfgDnsttLogConnections")) document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections;
|
||||
|
||||
// UDPGW
|
||||
const hasUdpgw = !!c.udpgw;
|
||||
@@ -1080,6 +1200,8 @@ async function loadServerConfig() {
|
||||
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
|
||||
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
|
||||
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
|
||||
if (document.getElementById("cfgUdpgwAutoRestart")) document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
|
||||
if (document.getElementById("cfgUdpgwRestartGrace")) document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
|
||||
document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
|
||||
|
||||
// TLS forwarders
|
||||
@@ -1105,10 +1227,13 @@ async function saveServerConfig() {
|
||||
|
||||
const extraLines = document.getElementById("cfgExtraListen").value
|
||||
.split("\n").map(s => s.trim()).filter(Boolean);
|
||||
const dnsttDomains = readDnsttDomains("cfgDnsttDomains");
|
||||
|
||||
const cfg = {
|
||||
listen: document.getElementById("cfgListen").value.trim(),
|
||||
extra_listen: extraLines,
|
||||
proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart") ? document.getElementById("cfgProxyAutoRestart").value.trim() : "",
|
||||
proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace") ? document.getElementById("cfgProxyRestartGrace").value.trim() : "",
|
||||
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
|
||||
admin_dir: "/opt/sshpanel/admin",
|
||||
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
|
||||
@@ -1118,17 +1243,34 @@ async function saveServerConfig() {
|
||||
banner: document.getElementById("cfgBanner").value,
|
||||
banner_file: "/opt/sshpanel/banner.txt",
|
||||
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
|
||||
domain: document.getElementById("cfgDnsttDomain").value.trim(),
|
||||
domain: dnsttDomains[0] || "",
|
||||
domains: dnsttDomains,
|
||||
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
|
||||
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled") ? document.getElementById("cfgDnsttFakeEnabled").checked : false,
|
||||
fake_dns_listen: document.getElementById("cfgDnsttFakeListen") ? document.getElementById("cfgDnsttFakeListen").value.trim() : "",
|
||||
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain") ? document.getElementById("cfgDnsttFakeDomain").value.trim() : "",
|
||||
fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers") ? document.getElementById("cfgDnsttFakeWorkers").value || "0" : "0", 10),
|
||||
dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers") ? document.getElementById("cfgDnsttRespWorkers").value || "0" : "0", 10),
|
||||
auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(),
|
||||
max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions") ? document.getElementById("cfgDnsttMaxSessions").value || "0" : "0", 10),
|
||||
max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams") ? document.getElementById("cfgDnsttMaxStreams").value || "0" : "0", 10),
|
||||
pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses") ? document.getElementById("cfgDnsttPendingResponses").value || "0" : "0", 10),
|
||||
stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer") ? document.getElementById("cfgDnsttStreamBuffer").value || "0" : "0", 10),
|
||||
udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer") ? document.getElementById("cfgDnsttUDPReadBuffer").value || "0" : "0", 10),
|
||||
udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer") ? document.getElementById("cfgDnsttUDPWriteBuffer").value || "0" : "0", 10),
|
||||
privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
|
||||
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
|
||||
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
|
||||
log_connections: document.getElementById("cfgDnsttLogConnections") ? document.getElementById("cfgDnsttLogConnections").checked : false,
|
||||
} : null,
|
||||
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
|
||||
listen: document.getElementById("cfgUdpgwListen").value.trim(),
|
||||
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
|
||||
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
|
||||
map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
|
||||
auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart") ? document.getElementById("cfgUdpgwAutoRestart").value.trim() : "",
|
||||
auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace") ? document.getElementById("cfgUdpgwRestartGrace").value.trim() : "",
|
||||
debug: document.getElementById("cfgUdpgwDebug").checked,
|
||||
} : null,
|
||||
tls_forwarders: tlsArr,
|
||||
|
||||
+172
-2
@@ -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
|
||||
}
|
||||
|
||||
+797
-114
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,26 @@ func (p *listenerPool) Has(addr string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// StopAll closes every listener in the pool. Active SSH sessions are not owned
|
||||
// by this pool; callers that want a hard restart should also close tracked SSH
|
||||
// server connections through userMgr.DisconnectAll().
|
||||
func (p *listenerPool) StopAll(reason string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for addr, ln := range p.entries {
|
||||
_ = ln.Close()
|
||||
delete(p.entries, addr)
|
||||
if reason != "" {
|
||||
log.Printf("hotreload: stopped %s (%s)", addr, reason)
|
||||
} else {
|
||||
log.Printf("hotreload: stopped %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *listenerPool) HasAll(addrs []string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
@@ -179,6 +199,23 @@ func (p *tlsListenerPool) Has(addr string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *tlsListenerPool) StopAll(reason string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for addr, ln := range p.entries {
|
||||
_ = ln.Close()
|
||||
delete(p.entries, addr)
|
||||
if reason != "" {
|
||||
log.Printf("hotreload: stopped TLS %s (%s)", addr, reason)
|
||||
} else {
|
||||
log.Printf("hotreload: stopped TLS %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tlsListenerPool) HasAll(forwarders []TLSForwarderConfig) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
@@ -283,6 +320,7 @@ func joinAddrs(addrs []string) string {
|
||||
|
||||
func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
|
||||
report := newReloadReport()
|
||||
stopProxyAutoRestart()
|
||||
// Banner
|
||||
bt := newCfg.Banner
|
||||
if bt == "" && newCfg.BannerFile != "" {
|
||||
@@ -372,9 +410,13 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
|
||||
|
||||
// Xray — update stored config then restart/stop as needed.
|
||||
if newCfg.Xray != nil {
|
||||
newCfg.Xray.NormalizeDefaults()
|
||||
xrayMgr.mu.Lock()
|
||||
xrayMgr.cfg = newCfg.Xray
|
||||
xrayMgr.mu.Unlock()
|
||||
if !newCfg.Xray.UseNative() {
|
||||
xrayMgr.startStatsPoller()
|
||||
}
|
||||
if newCfg.Xray.Enabled {
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
report.warnf("Xray failed to restart: %v", err)
|
||||
@@ -395,6 +437,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
|
||||
}
|
||||
|
||||
setGlobalCfg(newCfg)
|
||||
startProxyAutoRestart(newCfg)
|
||||
return report
|
||||
}
|
||||
|
||||
|
||||
+9
-4
@@ -455,8 +455,11 @@ cat > "$INSTALL_DIR/config.json" <<EOF
|
||||
"banner_file": "${INSTALL_DIR}/banner.txt",
|
||||
"xray": {
|
||||
"enabled": true,
|
||||
"mode": "native",
|
||||
"native": true,
|
||||
"bin_path": "${INSTALL_DIR}/xray",
|
||||
"config_file": "${INSTALL_DIR}/xray_config.json"
|
||||
"config_file": "${INSTALL_DIR}/xray_config.json",
|
||||
"native_config_file": "${INSTALL_DIR}/xray_native_config.json"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
@@ -467,8 +470,9 @@ UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null \
|
||||
|| python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null \
|
||||
|| echo "11111111-2222-3333-4444-555555555555")
|
||||
|
||||
# xray_config.json (default VLESS + SOCKS inbounds — no geoip routing needed)
|
||||
cat > "$INSTALL_DIR/xray_config.json" <<EOF
|
||||
# xray_native_config.json is used by the internal emulator. xray_config.json is
|
||||
# kept only for optional external-xray mode. Both start with the same default.
|
||||
cat > "$INSTALL_DIR/xray_native_config.json" <<EOF
|
||||
{
|
||||
"log": { "loglevel": "warning" },
|
||||
"inbounds": [
|
||||
@@ -497,7 +501,8 @@ cat > "$INSTALL_DIR/xray_config.json" <<EOF
|
||||
]
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/xray_config.json"
|
||||
cp -f "$INSTALL_DIR/xray_native_config.json" "$INSTALL_DIR/xray_config.json"
|
||||
chmod 600 "$INSTALL_DIR/xray_native_config.json" "$INSTALL_DIR/xray_config.json"
|
||||
info " VLESS UUID: ${UUID}"
|
||||
|
||||
# ── 9. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +64,17 @@ type TLSForwarderConfig struct {
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
|
||||
// ProxyAutoRestartInterval controls a watchdog that periodically hard-restarts
|
||||
// the public proxy listeners (listen, extra_listen, and TLS forwarders) and
|
||||
// closes active SSH sessions. Empty, "0s", "off", or "disabled" turns it off.
|
||||
// Valid examples: "30m", "6h", "24h". Minimum accepted value is 1m.
|
||||
ProxyAutoRestartInterval string `json:"proxy_auto_restart_interval,omitempty"`
|
||||
|
||||
// ProxyAutoRestartGrace is the delay between closing proxy listeners/sessions
|
||||
// and binding them again during an auto restart. Empty defaults to "2s".
|
||||
ProxyAutoRestartGrace string `json:"proxy_auto_restart_grace,omitempty"`
|
||||
|
||||
// Optional extra public listen addresses (multi‑port). These
|
||||
// addresses use the same HTTP‑cleanup and SSH handler as the
|
||||
// primary Listen address. For IPv6, use bracket form, e.g.
|
||||
@@ -126,14 +137,46 @@ type Config struct {
|
||||
// the tunnelled zone, and loads its private key from PrivKeyFile. The
|
||||
// corresponding public key must be distributed to clients.
|
||||
type DNSTTConfig struct {
|
||||
// Domain is the root of the DNS zone reserved for the tunnel (e.g. "t.example.com").
|
||||
// Domain is the primary/root DNS zone reserved for the tunnel (e.g. "t.example.com").
|
||||
// It is kept for backward compatibility and is also used as the first client
|
||||
// domain when Domains is empty.
|
||||
Domain string `json:"domain"`
|
||||
// UDPListen is the UDP address to listen on for incoming DNS queries.
|
||||
// The address should be IPv6‑formatted (e.g. "[::]:5300") and reachable by
|
||||
// recursive resolvers. Note: port 53 may require root privileges; binding
|
||||
// to an unprivileged port and using iptables to redirect port 53 is
|
||||
// recommended【561853413345496†L97-L109】.
|
||||
|
||||
// Domains optionally lists all DNS root zones/NS domains accepted by this
|
||||
// server. This allows the same DNSTT listener/key to answer for public and
|
||||
// local DNS deployments at the same time, for example:
|
||||
// ["t.example.com", "t.local.lan"]. The first normalized value is mirrored
|
||||
// into Domain for older clients/UI code.
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
// UDPListen is the UDP address to listen on for incoming DNS tunnel queries.
|
||||
// IPv6 addresses must use bracket form, for example "[::]:5300" or
|
||||
// "[2001:db8::1234]:53". IPv6 listeners are opened with udp6 so they do
|
||||
// not require a spare IPv4 address on the same port.
|
||||
UDPListen string `json:"udp_listen"`
|
||||
|
||||
// FakeDNSEnabled starts an extra built-in DNS listener for local/LAN testing.
|
||||
// It uses the same DNSTT private key and session pool, but only accepts the
|
||||
// FakeDNSDomain zone. This avoids needing a second DNS server for tests such
|
||||
// as t.local.lan over a dedicated IPv6 address.
|
||||
FakeDNSEnabled bool `json:"fake_dns_enabled,omitempty"`
|
||||
|
||||
// FakeDNSListen is the IPv4/IPv6 UDP address for the built-in local DNS
|
||||
// listener. For your case use an IPv6 address, for example
|
||||
// "[2001:db8::1234]:53" or "[::]:53".
|
||||
FakeDNSListen string `json:"fake_dns_listen,omitempty"`
|
||||
|
||||
// FakeDNSDomain is the local DNSTT zone accepted by the built-in DNS listener.
|
||||
// If empty while FakeDNSEnabled is true, it defaults to "t.local.lan".
|
||||
FakeDNSDomain string `json:"fake_dns_domain,omitempty"`
|
||||
|
||||
// FakeDNSWorkers controls how many concurrent UDP read/parse workers are used
|
||||
// by the built-in local DNS listener. Zero uses a safe automatic default.
|
||||
FakeDNSWorkers int `json:"fake_dns_workers,omitempty"`
|
||||
|
||||
// DNSResponseWorkers controls how many DNS response sender shards are used.
|
||||
// Zero keeps the safest default of one sender. Increase carefully only when
|
||||
// the DNSTT pending queue grows under load.
|
||||
DNSResponseWorkers int `json:"dns_response_workers,omitempty"`
|
||||
// PrivKeyFile is the path to the Noise server private key. Generate a
|
||||
// keypair with the dnstt tool (use -gen-key) and copy the resulting
|
||||
// private key here; the matching public key must be distributed to
|
||||
@@ -158,6 +201,41 @@ type DNSTTConfig struct {
|
||||
// printed to the console. Set this to true in combination with
|
||||
// disable_stats_log if you want a fully quiet DNSTT server.
|
||||
DisableConsoleLog bool `json:"disable_console_log"`
|
||||
|
||||
// AutoRestartInterval controls a watchdog that periodically cycles only the
|
||||
// integrated DNSTT UDP listener. Empty, "0s", "off", or "disabled" turns it
|
||||
// off. Valid examples: "30m", "2h", "6h". Minimum accepted value is 1m.
|
||||
AutoRestartInterval string `json:"auto_restart_interval,omitempty"`
|
||||
|
||||
// AutoRestartGrace is the delay between closing the old DNSTT UDP socket and
|
||||
// binding a new one during an auto restart. Empty defaults to "2s".
|
||||
AutoRestartGrace string `json:"auto_restart_grace,omitempty"`
|
||||
|
||||
// MaxSessions limits concurrently open DNSTT/KCP sessions. Zero uses a safe
|
||||
// default for large public servers. Negative disables the limit.
|
||||
MaxSessions int `json:"max_sessions,omitempty"`
|
||||
|
||||
// MaxStreams limits concurrently open smux/SSH streams across all DNSTT
|
||||
// sessions. Zero uses a safe default. Negative disables the limit.
|
||||
MaxStreams int `json:"max_streams,omitempty"`
|
||||
|
||||
// PendingResponses controls the buffer of queued DNS responses waiting for
|
||||
// sendLoop. Zero uses the default. Keeping it bounded prevents RAM spikes.
|
||||
PendingResponses int `json:"pending_responses,omitempty"`
|
||||
|
||||
// StreamBuffer sets smux MaxStreamBuffer in bytes. Zero uses the default.
|
||||
// Lower values reduce RAM use when thousands of clients are connected.
|
||||
StreamBuffer int `json:"stream_buffer,omitempty"`
|
||||
|
||||
// UDPReadBuffer and UDPWriteBuffer request OS socket buffers in bytes. Zero
|
||||
// uses the default. The kernel may clamp these unless sysctl limits are raised.
|
||||
UDPReadBuffer int `json:"udp_read_buffer,omitempty"`
|
||||
UDPWriteBuffer int `json:"udp_write_buffer,omitempty"`
|
||||
|
||||
// LogConnections enables per-session and per-stream DNSTT logs. Leave off on
|
||||
// servers with thousands of users because connection logging can become the
|
||||
// bottleneck and make crashes more likely.
|
||||
LogConnections bool `json:"log_connections,omitempty"`
|
||||
}
|
||||
|
||||
// UDPGWConfig defines the settings for the integrated UDP gateway. The
|
||||
@@ -214,6 +292,16 @@ type UDPGWConfig struct {
|
||||
// growth if a client sprays packets to many unique destinations.
|
||||
// Default is 32768.
|
||||
MaxMapEntries int `json:"max_map_entries"`
|
||||
|
||||
// AutoRestartInterval controls a watchdog that periodically hard-restarts
|
||||
// the integrated UDPGW listener and closes all connected UDPGW clients. Empty,
|
||||
// "0s", "off", or "disabled" turns it off. Valid examples: "30m", "6h", "24h".
|
||||
// Minimum accepted value is 1m.
|
||||
AutoRestartInterval string `json:"auto_restart_interval,omitempty"`
|
||||
|
||||
// AutoRestartGrace is the delay between closing the old UDPGW listener/clients
|
||||
// and binding a new one during an auto restart. Empty defaults to "2s".
|
||||
AutoRestartGrace string `json:"auto_restart_grace,omitempty"`
|
||||
}
|
||||
|
||||
type UserConfig struct {
|
||||
@@ -341,6 +429,35 @@ func (m *UserManager) DisconnectUser(username string) {
|
||||
}
|
||||
}
|
||||
|
||||
// DisconnectAll closes every authenticated SSH connection currently tracked by
|
||||
// the user manager. It is used by the proxy hard auto-restart to mimic a real
|
||||
// service reboot instead of only reopening listening sockets.
|
||||
func (m *UserManager) DisconnectAll() int {
|
||||
m.mu.RLock()
|
||||
states := make([]*UserState, 0, len(m.users))
|
||||
for _, u := range m.users {
|
||||
states = append(states, u)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
seen := make(map[*ssh.ServerConn]struct{})
|
||||
for _, u := range states {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
u.mu.Lock()
|
||||
for c := range u.conns {
|
||||
seen[c] = struct{}{}
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
for c := range seen {
|
||||
_ = c.Close()
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// Global state
|
||||
var (
|
||||
userMgr = &UserManager{users: make(map[string]*UserState)}
|
||||
@@ -1030,6 +1147,19 @@ func readNetDev() (map[string]ifaceCounters, error) {
|
||||
|
||||
// ---------- Config loading ----------
|
||||
|
||||
const defaultInstalledConfigPath = "/opt/sshpanel/config.json"
|
||||
|
||||
func resolveMainConfigPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
if _, err := os.Stat("config.json"); err == nil {
|
||||
return "config.json"
|
||||
}
|
||||
return defaultInstalledConfigPath
|
||||
}
|
||||
|
||||
func loadConfig(path string) (*Config, map[string]*UserState, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -1040,6 +1170,9 @@ func loadConfig(path string) (*Config, map[string]*UserState, error) {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if cfg.Xray != nil {
|
||||
cfg.Xray.NormalizeDefaults()
|
||||
}
|
||||
|
||||
if cfg.Listen == "" {
|
||||
cfg.Listen = ":2222"
|
||||
@@ -1721,6 +1854,9 @@ func handleDnsttStats(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/dnstt", nil, "") {
|
||||
return
|
||||
}
|
||||
// Obtain a copy of the current snapshot.
|
||||
stats := GetDNSTTStatsSnapshot()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -2621,19 +2757,20 @@ func main() {
|
||||
log.Printf("GOMEMLIMIT auto-set to 80%% of RAM: %d MB", limit/1024/1024)
|
||||
}
|
||||
|
||||
configPath := flag.String("config", "config.json", "path to JSON config file")
|
||||
configPath := flag.String("config", "", "path to JSON config file (default: ./config.json if present, otherwise /opt/sshpanel/config.json)")
|
||||
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
|
||||
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, userMap, err := loadConfig(*configPath)
|
||||
resolvedConfigPath := resolveMainConfigPath(*configPath)
|
||||
cfg, userMap, err := loadConfig(resolvedConfigPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
userMgr.ReplaceAll(userMap)
|
||||
|
||||
// Store config path and live config for hot-reload via admin API.
|
||||
globalCfgPath = *configPath
|
||||
globalCfgPath = resolvedConfigPath
|
||||
setGlobalCfg(cfg)
|
||||
|
||||
userCountEnabled = cfg.UserCount || *userCountFlag
|
||||
@@ -2677,8 +2814,14 @@ func main() {
|
||||
if err := store.EnsureXrayClientsSchema(ctx); err != nil {
|
||||
log.Printf("xray clients table: %v", err)
|
||||
} else {
|
||||
if err := store.ResetXrayActiveConnections(ctx); err != nil {
|
||||
log.Printf("xray active connection reset: %v", err)
|
||||
}
|
||||
startXrayClientExpiryChecker(store)
|
||||
}
|
||||
if err := store.EnsureXrayConfigSchema(ctx); err != nil {
|
||||
log.Printf("xray config table disabled: %v", err)
|
||||
}
|
||||
if err := store.EnsureIfaceUsageTables(ctx); err != nil {
|
||||
log.Printf("vnstat usage tables disabled: %v", err)
|
||||
}
|
||||
@@ -2892,6 +3035,9 @@ func main() {
|
||||
log.Printf("failed to start TLS listener: %v", e)
|
||||
}
|
||||
|
||||
// Start proxy hard auto-restart after the initial listeners are bound.
|
||||
startProxyAutoRestart(cfg)
|
||||
|
||||
// Print user counts once at startup.
|
||||
updateUserDisplay()
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
proxyAutoMu sync.Mutex
|
||||
proxyAutoCancel context.CancelFunc
|
||||
)
|
||||
|
||||
// startProxyAutoRestart starts a watchdog that periodically hard-restarts the
|
||||
// public SSH/HTTP proxy layer. Unlike a normal hot reload, this intentionally
|
||||
// closes active SSH sessions so the behavior is close to a service reboot.
|
||||
func startProxyAutoRestart(cfg *Config) {
|
||||
interval := proxyAutoRestartInterval(cfg)
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
grace := proxyAutoRestartGrace(cfg)
|
||||
cfgCopy := cloneProxyRestartConfig(cfg)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
proxyAutoMu.Lock()
|
||||
if proxyAutoCancel != nil {
|
||||
proxyAutoCancel()
|
||||
}
|
||||
proxyAutoCancel = cancel
|
||||
proxyAutoMu.Unlock()
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
log.Printf("proxy auto restart enabled: interval=%s grace=%s mode=hard", interval, grace)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
restartProxyHard(ctx, cfgCopy, grace)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func stopProxyAutoRestart() {
|
||||
proxyAutoMu.Lock()
|
||||
defer proxyAutoMu.Unlock()
|
||||
if proxyAutoCancel != nil {
|
||||
proxyAutoCancel()
|
||||
proxyAutoCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneProxyRestartConfig(cfg *Config) *Config {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
out := &Config{
|
||||
Listen: cfg.Listen,
|
||||
ProxyAutoRestartInterval: cfg.ProxyAutoRestartInterval,
|
||||
ProxyAutoRestartGrace: cfg.ProxyAutoRestartGrace,
|
||||
}
|
||||
out.ExtraListen = append([]string(nil), cfg.ExtraListen...)
|
||||
out.TLSForwarders = append([]TLSForwarderConfig(nil), cfg.TLSForwarders...)
|
||||
return out
|
||||
}
|
||||
|
||||
func restartProxyHard(ctx context.Context, cfg *Config, grace time.Duration) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("proxy auto restart: stopping public proxy listeners and active SSH sessions")
|
||||
if publicPool != nil {
|
||||
publicPool.StopAll("proxy auto restart")
|
||||
}
|
||||
if tlsPool != nil {
|
||||
tlsPool.StopAll("proxy auto restart")
|
||||
}
|
||||
closed := userMgr.DisconnectAll()
|
||||
if closed > 0 {
|
||||
log.Printf("proxy auto restart: closed %d active SSH session(s)", closed)
|
||||
}
|
||||
if !sleepOrContextDone(ctx, grace) {
|
||||
return
|
||||
}
|
||||
|
||||
publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...)
|
||||
for attempt := 1; ; attempt++ {
|
||||
errs := []error{}
|
||||
if publicPool != nil {
|
||||
errs = append(errs, publicPool.Sync(publicAddrs)...)
|
||||
}
|
||||
if tlsPool != nil {
|
||||
errs = append(errs, tlsPool.Sync(cfg.TLSForwarders)...)
|
||||
}
|
||||
|
||||
ok := len(errs) == 0
|
||||
if publicPool != nil && !publicPool.HasAll(publicAddrs) {
|
||||
ok = false
|
||||
}
|
||||
if tlsPool != nil && !tlsPool.HasAll(cfg.TLSForwarders) {
|
||||
ok = false
|
||||
}
|
||||
if ok {
|
||||
log.Printf("proxy auto restart: public proxy restarted")
|
||||
return
|
||||
}
|
||||
|
||||
for _, err := range errs {
|
||||
log.Printf("proxy auto restart: start attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
log.Printf("proxy auto restart: start attempt %d incomplete; one or more listeners are still down", attempt)
|
||||
}
|
||||
if !sleepOrContextDone(ctx, 10*time.Second) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func proxyAutoRestartInterval(cfg *Config) time.Duration {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(cfg.ProxyAutoRestartInterval)
|
||||
if raw == "" || raw == "0" || raw == "0s" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
log.Printf("proxy auto restart disabled: invalid interval %q: %v", raw, err)
|
||||
return 0
|
||||
}
|
||||
if d < time.Minute {
|
||||
log.Printf("proxy auto restart disabled: interval %q is below minimum 1m", raw)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func proxyAutoRestartGrace(cfg *Config) time.Duration {
|
||||
if cfg == nil || strings.TrimSpace(cfg.ProxyAutoRestartGrace) == "" {
|
||||
return 2 * time.Second
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(cfg.ProxyAutoRestartGrace))
|
||||
if err != nil || d < 0 {
|
||||
log.Printf("proxy auto restart: invalid grace %q, using 2s", cfg.ProxyAutoRestartGrace)
|
||||
return 2 * time.Second
|
||||
}
|
||||
if d > time.Minute {
|
||||
return time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func sleepOrContextDone(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(d):
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+145
-5
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+105
-28
@@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnsureXrayConfigSchema creates the DB table used as the canonical store for
|
||||
// Xray JSON configs. The config_file path is used as a stable key so local and
|
||||
// remote nodes can keep independent configs in the same database if needed.
|
||||
func (s *Store) EnsureXrayConfigSchema(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS xray_configs (
|
||||
config_key TEXT PRIMARY KEY,
|
||||
config_json JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE xray_configs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetXrayConfig(ctx context.Context, configKey string) ([]byte, bool, error) {
|
||||
if configKey == "" {
|
||||
configKey = "default"
|
||||
}
|
||||
var raw string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT config_json::text FROM xray_configs WHERE config_key = $1`, configKey).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(raw)) {
|
||||
return nil, false, fmt.Errorf("stored Xray config %q is not valid JSON", configKey)
|
||||
}
|
||||
return []byte(raw), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertXrayConfig(ctx context.Context, configKey string, data []byte) error {
|
||||
if configKey == "" {
|
||||
configKey = "default"
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return fmt.Errorf("invalid Xray JSON config")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_configs (config_key, config_json, updated_at)
|
||||
VALUES ($1, $2::jsonb, NOW())
|
||||
ON CONFLICT (config_key) DO UPDATE SET
|
||||
config_json = EXCLUDED.config_json,
|
||||
updated_at = NOW()`, configKey, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListXrayClientsByInbound(ctx context.Context, inboundTag string) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE inbound_tag = $1 ORDER BY created_at DESC`, inboundTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanXrayClientMetaRows(rows)
|
||||
}
|
||||
|
||||
// ImportXrayClientsFromConfig mirrors client UUIDs found in an existing Xray
|
||||
// JSON config into xray_clients. This lets native mode inherit users from the
|
||||
// external Xray config and keeps the DB as the hot-reloadable client index.
|
||||
// It intentionally preserves owner/expiry/quota fields for existing rows.
|
||||
func (s *Store) ImportXrayClientsFromConfig(ctx context.Context, data []byte) (int, error) {
|
||||
if s == nil || len(data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var cfg struct {
|
||||
Inbounds []struct {
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
Settings struct {
|
||||
Clients []struct {
|
||||
ID string `json:"id"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
} `json:"clients"`
|
||||
Users []struct {
|
||||
ID string `json:"id"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
} `json:"users"`
|
||||
} `json:"settings"`
|
||||
} `json:"inbounds"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
imported := 0
|
||||
for _, ib := range cfg.Inbounds {
|
||||
proto := strings.ToLower(strings.TrimSpace(ib.Protocol))
|
||||
if proto != "vless" && proto != "vmess" && proto != "trojan" {
|
||||
continue
|
||||
}
|
||||
inboundTag := strings.TrimSpace(ib.Tag)
|
||||
configClients := ib.Settings.Clients
|
||||
if len(ib.Settings.Users) > 0 {
|
||||
configClients = append(configClients, ib.Settings.Users...)
|
||||
}
|
||||
for _, c := range configClients {
|
||||
uuid := strings.TrimSpace(c.ID)
|
||||
if uuid == "" {
|
||||
uuid = strings.TrimSpace(c.Password)
|
||||
}
|
||||
if uuid == "" {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(c.Email)
|
||||
if email == "" {
|
||||
email = uuid
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
email = CASE WHEN xray_clients.email = '' THEN EXCLUDED.email ELSE xray_clients.email END,
|
||||
name = CASE WHEN xray_clients.name = '' THEN EXCLUDED.name ELSE xray_clients.name END,
|
||||
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END`,
|
||||
uuid, email, email, inboundTag)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
+764
-86
File diff suppressed because it is too large
Load Diff
+1178
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
nativeUDPMaxPacket = 65535
|
||||
nativeUDPBufferSize = 64 * 1024
|
||||
nativeUDPIdle = 2 * time.Minute
|
||||
)
|
||||
|
||||
// nativeVLESSUDPTunnel implements VLESS UDP-over-stream framing for a normal
|
||||
// VLESS CommandUDP request. Xray uses classic 2-byte length-prefixed packets
|
||||
// for this command. Do not auto-detect XUDP here: real DNS queries often have
|
||||
// bytes 2/3 equal to 0x01/0x00, which looked like our old loose XUDP metadata
|
||||
// check and caused the server to block waiting for a fake second payload.
|
||||
// XUDP belongs to VLESS CommandMux and is handled separately when Mux support
|
||||
// is implemented.
|
||||
func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, up, down *rate.Limiter) {
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
|
||||
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
|
||||
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
closeAll := func() {
|
||||
_ = backend.Close()
|
||||
_ = client.Close()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
payload, err := readVLESSLengthPacket(client)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("native xray: VLESS UDP client read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(up, len(payload)); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(payload)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("native xray: VLESS UDP backend write failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, nativeUDPBufferSize)
|
||||
for {
|
||||
_ = backend.SetReadDeadline(time.Now().Add(nativeUDPIdle))
|
||||
n, err := backend.Read(buf)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return
|
||||
}
|
||||
if err != io.EOF {
|
||||
log.Printf("native xray: VLESS UDP backend read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
return
|
||||
}
|
||||
if err := writeVLESSLengthPacket(client, buf[:n]); err != nil {
|
||||
log.Printf("native xray: VLESS UDP client write failed: %v", err)
|
||||
return
|
||||
}
|
||||
downMeter.add(n)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
upMeter.flush()
|
||||
downMeter.flush()
|
||||
closeAll()
|
||||
}
|
||||
|
||||
type vlessUDPPacketCodec struct {
|
||||
mu sync.RWMutex
|
||||
decided bool
|
||||
xudp bool
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) setXUDP(v bool) {
|
||||
c.mu.Lock()
|
||||
if !c.decided {
|
||||
c.decided = true
|
||||
c.xudp = v
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) useXUDP() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.decided && c.xudp
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) Read(r io.Reader) ([]byte, error) {
|
||||
if c.useXUDP() {
|
||||
return readVLESSXUDPPacket(r)
|
||||
}
|
||||
return c.readAuto(r)
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) Write(w io.Writer, payload []byte) error {
|
||||
if c.useXUDP() {
|
||||
return writeVLESSXUDPPacket(w, payload)
|
||||
}
|
||||
return writeVLESSLengthPacket(w, payload)
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) readAuto(r io.Reader) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n == 0 {
|
||||
c.setXUDP(false)
|
||||
return []byte{}, nil
|
||||
}
|
||||
if n > nativeUDPMaxPacket {
|
||||
return nil, fmt.Errorf("udp packet too large: %d", n)
|
||||
}
|
||||
|
||||
// XUDP starts with a metadata frame length, not a payload length. Metadata is
|
||||
// small and has command/option bytes at offsets 2/3 after the two-byte mux ID.
|
||||
// Read a possible metadata frame once and fall back to normal length-prefixed
|
||||
// UDP if it does not match the XUDP shape. This lets the native emulator work
|
||||
// with clients whose default packet encoding is xudp while preserving classic
|
||||
// VLESS UDP framing.
|
||||
if n >= 4 && n <= 512 {
|
||||
candidate := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, candidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isVLESSXUDPMetadata(candidate) {
|
||||
c.setXUDP(true)
|
||||
return readVLESSXUDPPayloadAfterMeta(r, candidate)
|
||||
}
|
||||
c.setXUDP(false)
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
c.setXUDP(false)
|
||||
pkt := make([]byte, n)
|
||||
_, err := io.ReadFull(r, pkt)
|
||||
return pkt, err
|
||||
}
|
||||
|
||||
func readVLESSLengthPacket(r io.Reader) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if n > nativeUDPMaxPacket {
|
||||
return nil, fmt.Errorf("udp packet too large: %d", n)
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
_, err := io.ReadFull(r, pkt)
|
||||
return pkt, err
|
||||
}
|
||||
|
||||
func writeVLESSLengthPacket(w io.Writer, payload []byte) error {
|
||||
if len(payload) > nativeUDPMaxPacket {
|
||||
return fmt.Errorf("udp packet too large: %d", len(payload))
|
||||
}
|
||||
var lenBuf [2]byte
|
||||
binary.BigEndian.PutUint16(lenBuf[:], uint16(len(payload)))
|
||||
if _, err := w.Write(lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func isVLESSXUDPMetadata(meta []byte) bool {
|
||||
if len(meta) < 4 {
|
||||
return false
|
||||
}
|
||||
cmd := meta[2]
|
||||
opt := meta[3]
|
||||
if cmd != 1 && cmd != 2 && cmd != 4 { // New, Keep, End/discard
|
||||
return false
|
||||
}
|
||||
return opt == 0 || opt == 1
|
||||
}
|
||||
|
||||
func readVLESSXUDPPacket(r io.Reader) ([]byte, error) {
|
||||
for {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n < 4 || n > 512 {
|
||||
return nil, fmt.Errorf("bad xudp metadata length: %d", n)
|
||||
}
|
||||
meta := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isVLESSXUDPMetadata(meta) {
|
||||
return nil, fmt.Errorf("bad xudp metadata command/option")
|
||||
}
|
||||
payload, err := readVLESSXUDPPayloadAfterMeta(r, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload != nil {
|
||||
return payload, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readVLESSXUDPPayloadAfterMeta(r io.Reader, meta []byte) ([]byte, error) {
|
||||
if len(meta) < 4 {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
if meta[2] == 4 { // discard/end marker
|
||||
return nil, nil
|
||||
}
|
||||
if meta[3] != 1 { // no payload attached
|
||||
return nil, nil
|
||||
}
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if n > nativeUDPMaxPacket {
|
||||
return nil, fmt.Errorf("xudp payload too large: %d", n)
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
_, err := io.ReadFull(r, pkt)
|
||||
return pkt, err
|
||||
}
|
||||
|
||||
func writeVLESSXUDPPacket(w io.Writer, payload []byte) error {
|
||||
if len(payload) > nativeUDPMaxPacket {
|
||||
return fmt.Errorf("udp packet too large: %d", len(payload))
|
||||
}
|
||||
// Metadata length 4, mux session id 0, command Keep, option payload-present.
|
||||
// This is accepted by Xray's xudp.PacketReader for responses when the UDP
|
||||
// destination is already known from the request header.
|
||||
var header [8]byte
|
||||
binary.BigEndian.PutUint16(header[0:2], 4)
|
||||
header[2] = 0
|
||||
header[3] = 0
|
||||
header[4] = 2 // Keep
|
||||
header[5] = 1 // Opt: payload follows
|
||||
binary.BigEndian.PutUint16(header[6:8], uint16(len(payload)))
|
||||
if _, err := w.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// nativeVMessUDPTunnel maps one VMess body chunk to one UDP datagram. VMess AEAD
|
||||
// chunking already preserves packet boundaries, so no extra VLESS length prefix
|
||||
// is added inside the encrypted body.
|
||||
func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, email string, up, down *rate.Limiter) {
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
|
||||
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
|
||||
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
closeAll := func() {
|
||||
_ = backend.Close()
|
||||
_ = client.Close()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
pkt, err := client.ReadPacket()
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("native xray: VMess UDP client read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(pkt) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(up, len(pkt)); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(pkt)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("native xray: VMess UDP backend write failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, nativeUDPBufferSize)
|
||||
for {
|
||||
_ = backend.SetReadDeadline(time.Now().Add(nativeUDPIdle))
|
||||
n, err := backend.Read(buf)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return
|
||||
}
|
||||
if err != io.EOF {
|
||||
log.Printf("native xray: VMess UDP backend read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
return
|
||||
}
|
||||
if err := client.WritePacket(buf[:n]); err != nil {
|
||||
log.Printf("native xray: VMess UDP client write failed: %v", err)
|
||||
return
|
||||
}
|
||||
downMeter.add(n)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
upMeter.flush()
|
||||
downMeter.flush()
|
||||
closeAll()
|
||||
}
|
||||
|
||||
func waitNativeRate(lim *rate.Limiter, n int) error {
|
||||
if lim == nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return lim.WaitN(context.Background(), n)
|
||||
}
|
||||
+728
@@ -0,0 +1,728 @@
|
||||
package main
|
||||
|
||||
// Pure-Go VMess (AEAD) server, part of the in-process Xray emulator.
|
||||
//
|
||||
// This implements the modern "VMess AEAD" protocol (alterId = 0) exactly as
|
||||
// spoken by current Xray/v2ray clients: AEAD-authenticated request header,
|
||||
// AES-128-GCM / ChaCha20-Poly1305 chunked body with SHAKE-masked lengths,
|
||||
// optional global padding and authenticated length, and the AEAD response
|
||||
// header + body. Byte offsets, KDF labels and orderings follow the v2fly/xray
|
||||
// reference (proxy/vmess/{aead,encoding}). Legacy MD5-auth VMess (alterId > 0)
|
||||
// is intentionally not supported.
|
||||
//
|
||||
// TCP and UDP commands are served. Mux is intentionally not supported in the
|
||||
// native emulator yet.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/crc32"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// ---------- constants ----------
|
||||
|
||||
const vmessCmdKeyMagic = "c48619fe-8f02-49e0-b9e9-edf763e17e21"
|
||||
|
||||
const (
|
||||
kdfSaltVMessAEADKDF = "VMess AEAD KDF"
|
||||
|
||||
kdfLabelAuthIDEncryptionKey = "AES Auth ID Encryption"
|
||||
|
||||
kdfLabelReqHeaderLenKey = "VMess Header AEAD Key_Length"
|
||||
kdfLabelReqHeaderLenIV = "VMess Header AEAD Nonce_Length"
|
||||
kdfLabelReqHeaderKey = "VMess Header AEAD Key"
|
||||
kdfLabelReqHeaderIV = "VMess Header AEAD Nonce"
|
||||
|
||||
kdfLabelRespHeaderLenKey = "AEAD Resp Header Len Key"
|
||||
kdfLabelRespHeaderLenIV = "AEAD Resp Header Len IV"
|
||||
kdfLabelRespHeaderKey = "AEAD Resp Header Key"
|
||||
kdfLabelRespHeaderIV = "AEAD Resp Header IV"
|
||||
|
||||
kdfLabelAuthLen = "auth_len"
|
||||
)
|
||||
|
||||
// VMess request option flags (header byte 34).
|
||||
const (
|
||||
vmessOptChunkStream = 0x01
|
||||
vmessOptChunkMasking = 0x04
|
||||
vmessOptGlobalPadding = 0x08
|
||||
vmessOptAuthenticatedLength = 0x10
|
||||
)
|
||||
|
||||
// VMess security types (low nibble of header byte 35).
|
||||
const (
|
||||
vmessSecAES128GCM = 3
|
||||
vmessSecChaCha20Poly1305 = 4
|
||||
vmessSecNone = 5
|
||||
)
|
||||
|
||||
// VMess commands (header byte 37).
|
||||
const (
|
||||
vmessCmdTCP = 1
|
||||
vmessCmdUDP = 2
|
||||
vmessCmdMux = 3
|
||||
)
|
||||
|
||||
const vmessTimeWindowSeconds = 120
|
||||
|
||||
// ---------- KDF ("VMess AEAD KDF", nested HMAC-SHA256) ----------
|
||||
|
||||
type hmacCreator struct {
|
||||
parent *hmacCreator
|
||||
value []byte
|
||||
}
|
||||
|
||||
func newHMAC(f func() hash.Hash, key []byte) hash.Hash {
|
||||
return hmac.New(f, key)
|
||||
}
|
||||
|
||||
func (h *hmacCreator) create() hash.Hash {
|
||||
if h.parent == nil {
|
||||
return newHMAC(sha256.New, h.value)
|
||||
}
|
||||
return newHMAC(h.parent.create, h.value)
|
||||
}
|
||||
|
||||
func vmessKDF(key []byte, path ...string) []byte {
|
||||
c := &hmacCreator{value: []byte(kdfSaltVMessAEADKDF)}
|
||||
for _, p := range path {
|
||||
c = &hmacCreator{value: []byte(p), parent: c}
|
||||
}
|
||||
h := c.create()
|
||||
h.Write(key)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func vmessKDF16(key []byte, path ...string) []byte {
|
||||
return vmessKDF(key, path...)[:16]
|
||||
}
|
||||
|
||||
// ---------- command key / crypto helpers ----------
|
||||
|
||||
func vmessCmdKey(uuid [16]byte) [16]byte {
|
||||
h := md5.New()
|
||||
h.Write(uuid[:])
|
||||
h.Write([]byte(vmessCmdKeyMagic))
|
||||
var out [16]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
func newAESGCM(key []byte) cipher.AEAD {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic(err) // only happens on wrong key length — a programmer error
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return gcm
|
||||
}
|
||||
|
||||
// vmessChaChaKey expands a 16-byte key into the 32-byte ChaCha20 key VMess uses:
|
||||
// MD5(key) || MD5(MD5(key)).
|
||||
func vmessChaChaKey(key []byte) []byte {
|
||||
h1 := md5.Sum(key)
|
||||
h2 := md5.Sum(h1[:])
|
||||
out := make([]byte, 32)
|
||||
copy(out[0:16], h1[:])
|
||||
copy(out[16:32], h2[:])
|
||||
return out
|
||||
}
|
||||
|
||||
func absInt64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func shakeNext(s sha3.ShakeHash) uint16 {
|
||||
var b [2]byte
|
||||
_, _ = s.Read(b[:])
|
||||
return binary.BigEndian.Uint16(b[:])
|
||||
}
|
||||
|
||||
// ---------- auth ID matching ----------
|
||||
|
||||
// matchVMess tries every VMess client's auth-ID cipher against the 16-byte
|
||||
// auth ID, returning the client whose key decrypts to a CRC-valid, in-window
|
||||
// timestamp. This is O(clients) AES blocks per connection.
|
||||
func (ib *nativeInbound) matchVMess(authid [16]byte, now int64) *nativeXrayClient {
|
||||
ib.clientMu.RLock()
|
||||
defer ib.clientMu.RUnlock()
|
||||
for _, c := range ib.clientsByID {
|
||||
if c.authIDCipher == nil {
|
||||
continue
|
||||
}
|
||||
var dec [16]byte
|
||||
c.authIDCipher.Decrypt(dec[:], authid[:])
|
||||
if crc32.ChecksumIEEE(dec[0:12]) != binary.BigEndian.Uint32(dec[12:16]) {
|
||||
continue
|
||||
}
|
||||
t := int64(binary.BigEndian.Uint64(dec[0:8]))
|
||||
if t < 0 || absInt64(t-now) > vmessTimeWindowSeconds {
|
||||
continue
|
||||
}
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- request header ----------
|
||||
|
||||
type vmessRequest struct {
|
||||
bodyIV [16]byte
|
||||
bodyKey [16]byte
|
||||
respV byte
|
||||
option byte
|
||||
security byte
|
||||
command byte
|
||||
host string
|
||||
port uint16
|
||||
}
|
||||
|
||||
// openVMessHeader reads and decrypts the AEAD request header from r, given the
|
||||
// user's command key and the already-read 16-byte auth ID. r must be positioned
|
||||
// immediately after the auth ID.
|
||||
func openVMessHeader(cmdKey [16]byte, authid [16]byte, r io.Reader) ([]byte, error) {
|
||||
var lenBlock [18]byte // 2-byte length + 16-byte tag
|
||||
if _, err := io.ReadFull(r, lenBlock[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var connNonce [8]byte
|
||||
if _, err := io.ReadFull(r, connNonce[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aid := string(authid[:])
|
||||
cn := string(connNonce[:])
|
||||
|
||||
lenGCM := newAESGCM(vmessKDF16(cmdKey[:], kdfLabelReqHeaderLenKey, aid, cn))
|
||||
lenNonce := vmessKDF(cmdKey[:], kdfLabelReqHeaderLenIV, aid, cn)[:12]
|
||||
lenPlain, err := lenGCM.Open(nil, lenNonce, lenBlock[:], authid[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: header length decrypt: %w", err)
|
||||
}
|
||||
headerLen := int(binary.BigEndian.Uint16(lenPlain))
|
||||
if headerLen < 38 || headerLen > 512 {
|
||||
return nil, fmt.Errorf("vmess: implausible header length %d", headerLen)
|
||||
}
|
||||
|
||||
payload := make([]byte, headerLen+16)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payGCM := newAESGCM(vmessKDF16(cmdKey[:], kdfLabelReqHeaderKey, aid, cn))
|
||||
payNonce := vmessKDF(cmdKey[:], kdfLabelReqHeaderIV, aid, cn)[:12]
|
||||
header, err := payGCM.Open(nil, payNonce, payload, authid[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: header payload decrypt: %w", err)
|
||||
}
|
||||
return header, nil
|
||||
}
|
||||
|
||||
// parseVMessHeader parses the decrypted request header plaintext.
|
||||
func parseVMessHeader(h []byte) (vmessRequest, error) {
|
||||
var req vmessRequest
|
||||
if len(h) < 40 {
|
||||
return req, errors.New("vmess: header too short")
|
||||
}
|
||||
if h[0] != 1 {
|
||||
return req, fmt.Errorf("vmess: unsupported version %d", h[0])
|
||||
}
|
||||
copy(req.bodyIV[:], h[1:17])
|
||||
copy(req.bodyKey[:], h[17:33])
|
||||
req.respV = h[33]
|
||||
req.option = h[34]
|
||||
req.security = h[35] & 0x0f
|
||||
paddingLen := int(h[35] >> 4)
|
||||
req.command = h[37]
|
||||
req.port = binary.BigEndian.Uint16(h[38:40])
|
||||
|
||||
host, next, err := parseVMessAddress(h, 40)
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req.host = host
|
||||
|
||||
if next+paddingLen+4 != len(h) {
|
||||
return req, fmt.Errorf("vmess: header length mismatch (addr end %d + pad %d + 4 != %d)", next, paddingLen, len(h))
|
||||
}
|
||||
|
||||
f := fnv.New32a()
|
||||
f.Write(h[:len(h)-4])
|
||||
if binary.BigEndian.Uint32(h[len(h)-4:]) != f.Sum32() {
|
||||
return req, errors.New("vmess: header checksum mismatch")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func parseVMessAddress(h []byte, off int) (host string, next int, err error) {
|
||||
if off >= len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
atyp := h[off]
|
||||
off++
|
||||
switch atyp {
|
||||
case atypIPv4:
|
||||
if off+4 > len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
host = net.IP(h[off : off+4]).String()
|
||||
off += 4
|
||||
case atypIPv6:
|
||||
if off+16 > len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
host = net.IP(h[off : off+16]).String()
|
||||
off += 16
|
||||
case atypDomain:
|
||||
if off >= len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
l := int(h[off])
|
||||
off++
|
||||
if off+l > len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
host = string(h[off : off+l])
|
||||
off += l
|
||||
default:
|
||||
return "", 0, fmt.Errorf("vmess: unknown address type %d", atyp)
|
||||
}
|
||||
return host, off, nil
|
||||
}
|
||||
|
||||
// ---------- response header ----------
|
||||
|
||||
func writeVMessResponseHeader(w io.Writer, respBodyKey, respBodyIV [16]byte, respV byte) error {
|
||||
header := []byte{respV, 0, 0, 0} // V echo, option 0, command 0, command-data-length 0
|
||||
|
||||
lenGCM := newAESGCM(vmessKDF16(respBodyKey[:], kdfLabelRespHeaderLenKey))
|
||||
lenNonce := vmessKDF(respBodyIV[:], kdfLabelRespHeaderLenIV)[:12]
|
||||
var lenPlain [2]byte
|
||||
binary.BigEndian.PutUint16(lenPlain[:], uint16(len(header)))
|
||||
lenSealed := lenGCM.Seal(nil, lenNonce, lenPlain[:], nil) // AAD nil
|
||||
|
||||
payGCM := newAESGCM(vmessKDF16(respBodyKey[:], kdfLabelRespHeaderKey))
|
||||
payNonce := vmessKDF(respBodyIV[:], kdfLabelRespHeaderIV)[:12]
|
||||
paySealed := payGCM.Seal(nil, payNonce, header, nil) // AAD nil
|
||||
|
||||
out := make([]byte, 0, len(lenSealed)+len(paySealed))
|
||||
out = append(out, lenSealed...)
|
||||
out = append(out, paySealed...)
|
||||
_, err := w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- authenticated-length size parser ----------
|
||||
|
||||
// vmessAuthLen encodes/decodes the 2-byte chunk length as an AEAD-sealed field
|
||||
// (option AuthenticatedLength). It always derives its key from the *request*
|
||||
// body key/IV, in both directions, per the reference.
|
||||
type vmessAuthLen struct {
|
||||
aead cipher.AEAD
|
||||
count uint16
|
||||
ivTail [10]byte
|
||||
nonce [12]byte
|
||||
}
|
||||
|
||||
func newVMessAuthLen(reqBodyKey, reqBodyIV [16]byte, chacha bool) *vmessAuthLen {
|
||||
keyMat := vmessKDF16(reqBodyKey[:], kdfLabelAuthLen)
|
||||
var aead cipher.AEAD
|
||||
if chacha {
|
||||
a, _ := chacha20poly1305.New(vmessChaChaKey(keyMat))
|
||||
aead = a
|
||||
} else {
|
||||
aead = newAESGCM(keyMat)
|
||||
}
|
||||
al := &vmessAuthLen{aead: aead}
|
||||
copy(al.ivTail[:], reqBodyIV[2:12])
|
||||
return al
|
||||
}
|
||||
|
||||
func (al *vmessAuthLen) nextNonce() []byte {
|
||||
binary.BigEndian.PutUint16(al.nonce[0:2], al.count)
|
||||
copy(al.nonce[2:12], al.ivTail[:])
|
||||
al.count++
|
||||
return al.nonce[:12]
|
||||
}
|
||||
|
||||
func (al *vmessAuthLen) decode(r io.Reader) (int, error) {
|
||||
var buf [18]byte
|
||||
if _, err := io.ReadFull(r, buf[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
plain, err := al.aead.Open(nil, al.nextNonce(), buf[:], nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("vmess: auth-len decrypt: %w", err)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(plain)) + 16, nil
|
||||
}
|
||||
|
||||
func (al *vmessAuthLen) encode(out *bytes.Buffer, size int) {
|
||||
var lenPlain [2]byte
|
||||
binary.BigEndian.PutUint16(lenPlain[:], uint16(size-16))
|
||||
out.Write(al.aead.Seal(nil, al.nextNonce(), lenPlain[:], nil))
|
||||
}
|
||||
|
||||
// ---------- body chunk reader/writer ----------
|
||||
|
||||
const vmessMaxChunk = 64*1024 + 64
|
||||
|
||||
type vmessChunkReader struct {
|
||||
r io.Reader
|
||||
aead cipher.AEAD // nil for security "none"
|
||||
overhead int
|
||||
ivTail [10]byte
|
||||
count uint16
|
||||
nonce [12]byte
|
||||
shake sha3.ShakeHash // non-nil when chunk masking is enabled
|
||||
authLen *vmessAuthLen // non-nil when authenticated length is enabled
|
||||
globalPad bool
|
||||
leftover []byte
|
||||
eof bool
|
||||
}
|
||||
|
||||
func (cr *vmessChunkReader) nextNonce() []byte {
|
||||
binary.BigEndian.PutUint16(cr.nonce[0:2], cr.count)
|
||||
copy(cr.nonce[2:12], cr.ivTail[:])
|
||||
cr.count++
|
||||
return cr.nonce[:12]
|
||||
}
|
||||
|
||||
func (cr *vmessChunkReader) readChunk() ([]byte, error) {
|
||||
// Padding length is always drawn from the SHAKE stream before the size.
|
||||
pad := 0
|
||||
if cr.shake != nil && cr.globalPad {
|
||||
pad = int(shakeNext(cr.shake) % 64)
|
||||
}
|
||||
|
||||
var size int
|
||||
switch {
|
||||
case cr.authLen != nil:
|
||||
s, err := cr.authLen.decode(cr.r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size = s
|
||||
case cr.shake != nil:
|
||||
var b [2]byte
|
||||
if _, err := io.ReadFull(cr.r, b[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size = int(shakeNext(cr.shake) ^ binary.BigEndian.Uint16(b[:]))
|
||||
default:
|
||||
var b [2]byte
|
||||
if _, err := io.ReadFull(cr.r, b[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size = int(binary.BigEndian.Uint16(b[:]))
|
||||
}
|
||||
|
||||
// size == overhead + pad means an empty (terminating) chunk.
|
||||
if size == cr.overhead+pad {
|
||||
return nil, io.EOF
|
||||
}
|
||||
if size < cr.overhead+pad || size > vmessMaxChunk {
|
||||
return nil, fmt.Errorf("vmess: bad chunk size %d", size)
|
||||
}
|
||||
|
||||
data := make([]byte, size)
|
||||
if _, err := io.ReadFull(cr.r, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealed := data[:size-pad] // trailing pad bytes are clear-text, discarded
|
||||
if cr.aead == nil {
|
||||
return sealed, nil
|
||||
}
|
||||
plain, err := cr.aead.Open(nil, cr.nextNonce(), sealed, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: body decrypt: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func (cr *vmessChunkReader) Read(p []byte) (int, error) {
|
||||
for len(cr.leftover) == 0 {
|
||||
if cr.eof {
|
||||
return 0, io.EOF
|
||||
}
|
||||
chunk, err := cr.readChunk()
|
||||
if err == io.EOF {
|
||||
cr.eof = true
|
||||
return 0, io.EOF
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cr.leftover = chunk
|
||||
}
|
||||
n := copy(p, cr.leftover)
|
||||
cr.leftover = cr.leftover[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type vmessChunkWriter struct {
|
||||
w io.Writer
|
||||
aead cipher.AEAD
|
||||
overhead int
|
||||
ivTail [10]byte
|
||||
count uint16
|
||||
nonce [12]byte
|
||||
shake sha3.ShakeHash
|
||||
authLen *vmessAuthLen
|
||||
globalPad bool
|
||||
}
|
||||
|
||||
func (cw *vmessChunkWriter) nextNonce() []byte {
|
||||
binary.BigEndian.PutUint16(cw.nonce[0:2], cw.count)
|
||||
copy(cw.nonce[2:12], cw.ivTail[:])
|
||||
cw.count++
|
||||
return cw.nonce[:12]
|
||||
}
|
||||
|
||||
func (cw *vmessChunkWriter) writeChunk(p []byte) error {
|
||||
var out bytes.Buffer
|
||||
|
||||
pad := 0
|
||||
if cw.shake != nil && cw.globalPad {
|
||||
pad = int(shakeNext(cw.shake) % 64)
|
||||
}
|
||||
size := len(p) + cw.overhead + pad
|
||||
|
||||
switch {
|
||||
case cw.authLen != nil:
|
||||
cw.authLen.encode(&out, size)
|
||||
case cw.shake != nil:
|
||||
var b [2]byte
|
||||
binary.BigEndian.PutUint16(b[:], shakeNext(cw.shake)^uint16(size))
|
||||
out.Write(b[:])
|
||||
default:
|
||||
var b [2]byte
|
||||
binary.BigEndian.PutUint16(b[:], uint16(size))
|
||||
out.Write(b[:])
|
||||
}
|
||||
|
||||
if cw.aead != nil {
|
||||
out.Write(cw.aead.Seal(nil, cw.nextNonce(), p, nil))
|
||||
} else {
|
||||
out.Write(p)
|
||||
}
|
||||
if pad > 0 {
|
||||
padBytes := make([]byte, pad)
|
||||
_, _ = rand.Read(padBytes)
|
||||
out.Write(padBytes)
|
||||
}
|
||||
_, err := cw.w.Write(out.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- vmessConn: net.Conn view of a decoded VMess session ----------
|
||||
|
||||
type nativeVMessStream interface {
|
||||
net.Conn
|
||||
ReadPacket() ([]byte, error)
|
||||
WritePacket([]byte) error
|
||||
}
|
||||
|
||||
type vmessConn struct {
|
||||
net.Conn
|
||||
reader *vmessChunkReader
|
||||
writer *vmessChunkWriter
|
||||
terminateOnClose bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (c *vmessConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
|
||||
|
||||
// ReadPacket returns exactly one decrypted VMess body chunk. UDP-over-VMess uses
|
||||
// one VMess chunk per UDP datagram, so packet handling must bypass the streamy
|
||||
// Read method that can merge/split chunks.
|
||||
func (c *vmessConn) ReadPacket() ([]byte, error) { return c.reader.readChunk() }
|
||||
|
||||
func (c *vmessConn) WritePacket(p []byte) error { return c.writer.writeChunk(p) }
|
||||
|
||||
func (c *vmessConn) Write(p []byte) (int, error) {
|
||||
// Bound each chunk well under the uint16 length field.
|
||||
const maxChunk = 16 * 1024
|
||||
total := 0
|
||||
for len(p) > 0 {
|
||||
n := len(p)
|
||||
if n > maxChunk {
|
||||
n = maxChunk
|
||||
}
|
||||
if err := c.writer.writeChunk(p[:n]); err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += n
|
||||
p = p[n:]
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (c *vmessConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
if c.terminateOnClose {
|
||||
_ = c.writer.writeChunk(nil) // terminating empty chunk
|
||||
}
|
||||
})
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// vmessRawConn is used for VMess security=none when the client did not request
|
||||
// ChunkStream. Xray's own server returns a raw reader/writer in that exact case;
|
||||
// treating the following TLS ClientHello/HTTP bytes as a VMess chunk length makes
|
||||
// real clients authenticate but then pass no data.
|
||||
type vmessRawConn struct {
|
||||
net.Conn
|
||||
}
|
||||
|
||||
func (c *vmessRawConn) ReadPacket() ([]byte, error) {
|
||||
buf := make([]byte, 64*1024)
|
||||
n, err := c.Conn.Read(buf)
|
||||
if n > 0 {
|
||||
return buf[:n], nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (c *vmessRawConn) WritePacket(p []byte) error {
|
||||
_, err := c.Conn.Write(p)
|
||||
return err
|
||||
}
|
||||
|
||||
func newVMessConn(stream net.Conn, req vmessRequest, respBodyKey, respBodyIV [16]byte) (nativeVMessStream, error) {
|
||||
chunkMask := req.option&vmessOptChunkMasking != 0
|
||||
globalPad := req.option&vmessOptGlobalPadding != 0
|
||||
authLen := req.option&vmessOptAuthenticatedLength != 0
|
||||
chacha := req.security == vmessSecChaCha20Poly1305
|
||||
|
||||
if req.security == vmessSecNone && req.option&vmessOptChunkStream == 0 {
|
||||
return &vmessRawConn{Conn: stream}, nil
|
||||
}
|
||||
|
||||
var readAEAD, writeAEAD cipher.AEAD
|
||||
overhead := 16
|
||||
switch req.security {
|
||||
case vmessSecAES128GCM:
|
||||
readAEAD = newAESGCM(req.bodyKey[:])
|
||||
writeAEAD = newAESGCM(respBodyKey[:])
|
||||
case vmessSecChaCha20Poly1305:
|
||||
ra, _ := chacha20poly1305.New(vmessChaChaKey(req.bodyKey[:]))
|
||||
wa, _ := chacha20poly1305.New(vmessChaChaKey(respBodyKey[:]))
|
||||
readAEAD, writeAEAD = ra, wa
|
||||
case vmessSecNone:
|
||||
overhead = 0
|
||||
default:
|
||||
return nil, fmt.Errorf("vmess: unsupported security %d", req.security)
|
||||
}
|
||||
|
||||
cr := &vmessChunkReader{r: stream, aead: readAEAD, overhead: overhead, globalPad: globalPad}
|
||||
copy(cr.ivTail[:], req.bodyIV[2:12])
|
||||
cw := &vmessChunkWriter{w: stream, aead: writeAEAD, overhead: overhead, globalPad: globalPad}
|
||||
copy(cw.ivTail[:], respBodyIV[2:12])
|
||||
|
||||
if chunkMask {
|
||||
rs := sha3.NewShake128()
|
||||
rs.Write(req.bodyIV[:])
|
||||
cr.shake = rs
|
||||
ws := sha3.NewShake128()
|
||||
ws.Write(respBodyIV[:])
|
||||
cw.shake = ws
|
||||
}
|
||||
if authLen {
|
||||
cr.authLen = newVMessAuthLen(req.bodyKey, req.bodyIV, chacha)
|
||||
cw.authLen = newVMessAuthLen(req.bodyKey, req.bodyIV, chacha)
|
||||
}
|
||||
|
||||
return &vmessConn{Conn: stream, reader: cr, writer: cw, terminateOnClose: req.option&vmessOptChunkStream != 0 || req.security != vmessSecNone}, nil
|
||||
}
|
||||
|
||||
// ---------- handler ----------
|
||||
|
||||
func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
|
||||
_ = stream.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
var authid [16]byte
|
||||
if _, err := io.ReadFull(stream, authid[:]); err != nil {
|
||||
return
|
||||
}
|
||||
client := ib.matchVMess(authid, time.Now().Unix())
|
||||
if client == nil {
|
||||
log.Printf("native xray: inbound %q rejected unknown/expired VMess auth id from %s", ib.tag, remote)
|
||||
return
|
||||
}
|
||||
|
||||
header, err := openVMessHeader(client.cmdKey, authid, stream)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess header open failed from %s: %v", ib.tag, remote, err)
|
||||
return
|
||||
}
|
||||
req, err := parseVMessHeader(header)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess header parse failed from %s: %v", ib.tag, remote, err)
|
||||
return
|
||||
}
|
||||
_ = stream.SetReadDeadline(time.Time{})
|
||||
|
||||
if req.command != vmessCmdTCP && req.command != vmessCmdUDP {
|
||||
log.Printf("native xray: inbound %q VMess command %d not supported yet", ib.tag, req.command)
|
||||
return
|
||||
}
|
||||
|
||||
respBodyKey := sha256.Sum256(req.bodyKey[:])
|
||||
respBodyIV := sha256.Sum256(req.bodyIV[:])
|
||||
var rk, riv [16]byte
|
||||
copy(rk[:], respBodyKey[:16])
|
||||
copy(riv[:], respBodyIV[:16])
|
||||
|
||||
if err := writeVMessResponseHeader(stream, rk, riv, req.respV); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
vc, err := newVMessConn(stream, req, rk, riv)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess codec: %v", ib.tag, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.command {
|
||||
case vmessCmdTCP:
|
||||
backend, target, err := ib.nativeDialTCP(req.host, req.port)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess TCP dial %s failed: %v", ib.tag, target, err)
|
||||
return
|
||||
}
|
||||
log.Printf("native xray: vmess/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
case vmessCmdUDP:
|
||||
backend, target, err := ib.nativeDialUDP(req.host, req.port)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess UDP dial %s failed: %v", ib.tag, target, err)
|
||||
return
|
||||
}
|
||||
log.Printf("native xray: vmess/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeVMessUDPTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
}
|
||||
}
|
||||
+906
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user