Compare commits

...

26 Commits

Author SHA1 Message Date
6cd9626db9 optimization 2 2026-05-27 15:09:42 -03:00
1479e6ac73 Optimization 2026-05-27 15:04:01 -03:00
f64f7fdc4d Install in any distro 2026-05-15 17:01:24 -03:00
15859dc7f3 Fix apt 2026-05-14 14:33:40 -03:00
60cb2e3cdb Fix xray config bug 2026-05-11 22:45:22 -03:00
f1a587e00d Mult node launch 2026-05-11 22:10:17 -03:00
1ad8b868ab FIx mult panel server 2026-05-11 21:52:07 -03:00
67d56b2a76 Fix Mult server 2026-05-11 14:39:55 -03:00
b66d194fa7 Mult server 2026-05-11 14:32:16 -03:00
391db7708f New panel 2026-05-10 18:42:38 -03:00
603ae906a1 Fix panel 2026-05-10 18:32:59 -03:00
4a04ff79f0 Fix panel 2026-05-10 18:21:03 -03:00
e00a7bd93c Fix panel 2026-05-10 18:14:16 -03:00
77a722d4ed Fix Admin panel and xray count 2026-05-10 18:05:24 -03:00
03c43debf4 Panel Update 2026-05-10 17:52:36 -03:00
51aedfd3c7 Fix Mkdir crash service 2026-05-03 22:02:12 -03:00
3c7b02b8db Fix Daily usage 2026-05-03 21:54:48 -03:00
3ddd934d9a Ignore LO , dont re-enable the iptables redirect if disabled 2026-05-03 11:14:32 -03:00
c74f6e2282 New Features and safe log 2026-05-03 11:05:13 -03:00
43482c88fa Fix stuck users 2026-05-03 10:15:28 -03:00
09f3959aa2 Simple update 2026-05-02 23:40:09 -03:00
9b5f436a6e Fix udp description 2026-05-02 23:34:30 -03:00
d01fb919aa Safe Update 2026-05-02 23:20:13 -03:00
41aca3b7f3 Update Vmess Doc 2026-05-02 18:48:26 -03:00
c1bb3c7a97 Merge branch 'main' of https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB 2026-05-02 18:47:14 -03:00
f8fac513e3 Update Vmess 2026-05-02 18:46:46 -03:00
22 changed files with 10407 additions and 1962 deletions

300
README.md
View File

@@ -2,7 +2,46 @@
## PT-BR
DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, painel web, PostgreSQL, integração com Xray-core e API pública para consultar status de usuário.
DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, painel web, PostgreSQL, integração com Xray-core/V2Ray e API pública para consultar status de usuários SSH e clientes Xray.
### Recursos principais
- SSH com HTTP Injection
- Painel web administrativo
- Banco de dados PostgreSQL
- Integração com Xray-core/V2Ray
- Configurador visual para VLESS e VMess
- API pública `/check` para consultar usuário ou UUID
- Aba de logs no painel para ver logs do sistema, DNSTT e Xray
- Salvamento live das configurações principais, com checagem se o serviço realmente subiu
- Serviço `systemd` para iniciar automaticamente com o sistema
### Protocolos suportados no configurador Xray/V2Ray
O painel possui suporte para criação e gerenciamento de configurações Xray/V2Ray com:
```text
VLESS
VMess
Trojan
Shadowsocks
SOCKS
```
Para VMess, o painel gera clientes com `alterId: 0`.
Transportes disponíveis para VLESS/VMess no configurador visual:
```text
TCP
WebSocket
XHTTP
HTTPUpgrade
HTTP/2
gRPC
```
Observação: Reality deve ser usado apenas em protocolos compatíveis. No configurador visual, VMess não usa Reality.
### Requisitos
@@ -44,9 +83,9 @@ Server IP
SSH ports
VLESS port
VLESS UUID
VMess port
Admin panel URL
Admin login
Admin password
Admin login/password, quando aplicável
Admin token
```
@@ -59,19 +98,59 @@ Admin token
/opt/sshpanel/xray_config.json
/opt/sshpanel/admin/
/opt/sshpanel/logs/panel.log
/opt/sshpanel/update.sh
/opt/sshpanel/change_admin_password.sh
/etc/systemd/system/sshpanel.service
```
O instalador monta `/opt/sshpanel/logs` como tmpfs de 15 MiB quando possível, para reduzir gravações no SD card. O `panel.log` é limpo automaticamente quando passa de 1 MiB, e também pode ser limpo manualmente pela aba Logs do painel.
### Portas padrão
```text
80 SSH com HTTP Injection
8080 SSH extra com HTTP Injection
53/udp DNS público para DNSTT, redirecionado para 5300/udp
5300/udp DNSTT interno
9090 Painel web + API pública /check
10086 Xray VLESS
10087 Xray VMess
10088 SOCKS local em 127.0.0.1
```
Libere no firewall apenas as portas que você realmente usa. Exemplo com `ufw`:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 8080/tcp
sudo ufw allow 53/udp
sudo ufw allow 9090/tcp
sudo ufw allow 10086/tcp
sudo ufw allow 10087/tcp
```
### DNSTT na porta DNS 53
O instalador cria o serviço `sshpanel-dnstt-redirect.service`, que libera a porta 53 removendo o `systemd-resolved` quando ele existe, fixa `/etc/resolv.conf` com `1.1.1.1` e adiciona uma regra NAT para redirecionar DNS UDP público da porta `53` para o DNSTT em `5300`.
Comandos manuais equivalentes em sistemas com `iptables`:
```bash
sudo systemctl disable --now systemd-resolved.service || true
sudo rm -f /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
sudo iptables -t nat -C PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300 2>/dev/null \
|| sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300
```
Verificar o redirect:
```bash
systemctl status sshpanel-dnstt-redirect --no-pager -l
sudo iptables -t nat -S PREROUTING | grep 5300
```
### Comandos úteis
Ver status do serviço:
@@ -98,15 +177,68 @@ Reiniciar serviço:
systemctl restart sshpanel
```
### Atualização
### Trocar senha perdida do admin
Entre na nova pasta do código e execute:
Se o dono perdeu a senha do painel, acesse o servidor como `root` e execute:
```bash
sudo bash update.sh
sudo bash /opt/sshpanel/change_admin_password.sh
```
O update recompila o binário e atualiza os arquivos do painel web, mantendo as configurações e dados existentes.
Também é possível passar a senha direto no comando:
```bash
sudo bash /opt/sshpanel/change_admin_password.sh admin 'NovaSenhaForteAqui'
```
Ou gerar uma senha nova automaticamente:
```bash
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
```
O script atualiza o usuário `admin` no PostgreSQL, ativa ele como `superadmin`, salva `ADMIN_PASSWORD` em `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
### Atualização automática pelo Git
Depois da instalação, o `update.sh` fica salvo em `/opt/sshpanel/update.sh`. Para atualizar o servidor, o dono só precisa executar:
```bash
sudo bash /opt/sshpanel/update.sh
```
O script baixa automaticamente os arquivos mais recentes do Git:
```text
https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
```
Depois ele recompila o binário e atualiza o painel web e os scripts auxiliares, mantendo as configurações e dados existentes.
O update preserva:
```text
/opt/sshpanel/.env
/opt/sshpanel/config.json
/opt/sshpanel/xray_config.json
Banco de dados PostgreSQL
Usuários SSH/Xray
Chaves SSH
Certificados
Logs
```
Se quiser forçar uma branch/ref específica:
```bash
sudo UPDATE_REF=main bash /opt/sshpanel/update.sh
```
Se quiser usar outro repositório:
```bash
sudo REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git bash /opt/sshpanel/update.sh
```
### API pública CheckUser
@@ -128,7 +260,7 @@ Consultar usuário SSH:
curl "http://SERVER_IP:9090/check?user=testuser"
```
Consultar UUID Xray:
Consultar UUID Xray/V2Ray:
```bash
curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1"
@@ -164,7 +296,7 @@ Campos da resposta:
| Campo | Tipo | Descrição |
| --- | --- | --- |
| `username` | string | Usuário SSH, nome do cliente Xray ou UUID. |
| `username` | string | Usuário SSH, nome do cliente Xray/V2Ray ou UUID. |
| `count_connections` | number | Conexões SSH ativas no momento. |
| `expiration_date` | string | Data de expiração em `DD/MM/YYYY` ou `Unlimited`. |
| `expiration_days` | number | Dias restantes. `-1` significa ilimitado. |
@@ -192,7 +324,46 @@ Erros comuns:
## EN-US
DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, PostgreSQL, Xray-core integration, and a public API for checking user status.
DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, PostgreSQL, Xray-core/V2Ray integration, and a public API for checking SSH users and Xray clients.
### Main features
- SSH with HTTP Injection
- Administrative web panel
- PostgreSQL database
- Xray-core/V2Ray integration
- Visual configurator for VLESS and VMess
- Public `/check` API for checking username or UUID
- Logs tab in the panel for system, DNSTT, and Xray logs
- Live-save for main service settings, with checks that enabled services actually started
- `systemd` service for automatic startup
### Supported protocols in the Xray/V2Ray configurator
The panel supports creating and managing Xray/V2Ray configurations with:
```text
VLESS
VMess
Trojan
Shadowsocks
SOCKS
```
For VMess, the panel generates clients with `alterId: 0`.
Available transports for VLESS/VMess in the visual configurator:
```text
TCP
WebSocket
XHTTP
HTTPUpgrade
HTTP/2
gRPC
```
Note: Reality should only be used with compatible protocols. In the visual configurator, VMess does not use Reality.
### Requirements
@@ -234,9 +405,9 @@ Server IP
SSH ports
VLESS port
VLESS UUID
VMess port
Admin panel URL
Admin login
Admin password
Admin login/password, when applicable
Admin token
```
@@ -249,6 +420,8 @@ Admin token
/opt/sshpanel/xray_config.json
/opt/sshpanel/admin/
/opt/sshpanel/logs/panel.log
/opt/sshpanel/update.sh
/opt/sshpanel/change_admin_password.sh
/etc/systemd/system/sshpanel.service
```
@@ -257,11 +430,47 @@ Admin token
```text
80 SSH with HTTP Injection
8080 Extra SSH with HTTP Injection
53/udp Public DNS for DNSTT, redirected to 5300/udp
5300/udp Internal DNSTT listener
9090 Web panel + public /check API
10086 Xray VLESS
10087 Xray VMess
10088 Local SOCKS on 127.0.0.1
```
Open only the ports that you actually use. Example with `ufw`:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 8080/tcp
sudo ufw allow 53/udp
sudo ufw allow 9090/tcp
sudo ufw allow 10086/tcp
sudo ufw allow 10087/tcp
```
### DNSTT on DNS port 53
The installer creates `sshpanel-dnstt-redirect.service`. It frees port 53 by stopping `systemd-resolved` when present, writes `/etc/resolv.conf` with `1.1.1.1`, and adds a NAT rule that redirects public UDP DNS traffic from port `53` to DNSTT on `5300`.
Equivalent manual commands on systems with `iptables`:
```bash
sudo systemctl disable --now systemd-resolved.service || true
sudo rm -f /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
sudo iptables -t nat -C PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300 2>/dev/null \
|| sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300
```
Check the redirect:
```bash
systemctl status sshpanel-dnstt-redirect --no-pager -l
sudo iptables -t nat -S PREROUTING | grep 5300
```
### Useful commands
Check service status:
@@ -282,21 +491,76 @@ Follow panel log file:
tail -f /opt/sshpanel/logs/panel.log
```
When possible, `/opt/sshpanel/logs` is mounted as a 15 MiB tmpfs RAM disk by the service. `panel.log` is automatically cleaned after it exceeds 1 MiB, and the Logs tab also has a manual clean button.
Restart service:
```bash
systemctl restart sshpanel
```
### Update
### Reset lost admin password
Enter the new source-code folder and run:
If the owner loses the web panel password, access the server as `root` and run:
```bash
sudo bash update.sh
sudo bash /opt/sshpanel/change_admin_password.sh
```
The update script rebuilds the binary and updates the web panel files while keeping existing configuration and user data.
You can also pass the password directly:
```bash
sudo bash /opt/sshpanel/change_admin_password.sh admin 'NewStrongPasswordHere'
```
Or generate a new password automatically:
```bash
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
```
The script updates the `admin` user in PostgreSQL, enables it as `superadmin`, saves `ADMIN_PASSWORD` in `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
### Automatic Git update
After installation, `update.sh` is saved at `/opt/sshpanel/update.sh`. To update the server, the owner only needs to run:
```bash
sudo bash /opt/sshpanel/update.sh
```
The script automatically downloads the latest files from Git:
```text
https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
```
Then it rebuilds the binary and updates the web panel and helper scripts while keeping existing configuration and user data.
The update preserves:
```text
/opt/sshpanel/.env
/opt/sshpanel/config.json
/opt/sshpanel/xray_config.json
PostgreSQL database
SSH/Xray users
SSH keys
Certificates
Logs
```
To force a specific branch/ref:
```bash
sudo UPDATE_REF=main bash /opt/sshpanel/update.sh
```
To use another repository:
```bash
sudo REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git bash /opt/sshpanel/update.sh
```
### Public CheckUser API
@@ -318,7 +582,7 @@ Check SSH username:
curl "http://SERVER_IP:9090/check?user=testuser"
```
Check Xray UUID:
Check Xray/V2Ray UUID:
```bash
curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1"
@@ -354,7 +618,7 @@ Response fields:
| Field | Type | Description |
| --- | --- | --- |
| `username` | string | SSH username, Xray client name, or UUID. |
| `username` | string | SSH username, Xray/V2Ray client name, or UUID. |
| `count_connections` | number | Current active SSH connections. |
| `expiration_date` | string | Expiration date in `DD/MM/YYYY` or `Unlimited`. |
| `expiration_days` | number | Remaining days. `-1` means unlimited. |

629
admin/assets/app.css Normal file
View File

@@ -0,0 +1,629 @@
/* DragonCore Command - original black-only admin panel */
:root{
color-scheme:dark;
--bg:#020305;
--bg-2:#07090d;
--panel:#0a0d12;
--panel-2:#0d1118;
--card:#0c1017;
--card-bg:#0c1017;
--card-2:#101620;
--card-3:#121a25;
--input-bg:#070b11;
--line:#1b2636;
--line-2:#27364b;
--border:rgba(148,163,184,.14);
--text:#f3f7ff;
--text-2:#d6dfec;
--muted:#8390a3;
--muted-2:#657386;
--accent:#22d3ee;
--accent-2:#8b5cf6;
--accent-3:#14f195;
--accent-soft:rgba(34,211,238,.13);
--success:#31d67b;
--danger:#ff5b69;
--warn:#ffc857;
--radius-xl:24px;
--radius-lg:18px;
--radius-md:14px;
--shadow:0 22px 70px rgba(0,0,0,.48);
--glow:0 0 0 1px rgba(34,211,238,.05),0 0 42px rgba(34,211,238,.10);
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{width:100%;min-height:100%;overflow-x:hidden;background:var(--bg);}
body{
font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
color:var(--text);
min-height:100vh;
background:
radial-gradient(circle at 18% -10%,rgba(34,211,238,.16),transparent 34%),
radial-gradient(circle at 92% 12%,rgba(139,92,246,.18),transparent 34%),
linear-gradient(180deg,#020305 0%,#05070b 46%,#020305 100%);
}
body::before{
content:"";
position:fixed;
inset:0;
pointer-events:none;
opacity:.28;
background-image:
linear-gradient(rgba(255,255,255,.032) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.032) 1px,transparent 1px);
background-size:54px 54px;
mask-image:linear-gradient(to bottom,rgba(0,0,0,.8),transparent 80%);
}
button,input,select,textarea{font:inherit;}
button{appearance:none;}
a{color:inherit;}
.hidden{display:none!important;}
.i18n-pending body{visibility:hidden;}
.app{min-height:100vh;padding:0;background:transparent;}
.shell{min-height:100vh;width:100%;max-width:none;margin:0;padding:0;background:transparent;border:0;box-shadow:none;}
.panel-layout{min-height:100vh;display:block;background:transparent;}
@supports (min-height:100dvh){.app,.shell,.panel-layout{min-height:100dvh;}}
/* Desktop shell alignment: keep sidebar and content aligned so the brand panel does not look clipped */
@media(min-width:901px){
.panel-layout{display:grid;grid-template-columns:300px minmax(0,1fr);gap:18px;padding:18px;}
.sidebar{position:sticky;left:auto;top:18px;bottom:auto;width:300px;height:calc(100vh - 36px);max-height:calc(100vh - 36px);}
@supports (height:100dvh){.sidebar{height:calc(100dvh - 36px);max-height:calc(100dvh - 36px);}}
.workspace{margin-left:0;min-height:calc(100vh - 36px);border:1px solid rgba(148,163,184,.10);border-radius:28px;overflow:hidden;background:linear-gradient(180deg,rgba(6,9,14,.52),rgba(2,3,5,.18));box-shadow:var(--shadow);}
@supports (min-height:100dvh){.workspace{min-height:calc(100dvh - 36px);}}
}
/* Login */
.overlay{
position:fixed;inset:0;z-index:50;
display:flex;align-items:center;justify-content:center;
padding:22px;
background:
radial-gradient(circle at 50% 0%,rgba(34,211,238,.18),transparent 38%),
radial-gradient(circle at 12% 86%,rgba(139,92,246,.18),transparent 35%),
rgba(2,3,5,.96);
}
.overlay-inner{
width:min(100%,390px);
position:relative;
padding:28px;
border-radius:28px;
border:1px solid rgba(148,163,184,.14);
background:linear-gradient(180deg,rgba(14,20,30,.96),rgba(6,9,14,.98));
box-shadow:0 32px 90px rgba(0,0,0,.72),0 0 80px rgba(34,211,238,.08);
overflow:hidden;
}
.overlay-inner::before{
content:"";position:absolute;left:0;right:0;top:0;height:3px;
background:linear-gradient(90deg,var(--accent),var(--accent-2),var(--accent-3));
}
.ov-title{font-size:1.28rem;line-height:1.1;font-weight:850;letter-spacing:.01em;margin-bottom:8px;}
.ov-sub{font-size:.88rem;line-height:1.5;color:var(--muted);margin-bottom:20px;}
.ov-field,
.field input,.field select,.field textarea,.code-area{
width:100%;min-width:0;outline:0;color:var(--text);
border:1px solid var(--line);
background:linear-gradient(180deg,var(--input-bg),#06090f);
border-radius:14px;
padding:11px 13px;
transition:border-color .16s ease,box-shadow .16s ease,background .16s ease;
}
.ov-field{margin:7px 0;}
.ov-field::placeholder,input::placeholder,textarea::placeholder{color:#526073;}
.ov-field:focus,
.field input:focus,.field select:focus,.field textarea:focus,.code-area:focus{
border-color:rgba(34,211,238,.62);
box-shadow:0 0 0 3px rgba(34,211,238,.10),0 0 32px rgba(34,211,238,.08);
}
input[type="datetime-local"],input[type="date"],input[type="time"]{color-scheme:dark;}
input[type="checkbox"]{accent-color:var(--accent);}
select{color-scheme:dark;}
/* Shell */
.sidebar{
position:fixed;left:18px;top:18px;bottom:18px;z-index:25;
width:284px;display:flex;flex-direction:column;overflow:hidden;
border:1px solid rgba(148,163,184,.12);
border-radius:28px;
background:linear-gradient(180deg,rgba(12,16,23,.94),rgba(5,8,13,.96));
box-shadow:var(--shadow),var(--glow);
backdrop-filter:blur(18px);
}
.brand-block{height:116px;display:flex;align-items:center;gap:14px;padding:22px 28px 22px 22px;border-bottom:1px solid rgba(148,163,184,.10);}
.brand-mark{
width:58px;height:58px;display:grid;place-items:center;border-radius:20px;
color:#061015;font-size:1rem;font-weight:950;letter-spacing:-.05em;
background:linear-gradient(135deg,var(--accent),var(--accent-3));
box-shadow:0 16px 44px rgba(34,211,238,.18),inset 0 1px 0 rgba(255,255,255,.45);
}
.brand-copy{display:flex;flex-direction:column;gap:5px;min-width:0;}
.brand-copy strong{font-size:1.08rem;font-weight:900;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.brand-copy span{font-size:.7rem;text-transform:uppercase;letter-spacing:.22em;color:var(--muted);white-space:nowrap;}
.side-nav{flex:1;display:flex;flex-direction:column;gap:7px;padding:18px 14px 20px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--line-2) transparent;}
.nav-group-label{margin:18px 12px 5px;color:var(--muted-2);font-size:.68rem;text-transform:uppercase;letter-spacing:.2em;font-weight:800;}
.tab-btn{
display:inline-flex;align-items:center;justify-content:center;gap:10px;
border:1px solid transparent;border-radius:999px;background:transparent;color:var(--muted);
padding:8px 13px;font-size:.82rem;font-weight:760;cursor:pointer;
transition:background .16s ease,border-color .16s ease,color .16s ease,transform .16s ease,box-shadow .16s ease;
}
.tab-btn:hover{color:var(--text);border-color:rgba(148,163,184,.16);background:rgba(255,255,255,.035);}
.side-nav .tab-btn{width:100%;justify-content:flex-start;border-radius:18px;padding:12px 13px;color:var(--text-2);font-size:.92rem;}
.side-nav .tab-btn.active{
color:#fff;border-color:rgba(34,211,238,.28);
background:
linear-gradient(135deg,rgba(34,211,238,.18),rgba(139,92,246,.13)),
rgba(255,255,255,.045);
box-shadow:inset 3px 0 0 var(--accent),0 14px 28px rgba(0,0,0,.22);
}
.nav-icon{width:26px;height:26px;display:grid;place-items:center;border-radius:10px;background:rgba(255,255,255,.05);font-size:.95rem;}
.side-nav .tab-btn.active .nav-icon{background:rgba(34,211,238,.15);color:var(--accent);}
.workspace{margin-left:320px;min-height:100vh;display:flex;flex-direction:column;min-width:0;}
@supports (min-height:100dvh){.workspace{min-height:100dvh;}}
.topbar{
position:sticky;top:0;z-index:18;
height:92px;margin:0;padding:18px 30px;
display:flex;align-items:center;justify-content:space-between;gap:18px;
border-bottom:1px solid rgba(148,163,184,.10);
background:linear-gradient(180deg,rgba(2,3,5,.88),rgba(2,3,5,.66));
backdrop-filter:blur(18px);
}
.topbar-left,.topbar-actions{display:flex;align-items:center;gap:12px;min-width:0;}
.topbar-title{display:flex;flex-direction:column;gap:4px;min-width:0;}
.topbar-title span{font-size:.68rem;line-height:1;text-transform:uppercase;letter-spacing:.22em;color:var(--accent);font-weight:850;}
.topbar-title strong{font-size:1.18rem;line-height:1.15;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.icon-btn,
.language-select,
.user-pill{
min-height:42px;border:1px solid rgba(148,163,184,.14);border-radius:15px;
background:rgba(255,255,255,.045);color:var(--text);
box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
}
.icon-btn{width:42px;display:none;align-items:center;justify-content:center;cursor:pointer;}
.language-select{padding:0 12px;font-size:.8rem;font-weight:800;outline:0;}
.user-pill{display:flex;align-items:center;gap:8px;padding:0 12px;max-width:230px;}
.user-pill strong{font-size:.84rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.workspace-main{width:100%;min-width:0;padding:30px;}
/* Cards and dashboard */
.tab-pane{display:none;animation:fadeIn .2s ease both;}
.tab-pane.active{display:block;}
@keyframes fadeIn{from{opacity:.35;transform:translateY(8px)}to{opacity:1;transform:none}}
.card{
min-width:0;position:relative;overflow:hidden;
border:1px solid rgba(148,163,184,.12);
border-radius:var(--radius-xl);
background:linear-gradient(180deg,rgba(16,22,32,.94),rgba(9,13,19,.96));
box-shadow:0 20px 58px rgba(0,0,0,.26),inset 0 1px 0 rgba(255,255,255,.025);
padding:18px;
}
.card::before{
content:"";position:absolute;left:0;right:0;top:0;height:1px;
background:linear-gradient(90deg,transparent,rgba(34,211,238,.28),transparent);
pointer-events:none;
}
.card+.card{margin-top:18px;}
.card-hdr{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:15px;min-width:0;}
.card-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;font-size:1rem;font-weight:900;letter-spacing:.005em;}
.card-actions,.form-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.grid2{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.62fr);gap:18px;align-items:start;}
.dashboard-lower{margin-top:18px;}
.dash-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
.dash-card{
position:relative;min-height:154px;overflow:hidden;
border:1px solid rgba(148,163,184,.12);
border-radius:28px;padding:20px;
background:
radial-gradient(circle at 90% 0%,rgba(255,255,255,.08),transparent 34%),
linear-gradient(180deg,rgba(16,22,32,.95),rgba(8,12,18,.98));
box-shadow:0 20px 60px rgba(0,0,0,.28);
}
.dash-card::after{content:"";position:absolute;inset:auto -35px -52px auto;width:140px;height:140px;border-radius:999px;background:var(--accent-soft);filter:blur(2px);}
.dash-card-main{position:relative;z-index:1;display:flex;flex-direction:column;gap:8px;}
.dash-label{color:var(--muted);font-size:.74rem;text-transform:uppercase;letter-spacing:.14em;font-weight:850;}
.dash-card strong{font-size:2rem;letter-spacing:-.05em;line-height:1.05;}
.dash-card small{font-size:.78rem;line-height:1.35;color:var(--muted);}
.dash-icon{
position:absolute;right:17px;top:17px;width:44px;height:44px;border-radius:17px;
display:grid;place-items:center;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.075);
color:var(--accent);font-size:1.15rem;
}
.accent-blue{--accent-soft:rgba(34,211,238,.13);}
.accent-green{--accent-soft:rgba(20,241,149,.12);}
.accent-purple{--accent-soft:rgba(139,92,246,.14);}
.accent-orange{--accent-soft:rgba(255,200,87,.13);}
.quick-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;}
.quick-action{
text-align:left;border:1px solid rgba(148,163,184,.12);border-radius:18px;
background:rgba(255,255,255,.035);color:var(--text);padding:14px;cursor:pointer;
transition:transform .16s ease,border-color .16s ease,background .16s ease;
}
.quick-action:hover{transform:translateY(-1px);border-color:rgba(34,211,238,.28);background:rgba(34,211,238,.06);}
.quick-action strong{display:block;font-size:.9rem;margin-bottom:5px;}
.quick-action span{display:block;color:var(--muted);font-size:.77rem;line-height:1.35;}
/* UI pieces */
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:40px;
border:1px solid rgba(34,211,238,.22);border-radius:14px;
padding:9px 14px;cursor:pointer;
color:#031014;font-weight:900;font-size:.82rem;
background:linear-gradient(135deg,var(--accent),var(--accent-3));
box-shadow:0 12px 30px rgba(34,211,238,.16);
transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease,background .15s ease,color .15s ease;
}
.btn:hover{transform:translateY(-1px);box-shadow:0 16px 36px rgba(34,211,238,.22);}
.btn-sm{min-height:34px;padding:7px 11px;font-size:.75rem;border-radius:12px;}
.btn-ghost{color:var(--text-2);background:rgba(255,255,255,.045);border-color:rgba(148,163,184,.14);box-shadow:none;}
.btn-ghost:hover{color:var(--text);background:rgba(34,211,238,.075);border-color:rgba(34,211,238,.28);box-shadow:none;}
.btn-danger{color:#ffdce1;background:rgba(255,91,105,.12);border-color:rgba(255,91,105,.34);box-shadow:none;}
.btn-danger:hover{background:rgba(255,91,105,.18);box-shadow:none;}
.btn-warn{color:#fff3cf;background:rgba(255,200,87,.12);border-color:rgba(255,200,87,.34);box-shadow:none;}
.btn-light,.btn-soft{color:var(--text);background:rgba(255,255,255,.07);border-color:rgba(148,163,184,.16);box-shadow:none;}
.chip{
display:inline-flex;align-items:center;justify-content:center;gap:5px;
border:1px solid rgba(148,163,184,.14);border-radius:999px;
padding:4px 9px;background:rgba(255,255,255,.045);color:var(--text-2);
font-size:.69rem;font-weight:900;letter-spacing:.02em;white-space:nowrap;
}
.chip.green{color:#9ff4bf;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.10);}
.chip.warn{color:#ffe3a1;border-color:rgba(255,200,87,.28);background:rgba(255,200,87,.10);}
.chip.red{color:#ffc6cc;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.10);}
.hint{font-size:.76rem;line-height:1.45;color:var(--muted);}
.statusbar{margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;color:var(--muted);font-size:.76rem;}
.badge-on,.badge-off{display:inline-flex;align-items:center;gap:6px;font-size:.73rem;font-weight:900;}
.badge-on{color:var(--success);}
.badge-off{color:var(--muted);}
.badge-on::before,.badge-off::before{content:"";width:7px;height:7px;border-radius:999px;background:currentColor;box-shadow:0 0 14px currentColor;}
/* Metrics */
.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;}
.metric{min-width:0;border:1px solid rgba(148,163,184,.11);border-radius:18px;background:rgba(255,255,255,.035);padding:14px;}
.m-label{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:.14em;font-weight:850;}
.m-val{margin-top:7px;font-size:1.14rem;line-height:1.15;font-weight:950;color:var(--text);word-break:break-word;}
/* Forms */
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px;}
.field{display:flex;flex-direction:column;gap:6px;min-width:0;}
.field label{color:var(--text-2);font-size:.75rem;font-weight:850;letter-spacing:.01em;}
.field-row{display:flex;align-items:center;gap:8px;min-width:0;}
.field-row input{flex:1 1 auto;}
.form-actions{margin-top:13px;}
.collapsible.collapsed{display:none;}
textarea{resize:vertical;}
.code-area{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:.75rem;line-height:1.45;min-height:160px;}
pre.log-box,.log-box{
display:block;width:100%;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;
color:#b7c3d4;background:#05080d;border:1px solid rgba(148,163,184,.13);border-radius:18px;
padding:14px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:.73rem;line-height:1.5;
}
/* Tables */
.tbl-wrap{width:100%;overflow:auto;border:1px solid rgba(148,163,184,.12);border-radius:20px;background:rgba(3,6,10,.55);}
table{width:100%;border-collapse:separate;border-spacing:0;min-width:760px;font-size:.8rem;}
th,td{padding:11px 12px;text-align:left;vertical-align:middle;border-bottom:1px solid rgba(148,163,184,.09);}
th{position:sticky;top:0;z-index:1;background:#080c12;color:var(--muted);font-size:.69rem;text-transform:uppercase;letter-spacing:.12em;font-weight:950;}
tbody tr{transition:background .14s ease;}
tbody tr:hover{background:rgba(34,211,238,.045);}
tbody tr:last-child td{border-bottom:0;}
td{color:var(--text-2);}
td .btn+ .btn{margin-left:6px;}
.table-meter,.mini-meter,.quota-meter,.bar{position:relative;display:block;overflow:hidden;background:rgba(148,163,184,.12);border-radius:999px;}
.mini-meter{height:7px;margin-top:8px;}
.quota-meter{height:12px;margin:14px 0 9px;}
.table-meter{height:6px;margin-top:6px;max-width:170px;}
.bar{height:8px;margin-top:8px;}
.table-meter span,.mini-meter span,.quota-meter span,.bar-inner{display:block;height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-3));box-shadow:0 0 18px rgba(34,211,238,.24);transition:width .25s ease;}
/* Save/config helpers */
.save-bar{
position:sticky;bottom:18px;z-index:10;
margin-top:18px;padding:14px 16px;
display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;
border:1px solid rgba(148,163,184,.14);border-radius:22px;
background:linear-gradient(180deg,rgba(14,20,30,.92),rgba(7,10,16,.94));
box-shadow:0 22px 60px rgba(0,0,0,.42);
backdrop-filter:blur(16px);
}
.save-bar-actions{margin:0;}
.mini-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:14px;}
.mini-summary span{display:flex;flex-direction:column;gap:5px;border:1px solid rgba(148,163,184,.12);border-radius:16px;padding:12px;background:rgba(255,255,255,.035);}
.mini-summary strong{font-size:1rem;}
.mini-summary small{color:var(--muted);font-size:.72rem;}
.reseller-helper-card{margin-bottom:18px;}
hr{border:0;border-top:1px solid rgba(148,163,184,.12);margin:14px 0;}
/* Desktop shell alignment override */
@media(min-width:901px){
.panel-layout{display:grid;grid-template-columns:300px minmax(0,1fr);gap:18px;padding:18px;}
.sidebar{position:sticky;left:auto;top:18px;bottom:auto;width:300px;height:calc(100vh - 36px);max-height:calc(100vh - 36px);}
@supports (height:100dvh){.sidebar{height:calc(100dvh - 36px);max-height:calc(100dvh - 36px);}}
.workspace{margin-left:0;min-height:calc(100vh - 36px);border:1px solid rgba(148,163,184,.10);border-radius:28px;overflow:hidden;background:linear-gradient(180deg,rgba(6,9,14,.52),rgba(2,3,5,.18));box-shadow:var(--shadow);}
@supports (min-height:100dvh){.workspace{min-height:calc(100dvh - 36px);}}
}
/* Layout stability fixes: keep wide pages from leaving broken empty columns */
@media(min-width:1321px){
.dash-grid{grid-template-columns:repeat(12,minmax(0,1fr));}
.dash-grid>.dash-card{grid-column:span 3;}
.dash-grid>.dash-resource{grid-column:span 4;}
#mainApp.role-superadmin .dashboard-lower{grid-template-columns:1fr;}
#mainApp.role-superadmin .dashboard-lower>.card:not(.hidden){grid-column:1/-1;}
#mainApp.role-superadmin .dashboard-lower .quick-actions{grid-template-columns:repeat(4,minmax(0,1fr));}
#mainApp.role-reseller .dashboard-lower{grid-template-columns:minmax(0,1fr) minmax(360px,.62fr);}
}
@supports selector(:has(*)){
.dashboard-lower:has(#dashboardQuotaCard.hidden){grid-template-columns:1fr;}
.dashboard-lower:has(#dashboardQuotaCard.hidden)>.card:not(.hidden){grid-column:1/-1;}
}
/* Mobile drawer */
.drawer-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:22;backdrop-filter:blur(2px);}
body.drawer-open .drawer-backdrop,body.sidebar-open .drawer-backdrop{display:block;}
@media(max-width:1320px){
.dash-grid{grid-template-columns:repeat(2,minmax(0,1fr));}
.grid2{grid-template-columns:1fr;}
.metrics{grid-template-columns:repeat(2,minmax(0,1fr));}
}
@media(max-width:900px){
.panel-layout{display:block;padding:0;}
.workspace{border:0;border-radius:0;overflow:visible;background:transparent;box-shadow:none;}
.sidebar{left:12px;top:12px;bottom:12px;transform:translateX(calc(-100% - 24px));transition:transform .2s ease;width:min(86vw,310px);}
body.drawer-open .sidebar,body.sidebar-open .sidebar,.sidebar.open{transform:translateX(0);}
.workspace{margin-left:0;}
.icon-btn{display:inline-flex;}
.topbar{height:auto;min-height:78px;padding:16px;align-items:flex-start;}
.topbar-actions{margin-left:auto;gap:8px;flex-wrap:wrap;justify-content:flex-end;}
.workspace-main{padding:18px 14px 26px;}
.dash-grid{grid-template-columns:1fr;gap:12px;}
.quick-actions{grid-template-columns:1fr;}
.language-select,.user-pill{min-height:38px;}
}
@media(max-width:640px){
.topbar{display:grid;grid-template-columns:1fr;gap:12px;}
.topbar-left,.topbar-actions{width:100%;}
.topbar-actions{justify-content:flex-start;}
.topbar-title strong{font-size:1.02rem;}
.user-pill{max-width:100%;}
.form-grid,.metrics,.mini-summary{grid-template-columns:1fr!important;}
.card{border-radius:20px;padding:14px;}
.dash-card{min-height:132px;border-radius:22px;padding:17px;}
.dash-card strong{font-size:1.65rem;}
.card-hdr{align-items:flex-start;flex-direction:column;}
.field-row{flex-wrap:wrap;}
.field-row .btn{flex:0 0 auto;}
.save-bar{bottom:10px;border-radius:18px;}
table{font-size:.76rem;}
th,td{padding:9px 10px;}
}
/* --- UI polish fixes for servers page / sidebar / language selector --- */
@media(min-width:901px){
.panel-layout{align-items:start;}
.sidebar{align-self:start; position:sticky; top:18px;}
}
/* Keep the sidebar visible while long pages scroll */
.sidebar{
overflow:hidden;
}
.side-nav{
overscroll-behavior:contain;
}
/* Better top alignment for paired cards */
.grid2,
.servers-grid{
align-items:start;
}
.grid2 > .card,
.grid2 > div,
.servers-grid > .card,
.servers-grid > div{
align-self:start;
margin-top:0 !important;
}
/* Server form checkbox rows should align visually with the input fields */
.server-form-grid{
align-items:start;
}
.server-form-grid > .toggle-field{
min-height:44px;
display:flex;
align-items:center;
gap:8px;
padding:0 12px;
border:1px solid var(--line);
border-radius:14px;
background:linear-gradient(180deg,var(--input-bg),#06090f);
color:var(--text-2);
font-size:.8rem;
font-weight:800;
cursor:pointer;
}
.server-form-grid > .toggle-field input{
flex:0 0 auto;
}
/* Language selector dark theme fix */
.language-select{
color:var(--text);
background:linear-gradient(180deg,rgba(17,23,32,.94),rgba(10,14,21,.98));
border-color:rgba(148,163,184,.18);
}
.language-select:hover,
.language-select:focus{
border-color:rgba(34,211,238,.42);
box-shadow:0 0 0 3px rgba(34,211,238,.10), 0 0 22px rgba(34,211,238,.08);
}
.language-select option,
.language-select optgroup{
background:#0d1118;
color:#f3f7ff;
}
/* Small visual consistency improvements */
.topbar-actions{
align-items:center;
}
.card-hdr{
align-items:flex-start;
}
.card-hdr > .card-actions{
align-items:center;
}
/* --- sidebar follow-scroll fix --- */
@media(min-width:901px){
.panel-layout{
display:block;
padding:18px;
}
.sidebar{
position:fixed !important;
top:18px !important;
left:18px !important;
bottom:auto !important;
width:300px !important;
height:calc(100vh - 36px) !important;
max-height:calc(100vh - 36px) !important;
z-index:30;
}
@supports (height:100dvh){
.sidebar{
height:calc(100dvh - 36px) !important;
max-height:calc(100dvh - 36px) !important;
}
}
.workspace{
margin-left:336px !important;
min-height:calc(100vh - 36px);
}
@supports (min-height:100dvh){
.workspace{min-height:calc(100dvh - 36px);}
}
}
/* --- Servers status page --- */
.servers-status-toolbar{margin-bottom:16px;}
.servers-status-grid{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(330px,1fr));
gap:16px;
align-items:start;
}
.server-status-card{
position:relative;
overflow:hidden;
border:1px solid rgba(148,163,184,.12);
border-radius:24px;
padding:16px;
background:
radial-gradient(circle at 90% 0%,rgba(255,255,255,.08),transparent 34%),
linear-gradient(180deg,rgba(16,22,32,.95),rgba(8,12,18,.98));
box-shadow:0 20px 58px rgba(0,0,0,.26),inset 0 1px 0 rgba(255,255,255,.025);
}
.server-status-card::after{
content:"";
position:absolute;
right:-38px;
bottom:-58px;
width:150px;
height:150px;
border-radius:999px;
background:rgba(34,211,238,.12);
pointer-events:none;
}
.server-status-offline{opacity:.72;}
.server-status-offline::after{background:rgba(255,91,105,.11);}
.server-status-head{
position:relative;
z-index:1;
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:12px;
margin-bottom:12px;
}
.server-status-title{font-size:1rem;font-weight:950;color:var(--text);line-height:1.1;}
.server-status-url{margin-top:5px;color:var(--muted);font-size:.72rem;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;word-break:break-all;}
.server-status-badges{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap;}
.server-status-error{position:relative;z-index:1;margin-bottom:10px;color:#ffc6cc;font-size:.76rem;}
.server-mini-grid{
position:relative;
z-index:1;
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px;
}
.server-mini-metric{
min-width:0;
border:1px solid rgba(148,163,184,.11);
border-radius:18px;
padding:12px;
background:rgba(255,255,255,.035);
}
.server-mini-label{color:var(--muted);font-size:.66rem;text-transform:uppercase;letter-spacing:.14em;font-weight:900;}
.server-mini-value{margin-top:6px;font-size:1.28rem;line-height:1.05;font-weight:950;color:var(--text);letter-spacing:-.04em;}
.server-mini-note{margin-top:5px;min-height:15px;color:var(--muted);font-size:.7rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.server-mini-bar{height:7px;margin-top:9px;border-radius:999px;background:rgba(148,163,184,.12);overflow:hidden;}
.server-mini-bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-3));box-shadow:0 0 18px rgba(34,211,238,.24);transition:width .25s ease;}
.server-status-footer{
position:relative;
z-index:1;
display:grid;
gap:5px;
margin-top:12px;
color:var(--muted);
font-size:.72rem;
line-height:1.35;
}
@media(max-width:640px){
.servers-status-grid{grid-template-columns:1fr;}
.server-mini-grid{grid-template-columns:1fr;}
}
/* --- Xray full config and select color fixes --- */
.field select,
select,
#xrayServerSelect,
#wzLogLevel,
#wzProtocol,
#wzNetwork,
#wzXHTTPMode,
#wzTLS,
#wzSSMethod {
color:#f3f7ff !important;
background:#070b12 !important;
border-color:rgba(34,211,238,.26) !important;
color-scheme:dark;
}
.field select option,
select option,
.field select optgroup,
select optgroup {
color:#f3f7ff !important;
background:#0b111a !important;
}
#xrayServerHint.hidden,
#sshServerHint.hidden { display:none !important; }
select option:checked,
.field select option:checked {
background:#1f2a3a !important;
color:#f8fafc !important;
}
select:disabled {
color:#94a3b8 !important;
background:#070b12 !important;
}

2942
admin/assets/app.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1660
admin_script.js Normal file

File diff suppressed because it is too large Load Diff

28
auth.go
View File

@@ -443,6 +443,7 @@ func startResellerExpiryChecker(store *Store) {
u.IsActive = false
adminUsers.set(u)
disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
}
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
@@ -536,10 +537,12 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
}
if s.Role == RoleReseller {
if u, ok := adminUsers.get(s.Username); ok {
resp["max_users"] = u.MaxUsers
resp["used_users"] = countOwnedUsers(s.Username)
resp["max_users"] = u.MaxUsers
resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username)
resp["used_ssh_users"] = countOwnedUsers(s.Username)
resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username)
resp["expires_at"] = u.ExpiresAt
resp["is_active"] = u.IsActive
resp["is_active"] = u.IsActive
}
}
w.Header().Set("Content-Type", "application/json")
@@ -554,6 +557,8 @@ type ResellerDTO struct {
Role string `json:"role"`
MaxUsers int `json:"max_users"`
UsedUsers int `json:"used_users"`
UsedSSH int `json:"used_ssh_users"`
UsedXray int `json:"used_xray_users"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
@@ -577,7 +582,9 @@ func handleListResellers(store *Store) http.HandlerFunc {
Username: u.Username,
Role: u.Role,
MaxUsers: u.MaxUsers,
UsedUsers: countOwnedUsers(u.Username),
UsedUsers: countOwnedQuota(r.Context(), store, u.Username),
UsedSSH: countOwnedUsers(u.Username),
UsedXray: countOwnedXrayClients(r.Context(), store, u.Username),
ExpiresAt: u.ExpiresAt,
IsActive: u.IsActive,
CreatedAt: u.CreatedAt,
@@ -633,8 +640,8 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
if p.Password != "" {
u.PasswordHash = hashAdminPassword(p.Password)
}
u.MaxUsers = p.MaxUsers
u.IsActive = p.IsActive
u.MaxUsers = p.MaxUsers
u.IsActive = p.IsActive
u.ExpiresAt = nil
if p.ExpiresAt != "" {
t, err := time.Parse(time.RFC3339, p.ExpiresAt)
@@ -652,8 +659,12 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
}
adminUsers.set(u)
// If reseller was reactivated, users can reconnect automatically.
// Reconnect of existing SSH connections happens via the expiry checker.
if u.Role == RoleReseller {
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
}
}
w.WriteHeader(http.StatusCreated)
}
@@ -676,6 +687,7 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
return
}
disconnectOwnerUsers(username)
removeOwnerXrayClients(ctx, store, username)
adminUsers.delete(username)
w.WriteHeader(http.StatusNoContent)
}

236
change_admin_password.sh Normal file
View File

@@ -0,0 +1,236 @@
#!/bin/bash
# DragonCoreSSH V40 admin password recovery tool.
# Usage:
# sudo bash change_admin_password.sh
# sudo bash change_admin_password.sh admin 'NewPasswordHere'
# sudo bash change_admin_password.sh --user admin --generate
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
INSTALL_DIR="${INSTALL_DIR:-/opt/sshpanel}"
SERVICE_NAME="${SERVICE_NAME:-sshpanel}"
ENV_FILE="${ENV_FILE:-${INSTALL_DIR}/.env}"
ADMIN_USER=""
NEW_PASSWORD=""
GENERATE_PASSWORD=false
NO_RESTART=false
usage() {
cat <<USAGE
DragonCoreSSH V40 admin password recovery
Usage:
sudo bash $0
sudo bash $0 admin 'NewPasswordHere'
sudo bash $0 --user admin --password 'NewPasswordHere'
sudo bash $0 --user admin --generate
Options:
-u, --user USERNAME Admin username to reset. Default: admin
-p, --password PASSWORD New password. If omitted, you will be prompted.
-g, --generate Generate a strong random password.
--no-restart Do not restart the sshpanel service after changing DB.
-h, --help Show this help.
Environment overrides:
INSTALL_DIR=/opt/sshpanel
ENV_FILE=/opt/sshpanel/.env
SERVICE_NAME=sshpanel
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
-u|--user)
[[ $# -ge 2 ]] || error "Missing value for $1"
ADMIN_USER="$2"
shift 2
;;
-p|--password)
[[ $# -ge 2 ]] || error "Missing value for $1"
NEW_PASSWORD="$2"
shift 2
;;
-g|--generate)
GENERATE_PASSWORD=true
shift
;;
--no-restart)
NO_RESTART=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-* )
error "Unknown option: $1"
;;
*)
if [[ -z "$ADMIN_USER" ]]; then
ADMIN_USER="$1"
elif [[ -z "$NEW_PASSWORD" ]]; then
NEW_PASSWORD="$1"
else
error "Too many positional arguments. Use --help for usage."
fi
shift
;;
esac
done
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
[[ -f "$ENV_FILE" ]] || error "Environment file not found: $ENV_FILE"
command -v psql >/dev/null 2>&1 || error "psql not found. Install PostgreSQL client first."
get_env_value() {
local key="$1"
awk -v key="$key" '
$0 ~ "^" key "=" {
sub("^[^=]*=", "")
gsub(/^\"|\"$/, "")
gsub(/^\047|\047$/, "")
print
exit
}
' "$ENV_FILE"
}
update_env_password() {
local new_password="$1"
local tmp
tmp="$(mktemp)"
awk -v line="ADMIN_PASSWORD=${new_password}" '
BEGIN { done = 0 }
/^ADMIN_PASSWORD=/ { print line; done = 1; next }
{ print }
END { if (!done) print line }
' "$ENV_FILE" > "$tmp"
cat "$tmp" > "$ENV_FILE"
rm -f "$tmp"
chmod 600 "$ENV_FILE" 2>/dev/null || true
}
generate_password() {
local pw=""
if command -v openssl >/dev/null 2>&1; then
pw="$(openssl rand -base64 24 | tr -d '\n' | tr -d '=/+' | head -c 24 || true)"
fi
if [[ ${#pw} -lt 20 ]]; then
pw="$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24 || true)"
fi
if [[ ${#pw} -lt 20 ]]; then
pw="DragonCore$(date +%s%N)"
fi
printf '%s' "$pw"
}
hash_password() {
local pw="$1"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$pw" | sha256sum | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$pw" | shasum -a 256 | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
printf '%s' "$pw" | openssl dgst -sha256 -r | awk '{print $1}'
else
error "No SHA-256 tool found. Install coreutils or openssl."
fi
}
PG_DSN="$(get_env_value PG_DSN)"
[[ -n "$PG_DSN" ]] || error "PG_DSN not found inside $ENV_FILE"
if [[ -z "$ADMIN_USER" ]]; then
read -r -p "Admin username [admin]: " ADMIN_USER
ADMIN_USER="${ADMIN_USER:-admin}"
fi
[[ -n "$ADMIN_USER" ]] || error "Admin username cannot be empty."
if $GENERATE_PASSWORD; then
NEW_PASSWORD="$(generate_password)"
elif [[ -z "$NEW_PASSWORD" ]]; then
read -r -s -p "New password: " PASS1
echo
read -r -s -p "Confirm password: " PASS2
echo
[[ "$PASS1" == "$PASS2" ]] || error "Passwords do not match."
NEW_PASSWORD="$PASS1"
fi
[[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty."
if [[ ${#NEW_PASSWORD} -lt 8 ]]; then
error "Password must have at least 8 characters."
fi
PASSWORD_HASH="$(hash_password "$NEW_PASSWORD")"
[[ ${#PASSWORD_HASH} -eq 64 ]] || error "Failed to generate valid SHA-256 password hash."
info "Updating admin user '${ADMIN_USER}' in PostgreSQL..."
psql "$PG_DSN" -v ON_ERROR_STOP=1 \
-v admin_user="$ADMIN_USER" \
-v password_hash="$PASSWORD_HASH" <<'SQL'
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'reseller',
max_users INT NOT NULL DEFAULT 30,
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
VALUES (:'admin_user', :'password_hash', 'superadmin', 0, NULL, TRUE)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
role = 'superadmin',
max_users = 0,
expires_at = NULL,
is_active = TRUE;
SQL
if [[ "$ADMIN_USER" == "admin" ]]; then
update_env_password "$NEW_PASSWORD"
info "Updated ADMIN_PASSWORD inside $ENV_FILE"
else
warn "ADMIN_PASSWORD in $ENV_FILE was not changed because username is not 'admin'."
fi
if ! $NO_RESTART; then
info "Restarting ${SERVICE_NAME} so the in-memory admin cache reloads..."
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files "${SERVICE_NAME}.service" >/dev/null 2>&1; then
systemctl restart "$SERVICE_NAME"
sleep 1
if systemctl is-active --quiet "$SERVICE_NAME"; then
info "${SERVICE_NAME} restarted successfully."
else
warn "${SERVICE_NAME} is not active after restart. Last logs:"
journalctl -u "$SERVICE_NAME" -n 30 --no-pager 2>/dev/null || true
exit 1
fi
elif command -v service >/dev/null 2>&1; then
service "$SERVICE_NAME" restart || warn "Could not restart ${SERVICE_NAME}. Restart it manually."
else
warn "Could not restart ${SERVICE_NAME}. Restart it manually before logging in."
fi
else
warn "Service restart skipped. Restart ${SERVICE_NAME} manually before logging in."
fi
echo
info "Admin password changed."
echo " Username : ${ADMIN_USER}"
echo " Password : ${NEW_PASSWORD}"
echo
warn "Save this password now. It is only shown here."

View File

@@ -59,7 +59,8 @@ func checkSSHUser(w http.ResponseWriter, username string) {
}
u.mu.Lock()
activeConns := u.ActiveConns
activeConns := len(u.conns)
u.ActiveConns = activeConns
maxConns := u.Cfg.MaxConnections
expiresAt := u.ExpiresAt
u.mu.Unlock()

146
config_safety.go Normal file
View File

@@ -0,0 +1,146 @@
package main
import (
"fmt"
"log"
"net"
"strings"
)
const (
defaultMainListen = "0.0.0.0:80"
defaultExtraListen = "0.0.0.0:8080"
defaultDNSTTListen = "[::]:5300"
defaultUDPGWListen = "0.0.0.0:7400"
)
func normalizeRuntimePorts(cfg *Config) []string {
var warnings []string
warn := func(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
warnings = append(warnings, msg)
log.Printf("config safety: %s", msg)
}
cfg.Listen = strings.TrimSpace(cfg.Listen)
if cfg.Listen == "" {
cfg.Listen = defaultMainListen
}
if err := tcpAddrAvailableForPool(cfg.Listen, publicPool); err != nil {
old := cfg.Listen
cfg.Listen = defaultMainListen
warn("main listener %s is unavailable (%v); using default %s", old, err, cfg.Listen)
if err2 := tcpAddrAvailableForPool(cfg.Listen, publicPool); err2 != nil {
warn("default main listener %s is also unavailable: %v", cfg.Listen, err2)
}
}
seen := map[string]bool{cfg.Listen: true}
extra := make([]string, 0, len(cfg.ExtraListen))
for _, addr := range cfg.ExtraListen {
addr = strings.TrimSpace(addr)
if addr == "" || seen[addr] {
continue
}
if err := tcpAddrAvailableForPool(addr, publicPool); err != nil {
warn("extra listener %s is unavailable (%v)", addr, err)
fallback := defaultExtraListen
if !seen[fallback] {
if err2 := tcpAddrAvailableForPool(fallback, publicPool); err2 == nil {
extra = append(extra, fallback)
seen[fallback] = true
warn("extra listener fell back to default %s", fallback)
} else {
warn("default extra listener %s is also unavailable: %v", fallback, err2)
}
}
continue
}
extra = append(extra, addr)
seen[addr] = true
}
cfg.ExtraListen = extra
// DragonCore no longer uses an internal local SSH listener.
cfg.LocalSSHListen = ""
if cfg.DNSTT != nil {
cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen)
if cfg.DNSTT.UDPListen == "" {
cfg.DNSTT.UDPListen = defaultDNSTTListen
}
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err != nil {
old := cfg.DNSTT.UDPListen
cfg.DNSTT.UDPListen = defaultDNSTTListen
warn("DNSTT UDP listener %s is unavailable (%v); using default %s", old, err, cfg.DNSTT.UDPListen)
if err2 := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err2 != nil {
warn("default DNSTT UDP listener %s is also unavailable: %v", cfg.DNSTT.UDPListen, err2)
}
}
}
if cfg.UDPGW != nil {
cfg.UDPGW.Listen = strings.TrimSpace(cfg.UDPGW.Listen)
if cfg.UDPGW.Listen == "" {
cfg.UDPGW.Listen = defaultUDPGWListen
}
if err := tcpAddrAvailableForUDPGW(cfg.UDPGW.Listen); err != nil {
old := cfg.UDPGW.Listen
cfg.UDPGW.Listen = defaultUDPGWListen
warn("UDPGW listener %s is unavailable (%v); using default %s", old, err, cfg.UDPGW.Listen)
if err2 := tcpAddrAvailableForUDPGW(cfg.UDPGW.Listen); err2 != nil {
warn("default UDPGW listener %s is also unavailable: %v", cfg.UDPGW.Listen, err2)
}
}
}
return warnings
}
func tcpAddrAvailableForPool(addr string, pool *listenerPool) error {
if addr == "" {
return nil
}
if pool != nil && pool.Has(addr) {
return nil
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return ln.Close()
}
func tcpAddrAvailableForUDPGW(addr string) error {
if addr == "" {
return nil
}
globalCfgMu.RLock()
current := globalCfg != nil && globalCfg.UDPGW != nil && globalCfg.UDPGW.Listen == addr && udpgwRunning()
globalCfgMu.RUnlock()
if current {
return nil
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return ln.Close()
}
func udpAddrAvailableForDNSTT(addr string) error {
if addr == "" {
return nil
}
globalCfgMu.RLock()
current := globalCfg != nil && globalCfg.DNSTT != nil && globalCfg.DNSTT.UDPListen == addr && dnsttRunning()
globalCfgMu.RUnlock()
if current {
return nil
}
pc, err := net.ListenPacket("udp", addr)
if err != nil {
return err
}
return pc.Close()
}

View File

@@ -14,6 +14,7 @@ import (
"encoding/base32"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -332,55 +333,79 @@ func getDNSTTLogLines() []string {
// the Noise private key from cfg.PrivKeyFile, parses cfg.Domain into a dns.Name,
// and then launches runDNSTT in a goroutine. Any errors during start are
// logged. The SSH server configuration is used when handling streams.
func startDNSTT(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) {
func startDNSTT(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) error {
if cfg == nil {
return
return nil
}
startDNSTTCapReaper()
dnsttSSHConfig = sshConf
// Configure whether periodic DNSTT statistics should be emitted to stderr.
// When DisableStatsLog is true, stats will be collected but log lines are suppressed.
if cfg != nil {
dnsttPrintStats = !cfg.DisableStatsLog
// Initialise the log buffer once. Use a capacity of 100 lines (~few KB).
if dnsttLogBuf == nil {
dnsttLogBuf = newDNSTTLogBuffer(100)
}
// Configure the DNSTT logger output. If DisableConsoleLog is set,
// write only to the buffer; otherwise tee to both the buffer and stderr.
if cfg.DisableConsoleLog {
dnsttLog.SetOutput(dnsttLogBuf)
} else {
dnsttLog.SetOutput(io.MultiWriter(dnsttLogBuf, os.Stderr))
}
dnsttPrintStats = !cfg.DisableStatsLog
// Initialise the log buffer once. Use a capacity of 100 lines (~few KB).
if dnsttLogBuf == nil {
dnsttLogBuf = newDNSTTLogBuffer(100)
}
// Configure the DNSTT logger output. If DisableConsoleLog is set,
// write only to the buffer; otherwise tee to both the buffer and stderr.
if cfg.DisableConsoleLog {
dnsttLog.SetOutput(dnsttLogBuf)
} else {
dnsttLog.SetOutput(io.MultiWriter(dnsttLogBuf, os.Stderr))
}
// Read the private key from file.
f, err := os.Open(cfg.PrivKeyFile)
if err != nil {
dnsttLog.Printf("cannot open privkey file %s: %v", cfg.PrivKeyFile, err)
return
msg := fmt.Errorf("cannot open privkey file %s: %w", cfg.PrivKeyFile, err)
dnsttLog.Print(msg.Error())
return msg
}
privkey, err := noise.ReadKey(f)
f.Close()
if err != nil {
dnsttLog.Printf("cannot read privkey from file: %v", err)
return
msg := fmt.Errorf("cannot read privkey from file: %w", err)
dnsttLog.Print(msg.Error())
return msg
}
// Parse the domain name. dns.ParseName accepts a domain with a trailing
// dot or without. Any error here will abort the dnstt server.
// Parse the domain name. dns.ParseName accepts a domain with a trailing
// dot or without. Any error here will abort the dnstt server.
domain, err := dns.ParseName(cfg.Domain)
if err != nil {
dnsttLog.Printf("invalid domain %q: %v", cfg.Domain, err)
return
msg := fmt.Errorf("invalid domain %q: %w", cfg.Domain, err)
dnsttLog.Print(msg.Error())
return msg
}
udpListen := cfg.UDPListen
if udpListen == "" {
udpListen = defaultDNSTTListen
cfg.UDPListen = udpListen
}
// Bind synchronously so the admin panel can immediately know whether DNSTT
// really started or failed because of a bad address/locked port.
dnsConn, err := net.ListenPacket("udp", udpListen)
if err != nil {
msg := fmt.Errorf("dnstt: opening UDP listener on %s: %w", udpListen, err)
dnsttLog.Print(msg.Error())
return msg
}
// Log initialisation parameters so DNSTT startup is visible even when
// quiet logging is enabled. This helps with debugging.
dnsttLog.Printf("starting: domain=%q udp_listen=%q privkey=%q", cfg.Domain, cfg.UDPListen, cfg.PrivKeyFile)
// quiet logging is enabled. This helps with debugging.
dnsttLog.Printf("starting: domain=%q udp_listen=%q privkey=%q", cfg.Domain, udpListen, cfg.PrivKeyFile)
go func() {
if err := runDNSTT(privkey, domain, cfg.UDPListen); err != nil {
if err := runDNSTTOnConn(privkey, domain, udpListen, dnsConn); err != nil && !errors.Is(err, net.ErrClosed) {
dnsttLog.Printf("server exited with error: %v", err)
}
}()
return nil
}
func dnsttRunning() bool {
dnsttConnMu.Lock()
defer dnsttConnMu.Unlock()
return dnsttConn != nil
}
// handleDNSTTStream accepts a smux.Stream from a client and hands it off to
@@ -991,6 +1016,10 @@ func runDNSTT(privkey []byte, domain dns.Name, udpListen string) error {
if err != nil {
return fmt.Errorf("dnstt: opening UDP listener on %s: %v", udpListen, err)
}
return runDNSTTOnConn(privkey, domain, udpListen, dnsConn)
}
func runDNSTTOnConn(privkey []byte, domain dns.Name, udpListen string, dnsConn net.PacketConn) error {
if udp, ok := dnsConn.(*net.UDPConn); ok {
_ = udp.SetReadBuffer(4 * 1024 * 1024)
_ = udp.SetWriteBuffer(4 * 1024 * 1024)
@@ -998,11 +1027,18 @@ func runDNSTT(privkey []byte, domain dns.Name, udpListen string) error {
// Register so stopDNSTT() can close this socket and unblock the read loop.
dnsttConnMu.Lock()
if dnsttConn != nil {
if dnsttConn != nil && dnsttConn != dnsConn {
_ = dnsttConn.Close()
}
dnsttConn = dnsConn
dnsttConnMu.Unlock()
defer func() {
dnsttConnMu.Lock()
if dnsttConn == dnsConn {
dnsttConn = nil
}
dnsttConnMu.Unlock()
}()
// Log readiness of the UDP listener.
dnsttLog.Printf("udp listener ready on %s", udpListen)
// compute maximum encoded payload and resulting MTU

View File

@@ -9,7 +9,9 @@ import (
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
@@ -86,6 +88,31 @@ func (p *listenerPool) Sync(addrs []string) []error {
return errs
}
func (p *listenerPool) Has(addr string) bool {
if p == nil || addr == "" {
return false
}
p.mu.Lock()
defer p.mu.Unlock()
_, ok := p.entries[addr]
return ok
}
func (p *listenerPool) HasAll(addrs []string) bool {
if p == nil {
return false
}
for _, addr := range addrs {
if addr == "" {
continue
}
if !p.Has(addr) {
return false
}
}
return true
}
// ---------- Dynamic TLS listener pool ----------
type tlsListenerPool struct {
@@ -142,11 +169,35 @@ func (p *tlsListenerPool) Sync(forwarders []TLSForwarderConfig) []error {
return errs
}
func (p *tlsListenerPool) Has(addr string) bool {
if p == nil || addr == "" {
return false
}
p.mu.Lock()
defer p.mu.Unlock()
_, ok := p.entries[addr]
return ok
}
func (p *tlsListenerPool) HasAll(forwarders []TLSForwarderConfig) bool {
if p == nil {
return false
}
for _, f := range forwarders {
if f.Listen == "" {
continue
}
if !p.Has(f.Listen) {
return false
}
}
return true
}
// ---------- Global pool instances (initialised in main) ----------
var (
publicPool *listenerPool // HTTP+SSH: listen + extra_listen
localPool *listenerPool // raw SSH: local_ssh_listen
tlsPool *tlsListenerPool // TLS forwarders
)
@@ -196,8 +247,42 @@ func getAdminHandler() http.Handler {
// applyFullConfigReload applies every field in newCfg to the running server
// without a process restart. Port changes, DNSTT/UDPGW changes, Xray changes,
// and bandwidth defaults all take effect immediately.
// The only field that still requires a restart is host_key_file.
func applyFullConfigReload(newCfg *Config) {
// It returns a status report so the panel can show crashed or blocked services.
type ServiceReloadStatus struct {
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Listen string `json:"listen,omitempty"`
Error string `json:"error,omitempty"`
}
type ConfigReloadReport struct {
Applied bool `json:"applied"`
Warnings []string `json:"warnings,omitempty"`
Services map[string]ServiceReloadStatus `json:"services"`
}
func newReloadReport() ConfigReloadReport {
return ConfigReloadReport{Applied: true, Services: map[string]ServiceReloadStatus{}}
}
func (r *ConfigReloadReport) warnf(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
r.Warnings = append(r.Warnings, msg)
log.Printf("config reload: %s", msg)
}
func joinAddrs(addrs []string) string {
clean := make([]string, 0, len(addrs))
for _, a := range addrs {
if a = strings.TrimSpace(a); a != "" {
clean = append(clean, a)
}
}
return strings.Join(clean, ", ")
}
func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
report := newReloadReport()
// Banner
bt := newCfg.Banner
if bt == "" && newCfg.BannerFile != "" {
@@ -207,8 +292,10 @@ func applyFullConfigReload(newCfg *Config) {
}
setBannerText(bt)
// Default per-connection bandwidth limits (picked up by new connections)
// Default per-connection bandwidth limits and SSH inactivity cleanup
// (picked up by new connections).
setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown)
setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout)
// Quiet logging / user count display
if newCfg.Quiet {
@@ -226,44 +313,97 @@ func applyFullConfigReload(newCfg *Config) {
// Public SSH listeners (main listen + extra_listen)
publicAddrs := append([]string{newCfg.Listen}, newCfg.ExtraListen...)
for _, e := range publicPool.Sync(publicAddrs) {
log.Printf("hotreload: %v", e)
report.warnf("SSH listener error: %v", e)
}
report.Services["ssh"] = ServiceReloadStatus{
Enabled: true,
Running: publicPool.HasAll(publicAddrs),
Listen: joinAddrs(publicAddrs),
}
if !report.Services["ssh"].Running {
report.Services["ssh"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: joinAddrs(publicAddrs), Error: "one or more SSH listeners could not be opened"}
}
// Local raw SSH listener
var localAddrs []string
if newCfg.LocalSSHListen != "" {
localAddrs = []string{newCfg.LocalSSHListen}
}
for _, e := range localPool.Sync(localAddrs) {
log.Printf("hotreload: %v", e)
}
// Legacy local_ssh_listen is intentionally ignored. DragonCore handles DNSTT in-process.
newCfg.LocalSSHListen = ""
// TLS forwarders
for _, e := range tlsPool.Sync(newCfg.TLSForwarders) {
log.Printf("hotreload: %v", e)
report.warnf("TLS listener error: %v", e)
}
if len(newCfg.TLSForwarders) > 0 {
report.Services["tls"] = ServiceReloadStatus{
Enabled: true,
Running: tlsPool.HasAll(newCfg.TLSForwarders),
Listen: tlsForwarderList(newCfg.TLSForwarders),
}
if !report.Services["tls"].Running {
report.Services["tls"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: tlsForwarderList(newCfg.TLSForwarders), Error: "one or more TLS forwarders could not be opened"}
}
} else {
report.Services["tls"] = ServiceReloadStatus{Enabled: false, Running: false}
}
// DNSTT — stop current instance (no-op if not running) then start new one
// DNSTT — stop current instance (no-op if not running) then start new one.
stopDNSTT()
startDNSTT(newCfg.DNSTT, getSSHConfig())
if newCfg.DNSTT != nil {
if err := startDNSTT(newCfg.DNSTT, getSSHConfig()); err != nil {
report.warnf("DNSTT failed to start: %v", err)
report.Services["dnstt"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: newCfg.DNSTT.UDPListen, Error: err.Error()}
} else {
report.Services["dnstt"] = ServiceReloadStatus{Enabled: true, Running: true, Listen: newCfg.DNSTT.UDPListen}
}
} else {
report.Services["dnstt"] = ServiceReloadStatus{Enabled: false, Running: false}
}
// UDPGW — same pattern
// UDPGW — same pattern.
stopUDPGW()
startUDPGW(newCfg.UDPGW)
if newCfg.UDPGW != nil {
if err := startUDPGW(newCfg.UDPGW); err != nil {
report.warnf("UDPGW failed to start: %v", err)
report.Services["udpgw"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: newCfg.UDPGW.Listen, Error: err.Error()}
} else {
report.Services["udpgw"] = ServiceReloadStatus{Enabled: true, Running: udpgwRunning(), Listen: newCfg.UDPGW.Listen}
}
} else {
report.Services["udpgw"] = ServiceReloadStatus{Enabled: false, Running: false}
}
// Xray — update stored config then restart/stop as needed
// Xray — update stored config then restart/stop as needed.
if newCfg.Xray != nil {
xrayMgr.mu.Lock()
xrayMgr.cfg = newCfg.Xray
xrayMgr.mu.Unlock()
if newCfg.Xray.Enabled {
_ = xrayMgr.Restart()
if err := xrayMgr.Restart(); err != nil {
report.warnf("Xray failed to restart: %v", err)
}
time.Sleep(500 * time.Millisecond)
st := xrayMgr.Status()
report.Services["xray"] = ServiceReloadStatus{Enabled: true, Running: st.Running, Error: st.Error}
if !st.Running && st.Error == "" {
report.Services["xray"] = ServiceReloadStatus{Enabled: true, Running: false, Error: "xray exited immediately; check logs"}
}
} else {
_ = xrayMgr.Stop()
report.Services["xray"] = ServiceReloadStatus{Enabled: false, Running: false}
}
} else {
_ = xrayMgr.Stop()
report.Services["xray"] = ServiceReloadStatus{Enabled: false, Running: false}
}
setGlobalCfg(newCfg)
return report
}
func tlsForwarderList(forwarders []TLSForwarderConfig) string {
addrs := make([]string, 0, len(forwarders))
for _, f := range forwarders {
if strings.TrimSpace(f.Listen) != "" {
addrs = append(addrs, strings.TrimSpace(f.Listen))
}
}
return strings.Join(addrs, ", ")
}

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# Auto-install script for SSH Panel + Xray-core (Ubuntu/Debian/CentOS)
# Auto-install script for SSH Panel + Xray-core (multi-distro Linux/systemd)
# Usage: sudo bash install.sh
set -euo pipefail
@@ -11,58 +11,219 @@ error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
# ── config ──────────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/sshpanel"
SERVICE_NAME="sshpanel"
LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
# ────────────────────────────────────────────────────────────────────────────
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
# Cross-distro helpers -------------------------------------------------------
PKG_MANAGER=""
PKG_DEPS=()
PKG_OPTIONAL_DEPS=()
SYSTEMCTL_BIN=""
SH_BIN="$(command -v sh 2>/dev/null || echo /bin/sh)"
MOUNT_BIN="$(command -v mount 2>/dev/null || echo /bin/mount)"
MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)"
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
require_systemd() {
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
if [[ -z "$SYSTEMCTL_BIN" ]]; then
error "systemd was not found. This installer supports Linux distributions that use systemd for services."
fi
}
detect_pkg_manager() {
if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
elif command -v yum >/dev/null 2>&1; then
PKG_MANAGER="yum"
elif command -v zypper >/dev/null 2>&1; then
PKG_MANAGER="zypper"
elif command -v pacman >/dev/null 2>&1; then
PKG_MANAGER="pacman"
elif command -v apk >/dev/null 2>&1; then
PKG_MANAGER="apk"
else
error "No supported package manager found. Supported: apt, dnf, yum, zypper, pacman, apk."
fi
}
set_package_deps() {
case "$PKG_MANAGER" in
apt)
PKG_DEPS=(curl wget git rsync build-essential postgresql ca-certificates unzip openssh-client openssl python3 tar gzip)
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
;;
dnf|yum)
PKG_DEPS=(curl wget git rsync gcc make postgresql-server ca-certificates unzip openssh-clients openssl python3 tar gzip)
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
;;
zypper)
PKG_DEPS=(curl wget git rsync gcc make postgresql-server ca-certificates unzip openssh openssl python3 tar gzip)
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
;;
pacman)
PKG_DEPS=(curl wget git rsync base-devel postgresql ca-certificates unzip openssh openssl python tar gzip)
PKG_OPTIONAL_DEPS=(iptables-nft nftables)
;;
apk)
PKG_DEPS=(curl wget git rsync build-base postgresql ca-certificates unzip openssh-client openssl python3 tar gzip)
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
;;
esac
}
pkg_update() {
case "$PKG_MANAGER" in
apt) apt-get update -qq ;;
dnf) dnf makecache -q ;;
yum) yum makecache -q ;;
zypper) zypper --non-interactive refresh ;;
pacman) pacman -Sy --noconfirm ;;
apk) apk update ;;
esac
}
pkg_install() {
case "$PKG_MANAGER" in
apt) DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@" ;;
dnf) dnf install -y "$@" ;;
yum) yum install -y "$@" ;;
zypper) zypper --non-interactive install -y "$@" ;;
pacman) pacman -S --noconfirm --needed "$@" ;;
apk) apk add --no-cache "$@" ;;
esac
}
pkg_install_optional() {
local pkg
for pkg in "$@"; do
pkg_install "$pkg" >/dev/null 2>&1 || warn " Optional package '$pkg' could not be installed; continuing."
done
}
postgres_data_dir() {
for dir in /var/lib/postgresql/data /var/lib/pgsql/data /var/lib/postgres/data; do
[[ -d "$dir" || -d "$(dirname "$dir")" ]] && { printf '%s\n' "$dir"; return 0; }
done
printf '%s\n' /var/lib/postgresql/data
}
init_postgresql_if_needed() {
case "$PKG_MANAGER" in
dnf|yum|zypper)
postgresql-setup --initdb >/dev/null 2>&1 || true
;;
pacman)
local data_dir
data_dir="$(postgres_data_dir)"
if [[ ! -s "$data_dir/PG_VERSION" ]]; then
mkdir -p "$data_dir"
chown -R postgres:postgres "$(dirname "$data_dir")"
if command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- initdb -D "$data_dir" >/dev/null 2>&1 || true
else
su - postgres -c "initdb -D '$data_dir'" >/dev/null 2>&1 || true
fi
fi
;;
apk)
if command -v rc-service >/dev/null 2>&1; then
rc-service postgresql setup >/dev/null 2>&1 || true
fi
;;
esac
}
start_enable_postgresql() {
local started=false svc
for svc in postgresql postgresql.service; do
if "$SYSTEMCTL_BIN" start "$svc" >/dev/null 2>&1; then
"$SYSTEMCTL_BIN" enable "$svc" >/dev/null 2>&1 || true
started=true
break
fi
done
if ! $started && command -v service >/dev/null 2>&1; then
service postgresql start >/dev/null 2>&1 && started=true || true
fi
$started || warn " Could not start PostgreSQL automatically; continuing in case it is already running."
}
ensure_log_tmpfs_mount() {
local log_dir="${INSTALL_DIR}/logs"
local opts="rw,nosuid,nodev,noexec,noatime,nofail,size=${LOG_TMPFS_SIZE},mode=0755"
local tmp_fstab
mkdir -p "$log_dir"
if [[ -f /etc/fstab ]]; then
cp /etc/fstab "/etc/fstab.sshpanel.bak.$(date +%s)" 2>/dev/null || true
tmp_fstab="$(mktemp)"
awk -v mp="$log_dir" '!(($1 == "tmpfs") && ($2 == mp) && ($3 == "tmpfs")) {print}' /etc/fstab > "$tmp_fstab"
printf 'tmpfs %s tmpfs %s 0 0\n' "$log_dir" "$opts" >> "$tmp_fstab"
cat "$tmp_fstab" > /etc/fstab
rm -f "$tmp_fstab"
info " Log RAM disk automount saved in /etc/fstab: $log_dir (${LOG_TMPFS_SIZE})"
else
warn " /etc/fstab not found; service startup fallback will mount $log_dir as tmpfs"
fi
"${SYSTEMCTL_BIN:-systemctl}" daemon-reload >/dev/null 2>&1 || true
if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$log_dir"; then
mount -o "remount,size=${LOG_TMPFS_SIZE},mode=0755" "$log_dir" >/dev/null 2>&1 || true
else
mount "$log_dir" >/dev/null 2>&1 || mount -t tmpfs -o "size=${LOG_TMPFS_SIZE},mode=0755" tmpfs "$log_dir" >/dev/null 2>&1 || \
warn " Could not mount $log_dir as tmpfs now; service startup fallback will try again"
fi
touch "$log_dir/panel.log" >/dev/null 2>&1 || true
chmod 0644 "$log_dir/panel.log" >/dev/null 2>&1 || true
}
echo -e "\n${GREEN}══════════════════════════════════════════${NC}"
echo -e "${GREEN} SSH Panel + Xray-core · Installer ${NC}"
echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
# ── 1. OS detection ──────────────────────────────────────────────────────────
info "[1/9] Detecting OS…"
# ── 1. OS / package-manager detection ────────────────────────────────────────
info "[1/10] Detecting Linux distribution and package manager…"
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_LIKE="${ID_LIKE:-}"
OS_PRETTY="${PRETTY_NAME:-$OS_ID}"
else
OS_ID="unknown"
OS_LIKE=""
OS_PRETTY="unknown Linux"
fi
case "$OS_ID" in
ubuntu|debian|linuxmint)
PKG_UPDATE="apt-get update -qq"
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y"
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl"
;;
centos|rhel|rocky|almalinux)
PKG_UPDATE="yum makecache -q"
PKG_INSTALL="yum install -y"
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl"
;;
fedora)
PKG_UPDATE="dnf makecache -q"
PKG_INSTALL="dnf install -y"
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl"
;;
*)
warn "Unknown OS '$OS_ID' — attempting apt-get…"
PKG_UPDATE="apt-get update -qq"
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y"
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl"
;;
esac
info " OS: $OS_ID"
require_systemd
detect_pkg_manager
set_package_deps
info " OS : $OS_PRETTY"
info " ID / ID_LIKE : $OS_ID / ${OS_LIKE:-none}"
info " Package manager: $PKG_MANAGER"
info " Service manager: systemd"
# ── 2. System dependencies ───────────────────────────────────────────────────
info "[2/9] Installing system packages…"
eval "$PKG_UPDATE"
eval "$PKG_INSTALL $PKG_DEPS"
info "[2/10] Installing system packages…"
pkg_update
pkg_install "${PKG_DEPS[@]}"
pkg_install_optional "${PKG_OPTIONAL_DEPS[@]}"
# ── 3. Go ────────────────────────────────────────────────────────────────────
info "[3/9] Installing Go ${GO_VERSION}"
info "[3/10] Installing Go ${GO_VERSION}"
NEED_GO=true
if command -v go &>/dev/null; then
CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')
@@ -94,11 +255,12 @@ export PATH=$PATH:/usr/local/go/bin
go version
# ── 4. Directory layout ──────────────────────────────────────────────────────
info "[4/9] Setting up ${INSTALL_DIR}"
info "[4/10] Setting up ${INSTALL_DIR}"
mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/keys" "$INSTALL_DIR/logs"
ensure_log_tmpfs_mount
# ── 5. Build SSH panel binary ────────────────────────────────────────────────
info "[5/9] Building SSH Panel binary…"
info "[5/10] Building SSH Panel binary…"
cd "$SCRIPT_DIR"
export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_sshpanel
@@ -107,9 +269,19 @@ go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" .
info " Binary: $INSTALL_DIR/sshpanel"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel copied"
if [[ -f "$SCRIPT_DIR/update.sh" ]]; then
cp "$SCRIPT_DIR/update.sh" "$INSTALL_DIR/update.sh"
chmod 700 "$INSTALL_DIR/update.sh"
info " Git updater copied"
fi
if [[ -f "$SCRIPT_DIR/change_admin_password.sh" ]]; then
cp "$SCRIPT_DIR/change_admin_password.sh" "$INSTALL_DIR/change_admin_password.sh"
chmod 700 "$INSTALL_DIR/change_admin_password.sh"
info " Admin password recovery script copied"
fi
# ── 6. Xray binary ──────────────────────────────────────────────────────────
info "[6/9] Downloading Xray-core…"
info "[6/10] Downloading Xray-core…"
XRAY_VER=$(curl -sf "https://api.github.com/repos/XTLS/Xray-core/releases/latest" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30")
MACHINE=$(uname -m)
@@ -132,13 +304,9 @@ rm -f /tmp/xray.zip
"$INSTALL_DIR/xray" version
# ── 7. PostgreSQL ────────────────────────────────────────────────────────────
info "[7/9] Configuring PostgreSQL…"
case "$OS_ID" in
centos|rhel|rocky|almalinux|fedora)
postgresql-setup --initdb 2>/dev/null || true ;;
esac
systemctl start postgresql 2>/dev/null || service postgresql start 2>/dev/null || true
systemctl enable postgresql 2>/dev/null || true
info "[7/10] Configuring PostgreSQL…"
init_postgresql_if_needed
start_enable_postgresql
DB_NAME="sshpanel"
DB_USER="sshpanel"
@@ -230,7 +398,7 @@ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};
info " PostgreSQL database '${DB_NAME}' ready"
# ── 8. Config files ──────────────────────────────────────────────────────────
info "[8/9] Generating config files…"
info "[8/10] Generating config files…"
# Admin token
ADMIN_TOKEN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48 || true)
@@ -281,7 +449,6 @@ cat > "$INSTALL_DIR/config.json" <<EOF
{
"listen": "0.0.0.0:80",
"extra_listen": ["0.0.0.0:8080"],
"local_ssh_listen": "127.0.0.1:2222",
"host_key_file": "${INSTALL_DIR}/ssh_host_rsa_key",
"quiet": false,
"admin_dir": "${INSTALL_DIR}/admin",
@@ -333,33 +500,105 @@ EOF
chmod 600 "$INSTALL_DIR/xray_config.json"
info " VLESS UUID: ${UUID}"
# ── 9. Systemd service ───────────────────────────────────────────────────────
info "[9/9] Creating systemd service '${SERVICE_NAME}'…"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
# ── 9. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
info "[9/10] Configuring DNSTT DNS redirect (UDP 53 -> 5300)…"
cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS'
#!/bin/bash
set -euo pipefail
DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}"
DNSTT_PORT="${DNSTT_PORT:-5300}"
# Free port 53 on systemd-resolved based systems and keep outbound DNS working.
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true
fi
rm -f /etc/resolv.conf
printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf
# Open DNS/UDP in common Linux firewalls when they are active.
if command -v ufw >/dev/null 2>&1; then
ufw allow 53/udp >/dev/null 2>&1 || true
fi
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
add_iptables_rule() {
local bin="$1" chain="$2"
"$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \
|| "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT"
}
if command -v iptables >/dev/null 2>&1; then
add_iptables_rule iptables PREROUTING
fi
if command -v ip6tables >/dev/null 2>&1; then
add_iptables_rule ip6tables PREROUTING || true
fi
# Fallback for minimal systems where only nft is present.
if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
nft add table inet sshpanel_nat 2>/dev/null || true
nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true
nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \
|| nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT"
fi
EOS
chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh
cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF'
[Unit]
Description=SSH Panel + Xray-core Server
After=network.target postgresql.service
Wants=postgresql.service
Description=SSH Panel DNSTT DNS redirect (UDP 53 to 5300)
After=network.target
Before=sshpanel.service
[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${INSTALL_DIR}/.env
ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
Restart=always
RestartSec=5
User=root
LimitNOFILE=65536
StandardOutput=append:${INSTALL_DIR}/logs/panel.log
StandardError=append:${INSTALL_DIR}/logs/panel.log
Type=oneshot
ExecStart=/usr/local/sbin/sshpanel-dnstt-redirect.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
"$SYSTEMCTL_BIN" daemon-reload
"$SYSTEMCTL_BIN" enable --now sshpanel-dnstt-redirect.service || warn "DNSTT DNS redirect service failed; check: journalctl -u sshpanel-dnstt-redirect -e"
info " DNSTT DNS redirect installed: UDP 53 -> 5300"
# ── 10. Systemd service ──────────────────────────────────────────────────────
info "[10/10] Creating systemd service '${SERVICE_NAME}'…"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=SSH Panel + Xray-core Server
After=local-fs.target network.target postgresql.service sshpanel-dnstt-redirect.service
Wants=postgresql.service sshpanel-dnstt-redirect.service
[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${INSTALL_DIR}/.env
Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log
Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}
ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs
ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'
ExecStartPre=${SH_BIN} -c '${TOUCH_BIN} ${INSTALL_DIR}/logs/panel.log && ${CHMOD_BIN} 0644 ${INSTALL_DIR}/logs/panel.log || true'
ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
Restart=always
RestartSec=5
User=root
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
"$SYSTEMCTL_BIN" daemon-reload
"$SYSTEMCTL_BIN" enable "$SERVICE_NAME"
"$SYSTEMCTL_BIN" restart "$SERVICE_NAME"
sleep 2
echo ""
@@ -371,6 +610,7 @@ echo -e " Server IP : ${YELLOW}${SERVER_IP}${NC}"
echo -e " SSH ports : 80, 8080 (HTTP-injected SSH)"
echo -e " VLESS port : 10086"
echo -e " VLESS UUID : ${YELLOW}${UUID}${NC}"
echo -e " DNSTT DNS : UDP 53 redirects to local UDP 5300"
echo ""
echo -e " Admin panel : ${YELLOW}http://${SERVER_IP}:9090${NC}"
echo -e " Admin login : ${YELLOW}admin${NC}"
@@ -383,4 +623,4 @@ echo -e " tail -f ${INSTALL_DIR}/logs/panel.log"
echo ""
echo -e "${YELLOW}Save your admin login/password. The admin token is for API bearer-token access only.${NC}"
echo ""
systemctl status "$SERVICE_NAME" --no-pager -l || true
"$SYSTEMCTL_BIN" status "$SERVICE_NAME" --no-pager -l || true

516
main.go
View File

@@ -17,6 +17,7 @@ import (
"log"
"net"
"net/http"
"net/url"
"os"
"sort"
"strconv"
@@ -27,6 +28,7 @@ import (
_ "github.com/lib/pq"
"golang.org/x/crypto/ssh"
"golang.org/x/time/rate"
"runtime/debug"
)
const (
@@ -35,6 +37,9 @@ const (
tlsHandshakeTimeout = 15 * time.Second
// Dial timeout for direct-tcpip backend connections.
directTCPIPDialTimeout = 10 * time.Second
// Default post-auth SSH inactivity timeout. This is based on real bytes
// moving in either direction, so live upload/download tunnels are not closed.
defaultSSHIdleTimeout = 5 * time.Minute
)
// ---------- Config types ----------
@@ -65,9 +70,8 @@ type Config struct {
// "[::]:80", "[2001:db8::20]:8080". Empty slice means no
// additional listeners.
ExtraListen []string `json:"extra_listen"`
// Optional: local-only raw SSH listener for other daemons (e.g. DNSTT upstream)
// Set to "127.0.0.1:2222" or similar. Leave empty to disable.
LocalSSHListen string `json:"local_ssh_listen"`
// Legacy compatibility only. DragonCore no longer starts a local raw SSH listener.
LocalSSHListen string `json:"local_ssh_listen,omitempty"`
HostKeyFile string `json:"host_key_file"`
Quiet bool `json:"quiet"`
@@ -76,6 +80,11 @@ type Config struct {
UserCount bool `json:"user_count"`
// SSHIdleTimeout controls how long an authenticated SSH connection may
// remain with no bytes moving in either direction before it is closed and
// released from the active user count. Empty = default 5m. Use "0s" to disable.
SSHIdleTimeout string `json:"ssh_idle_timeout,omitempty"`
// NEW: Directory to serve the admin panel from
AdminDir string `json:"admin_dir"`
@@ -279,9 +288,12 @@ func (m *UserManager) ReplaceAll(newUsers map[string]*UserState) {
m.users = newUsers
}
// ReplaceAllPreserveRuntime replaces the user map while keeping runtime connection
// state (ActiveConns + conns) for users that already exist.
// This prevents the admin panel from showing everyone as "offline" after a DB reload.
// ReplaceAllPreserveRuntime replaces the user map while keeping the same
// runtime UserState object for users that already exist. This is important:
// active handleConn goroutines hold a pointer to the old UserState and run the
// decrement in a defer. If we copy ActiveConns into a new UserState during a DB
// reload, that later decrement happens on the old object and the visible count
// in the new map stays stuck.
func (m *UserManager) ReplaceAllPreserveRuntime(newUsers map[string]*UserState) {
m.mu.Lock()
old := m.users
@@ -289,10 +301,17 @@ func (m *UserManager) ReplaceAllPreserveRuntime(newUsers map[string]*UserState)
for username, nu := range newUsers {
if ou, ok := old[username]; ok && ou != nil && nu != nil {
ou.mu.Lock()
nu.ActiveConns = ou.ActiveConns
// Preserve the live connection set so we can still disconnect correctly.
nu.conns = ou.conns
ou.Cfg = nu.Cfg
ou.ExpiresAt = nu.ExpiresAt
ou.PubKey = nu.PubKey
if ou.conns == nil {
ou.conns = make(map[*ssh.ServerConn]struct{})
}
// Self-heal any previously stale counter by trusting the live connection map.
ou.ActiveConns = len(ou.conns)
ou.mu.Unlock()
newUsers[username] = ou
}
}
@@ -327,6 +346,9 @@ var (
userMgr = &UserManager{users: make(map[string]*UserState)}
userCountEnabled bool
sshIdleTimeoutMu sync.RWMutex
currentSSHIdleTimeout = defaultSSHIdleTimeout
displayMu sync.Mutex
lastDisplayLen int
@@ -355,13 +377,18 @@ func mbpsToBytesPerSec(mbps int) int64 {
return int64(mbps) * 1024 * 1024 / 8
}
var copyBufPool = sync.Pool{
New: func() interface{} { b := make([]byte, 32*1024); return &b },
}
func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
if lim == nil {
return io.Copy(dst, src)
}
const bufSize = 32 * 1024
buf := make([]byte, bufSize)
bufp := copyBufPool.Get().(*[]byte)
buf := *bufp
defer copyBufPool.Put(bufp)
ctx := context.Background()
for {
@@ -396,6 +423,111 @@ func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written
return written, err
}
func parseSSHIdleTimeout(raw string) time.Duration {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultSSHIdleTimeout
}
d, err := time.ParseDuration(raw)
if err != nil {
log.Printf("invalid ssh_idle_timeout %q: %v; using default %s", raw, err, defaultSSHIdleTimeout)
return defaultSSHIdleTimeout
}
if d < 0 {
log.Printf("invalid negative ssh_idle_timeout %q; using default %s", raw, defaultSSHIdleTimeout)
return defaultSSHIdleTimeout
}
return d
}
func setSSHIdleTimeoutFromConfig(raw string) {
d := parseSSHIdleTimeout(raw)
sshIdleTimeoutMu.Lock()
currentSSHIdleTimeout = d
sshIdleTimeoutMu.Unlock()
}
func getSSHIdleTimeout() time.Duration {
sshIdleTimeoutMu.RLock()
d := currentSSHIdleTimeout
sshIdleTimeoutMu.RUnlock()
return d
}
// activityConn tracks real SSH transport activity in both directions. The idle
// monitor uses this instead of a read deadline so download-only or upload-only
// tunnels are considered live and are not disconnected.
type activityConn struct {
net.Conn
mu sync.Mutex
last time.Time
}
func newActivityConn(c net.Conn) *activityConn {
return &activityConn{Conn: c, last: time.Now()}
}
func (c *activityConn) touch() {
c.mu.Lock()
c.last = time.Now()
c.mu.Unlock()
}
func (c *activityConn) LastActivity() time.Time {
c.mu.Lock()
last := c.last
c.mu.Unlock()
return last
}
func (c *activityConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
if n > 0 {
c.touch()
}
return n, err
}
func (c *activityConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
if n > 0 {
c.touch()
}
return n, err
}
func monitorSSHIdle(c *activityConn, sshConn *ssh.ServerConn, username string, idleTimeout time.Duration, done <-chan struct{}) {
if idleTimeout <= 0 {
return
}
checkEvery := idleTimeout / 4
if checkEvery < 5*time.Second {
checkEvery = 5 * time.Second
}
if checkEvery > 30*time.Second {
checkEvery = 30 * time.Second
}
ticker := time.NewTicker(checkEvery)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
idleFor := time.Since(c.LastActivity())
if idleFor >= idleTimeout {
log.Printf("ssh idle timeout: user=%s remote=%s idle=%s limit=%s; closing stale connection",
username, sshConn.RemoteAddr(), idleFor.Round(time.Second), idleTimeout)
_ = sshConn.Close()
_ = c.Close()
return
}
}
}
}
// ---------- Server stats (CPU + network interfaces) ----------
// per-interface stats returned by /api/stats
@@ -420,6 +552,10 @@ type ifaceCounters struct {
TxBytes uint64
}
func isIgnoredInterface(iface string) bool {
return iface == "" || iface == "lo"
}
func getCurrentStats() StatsDTO {
statsMu.RLock()
defer statsMu.RUnlock()
@@ -432,6 +568,48 @@ func setCurrentStats(s StatsDTO) {
statsMu.Unlock()
}
// primeCurrentStats fills RAM and interface totals immediately at startup so
// the dashboard does not show placeholder values while waiting for the first
// polling interval. CPU still becomes accurate after the second /proc/stat
// sample, but it is rendered as 0.0% instead of --.
func primeCurrentStats() {
netMap, _ := readNetDev()
interfaces := make([]InterfaceStats, 0, len(netMap))
for name, ctrs := range netMap {
if isIgnoredInterface(name) {
continue
}
st := InterfaceStats{Name: name}
if ifaceTotalsMgr != nil {
rxTotal, txTotal := ifaceTotalsMgr.ApplyKernel(name, ctrs.RxBytes, ctrs.TxBytes)
st.RxBytes = rxTotal
st.TxBytes = txTotal
} else {
st.RxBytes = ctrs.RxBytes
st.TxBytes = ctrs.TxBytes
}
interfaces = append(interfaces, st)
}
sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name })
memTotal, memAvail, _ := readMemInfo()
var memUsed uint64
var memPercent float64
if memTotal > 0 {
if memAvail <= memTotal {
memUsed = memTotal - memAvail
memPercent = 100.0 * float64(memUsed) / float64(memTotal)
}
}
setCurrentStats(StatsDTO{
CPUPercent: 0,
MemTotal: memTotal,
MemUsed: memUsed,
MemAvail: memAvail,
MemPercent: memPercent,
Interfaces: interfaces,
})
}
type IfaceTotals struct {
Iface string
TotalRxBytes uint64
@@ -439,6 +617,7 @@ type IfaceTotals struct {
LastKernelRxBytes uint64
LastKernelTxBytes uint64
UpdatedAt time.Time
ResetAt time.Time
}
type IfaceTotalsManager struct {
@@ -454,14 +633,33 @@ func NewIfaceTotalsManager() *IfaceTotalsManager {
// It is resilient to kernel counter resets (e.g. host reboot): if the kernel counter
// goes backwards, it treats the new value as "delta since reset".
func (tm *IfaceTotalsManager) ApplyKernel(iface string, kRx, kTx uint64) (totalRx, totalTx uint64) {
if isIgnoredInterface(iface) {
return 0, 0
}
tm.mu.Lock()
defer tm.mu.Unlock()
now := time.Now()
st, ok := tm.m[iface]
if !ok {
st = &IfaceTotals{Iface: iface}
st = &IfaceTotals{Iface: iface, ResetAt: now}
tm.m[iface] = st
}
if st.ResetAt.IsZero() {
st.ResetAt = now
}
// The live interface counters in the Stats tab are a rolling 30-day total.
// This reset does not touch the vnstat-style daily/monthly history tables.
if now.Sub(st.ResetAt) >= 30*24*time.Hour {
st.TotalRxBytes = 0
st.TotalTxBytes = 0
st.LastKernelRxBytes = kRx
st.LastKernelTxBytes = kTx
st.ResetAt = now
st.UpdatedAt = now
return 0, 0
}
// RX
if st.LastKernelRxBytes == 0 && st.TotalRxBytes == 0 {
@@ -484,14 +682,43 @@ func (tm *IfaceTotalsManager) ApplyKernel(iface string, kRx, kTx uint64) (totalR
}
st.LastKernelTxBytes = kTx
st.UpdatedAt = time.Now()
st.UpdatedAt = now
return st.TotalRxBytes, st.TotalTxBytes
}
func (tm *IfaceTotalsManager) ResetAllToKernel(netMap map[string]ifaceCounters) []IfaceTotals {
tm.mu.Lock()
defer tm.mu.Unlock()
now := time.Now()
tm.m = make(map[string]*IfaceTotals, len(netMap))
out := make([]IfaceTotals, 0, len(netMap))
for iface, ctrs := range netMap {
if isIgnoredInterface(iface) {
continue
}
st := &IfaceTotals{
Iface: iface,
TotalRxBytes: 0,
TotalTxBytes: 0,
LastKernelRxBytes: ctrs.RxBytes,
LastKernelTxBytes: ctrs.TxBytes,
UpdatedAt: now,
ResetAt: now,
}
tm.m[iface] = st
out = append(out, *st)
}
return out
}
func (tm *IfaceTotalsManager) Load(rows []IfaceTotals) {
tm.mu.Lock()
defer tm.mu.Unlock()
for _, r := range rows {
if isIgnoredInterface(r.Iface) {
continue
}
cp := r // copy
tm.m[r.Iface] = &cp
}
@@ -502,6 +729,9 @@ func (tm *IfaceTotalsManager) Snapshot() []IfaceTotals {
defer tm.mu.Unlock()
out := make([]IfaceTotals, 0, len(tm.m))
for _, v := range tm.m {
if v == nil || isIgnoredInterface(v.Iface) {
continue
}
out = append(out, *v)
}
return out
@@ -567,6 +797,9 @@ func startStatsCollector() {
dt := now.Sub(prevTime).Seconds()
if netMap != nil {
for name, ctrs := range netMap {
if isIgnoredInterface(name) {
continue
}
st := InterfaceStats{
Name: name,
}
@@ -581,14 +814,27 @@ func startStatsCollector() {
}
if prevNet != nil && dt > 0 {
if prev, ok := prevNet[name]; ok {
var rxDelta, txDelta uint64
if ctrs.RxBytes >= prev.RxBytes {
rxDelta := ctrs.RxBytes - prev.RxBytes
st.RxMbps = float64(rxDelta*8) / dt / 1_000_000
rxDelta = ctrs.RxBytes - prev.RxBytes
} else {
// kernel counter reset or wrap
rxDelta = ctrs.RxBytes
}
if ctrs.TxBytes >= prev.TxBytes {
txDelta := ctrs.TxBytes - prev.TxBytes
txDelta = ctrs.TxBytes - prev.TxBytes
} else {
txDelta = ctrs.TxBytes
}
if rxDelta > 0 {
st.RxMbps = float64(rxDelta*8) / dt / 1_000_000
}
if txDelta > 0 {
st.TxMbps = float64(txDelta*8) / dt / 1_000_000
}
if statsStore != nil && (rxDelta > 0 || txDelta > 0) {
addPendingIfaceUsage(name, rxDelta, txDelta)
}
}
}
interfaces = append(interfaces, st)
@@ -621,12 +867,18 @@ func startStatsCollector() {
Interfaces: interfaces,
})
// Persist interface totals periodically (optional).
// Persist interface totals and vnstat-style usage periodically (optional).
if flushTicker != nil && statsStore != nil && ifaceTotalsMgr != nil {
select {
case <-flushTicker.C:
ctx := context.Background()
_ = statsStore.UpsertIfaceTotals(ctx, ifaceTotalsMgr.Snapshot())
if deltas := flushPendingIfaceUsage(now); len(deltas) > 0 {
if err := statsStore.UpsertIfaceUsageDeltas(ctx, deltas); err != nil {
log.Printf("vnstat usage flush failed: %v", err)
restorePendingIfaceUsage(deltas)
}
}
default:
}
}
@@ -753,6 +1005,9 @@ func readNetDev() (map[string]ifaceCounters, error) {
continue
}
iface := strings.TrimSpace(parts[0])
if isIgnoredInterface(iface) {
continue
}
fields := strings.Fields(parts[1])
if len(fields) < 9 {
continue
@@ -866,6 +1121,9 @@ func NewStore(dsn string) (*Store, error) {
if err := store.EnsureAdminUsersSchema(ctx); err != nil {
return nil, err
}
if err := store.EnsureManagedServersSchema(ctx); err != nil {
return nil, err
}
return store, nil
}
@@ -998,22 +1256,31 @@ func (s *Store) DeleteUser(ctx context.Context, username string) error {
// ---------- Optional persistence for interface totals ----------
func (s *Store) EnsureIfaceTotalsTable(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS ssh_iface_totals (
stmts := []string{
`CREATE TABLE IF NOT EXISTS ssh_iface_totals (
iface TEXT PRIMARY KEY,
total_rx_bytes BIGINT NOT NULL DEFAULT 0,
total_tx_bytes BIGINT NOT NULL DEFAULT 0,
last_kernel_rx_bytes BIGINT NOT NULL DEFAULT 0,
last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
return err
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS reset_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) LoadIfaceTotals(ctx context.Context) ([]IfaceTotals, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at
FROM ssh_iface_totals`)
SELECT iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at
FROM ssh_iface_totals
WHERE iface <> 'lo'`)
if err != nil {
return nil, err
}
@@ -1022,11 +1289,12 @@ func (s *Store) LoadIfaceTotals(ctx context.Context) ([]IfaceTotals, error) {
out := []IfaceTotals{}
for rows.Next() {
var r IfaceTotals
var updated time.Time
if err := rows.Scan(&r.Iface, &r.TotalRxBytes, &r.TotalTxBytes, &r.LastKernelRxBytes, &r.LastKernelTxBytes, &updated); err != nil {
var updated, resetAt time.Time
if err := rows.Scan(&r.Iface, &r.TotalRxBytes, &r.TotalTxBytes, &r.LastKernelRxBytes, &r.LastKernelTxBytes, &updated, &resetAt); err != nil {
return nil, err
}
r.UpdatedAt = updated
r.ResetAt = resetAt
out = append(out, r)
}
if err := rows.Err(); err != nil {
@@ -1041,16 +1309,24 @@ func (s *Store) UpsertIfaceTotals(ctx context.Context, rows []IfaceTotals) error
}
// Simple loop (small N: number of interfaces). Keeps CPU/DB overhead minimal.
for _, r := range rows {
if isIgnoredInterface(r.Iface) {
continue
}
resetAt := r.ResetAt
if resetAt.IsZero() {
resetAt = time.Now()
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO ssh_iface_totals (iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
INSERT INTO ssh_iface_totals (iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at)
VALUES ($1, $2, $3, $4, $5, NOW(), $6)
ON CONFLICT (iface) DO UPDATE
SET total_rx_bytes = EXCLUDED.total_rx_bytes,
total_tx_bytes = EXCLUDED.total_tx_bytes,
last_kernel_rx_bytes = EXCLUDED.last_kernel_rx_bytes,
last_kernel_tx_bytes = EXCLUDED.last_kernel_tx_bytes,
updated_at = NOW()`,
r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes)
updated_at = NOW(),
reset_at = EXCLUDED.reset_at`,
r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes, resetAt)
if err != nil {
return err
}
@@ -1086,8 +1362,13 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
// Superadmin-only: server stats + DNSTT
mux.Handle("/api/stats", saSession(http.HandlerFunc(handleStats)))
// Server stats: visible to authenticated sessions; reset remains superadmin-only.
mux.Handle("/api/stats", sessionMiddleware(http.HandlerFunc(handleStats)))
mux.Handle("/api/stats/interfaces/reset", saSession(http.HandlerFunc(handleResetInterfaceStats(store))))
mux.Handle("/api/vnstat", saSession(http.HandlerFunc(handleVnstat(store))))
mux.Handle("/api/vnstat/reset", saSession(http.HandlerFunc(handleVnstatReset(store))))
mux.Handle("/api/system/logs", saSession(http.HandlerFunc(handleSystemLogs)))
mux.Handle("/api/system/logs/reset", saSession(http.HandlerFunc(handleSystemLogsReset)))
mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats)))
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
@@ -1096,26 +1377,34 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
// Superadmin-only: Xray-core management
mux.Handle("/api/xray/status", saSession(http.HandlerFunc(handleXrayStatus)))
// Master/slave server management. Superadmins can add slave nodes; all authenticated
// users can read the enabled server list to pick where accounts are created.
mux.Handle("/api/servers", sessionMiddleware(http.HandlerFunc(handleServers(store))))
mux.Handle("/api/servers/test", saSession(http.HandlerFunc(handleServerTest(store))))
mux.Handle("/api/servers/config", saSession(http.HandlerFunc(handleManagedServerConfig(store))))
// Xray-core management. Service/config/log actions are superadmin-only;
// authenticated resellers may list inbounds and manage their own Xray clients.
mux.Handle("/api/xray/status", sessionMiddleware(http.HandlerFunc(handleXrayStatus)))
mux.Handle("/api/xray/start", saSession(http.HandlerFunc(handleXrayStart)))
mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop)))
mux.Handle("/api/xray/restart", saSession(http.HandlerFunc(handleXrayRestart)))
mux.Handle("/api/xray/stats/repair", saSession(http.HandlerFunc(handleXrayRepairStats)))
mux.Handle("/api/xray/config", saSession(http.HandlerFunc(handleXrayConfig)))
mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs)))
mux.Handle("/api/xray/inbounds", saSession(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", saSession(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", saSession(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/remove", saSession(http.HandlerFunc(handleXrayClientRemove)))
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation
mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned)))
mux.Handle("/api/tls/letsencrypt", saSession(http.HandlerFunc(handleTLSLetsEncrypt)))
mux.Handle("/api/tls/upload-pem", saSession(http.HandlerFunc(handleTLSUploadPEM)))
mux.Handle("/api/tls/generate-selfsigned", saSession(handleManagedProxyOrLocal(store, handleTLSGenerateSelfSigned)))
mux.Handle("/api/tls/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt)))
mux.Handle("/api/tls/upload-pem", saSession(handleManagedProxyOrLocal(store, handleTLSUploadPEM)))
// Superadmin-only: DNSTT key management
mux.Handle("/api/dnstt/genkey", saSession(http.HandlerFunc(handleDnsttGenKey)))
mux.Handle("/api/dnstt/pubkey", saSession(http.HandlerFunc(handleDnsttGetPubKey)))
mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey)))
mux.Handle("/api/dnstt/pubkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGetPubKey)))
// Superadmin-only: server config (read/write config.json + live banner apply)
mux.Handle("/api/server/config", saSession(http.HandlerFunc(handleServerConfig)))
@@ -1152,6 +1441,7 @@ type UserDTO struct {
AllowStaticPassword bool `json:"allow_static_password"`
TOTPEnabled bool `json:"totp_enabled"`
OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
}
func handleListUsers(w http.ResponseWriter, r *http.Request) {
@@ -1161,11 +1451,19 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
}
sess := sessionFromCtx(r.Context())
filterOwner := ""
if sess != nil && sess.Role == RoleReseller {
filterOwner = sess.Username
}
if proxyManagedServerFromRequest(w, r, statsStore, "/api/users", nil, filterOwner) {
return
}
states := userMgr.List()
out := make([]UserDTO, 0, len(states))
for _, u := range states {
u.mu.Lock()
c := u.ActiveConns
c := len(u.conns)
u.ActiveConns = c
cfg := u.Cfg
expires := u.ExpiresAt
u.mu.Unlock()
@@ -1209,6 +1507,8 @@ type UserPayload struct {
TOTPWindow int `json:"totp_window"`
TOTPDigits int `json:"totp_digits"`
AllowStaticPassword bool `json:"allow_static_password"`
OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
}
func handleCreateUser(store *Store) http.HandlerFunc {
@@ -1233,6 +1533,27 @@ func handleCreateUser(store *Store) http.HandlerFunc {
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if !ms.EnableSSH {
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
return
}
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
p.OwnerUsername = sess.Username
}
p.ServerID = ""
body, _ := json.Marshal(p)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
// Decide what password to use:
// - if payload has non-empty password -> use it
@@ -1278,11 +1599,13 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existsInDB)
if !existsInDB {
owner, ok := adminUsers.get(sess.Username)
if ok && owner.MaxUsers > 0 && countOwnedUsers(sess.Username) >= owner.MaxUsers {
if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return
}
}
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
ownerUsername = strings.TrimSpace(p.OwnerUsername)
}
cfg := UserConfig{
@@ -1332,6 +1655,23 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, username, sess.Username) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
// Resellers may only delete their own users
sess := sessionFromCtx(ctx)
@@ -1364,6 +1704,9 @@ func handleStats(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if proxyManagedServerFromRequest(w, r, statsStore, "/api/stats", nil, "") {
return
}
stats := getCurrentStats()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stats)
@@ -1536,18 +1879,20 @@ func publicKeyCallback(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissio
// ---------- Connection handling ----------
func handleConn(tcpConn net.Conn, config *ssh.ServerConfig) {
defer tcpConn.Close()
trackedConn := newActivityConn(tcpConn)
defer trackedConn.Close()
// Prevent goroutine leaks from clients that connect but never complete the SSH handshake.
_ = tcpConn.SetReadDeadline(time.Now().Add(sshHandshakeTimeout))
_ = trackedConn.SetReadDeadline(time.Now().Add(sshHandshakeTimeout))
sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, config)
sshConn, chans, reqs, err := ssh.NewServerConn(trackedConn, config)
if err != nil {
log.Println("ssh handshake failed:", err)
return
}
// Clear deadlines after a successful handshake.
_ = tcpConn.SetDeadline(time.Time{})
// Clear deadlines after a successful handshake. Runtime cleanup is handled
// by monitorSSHIdle, which checks traffic in both directions.
_ = trackedConn.SetDeadline(time.Time{})
username := sshConn.User()
log.Printf("new SSH connection from %s as %s", sshConn.RemoteAddr(), username)
@@ -1558,19 +1903,22 @@ func handleConn(tcpConn net.Conn, config *ssh.ServerConfig) {
return
}
// Track active connection and enforce max_connections
// Track active connection and enforce max_connections. The connection map is
// treated as the source of truth so stale counters can self-heal.
u.mu.Lock()
if u.Cfg.MaxConnections > 0 && u.ActiveConns >= u.Cfg.MaxConnections {
if u.conns == nil {
u.conns = make(map[*ssh.ServerConn]struct{})
}
activeConns := len(u.conns)
u.ActiveConns = activeConns
if u.Cfg.MaxConnections > 0 && activeConns >= u.Cfg.MaxConnections {
u.mu.Unlock()
log.Printf("user %s exceeded max_connections (%d)", username, u.Cfg.MaxConnections)
sshConn.Close()
return
}
u.ActiveConns++
if u.conns == nil {
u.conns = make(map[*ssh.ServerConn]struct{})
}
u.conns[sshConn] = struct{}{}
u.ActiveConns = len(u.conns)
u.mu.Unlock()
// Use per-user limit if set; otherwise fall back to the global config default.
@@ -1596,10 +1944,16 @@ func handleConn(tcpConn net.Conn, config *ssh.ServerConfig) {
updateUserDisplay()
idleDone := make(chan struct{})
if idleTimeout := getSSHIdleTimeout(); idleTimeout > 0 {
go monitorSSHIdle(trackedConn, sshConn, username, idleTimeout, idleDone)
}
defer func() {
close(idleDone)
u.mu.Lock()
u.ActiveConns--
delete(u.conns, sshConn)
u.ActiveConns = len(u.conns)
u.mu.Unlock()
updateUserDisplay()
@@ -1727,7 +2081,8 @@ func updateUserDisplay() {
parts := make([]string, 0, len(userStates))
for _, u := range userStates {
u.mu.Lock()
c := u.ActiveConns
c := len(u.conns)
u.ActiveConns = c
name := u.Cfg.Username
u.mu.Unlock()
parts = append(parts, fmt.Sprintf("%s: %d", name, c))
@@ -1781,7 +2136,7 @@ func handleHTTP80Conn(raw net.Conn, sshConfig *ssh.ServerConfig) {
_, _ = raw.Write([]byte(fmt.Sprintf("HTTP/1.1 101 %s\r\n\r\n", status)))
skip200 := false
br := bufio.NewReaderSize(raw, 32<<10)
br := bufio.NewReaderSize(raw, 4<<10)
// Drain chained HTTP header blocks with a short rolling deadline so Peek/ReadBytes never stalls.
cleanWindow := 30 * time.Second
@@ -2260,6 +2615,12 @@ func maybeHTTPStartPrefix(b []byte) bool {
}
func main() {
if memTotal, _, err := readMemInfo(); err == nil && memTotal > 0 {
limit := int64(memTotal) * 80 / 100
debug.SetMemoryLimit(limit)
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")
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
@@ -2318,6 +2679,9 @@ func main() {
} else {
startXrayClientExpiryChecker(store)
}
if err := store.EnsureIfaceUsageTables(ctx); err != nil {
log.Printf("vnstat usage tables disabled: %v", err)
}
if err := store.EnsureIfaceTotalsTable(ctx); err == nil {
rows, err2 := store.LoadIfaceTotals(ctx)
if err2 == nil {
@@ -2339,6 +2703,7 @@ func main() {
}
// start background collector for CPU + interface stats
primeCurrentStats()
startStatsCollector()
adminAddr := os.Getenv("ADMIN_HTTP_ADDR")
@@ -2493,35 +2858,38 @@ func main() {
if quietLogs {
log.SetOutput(io.Discard)
}
startPanelLogLimiter()
// Initialise default per-connection bandwidth limits.
// Initialise default per-connection bandwidth limits and SSH inactivity cleanup.
setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown)
// Start the integrated DNSTT and UDPGW if configured.
startDNSTT(cfg.DNSTT, sshConfig)
startUDPGW(cfg.UDPGW)
setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout)
// Initialise listener pools (used for initial startup and hot-reload alike).
publicPool = newListenerPool(serveHTTP80)
localPool = newListenerPool(serveRawSSH)
tlsPool = newTLSListenerPool()
for _, msg := range normalizeRuntimePorts(cfg) {
log.Printf("startup config fallback: %s", msg)
}
// Start the integrated DNSTT and UDPGW if configured. Startup errors are logged
// but do not crash the panel; the admin UI exposes the logs and service status.
if err := startDNSTT(cfg.DNSTT, sshConfig); err != nil {
log.Printf("dnstt auto-start failed: %v", err)
}
if err := startUDPGW(cfg.UDPGW); err != nil {
log.Printf("udpgw auto-start failed: %v", err)
}
// Start public SSH listeners (listen + extra_listen).
publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...)
for _, e := range publicPool.Sync(publicAddrs) {
log.Fatalf("failed to start listener: %v", e)
}
// Start local raw SSH listener if configured.
if cfg.LocalSSHListen != "" {
for _, e := range localPool.Sync([]string{cfg.LocalSSHListen}) {
log.Fatalf("failed to start local SSH listener: %v", e)
}
log.Printf("failed to start listener: %v", e)
}
// Start TLS forwarder listeners if configured.
for _, e := range tlsPool.Sync(cfg.TLSForwarders) {
log.Fatalf("failed to start TLS listener: %v", e)
log.Printf("failed to start TLS listener: %v", e)
}
// Print user counts once at startup.

605
managed_servers.go Normal file
View File

@@ -0,0 +1,605 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type ManagedServer struct {
ID int
Name string
BaseURL string
AdminUsername string
AdminKey string
EnableSSH bool
EnableXray bool
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
type ManagedServerDTO struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
AdminUsername string `json:"admin_username,omitempty"`
EnableSSH bool `json:"enable_ssh"`
EnableXray bool `json:"enable_xray"`
IsActive bool `json:"is_active"`
IsLocal bool `json:"is_local"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type ManagedServerPayload struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
AdminUsername string `json:"admin_username"`
AdminKey string `json:"admin_key"`
EnableSSH bool `json:"enable_ssh"`
EnableXray bool `json:"enable_xray"`
IsActive bool `json:"is_active"`
}
func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS managed_servers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL UNIQUE,
admin_username TEXT NOT NULL DEFAULT 'admin',
admin_key TEXT NOT NULL DEFAULT '',
enable_ssh BOOLEAN NOT NULL DEFAULT TRUE,
enable_xray BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
return err
}
func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active, created_at, updated_at
FROM managed_servers ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ManagedServer
for rows.Next() {
ms := &ManagedServer{}
if err := rows.Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt); err != nil {
return nil, err
}
out = append(out, ms)
}
return out, rows.Err()
}
func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, error) {
ms := &ManagedServer{}
err := s.db.QueryRowContext(ctx, `
SELECT id, name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active, created_at, updated_at
FROM managed_servers WHERE id=$1`, id).
Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return ms, nil
}
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
name := strings.TrimSpace(p.Name)
baseURL := normalizeManagedServerBaseURL(p.BaseURL)
adminUsername := strings.TrimSpace(p.AdminUsername)
if adminUsername == "" {
adminUsername = "admin"
}
if name == "" {
return nil, fmt.Errorf("server name required")
}
if baseURL == "" {
return nil, fmt.Errorf("base url required")
}
if p.ID != "" && p.ID != "local" {
id, err := strconv.Atoi(p.ID)
if err != nil || id <= 0 {
return nil, fmt.Errorf("invalid server id")
}
if strings.TrimSpace(p.AdminKey) == "" {
_, err = s.db.ExecContext(ctx, `
UPDATE managed_servers
SET name=$2, base_url=$3, admin_username=$4, enable_ssh=$5, enable_xray=$6, is_active=$7, updated_at=NOW()
WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
} else {
_, err = s.db.ExecContext(ctx, `
UPDATE managed_servers
SET name=$2, base_url=$3, admin_username=$4, admin_key=$5, enable_ssh=$6, enable_xray=$7, is_active=$8, updated_at=NOW()
WHERE id=$1`, id, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive)
}
if err != nil {
return nil, err
}
return s.GetManagedServer(ctx, id)
}
if strings.TrimSpace(p.AdminKey) == "" {
return nil, fmt.Errorf("admin key/password required")
}
var id int
err := s.db.QueryRowContext(ctx, `
INSERT INTO managed_servers (name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (base_url) DO UPDATE SET
name=EXCLUDED.name,
admin_username=EXCLUDED.admin_username,
admin_key=EXCLUDED.admin_key,
enable_ssh=EXCLUDED.enable_ssh,
enable_xray=EXCLUDED.enable_xray,
is_active=EXCLUDED.is_active,
updated_at=NOW()
RETURNING id`, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
if err != nil {
return nil, err
}
return s.GetManagedServer(ctx, id)
}
func (s *Store) DeleteManagedServer(ctx context.Context, id int) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM managed_servers WHERE id=$1`, id)
return err
}
func managedServerToDTO(ms *ManagedServer) ManagedServerDTO {
return ManagedServerDTO{
ID: strconv.Itoa(ms.ID),
Name: ms.Name,
BaseURL: ms.BaseURL,
AdminUsername: ms.AdminUsername,
EnableSSH: ms.EnableSSH,
EnableXray: ms.EnableXray,
IsActive: ms.IsActive,
CreatedAt: ms.CreatedAt,
UpdatedAt: ms.UpdatedAt,
}
}
func localManagedServerDTO() ManagedServerDTO {
cfg := getGlobalCfg()
xrayEnabled := cfg != nil && cfg.Xray != nil && cfg.Xray.Enabled
return ManagedServerDTO{
ID: "local",
Name: "Master node",
BaseURL: "local",
EnableSSH: true,
EnableXray: xrayEnabled,
IsActive: true,
IsLocal: true,
}
}
func normalizeManagedServerBaseURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
u.Path = strings.TrimRight(u.Path, "/")
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/")
}
func requestedServerID(r *http.Request) string {
id := strings.TrimSpace(r.URL.Query().Get("server_id"))
if id == "" {
id = strings.TrimSpace(r.URL.Query().Get("server"))
}
return id
}
func managedServerFromID(ctx context.Context, store *Store, id string) (*ManagedServer, bool, error) {
id = strings.TrimSpace(id)
if id == "" || id == "local" || id == "0" {
return nil, false, nil
}
if store == nil {
return nil, false, fmt.Errorf("database not configured")
}
n, err := strconv.Atoi(id)
if err != nil || n <= 0 {
return nil, false, fmt.Errorf("invalid server id")
}
ms, err := store.GetManagedServer(ctx, n)
if err != nil {
return nil, false, err
}
if ms == nil {
return nil, false, fmt.Errorf("server not found")
}
if !ms.IsActive {
return nil, false, fmt.Errorf("server is disabled")
}
return ms, true, nil
}
func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ms.BaseURL+"/api/auth/login", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("remote login failed: %s", strings.TrimSpace(string(data)))
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
return "", fmt.Errorf("remote login returned no token")
}
return out.Token, nil
}
func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path string, body []byte, contentType string) (int, []byte, string, error) {
token, err := remoteLoginToken(ctx, ms)
if err != nil {
return 0, nil, "", err
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
req, err := http.NewRequestWithContext(ctx, method, ms.BaseURL+path, bytes.NewReader(body))
if err != nil {
return 0, nil, "", err
}
if contentType == "" {
contentType = "application/json"
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("X-Session-Token", token)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, nil, "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil
}
func handleManagedProxyOrLocal(store *Store, local http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if proxyManagedServerFromRequest(w, r, store, "", nil, "") {
return
}
local(w, r)
}
}
func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentType string) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
if status == 0 {
status = http.StatusBadGateway
}
w.WriteHeader(status)
if len(body) > 0 {
_, _ = w.Write(body)
}
}
func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store *Store, remotePath string, body []byte, filterOwner string) bool {
ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return true
}
if !remote {
return false
}
if remotePath == "" {
remotePath = r.URL.Path
if r.URL.RawQuery != "" {
q := r.URL.Query()
q.Del("server_id")
q.Del("server")
if enc := q.Encode(); enc != "" {
remotePath += "?" + enc
}
}
}
if body == nil && r.Body != nil && r.Method != http.MethodGet {
body, _ = io.ReadAll(io.LimitReader(r.Body, 2*1024*1024))
}
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return true
}
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") {
if filtered, ok := filterRemoteOwnerJSON(remotePath, data, filterOwner); ok {
data = filtered
}
}
writeProxyResponse(w, status, data, ct)
return true
}
func filterRemoteOwnerJSON(path string, data []byte, owner string) ([]byte, bool) {
if owner == "" || len(data) == 0 {
return data, false
}
if strings.HasPrefix(path, "/api/users") {
var rows []map[string]interface{}
if err := json.Unmarshal(data, &rows); err != nil {
return data, false
}
out := rows[:0]
for _, row := range rows {
if strings.TrimSpace(fmt.Sprint(row["owner_username"])) == owner {
out = append(out, row)
}
}
filtered, _ := json.Marshal(out)
return filtered, true
}
if strings.HasPrefix(path, "/api/xray/inbounds") {
var inbounds []map[string]interface{}
if err := json.Unmarshal(data, &inbounds); err != nil {
return data, false
}
for _, ib := range inbounds {
clients, _ := ib["clients"].([]interface{})
filtered := make([]interface{}, 0, len(clients))
for _, c := range clients {
m, _ := c.(map[string]interface{})
if strings.TrimSpace(fmt.Sprint(m["owner_username"])) == owner {
filtered = append(filtered, c)
}
}
ib["clients"] = filtered
}
filtered, _ := json.Marshal(inbounds)
return filtered, true
}
return data, false
}
func handleServers(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if store == nil {
http.Error(w, "database not configured", http.StatusServiceUnavailable)
return
}
sess := sessionFromCtx(r.Context())
switch r.Method {
case http.MethodGet:
rows, err := store.ListManagedServers(r.Context())
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
out := []ManagedServerDTO{localManagedServerDTO()}
for _, ms := range rows {
if sess != nil && sess.Role == RoleReseller && !ms.IsActive {
continue
}
dto := managedServerToDTO(ms)
if sess != nil && sess.Role == RoleReseller {
dto.AdminUsername = ""
}
out = append(out, dto)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
case http.MethodPost:
if sess == nil || sess.Role != RoleSuperAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var p ManagedServerPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
ms, err := store.UpsertManagedServer(r.Context(), p)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(managedServerToDTO(ms))
case http.MethodDelete:
if sess == nil || sess.Role != RoleSuperAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
idStr := strings.TrimSpace(r.URL.Query().Get("id"))
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
http.Error(w, "invalid server id", http.StatusBadRequest)
return
}
if err := store.DeleteManagedServer(r.Context(), id); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
}
func handleServerTest(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if store == nil {
http.Error(w, "database not configured", http.StatusServiceUnavailable)
return
}
var p ManagedServerPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
ms := &ManagedServer{Name: p.Name, BaseURL: normalizeManagedServerBaseURL(p.BaseURL), AdminUsername: strings.TrimSpace(p.AdminUsername), AdminKey: p.AdminKey, EnableSSH: p.EnableSSH, EnableXray: p.EnableXray, IsActive: true}
if p.ID != "" && p.ID != "local" && (ms.BaseURL == "" || ms.AdminKey == "") {
id, _ := strconv.Atoi(p.ID)
if id > 0 {
stored, err := store.GetManagedServer(r.Context(), id)
if err == nil && stored != nil {
if ms.BaseURL == "" {
ms.BaseURL = stored.BaseURL
}
if ms.AdminUsername == "" {
ms.AdminUsername = stored.AdminUsername
}
if ms.AdminKey == "" {
ms.AdminKey = stored.AdminKey
}
}
}
}
if ms.AdminUsername == "" {
ms.AdminUsername = "admin"
}
if ms.BaseURL == "" || ms.AdminKey == "" {
http.Error(w, "base url and admin key/password required", http.StatusBadRequest)
return
}
token, err := remoteLoginToken(r.Context(), ms)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
_ = token
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if status < 200 || status >= 300 {
http.Error(w, strings.TrimSpace(string(data)), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "remote login ok"})
}
}
func handleManagedServerConfig(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := requestedServerID(r)
if id == "" || id == "local" || id == "0" {
handleServerConfig(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body := []byte(nil)
if r.Method == http.MethodPost {
var err error
body, err = io.ReadAll(io.LimitReader(r.Body, 512*1024))
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
}
ms, remote, err := managedServerFromID(r.Context(), store, id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !remote {
handleServerConfig(w, r)
return
}
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
if err != nil {
log.Printf("managed server config proxy %s: %v", ms.BaseURL, err)
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
}
}
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
if owner == "" || username == "" {
return false
}
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
if err != nil || status < 200 || status >= 300 {
return false
}
var rows []map[string]interface{}
if err := json.Unmarshal(data, &rows); err != nil {
return false
}
for _, row := range rows {
if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner {
return true
}
}
return false
}
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
if owner == "" || uuid == "" {
return false
}
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
if err != nil || status < 200 || status >= 300 {
return false
}
var inbounds []map[string]interface{}
if err := json.Unmarshal(data, &inbounds); err != nil {
return false
}
for _, ib := range inbounds {
clients, _ := ib["clients"].([]interface{})
for _, c := range clients {
m, _ := c.(map[string]interface{})
if fmt.Sprint(m["id"]) == uuid && fmt.Sprint(m["owner_username"]) == owner {
return true
}
}
}
return false
}

105
panel_log_limiter.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
defaultPanelLogMaxBytes int64 = 1 * 1024 * 1024
defaultPanelLogCheckEvery = 10 * time.Second
)
type panelLogResetResponse struct {
OK bool `json:"ok"`
Path string `json:"path"`
MaxBytes int64 `json:"max_bytes"`
}
func panelLogFilePath() string {
path := strings.TrimSpace(os.Getenv("PANEL_LOG_FILE"))
if path == "" {
path = defaultPanelLogFile
}
return path
}
func panelLogMaxBytes() int64 {
raw := strings.TrimSpace(os.Getenv("PANEL_LOG_MAX_BYTES"))
if raw == "" {
return defaultPanelLogMaxBytes
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return defaultPanelLogMaxBytes
}
// Do not allow a tiny limit that would cause continuous truncation.
if n < 64*1024 {
return 64 * 1024
}
return n
}
func startPanelLogLimiter() {
path := panelLogFilePath()
maxBytes := panelLogMaxBytes()
if path == "" || maxBytes <= 0 {
return
}
go func() {
_ = enforcePanelLogLimit(path, maxBytes)
ticker := time.NewTicker(defaultPanelLogCheckEvery)
defer ticker.Stop()
for range ticker.C {
_ = enforcePanelLogLimit(path, maxBytes)
}
}()
}
func enforcePanelLogLimit(path string, maxBytes int64) error {
st, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if st.Size() <= maxBytes {
return nil
}
return truncatePanelLog(path, maxBytes, "automatic 1 MiB log limit")
}
func truncatePanelLog(path string, maxBytes int64, reason string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s sshpanel: panel log cleaned (%s, max=%d bytes)\n", time.Now().Format(time.RFC3339), reason, maxBytes)
return err
}
func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
path := panelLogFilePath()
maxBytes := panelLogMaxBytes()
if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(panelLogResetResponse{OK: true, Path: path, MaxBytes: maxBytes})
}

View File

@@ -100,6 +100,8 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
}
globalCfgMu.RUnlock()
portWarnings := normalizeRuntimePorts(&newCfg)
out, err := json.MarshalIndent(newCfg, "", " ")
if err != nil {
http.Error(w, "marshal error", http.StatusInternalServerError)
@@ -110,8 +112,13 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
return
}
// Apply all changes live — no restart needed.
applyFullConfigReload(&newCfg)
// Apply all changes live and return health checks to the panel.
report := applyFullConfigReload(&newCfg)
if len(portWarnings) > 0 {
report.Warnings = append(portWarnings, report.Warnings...)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(report)
}

113
system_logs_api.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"bufio"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
)
const defaultPanelLogFile = "/opt/sshpanel/logs/panel.log"
type systemLogsResponse struct {
Source string `json:"source"`
Path string `json:"path,omitempty"`
Lines []string `json:"lines"`
}
func handleSystemLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
limit := 300
if raw := strings.TrimSpace(r.URL.Query().Get("lines")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
limit = n
}
}
if limit > 2000 {
limit = 2000
}
source := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
if source == "" {
source = "panel"
}
resp := systemLogsResponse{Source: source, Lines: []string{}}
switch source {
case "dnstt":
resp.Lines = limitLines(getDNSTTLogLines(), limit)
case "xray":
resp.Lines = limitLines(xrayLogBuf.snapshot(), limit)
default:
resp.Source = "panel"
path := panelLogFilePath()
resp.Path = path
lines, err := tailTextFile(path, limit)
if err != nil {
lines = []string{"unable to read " + path + ": " + err.Error()}
}
resp.Lines = lines
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func tailTextFile(path string, limit int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if limit <= 0 {
limit = 300
}
ring := make([]string, limit)
count := 0
scanner := bufio.NewScanner(f)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
ring[count%limit] = scanner.Text()
count++
}
if err := scanner.Err(); err != nil {
return nil, err
}
if count == 0 {
return []string{}, nil
}
outLen := count
if outLen > limit {
outLen = limit
}
out := make([]string, 0, outLen)
start := 0
if count > limit {
start = count % limit
}
for i := 0; i < outLen; i++ {
out = append(out, ring[(start+i)%limit])
}
return out, nil
}
func limitLines(lines []string, limit int) []string {
if len(lines) == 0 {
return []string{}
}
if limit <= 0 || len(lines) <= limit {
out := make([]string, len(lines))
copy(out, lines)
return out
}
out := make([]string, limit)
copy(out, lines[len(lines)-limit:])
return out
}

View File

@@ -40,23 +40,30 @@ func stopUDPGW() {
}
}
func udpgwRunning() bool {
udpgwMu.Lock()
defer udpgwMu.Unlock()
return udpgwLn != nil
}
// startUDPGW starts the integrated UDP gateway if cfg is nonnil and
// cfg.Listen is nonempty. It applies default values to any zero
// configuration fields and converts duration strings to time.Duration.
// The server runs in a goroutine; any fatal errors are logged and
// prevent the gateway from starting, but do not terminate the main
// process.
func startUDPGW(cfg *UDPGWConfig) {
func startUDPGW(cfg *UDPGWConfig) error {
if cfg == nil {
return
return nil
}
// Default the listen address to the standalone default (0.0.0.0:7400) if
// unspecified. This matches the behaviour of the original
// badvpn-udpgw program, which listens on all interfaces by default.
listenAddr := cfg.Listen
if listenAddr == "" {
listenAddr = "0.0.0.0:7400"
listenAddr = defaultUDPGWListen
}
cfg.Listen = listenAddr
// Apply defaults for numeric fields if zero.
c := &internalUDPGWConfig{}
c.listen = listenAddr
@@ -135,7 +142,7 @@ func startUDPGW(cfg *UDPGWConfig) {
ln, err := net.Listen("tcp", c.listen)
if err != nil {
log.Printf("udpgw: listen failed on %s: %v", c.listen, err)
return
return fmt.Errorf("udpgw: listen failed on %s: %w", c.listen, err)
}
// Register as the active listener so stopUDPGW can close it.
@@ -162,6 +169,7 @@ func startUDPGW(cfg *UDPGWConfig) {
go handleUDPGWClient(conn, c)
}
}()
return nil
}
// internalUDPGWConfig mirrors the exported UDPGWConfig but with

666
update.sh
View File

@@ -1,7 +1,21 @@
#!/bin/bash
# Update script for SSH Panel — updates the binary and admin panel in place.
# Preserves: .env, config.json, xray_config.json, SSH keys, database, certs.
# Usage: sudo bash update.sh
# Update script for DragonCoreSSH / SSH Panel.
# Pulls the newest source from Git, builds the new binary, and updates the
# installed files in place.
#
# Preserved:
# - /opt/sshpanel/.env
# - /opt/sshpanel/config.json
# - /opt/sshpanel/xray_config.json
# - SSH keys, certs, logs, database, users
#
# Usage:
# sudo bash /opt/sshpanel/update.sh
# sudo bash update.sh
#
# Optional:
# sudo UPDATE_REF=main bash /opt/sshpanel/update.sh
# sudo REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git bash /opt/sshpanel/update.sh
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
@@ -9,191 +23,569 @@ info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
# ── config ────────────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/sshpanel"
SERVICE_NAME="sshpanel"
# Config
INSTALL_DIR="${INSTALL_DIR:-/opt/sshpanel}"
SERVICE_NAME="${SERVICE_NAME:-sshpanel}"
LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
UPDATE_REF="${UPDATE_REF:-}"
SOURCE_CACHE_DIR="${SOURCE_CACHE_DIR:-${INSTALL_DIR}/source}"
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
# ─────────────────────────────────────────────────────────────────────────────
SOURCE_DIR=""
RESTART_NEEDED=false
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
echo -e "\n${GREEN}══════════════════════════════════════════${NC}"
echo -e "${GREEN} SSH Panel · Updater ${NC}"
echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
# Cross-distro helpers -------------------------------------------------------
PKG_MANAGER=""
UPDATE_DEPS=()
SYSTEMCTL_BIN=""
SH_BIN="$(command -v sh 2>/dev/null || echo /bin/sh)"
MOUNT_BIN="$(command -v mount 2>/dev/null || echo /bin/mount)"
MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)"
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
# ── 1. Pre-flight checks ──────────────────────────────────────────────────────
info "[1/5] Pre-flight checks…"
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found — run install.sh first."
[[ -f "$INSTALL_DIR/.env" ]] || error "$INSTALL_DIR/.env not found — run install.sh first."
[[ -f "$SCRIPT_DIR/go.mod" ]] || error "go.mod not found — run this script from the source directory."
info " Install dir : $INSTALL_DIR"
info " Source dir : $SCRIPT_DIR"
info " Go version : $GO_VERSION"
# ── 2. Go toolchain ───────────────────────────────────────────────────────────
info "[2/5] Checking Go toolchain…"
NEED_GO=true
if command -v go &>/dev/null; then
CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')
if [[ "$(printf '%s\n' "$GO_VERSION" "$CURRENT_GO" | sort -V | head -1)" == "$GO_VERSION" ]]; then
info " Go $CURRENT_GO already installed — skipping"
NEED_GO=false
require_systemd() {
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
if [[ -z "$SYSTEMCTL_BIN" ]]; then
error "systemd was not found. This updater supports Linux distributions that use systemd for services."
fi
fi
}
if $NEED_GO; then
MACHINE=$(uname -m)
case "$MACHINE" in
x86_64) GOARCH="amd64" ;;
aarch64) GOARCH="arm64" ;;
armv7l) GOARCH="armv6l" ;;
*) GOARCH="amd64" ;;
detect_pkg_manager() {
if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
elif command -v yum >/dev/null 2>&1; then
PKG_MANAGER="yum"
elif command -v zypper >/dev/null 2>&1; then
PKG_MANAGER="zypper"
elif command -v pacman >/dev/null 2>&1; then
PKG_MANAGER="pacman"
elif command -v apk >/dev/null 2>&1; then
PKG_MANAGER="apk"
else
error "No supported package manager found. Supported: apt, dnf, yum, zypper, pacman, apk."
fi
}
set_update_deps() {
case "$PKG_MANAGER" in
apt)
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
;;
dnf|yum)
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
;;
zypper)
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
;;
pacman)
UPDATE_DEPS=(git rsync wget ca-certificates python gcc make tar gzip)
;;
apk)
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
;;
esac
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
info " Downloading Go ${GO_VERSION} (${GOARCH})…"
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
rm -f /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
chmod +x /etc/profile.d/go.sh
fi
}
export PATH=$PATH:/usr/local/go/bin
go version
pkg_update() {
case "$PKG_MANAGER" in
apt) apt-get update -qq ;;
dnf) dnf makecache -q ;;
yum) yum makecache -q ;;
zypper) zypper --non-interactive refresh ;;
pacman) pacman -Sy --noconfirm ;;
apk) apk update ;;
esac
}
# ── 3. Build new binary ───────────────────────────────────────────────────────
info "[3/5] Building new sshpanel binary…"
pkg_install() {
case "$PKG_MANAGER" in
apt) DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@" ;;
dnf) dnf install -y "$@" ;;
yum) yum install -y "$@" ;;
zypper) zypper --non-interactive install -y "$@" ;;
pacman) pacman -S --noconfirm --needed "$@" ;;
apk) apk add --no-cache "$@" ;;
esac
}
cd "$SCRIPT_DIR"
export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_sshpanel
go mod download
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
info " Build complete."
ensure_update_dependencies() {
local missing=false cmd
for cmd in git rsync wget tar gzip gcc make; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing=true
fi
done
if ! command -v python3 >/dev/null 2>&1; then
missing=true
fi
if $missing; then
warn "One or more updater dependencies are missing. Installing them with $PKG_MANAGER..."
pkg_update
pkg_install "${UPDATE_DEPS[@]}"
fi
if ! command -v python3 >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then
ln -sf "$(command -v python)" /usr/local/bin/python3 2>/dev/null || true
fi
}
# ── 4. Apply update ───────────────────────────────────────────────────────────
info "[4/5] Applying update…"
echo -e "\n${GREEN}==========================================${NC}"
echo -e "${GREEN} DragonCoreSSH / SSH Panel Updater ${NC}"
echo -e "${GREEN}==========================================${NC}\n"
# Stop the service
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
info " Stopping $SERVICE_NAME"
systemctl stop "$SERVICE_NAME"
RESTART_NEEDED=true
else
RESTART_NEEDED=false
fi
# Helpers
need_cmd() {
command -v "$1" >/dev/null 2>&1 || error "Required command not found: $1"
}
# Backup old binary
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
info " Old binary backed up to sshpanel.bak"
fi
ensure_log_tmpfs_mount() {
local log_dir="${INSTALL_DIR}/logs"
local opts="rw,nosuid,nodev,noexec,noatime,nofail,size=${LOG_TMPFS_SIZE},mode=0755"
local tmp_fstab
# Replace binary
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
chmod +x "$INSTALL_DIR/sshpanel"
info " Binary updated."
mkdir -p "$log_dir"
# Update admin panel files
mkdir -p "$INSTALL_DIR/admin"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel updated."
if [[ -f /etc/fstab ]]; then
cp /etc/fstab "/etc/fstab.sshpanel.bak.$(date +%s)" 2>/dev/null || true
tmp_fstab="$(mktemp)"
awk -v mp="$log_dir" '!(($1 == "tmpfs") && ($2 == mp) && ($3 == "tmpfs")) {print}' /etc/fstab > "$tmp_fstab"
printf 'tmpfs %s tmpfs %s 0 0\n' "$log_dir" "$opts" >> "$tmp_fstab"
cat "$tmp_fstab" > /etc/fstab
rm -f "$tmp_fstab"
info " Log RAM disk automount saved in /etc/fstab: $log_dir (${LOG_TMPFS_SIZE})"
else
warn " /etc/fstab not found; service startup fallback will mount $log_dir as tmpfs"
fi
# Ensure banner file exists (new in this version)
if [[ ! -f "$INSTALL_DIR/banner.txt" ]]; then
touch "$INSTALL_DIR/banner.txt"
info " Created banner.txt"
fi
"${SYSTEMCTL_BIN:-systemctl}" daemon-reload >/dev/null 2>&1 || true
if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$log_dir"; then
mount -o "remount,size=${LOG_TMPFS_SIZE},mode=0755" "$log_dir" >/dev/null 2>&1 || true
else
mount "$log_dir" >/dev/null 2>&1 || mount -t tmpfs -o "size=${LOG_TMPFS_SIZE},mode=0755" tmpfs "$log_dir" >/dev/null 2>&1 || \
warn " Could not mount $log_dir as tmpfs now; service startup fallback will try again"
fi
# Ensure certs directory exists (new in this version)
mkdir -p "$INSTALL_DIR/certs"
touch "$log_dir/panel.log" >/dev/null 2>&1 || true
chmod 0644 "$log_dir/panel.log" >/dev/null 2>&1 || true
}
# Patch config.json to add missing fields introduced in this version
# without overwriting user-configured values.
CFG="$INSTALL_DIR/config.json"
if [[ -f "$CFG" ]]; then
# Add banner_file if not present
if ! python3 -c "import json,sys; d=json.load(open('$CFG')); sys.exit(0 if 'banner_file' in d else 1)" 2>/dev/null; then
python3 - "$CFG" << 'PYEOF'
install_git_if_missing() {
if command -v git >/dev/null 2>&1; then
return 0
fi
warn "git is not installed. Trying to install it..."
pkg_update
pkg_install git ca-certificates
}
remote_default_branch() {
local branch
branch="$(git ls-remote --symref "$REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {sub("refs/heads/", "", $2); print $2; exit}')"
if [[ -n "$branch" ]]; then
printf '%s\n' "$branch"
else
printf 'main\n'
fi
}
prepare_source_from_git() {
install_git_if_missing
if [[ -z "$UPDATE_REF" ]]; then
UPDATE_REF="$(remote_default_branch)"
fi
info "[1/7] Fetching latest files from Git..."
info " Repo : $REPO_URL"
info " Ref : $UPDATE_REF"
# If update.sh is being run from a real clone of this repo, update that folder.
# This is useful for developers who run the updater from the cloned project.
if [[ -d "$SCRIPT_DIR/.git" && -f "$SCRIPT_DIR/go.mod" ]]; then
SOURCE_DIR="$SCRIPT_DIR"
info " Updating existing source folder: $SOURCE_DIR"
git -C "$SOURCE_DIR" remote set-url origin "$REPO_URL" >/dev/null 2>&1 || true
git -C "$SOURCE_DIR" fetch --prune origin
git -C "$SOURCE_DIR" checkout "$UPDATE_REF" >/dev/null 2>&1 || true
git -C "$SOURCE_DIR" reset --hard "origin/$UPDATE_REF"
git -C "$SOURCE_DIR" clean -fd
return 0
fi
# Normal installed-server path: keep a local Git cache under /opt/sshpanel/source.
mkdir -p "$(dirname "$SOURCE_CACHE_DIR")"
if [[ -d "$SOURCE_CACHE_DIR/.git" ]]; then
SOURCE_DIR="$SOURCE_CACHE_DIR"
info " Updating cached source folder: $SOURCE_DIR"
git -C "$SOURCE_DIR" remote set-url origin "$REPO_URL" >/dev/null 2>&1 || true
git -C "$SOURCE_DIR" fetch --prune origin
git -C "$SOURCE_DIR" checkout "$UPDATE_REF" >/dev/null 2>&1 || true
git -C "$SOURCE_DIR" reset --hard "origin/$UPDATE_REF"
git -C "$SOURCE_DIR" clean -fd
else
rm -rf "$SOURCE_CACHE_DIR"
info " Cloning source folder to: $SOURCE_CACHE_DIR"
git clone --depth 1 --branch "$UPDATE_REF" "$REPO_URL" "$SOURCE_CACHE_DIR" || {
warn "Clone with ref '$UPDATE_REF' failed. Trying default clone..."
rm -rf "$SOURCE_CACHE_DIR"
git clone --depth 1 "$REPO_URL" "$SOURCE_CACHE_DIR"
}
SOURCE_DIR="$SOURCE_CACHE_DIR"
fi
[[ -f "$SOURCE_DIR/go.mod" ]] || error "Downloaded source is invalid: go.mod not found in $SOURCE_DIR"
[[ -d "$SOURCE_DIR/admin" ]] || error "Downloaded source is invalid: admin folder not found in $SOURCE_DIR"
}
install_go_if_needed() {
local go_version machine goarch go_url current_go need_go
go_version="$(awk '$1 == "go" {print $2; exit}' "$SOURCE_DIR/go.mod" 2>/dev/null || echo "1.22.5")"
need_go=true
info "[2/7] Checking Go toolchain..."
info " Required Go: $go_version"
if command -v go >/dev/null 2>&1; then
current_go="$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')"
if [[ "$(printf '%s\n' "$go_version" "$current_go" | sort -V | head -1)" == "$go_version" ]]; then
info " Go $current_go already installed."
need_go=false
fi
fi
if $need_go; then
machine="$(uname -m)"
case "$machine" in
x86_64) goarch="amd64" ;;
aarch64) goarch="arm64" ;;
armv7l) goarch="armv6l" ;;
*) goarch="amd64" ;;
esac
go_url="https://go.dev/dl/go${go_version}.linux-${goarch}.tar.gz"
info " Downloading Go ${go_version} (${goarch})..."
need_cmd wget
wget -q --show-progress -O /tmp/go.tar.gz "$go_url"
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
rm -f /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
chmod +x /etc/profile.d/go.sh
fi
export PATH=$PATH:/usr/local/go/bin
go version
}
build_binary() {
info "[3/7] Building new sshpanel binary..."
cd "$SOURCE_DIR"
export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_sshpanel
go mod download
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
info " Build complete."
}
stop_service() {
info "[4/7] Stopping service..."
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
"$SYSTEMCTL_BIN" stop "$SERVICE_NAME"
RESTART_NEEDED=true
info " $SERVICE_NAME stopped."
else
RESTART_NEEDED=false
warn " $SERVICE_NAME was not running."
fi
}
copy_optional_script() {
local name mode
name="$1"
mode="$2"
if [[ -f "$SOURCE_DIR/$name" ]]; then
cp "$SOURCE_DIR/$name" "$INSTALL_DIR/$name"
chmod "$mode" "$INSTALL_DIR/$name"
info " Updated $name"
fi
}
apply_update() {
info "[5/7] Applying update..."
mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/logs" "$INSTALL_DIR/certs"
ensure_log_tmpfs_mount
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
info " Old binary backed up to $INSTALL_DIR/sshpanel.bak"
fi
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
chmod 755 "$INSTALL_DIR/sshpanel"
info " Binary updated."
rsync -a --delete "$SOURCE_DIR/admin/" "$INSTALL_DIR/admin/"
info " Admin panel updated."
copy_optional_script "update.sh" 700
copy_optional_script "install.sh" 700
copy_optional_script "change_admin_password.sh" 700
# Keep a local copy of the latest source for easier support and future updates.
if [[ "$SOURCE_DIR" != "$SOURCE_CACHE_DIR" ]]; then
rm -rf "$SOURCE_CACHE_DIR"
mkdir -p "$SOURCE_CACHE_DIR"
rsync -a --delete --exclude '.git' "$SOURCE_DIR/" "$SOURCE_CACHE_DIR/"
info " Source files copied to $SOURCE_CACHE_DIR"
fi
[[ -f "$INSTALL_DIR/banner.txt" ]] || touch "$INSTALL_DIR/banner.txt"
}
patch_configs() {
info "[6/7] Patching config files without overwriting user settings..."
local cfg xcfg
cfg="$INSTALL_DIR/config.json"
xcfg="$INSTALL_DIR/xray_config.json"
if [[ -f "$cfg" ]]; then
python3 - "$cfg" <<'PYEOF'
import json, sys
path = sys.argv[1]
with open(path) as f:
d = json.load(f)
try:
with open(path) as f:
d = json.load(f)
except Exception as e:
print(f"[!] Could not parse {path}: {e}")
sys.exit(0)
changed = False
if 'banner_file' not in d:
d['banner_file'] = '/opt/sshpanel/banner.txt'
with open(path, 'w') as f:
json.dump(d, f, indent=2)
changed = True
if 'local_ssh_listen' in d:
d.pop('local_ssh_listen', None)
changed = True
if changed:
with open(path, 'w') as f:
json.dump(d, f, indent=2)
f.write('\n')
PYEOF
info " Added banner_file to config.json"
info " config.json checked."
fi
# Fix routing: remove geoip:private rules that require geoip.dat from xray_config.json
XCFG="$INSTALL_DIR/xray_config.json"
if [[ -f "$XCFG" ]]; then
if grep -q '"geoip:private"' "$XCFG" 2>/dev/null; then
python3 - "$XCFG" << 'PYEOF'
if [[ -f "$xcfg" ]] && grep -q '"geoip:private"' "$xcfg" 2>/dev/null; then
python3 - "$xcfg" <<'PYEOF'
import json, sys
path = sys.argv[1]
with open(path) as f:
d = json.load(f)
try:
with open(path) as f:
d = json.load(f)
except Exception as e:
print(f"[!] Could not parse {path}: {e}")
sys.exit(0)
routing = d.get('routing', {})
rules = routing.get('rules', [])
# Remove rules that reference geoip:private
new_rules = [r for r in rules if 'geoip:private' not in r.get('ip', [])]
if new_rules != rules:
if new_rules:
d['routing']['rules'] = new_rules
d.setdefault('routing', {})['rules'] = new_rules
else:
d.pop('routing', None)
with open(path, 'w') as f:
json.dump(d, f, indent=2)
f.write('\n')
PYEOF
info " Removed geoip:private routing rule from xray_config.json"
fi
info " Removed geoip:private routing rule from xray_config.json"
fi
}
dnstt_redirect_is_enabled() {
# Updates must not resurrect this service when an admin intentionally
# disabled/removed it because it can break ip6tables on some machines.
local unit="sshpanel-dnstt-redirect.service"
if "$SYSTEMCTL_BIN" is-enabled --quiet "$unit" 2>/dev/null; then
return 0
fi
return 1
}
write_sshpanel_systemd_override() {
local include_dnstt_redirect="${1:-false}"
mkdir -p /etc/systemd/system/sshpanel.service.d
{
echo "[Unit]"
if [[ "$include_dnstt_redirect" == "true" ]]; then
echo "Wants=sshpanel-dnstt-redirect.service"
echo "After=local-fs.target sshpanel-dnstt-redirect.service"
else
echo "After=local-fs.target"
fi
echo
echo "[Service]"
echo "Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log"
echo "Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}"
echo "ExecStartPre="
echo "ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs"
echo "ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'"
echo "ExecStartPre=${SH_BIN} -c '${TOUCH_BIN} ${INSTALL_DIR}/logs/panel.log && ${CHMOD_BIN} 0644 ${INSTALL_DIR}/logs/panel.log || true'"
echo "StandardOutput=journal"
echo "StandardError=journal"
} > /etc/systemd/system/sshpanel.service.d/override.conf
}
ensure_dnstt_redirect() {
if ! dnstt_redirect_is_enabled; then
warn " sshpanel-dnstt-redirect is disabled or removed; update will not recreate or enable it."
write_sshpanel_systemd_override false
"$SYSTEMCTL_BIN" daemon-reload
return 0
fi
info " Ensuring DNSTT DNS redirect service exists..."
cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS'
#!/bin/bash
set -euo pipefail
DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}"
DNSTT_PORT="${DNSTT_PORT:-5300}"
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true
fi
rm -f /etc/resolv.conf
printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf
if command -v ufw >/dev/null 2>&1; then
ufw allow 53/udp >/dev/null 2>&1 || true
fi
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
# ── 5. Restart service ────────────────────────────────────────────────────────
info "[5/5] Restarting service…"
add_iptables_rule() {
local bin="$1" chain="$2"
"$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \
|| "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT"
}
if $RESTART_NEEDED; then
systemctl start "$SERVICE_NAME"
if command -v iptables >/dev/null 2>&1; then
add_iptables_rule iptables PREROUTING
fi
if command -v ip6tables >/dev/null 2>&1; then
add_iptables_rule ip6tables PREROUTING || true
fi
if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
nft add table inet sshpanel_nat 2>/dev/null || true
nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true
nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \
|| nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT"
fi
EOS
chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh
cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF2'
[Unit]
Description=SSH Panel DNSTT DNS redirect (UDP 53 to 5300)
After=network.target
Before=sshpanel.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/sshpanel-dnstt-redirect.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF2
write_sshpanel_systemd_override true
"$SYSTEMCTL_BIN" daemon-reload
"$SYSTEMCTL_BIN" enable --now sshpanel-dnstt-redirect.service || warn "DNSTT redirect service failed. Check: journalctl -u sshpanel-dnstt-redirect -e"
}
restart_service() {
info "[7/7] Restarting service..."
ensure_dnstt_redirect
if $RESTART_NEEDED; then
info " Starting $SERVICE_NAME after update..."
else
warn " $SERVICE_NAME was not running before update; starting it now."
fi
"$SYSTEMCTL_BIN" start "$SERVICE_NAME"
sleep 2
if systemctl is-active --quiet "$SERVICE_NAME"; then
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE_NAME"; then
info " $SERVICE_NAME is running."
else
warn " $SERVICE_NAME failed to start — check logs:"
warn " journalctl -u $SERVICE_NAME -n 30 --no-pager"
warn " You can restore the old binary:"
warn " mv $INSTALL_DIR/sshpanel.bak $INSTALL_DIR/sshpanel && systemctl start $SERVICE_NAME"
warn " $SERVICE_NAME failed to start. Check logs:"
warn " journalctl -u $SERVICE_NAME -n 50 --no-pager"
if [[ -f "$INSTALL_DIR/sshpanel.bak" ]]; then
warn " Restore command:"
warn " cp $INSTALL_DIR/sshpanel.bak $INSTALL_DIR/sshpanel && systemctl start $SERVICE_NAME"
fi
exit 1
fi
else
warn " Service was not running; start it with: systemctl start $SERVICE_NAME"
fi
}
# Pre-flight
info "[0/7] Pre-flight checks..."
require_systemd
detect_pkg_manager
set_update_deps
ensure_update_dependencies
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found. Run install.sh first."
[[ -f "$INSTALL_DIR/.env" ]] || error "$INSTALL_DIR/.env not found. Run install.sh first."
need_cmd python3
need_cmd rsync
need_cmd git
need_cmd wget
info " Install dir : $INSTALL_DIR"
info " Cache dir : $SOURCE_CACHE_DIR"
info " Package manager : $PKG_MANAGER"
info " Service manager : systemd"
prepare_source_from_git
install_go_if_needed
build_binary
stop_service
apply_update
patch_configs
restart_service
echo ""
echo -e "${GREEN}══════════════════════════════════════════${NC}"
echo -e "${GREEN}==========================================${NC}"
echo -e "${GREEN} Update complete! ${NC}"
echo -e "${GREEN}══════════════════════════════════════════${NC}"
echo -e "${GREEN}==========================================${NC}"
echo ""
echo -e " Logs: ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
echo -e " Updated from: ${YELLOW}${REPO_URL}${NC}"
echo -e " Source ref : ${YELLOW}${UPDATE_REF}${NC}"
echo -e " Source cache: ${YELLOW}${SOURCE_CACHE_DIR}${NC}"
echo -e " Logs : ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
echo -e " Backup : ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
echo ""
echo -e " Backup: ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
echo -e "${YELLOW}Updated:${NC}"
echo -e " - sshpanel binary"
echo -e " - Admin panel"
echo -e " - update.sh / install.sh / helper scripts when available"
echo ""
echo -e "${YELLOW}What was updated:${NC}"
echo -e " • sshpanel binary"
echo -e " • Admin panel (admin/index.html)"
echo -e "${YELLOW}What was preserved:${NC}"
echo -e " • .env (DB credentials, tokens)"
echo -e " • config.json (your server settings)"
echo -e " • xray_config.json (your Xray settings)"
echo -e " • SSH host keys"
echo -e " • All user data in PostgreSQL"
echo -e "${YELLOW}Preserved:${NC}"
echo -e " - .env"
echo -e " - config.json"
echo -e " - xray_config.json"
echo -e " - SSH keys, certs, logs, database and users"
echo ""

366
vnstat_api.go Normal file
View File

@@ -0,0 +1,366 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"sync"
"time"
)
type IfaceUsageDelta struct {
Iface string
RxBytes uint64
TxBytes uint64
At time.Time
}
var ifaceUsagePending = struct {
mu sync.Mutex
m map[string]ifaceCounters
}{m: make(map[string]ifaceCounters)}
func addPendingIfaceUsage(iface string, rxBytes, txBytes uint64) {
if isIgnoredInterface(iface) || (rxBytes == 0 && txBytes == 0) {
return
}
ifaceUsagePending.mu.Lock()
defer ifaceUsagePending.mu.Unlock()
p := ifaceUsagePending.m[iface]
p.RxBytes += rxBytes
p.TxBytes += txBytes
ifaceUsagePending.m[iface] = p
}
func flushPendingIfaceUsage(at time.Time) []IfaceUsageDelta {
ifaceUsagePending.mu.Lock()
defer ifaceUsagePending.mu.Unlock()
if len(ifaceUsagePending.m) == 0 {
return nil
}
deltas := make([]IfaceUsageDelta, 0, len(ifaceUsagePending.m))
for iface, ctrs := range ifaceUsagePending.m {
if isIgnoredInterface(iface) {
continue
}
deltas = append(deltas, IfaceUsageDelta{Iface: iface, RxBytes: ctrs.RxBytes, TxBytes: ctrs.TxBytes, At: at})
}
ifaceUsagePending.m = make(map[string]ifaceCounters)
return deltas
}
func restorePendingIfaceUsage(deltas []IfaceUsageDelta) {
ifaceUsagePending.mu.Lock()
defer ifaceUsagePending.mu.Unlock()
for _, d := range deltas {
if isIgnoredInterface(d.Iface) {
continue
}
p := ifaceUsagePending.m[d.Iface]
p.RxBytes += d.RxBytes
p.TxBytes += d.TxBytes
ifaceUsagePending.m[d.Iface] = p
}
}
func clearPendingIfaceUsage() {
ifaceUsagePending.mu.Lock()
ifaceUsagePending.m = make(map[string]ifaceCounters)
ifaceUsagePending.mu.Unlock()
}
type VnstatUsageRow struct {
Iface string `json:"iface"`
Period string `json:"period"`
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
TotalBytes uint64 `json:"total_bytes"`
}
type VnstatDTO struct {
Daily []VnstatUsageRow `json:"daily"`
Monthly []VnstatUsageRow `json:"monthly"`
UpdatedAt time.Time `json:"updated_at"`
TodayPeriod string `json:"today_period"`
MonthPeriod string `json:"month_period"`
TodayTotalBytes uint64 `json:"today_total_bytes"`
MonthTotalBytes uint64 `json:"month_total_bytes"`
InterfaceCount int `json:"interface_count"`
}
func (s *Store) EnsureIfaceUsageTables(ctx context.Context) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS ssh_iface_daily_usage (
usage_date DATE NOT NULL,
iface TEXT NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (usage_date, iface)
)`,
`CREATE TABLE IF NOT EXISTS ssh_iface_monthly_usage (
month_start DATE NOT NULL,
iface TEXT NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (month_start, iface)
)`,
}
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) UpsertIfaceUsageDeltas(ctx context.Context, deltas []IfaceUsageDelta) error {
if len(deltas) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, d := range deltas {
if isIgnoredInterface(d.Iface) || (d.RxBytes == 0 && d.TxBytes == 0) {
continue
}
at := d.At
if at.IsZero() {
at = time.Now()
}
day := at.Format("2006-01-02")
month := time.Date(at.Year(), at.Month(), 1, 0, 0, 0, 0, at.Location()).Format("2006-01-02")
if _, err := tx.ExecContext(ctx, `
INSERT INTO ssh_iface_daily_usage (usage_date, iface, rx_bytes, tx_bytes, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (usage_date, iface) DO UPDATE
SET rx_bytes = ssh_iface_daily_usage.rx_bytes + EXCLUDED.rx_bytes,
tx_bytes = ssh_iface_daily_usage.tx_bytes + EXCLUDED.tx_bytes,
updated_at = NOW()`,
day, d.Iface, d.RxBytes, d.TxBytes); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO ssh_iface_monthly_usage (month_start, iface, rx_bytes, tx_bytes, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (month_start, iface) DO UPDATE
SET rx_bytes = ssh_iface_monthly_usage.rx_bytes + EXCLUDED.rx_bytes,
tx_bytes = ssh_iface_monthly_usage.tx_bytes + EXCLUDED.tx_bytes,
updated_at = NOW()`,
month, d.Iface, d.RxBytes, d.TxBytes); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) LoadIfaceUsage(ctx context.Context, days, months int) (VnstatDTO, error) {
if days <= 0 || days > 366 {
days = 31
}
if months <= 0 || months > 60 {
months = 12
}
now := time.Now()
todayPeriod := now.Format("2006-01-02")
monthPeriod := now.Format("2006-01")
out := VnstatDTO{UpdatedAt: now, TodayPeriod: todayPeriod, MonthPeriod: monthPeriod}
ifaceSet := make(map[string]struct{})
dailyRows, err := s.db.QueryContext(ctx, `
SELECT iface, usage_date::text, rx_bytes, tx_bytes
FROM ssh_iface_daily_usage
WHERE usage_date >= CURRENT_DATE - $1::int
AND iface <> 'lo'
ORDER BY usage_date DESC, iface ASC`, days-1)
if err != nil {
return out, err
}
defer dailyRows.Close()
for dailyRows.Next() {
var r VnstatUsageRow
if err := dailyRows.Scan(&r.Iface, &r.Period, &r.RxBytes, &r.TxBytes); err != nil {
return out, err
}
r.TotalBytes = r.RxBytes + r.TxBytes
out.Daily = append(out.Daily, r)
ifaceSet[r.Iface] = struct{}{}
if r.Period == todayPeriod {
out.TodayTotalBytes += r.TotalBytes
}
}
if err := dailyRows.Err(); err != nil {
return out, err
}
monthlyRows, err := s.db.QueryContext(ctx, `
SELECT iface, to_char(month_start, 'YYYY-MM') AS period, rx_bytes, tx_bytes
FROM ssh_iface_monthly_usage
WHERE month_start >= (date_trunc('month', CURRENT_DATE)::date - ($1::int * INTERVAL '1 month'))
AND iface <> 'lo'
ORDER BY month_start DESC, iface ASC`, months-1)
if err != nil {
return out, err
}
defer monthlyRows.Close()
for monthlyRows.Next() {
var r VnstatUsageRow
if err := monthlyRows.Scan(&r.Iface, &r.Period, &r.RxBytes, &r.TxBytes); err != nil {
return out, err
}
r.TotalBytes = r.RxBytes + r.TxBytes
out.Monthly = append(out.Monthly, r)
ifaceSet[r.Iface] = struct{}{}
if r.Period == monthPeriod {
out.MonthTotalBytes += r.TotalBytes
}
}
if err := monthlyRows.Err(); err != nil {
return out, err
}
out.InterfaceCount = len(ifaceSet)
return out, nil
}
func (s *Store) ResetIfaceUsage(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `TRUNCATE TABLE ssh_iface_daily_usage, ssh_iface_monthly_usage`); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ReplaceIfaceTotals(ctx context.Context, rows []IfaceTotals) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM ssh_iface_totals`); err != nil {
return err
}
for _, r := range rows {
if isIgnoredInterface(r.Iface) {
continue
}
resetAt := r.ResetAt
if resetAt.IsZero() {
resetAt = time.Now()
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO ssh_iface_totals (iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at)
VALUES ($1, $2, $3, $4, $5, NOW(), $6)`,
r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes, resetAt); err != nil {
return err
}
}
return tx.Commit()
}
func handleVnstat(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if store == nil {
http.Error(w, "database not configured", http.StatusServiceUnavailable)
return
}
days := parsePositiveInt(r.URL.Query().Get("days"), 31)
months := parsePositiveInt(r.URL.Query().Get("months"), 12)
data, err := store.LoadIfaceUsage(r.Context(), days, months)
if err != nil {
log.Printf("failed to load vnstat usage: %v", err)
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
}
}
func handleVnstatReset(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if store == nil {
http.Error(w, "database not configured", http.StatusServiceUnavailable)
return
}
if err := store.ResetIfaceUsage(r.Context()); err != nil {
log.Printf("failed to reset vnstat usage: %v", err)
http.Error(w, "db error", http.StatusInternalServerError)
return
}
clearPendingIfaceUsage()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
func handleResetInterfaceStats(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if store == nil || ifaceTotalsMgr == nil {
http.Error(w, "interface totals persistence not configured", http.StatusServiceUnavailable)
return
}
netMap, err := readNetDev()
if err != nil {
log.Printf("failed to read interfaces for reset: %v", err)
http.Error(w, "failed to read interfaces", http.StatusInternalServerError)
return
}
rows := ifaceTotalsMgr.ResetAllToKernel(netMap)
if err := store.ReplaceIfaceTotals(r.Context(), rows); err != nil {
log.Printf("failed to reset interface totals: %v", err)
http.Error(w, "db error", http.StatusInternalServerError)
return
}
stats := getCurrentStats()
for i := range stats.Interfaces {
stats.Interfaces[i].RxBytes = 0
stats.Interfaces[i].TxBytes = 0
}
setCurrentStats(stats)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
func parsePositiveInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return fallback
}
return n
}

View File

@@ -8,29 +8,39 @@ import (
)
// XrayClientMeta holds metadata stored in PostgreSQL for an Xray client.
// Xray's own config only stores uuid/email/level; expiry and display name live here.
// 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
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
}
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
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 '',
expires_at TIMESTAMPTZ,
max_conns INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
return err
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()
)`,
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
}
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error {
@@ -39,15 +49,16 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro
expiresAt = *m.ExpiresAt
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET
name = EXCLUDED.name,
email = EXCLUDED.email,
inbound_tag = EXCLUDED.inbound_tag,
expires_at = EXCLUDED.expires_at,
max_conns = EXCLUDED.max_conns`,
m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns)
name = EXCLUDED.name,
email = EXCLUDED.email,
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END,
owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END,
expires_at = EXCLUDED.expires_at,
max_conns = EXCLUDED.max_conns`,
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
return err
}
@@ -55,9 +66,9 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
m := &XrayClientMeta{}
var expiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, `
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at
FROM xray_clients WHERE uuid = $1`, uuid).
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt)
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt)
if err != nil {
return nil, err
}
@@ -74,17 +85,49 @@ 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, expires_at, max_conns, created_at
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at
FROM xray_clients ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanXrayClientMetaRows(rows)
}
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
FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername)
if err != nil {
return nil, err
}
defer rows.Close()
return scanXrayClientMetaRows(rows)
}
func (s *Store) CountXrayClientsByOwner(ctx context.Context, ownerUsername string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM xray_clients WHERE owner_username = $1`, ownerUsername).Scan(&n)
return n, err
}
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
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanXrayClientMetaRows(rows)
}
func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
var out []*XrayClientMeta
for rows.Next() {
m := &XrayClientMeta{}
var expiresAt sql.NullTime
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
return nil, err
}
if expiresAt.Valid {
@@ -95,27 +138,49 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro
return out, rows.Err()
}
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
if store == nil || ownerUsername == "" {
return 0
}
n, err := store.CountXrayClientsByOwner(ctx, ownerUsername)
if err != nil {
return nil, err
log.Printf("count xray clients for %s: %v", ownerUsername, err)
return 0
}
defer rows.Close()
var out []*XrayClientMeta
for rows.Next() {
m := &XrayClientMeta{}
var expiresAt sql.NullTime
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
return nil, err
}
if expiresAt.Valid {
m.ExpiresAt = &expiresAt.Time
}
out = append(out, m)
return n
}
func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
}
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
if store == nil || ownerUsername == "" {
return
}
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
if err != nil {
log.Printf("xray owner cleanup: list %s: %v", ownerUsername, err)
return
}
needRestart := false
for _, m := range clients {
if m.InboundTag != "" {
if err := xrayMgr.RemoveXrayClient(m.InboundTag, m.UUID); err != nil {
log.Printf("xray owner cleanup: remove %s from %s: %v", m.UUID, m.InboundTag, err)
} else {
needRestart = true
}
}
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
log.Printf("xray owner cleanup: delete meta %s: %v", m.UUID, err)
}
}
if needRestart {
if err := xrayMgr.Restart(); err != nil {
log.Printf("xray owner cleanup: restart: %v", err)
}
}
return out, rows.Err()
}
// startXrayClientExpiryChecker runs a background goroutine that removes expired

File diff suppressed because it is too large Load Diff