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 ## 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 ### Requisitos
@@ -44,9 +83,9 @@ Server IP
SSH ports SSH ports
VLESS port VLESS port
VLESS UUID VLESS UUID
VMess port
Admin panel URL Admin panel URL
Admin login Admin login/password, quando aplicável
Admin password
Admin token Admin token
``` ```
@@ -59,19 +98,59 @@ Admin token
/opt/sshpanel/xray_config.json /opt/sshpanel/xray_config.json
/opt/sshpanel/admin/ /opt/sshpanel/admin/
/opt/sshpanel/logs/panel.log /opt/sshpanel/logs/panel.log
/opt/sshpanel/update.sh
/opt/sshpanel/change_admin_password.sh
/etc/systemd/system/sshpanel.service /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 ### Portas padrão
```text ```text
80 SSH com HTTP Injection 80 SSH com HTTP Injection
8080 SSH extra 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 9090 Painel web + API pública /check
10086 Xray VLESS 10086 Xray VLESS
10087 Xray VMess
10088 SOCKS local em 127.0.0.1 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 ### Comandos úteis
Ver status do serviço: Ver status do serviço:
@@ -98,15 +177,68 @@ Reiniciar serviço:
systemctl restart sshpanel 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 ```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 ### API pública CheckUser
@@ -128,7 +260,7 @@ Consultar usuário SSH:
curl "http://SERVER_IP:9090/check?user=testuser" curl "http://SERVER_IP:9090/check?user=testuser"
``` ```
Consultar UUID Xray: Consultar UUID Xray/V2Ray:
```bash ```bash
curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1" curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1"
@@ -164,7 +296,7 @@ Campos da resposta:
| Campo | Tipo | Descrição | | 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. | | `count_connections` | number | Conexões SSH ativas no momento. |
| `expiration_date` | string | Data de expiração em `DD/MM/YYYY` ou `Unlimited`. | | `expiration_date` | string | Data de expiração em `DD/MM/YYYY` ou `Unlimited`. |
| `expiration_days` | number | Dias restantes. `-1` significa ilimitado. | | `expiration_days` | number | Dias restantes. `-1` significa ilimitado. |
@@ -192,7 +324,46 @@ Erros comuns:
## EN-US ## 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 ### Requirements
@@ -234,9 +405,9 @@ Server IP
SSH ports SSH ports
VLESS port VLESS port
VLESS UUID VLESS UUID
VMess port
Admin panel URL Admin panel URL
Admin login Admin login/password, when applicable
Admin password
Admin token Admin token
``` ```
@@ -249,6 +420,8 @@ Admin token
/opt/sshpanel/xray_config.json /opt/sshpanel/xray_config.json
/opt/sshpanel/admin/ /opt/sshpanel/admin/
/opt/sshpanel/logs/panel.log /opt/sshpanel/logs/panel.log
/opt/sshpanel/update.sh
/opt/sshpanel/change_admin_password.sh
/etc/systemd/system/sshpanel.service /etc/systemd/system/sshpanel.service
``` ```
@@ -257,11 +430,47 @@ Admin token
```text ```text
80 SSH with HTTP Injection 80 SSH with HTTP Injection
8080 Extra 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 9090 Web panel + public /check API
10086 Xray VLESS 10086 Xray VLESS
10087 Xray VMess
10088 Local SOCKS on 127.0.0.1 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 ### Useful commands
Check service status: Check service status:
@@ -282,21 +491,76 @@ Follow panel log file:
tail -f /opt/sshpanel/logs/panel.log 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: Restart service:
```bash ```bash
systemctl restart sshpanel 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 ```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 ### Public CheckUser API
@@ -318,7 +582,7 @@ Check SSH username:
curl "http://SERVER_IP:9090/check?user=testuser" curl "http://SERVER_IP:9090/check?user=testuser"
``` ```
Check Xray UUID: Check Xray/V2Ray UUID:
```bash ```bash
curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1" curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1"
@@ -354,7 +618,7 @@ Response fields:
| Field | Type | Description | | 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. | | `count_connections` | number | Current active SSH connections. |
| `expiration_date` | string | Expiration date in `DD/MM/YYYY` or `Unlimited`. | | `expiration_date` | string | Expiration date in `DD/MM/YYYY` or `Unlimited`. |
| `expiration_days` | number | Remaining days. `-1` means 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

20
auth.go
View File

@@ -443,6 +443,7 @@ func startResellerExpiryChecker(store *Store) {
u.IsActive = false u.IsActive = false
adminUsers.set(u) adminUsers.set(u)
disconnectOwnerUsers(u.Username) disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
} }
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil) // Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
@@ -537,7 +538,9 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
if s.Role == RoleReseller { if s.Role == RoleReseller {
if u, ok := adminUsers.get(s.Username); ok { if u, ok := adminUsers.get(s.Username); ok {
resp["max_users"] = u.MaxUsers resp["max_users"] = u.MaxUsers
resp["used_users"] = countOwnedUsers(s.Username) 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["expires_at"] = u.ExpiresAt
resp["is_active"] = u.IsActive resp["is_active"] = u.IsActive
} }
@@ -554,6 +557,8 @@ type ResellerDTO struct {
Role string `json:"role"` Role string `json:"role"`
MaxUsers int `json:"max_users"` MaxUsers int `json:"max_users"`
UsedUsers int `json:"used_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"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -577,7 +582,9 @@ func handleListResellers(store *Store) http.HandlerFunc {
Username: u.Username, Username: u.Username,
Role: u.Role, Role: u.Role,
MaxUsers: u.MaxUsers, 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, ExpiresAt: u.ExpiresAt,
IsActive: u.IsActive, IsActive: u.IsActive,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
@@ -652,8 +659,12 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
} }
adminUsers.set(u) adminUsers.set(u)
// If reseller was reactivated, users can reconnect automatically. if u.Role == RoleReseller {
// Reconnect of existing SSH connections happens via the expiry checker. if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
}
}
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
} }
@@ -676,6 +687,7 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
return return
} }
disconnectOwnerUsers(username) disconnectOwnerUsers(username)
removeOwnerXrayClients(ctx, store, username)
adminUsers.delete(username) adminUsers.delete(username)
w.WriteHeader(http.StatusNoContent) 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() u.mu.Lock()
activeConns := u.ActiveConns activeConns := len(u.conns)
u.ActiveConns = activeConns
maxConns := u.Cfg.MaxConnections maxConns := u.Cfg.MaxConnections
expiresAt := u.ExpiresAt expiresAt := u.ExpiresAt
u.mu.Unlock() 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/base32"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -332,15 +333,14 @@ func getDNSTTLogLines() []string {
// the Noise private key from cfg.PrivKeyFile, parses cfg.Domain into a dns.Name, // 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 // and then launches runDNSTT in a goroutine. Any errors during start are
// logged. The SSH server configuration is used when handling streams. // 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 { if cfg == nil {
return return nil
} }
startDNSTTCapReaper() startDNSTTCapReaper()
dnsttSSHConfig = sshConf dnsttSSHConfig = sshConf
// Configure whether periodic DNSTT statistics should be emitted to stderr. // Configure whether periodic DNSTT statistics should be emitted to stderr.
// When DisableStatsLog is true, stats will be collected but log lines are suppressed. // When DisableStatsLog is true, stats will be collected but log lines are suppressed.
if cfg != nil {
dnsttPrintStats = !cfg.DisableStatsLog dnsttPrintStats = !cfg.DisableStatsLog
// Initialise the log buffer once. Use a capacity of 100 lines (~few KB). // Initialise the log buffer once. Use a capacity of 100 lines (~few KB).
if dnsttLogBuf == nil { if dnsttLogBuf == nil {
@@ -353,34 +353,59 @@ func startDNSTT(cfg *DNSTTConfig, sshConf *ssh.ServerConfig) {
} else { } else {
dnsttLog.SetOutput(io.MultiWriter(dnsttLogBuf, os.Stderr)) dnsttLog.SetOutput(io.MultiWriter(dnsttLogBuf, os.Stderr))
} }
}
// Read the private key from file. // Read the private key from file.
f, err := os.Open(cfg.PrivKeyFile) f, err := os.Open(cfg.PrivKeyFile)
if err != nil { if err != nil {
dnsttLog.Printf("cannot open privkey file %s: %v", cfg.PrivKeyFile, err) msg := fmt.Errorf("cannot open privkey file %s: %w", cfg.PrivKeyFile, err)
return dnsttLog.Print(msg.Error())
return msg
} }
privkey, err := noise.ReadKey(f) privkey, err := noise.ReadKey(f)
f.Close() f.Close()
if err != nil { if err != nil {
dnsttLog.Printf("cannot read privkey from file: %v", err) msg := fmt.Errorf("cannot read privkey from file: %w", err)
return dnsttLog.Print(msg.Error())
return msg
} }
// Parse the domain name. dns.ParseName accepts a domain with a trailing // Parse the domain name. dns.ParseName accepts a domain with a trailing
// dot or without. Any error here will abort the dnstt server. // dot or without. Any error here will abort the dnstt server.
domain, err := dns.ParseName(cfg.Domain) domain, err := dns.ParseName(cfg.Domain)
if err != nil { if err != nil {
dnsttLog.Printf("invalid domain %q: %v", cfg.Domain, err) msg := fmt.Errorf("invalid domain %q: %w", cfg.Domain, err)
return 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 // Log initialisation parameters so DNSTT startup is visible even when
// quiet logging is enabled. This helps with debugging. // quiet logging is enabled. This helps with debugging.
dnsttLog.Printf("starting: domain=%q udp_listen=%q privkey=%q", cfg.Domain, cfg.UDPListen, cfg.PrivKeyFile) dnsttLog.Printf("starting: domain=%q udp_listen=%q privkey=%q", cfg.Domain, udpListen, cfg.PrivKeyFile)
go func() { 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) 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 // 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 { if err != nil {
return fmt.Errorf("dnstt: opening UDP listener on %s: %v", udpListen, err) 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 { if udp, ok := dnsConn.(*net.UDPConn); ok {
_ = udp.SetReadBuffer(4 * 1024 * 1024) _ = udp.SetReadBuffer(4 * 1024 * 1024)
_ = udp.SetWriteBuffer(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. // Register so stopDNSTT() can close this socket and unblock the read loop.
dnsttConnMu.Lock() dnsttConnMu.Lock()
if dnsttConn != nil { if dnsttConn != nil && dnsttConn != dnsConn {
_ = dnsttConn.Close() _ = dnsttConn.Close()
} }
dnsttConn = dnsConn dnsttConn = dnsConn
dnsttConnMu.Unlock() dnsttConnMu.Unlock()
defer func() {
dnsttConnMu.Lock()
if dnsttConn == dnsConn {
dnsttConn = nil
}
dnsttConnMu.Unlock()
}()
// Log readiness of the UDP listener. // Log readiness of the UDP listener.
dnsttLog.Printf("udp listener ready on %s", udpListen) dnsttLog.Printf("udp listener ready on %s", udpListen)
// compute maximum encoded payload and resulting MTU // compute maximum encoded payload and resulting MTU

View File

@@ -9,7 +9,9 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
"time"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -86,6 +88,31 @@ func (p *listenerPool) Sync(addrs []string) []error {
return errs 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 ---------- // ---------- Dynamic TLS listener pool ----------
type tlsListenerPool struct { type tlsListenerPool struct {
@@ -142,11 +169,35 @@ func (p *tlsListenerPool) Sync(forwarders []TLSForwarderConfig) []error {
return errs 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) ---------- // ---------- Global pool instances (initialised in main) ----------
var ( var (
publicPool *listenerPool // HTTP+SSH: listen + extra_listen publicPool *listenerPool // HTTP+SSH: listen + extra_listen
localPool *listenerPool // raw SSH: local_ssh_listen
tlsPool *tlsListenerPool // TLS forwarders tlsPool *tlsListenerPool // TLS forwarders
) )
@@ -196,8 +247,42 @@ func getAdminHandler() http.Handler {
// applyFullConfigReload applies every field in newCfg to the running server // applyFullConfigReload applies every field in newCfg to the running server
// without a process restart. Port changes, DNSTT/UDPGW changes, Xray changes, // without a process restart. Port changes, DNSTT/UDPGW changes, Xray changes,
// and bandwidth defaults all take effect immediately. // and bandwidth defaults all take effect immediately.
// The only field that still requires a restart is host_key_file. // It returns a status report so the panel can show crashed or blocked services.
func applyFullConfigReload(newCfg *Config) { 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 // Banner
bt := newCfg.Banner bt := newCfg.Banner
if bt == "" && newCfg.BannerFile != "" { if bt == "" && newCfg.BannerFile != "" {
@@ -207,8 +292,10 @@ func applyFullConfigReload(newCfg *Config) {
} }
setBannerText(bt) 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) setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown)
setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout)
// Quiet logging / user count display // Quiet logging / user count display
if newCfg.Quiet { if newCfg.Quiet {
@@ -226,44 +313,97 @@ func applyFullConfigReload(newCfg *Config) {
// Public SSH listeners (main listen + extra_listen) // Public SSH listeners (main listen + extra_listen)
publicAddrs := append([]string{newCfg.Listen}, newCfg.ExtraListen...) publicAddrs := append([]string{newCfg.Listen}, newCfg.ExtraListen...)
for _, e := range publicPool.Sync(publicAddrs) { 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 // Legacy local_ssh_listen is intentionally ignored. DragonCore handles DNSTT in-process.
var localAddrs []string newCfg.LocalSSHListen = ""
if newCfg.LocalSSHListen != "" {
localAddrs = []string{newCfg.LocalSSHListen}
}
for _, e := range localPool.Sync(localAddrs) {
log.Printf("hotreload: %v", e)
}
// TLS forwarders // TLS forwarders
for _, e := range tlsPool.Sync(newCfg.TLSForwarders) { 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() 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() 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 { if newCfg.Xray != nil {
xrayMgr.mu.Lock() xrayMgr.mu.Lock()
xrayMgr.cfg = newCfg.Xray xrayMgr.cfg = newCfg.Xray
xrayMgr.mu.Unlock() xrayMgr.mu.Unlock()
if newCfg.Xray.Enabled { if newCfg.Xray.Enabled {
_ = xrayMgr.Restart() if err := xrayMgr.Restart(); err != nil {
} else { report.warnf("Xray failed to restart: %v", err)
_ = xrayMgr.Stop() }
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 { } else {
_ = xrayMgr.Stop() _ = xrayMgr.Stop()
report.Services["xray"] = ServiceReloadStatus{Enabled: false, Running: false}
}
} else {
_ = xrayMgr.Stop()
report.Services["xray"] = ServiceReloadStatus{Enabled: false, Running: false}
} }
setGlobalCfg(newCfg) 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 #!/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 # Usage: sudo bash install.sh
set -euo pipefail set -euo pipefail
@@ -11,58 +11,219 @@ error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
# ── config ────────────────────────────────────────────────────────────────── # ── config ──────────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/sshpanel" INSTALL_DIR="/opt/sshpanel"
SERVICE_NAME="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)" 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")}" 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" [[ $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 "\n${GREEN}══════════════════════════════════════════${NC}"
echo -e "${GREEN} SSH Panel + Xray-core · Installer ${NC}" echo -e "${GREEN} SSH Panel + Xray-core · Installer ${NC}"
echo -e "${GREEN}══════════════════════════════════════════${NC}\n" echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
# ── 1. OS detection ────────────────────────────────────────────────────────── # ── 1. OS / package-manager detection ────────────────────────────────────────
info "[1/9] Detecting OS…" info "[1/10] Detecting Linux distribution and package manager…"
if [[ -f /etc/os-release ]]; then if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. /etc/os-release . /etc/os-release
OS_ID="${ID:-unknown}" OS_ID="${ID:-unknown}"
OS_LIKE="${ID_LIKE:-}"
OS_PRETTY="${PRETTY_NAME:-$OS_ID}"
else else
OS_ID="unknown" OS_ID="unknown"
OS_LIKE=""
OS_PRETTY="unknown Linux"
fi fi
case "$OS_ID" in require_systemd
ubuntu|debian|linuxmint) detect_pkg_manager
PKG_UPDATE="apt-get update -qq" set_package_deps
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y" info " OS : $OS_PRETTY"
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl" info " ID / ID_LIKE : $OS_ID / ${OS_LIKE:-none}"
;; info " Package manager: $PKG_MANAGER"
centos|rhel|rocky|almalinux) info " Service manager: systemd"
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"
# ── 2. System dependencies ─────────────────────────────────────────────────── # ── 2. System dependencies ───────────────────────────────────────────────────
info "[2/9] Installing system packages…" info "[2/10] Installing system packages…"
eval "$PKG_UPDATE" pkg_update
eval "$PKG_INSTALL $PKG_DEPS" pkg_install "${PKG_DEPS[@]}"
pkg_install_optional "${PKG_OPTIONAL_DEPS[@]}"
# ── 3. Go ──────────────────────────────────────────────────────────────────── # ── 3. Go ────────────────────────────────────────────────────────────────────
info "[3/9] Installing Go ${GO_VERSION}" info "[3/10] Installing Go ${GO_VERSION}"
NEED_GO=true NEED_GO=true
if command -v go &>/dev/null; then if command -v go &>/dev/null; then
CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//') 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 go version
# ── 4. Directory layout ────────────────────────────────────────────────────── # ── 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" mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/keys" "$INSTALL_DIR/logs"
ensure_log_tmpfs_mount
# ── 5. Build SSH panel binary ──────────────────────────────────────────────── # ── 5. Build SSH panel binary ────────────────────────────────────────────────
info "[5/9] Building SSH Panel binary…" info "[5/10] Building SSH Panel binary…"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
export GOPATH=/tmp/gopath_sshpanel export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_sshpanel export GOCACHE=/tmp/gocache_sshpanel
@@ -107,9 +269,19 @@ go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" .
info " Binary: $INSTALL_DIR/sshpanel" info " Binary: $INSTALL_DIR/sshpanel"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/" cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel copied" 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 ────────────────────────────────────────────────────────── # ── 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" \ 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") | grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30")
MACHINE=$(uname -m) MACHINE=$(uname -m)
@@ -132,13 +304,9 @@ rm -f /tmp/xray.zip
"$INSTALL_DIR/xray" version "$INSTALL_DIR/xray" version
# ── 7. PostgreSQL ──────────────────────────────────────────────────────────── # ── 7. PostgreSQL ────────────────────────────────────────────────────────────
info "[7/9] Configuring PostgreSQL…" info "[7/10] Configuring PostgreSQL…"
case "$OS_ID" in init_postgresql_if_needed
centos|rhel|rocky|almalinux|fedora) start_enable_postgresql
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
DB_NAME="sshpanel" DB_NAME="sshpanel"
DB_USER="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" info " PostgreSQL database '${DB_NAME}' ready"
# ── 8. Config files ────────────────────────────────────────────────────────── # ── 8. Config files ──────────────────────────────────────────────────────────
info "[8/9] Generating config files…" info "[8/10] Generating config files…"
# Admin token # Admin token
ADMIN_TOKEN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48 || true) 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", "listen": "0.0.0.0:80",
"extra_listen": ["0.0.0.0:8080"], "extra_listen": ["0.0.0.0:8080"],
"local_ssh_listen": "127.0.0.1:2222",
"host_key_file": "${INSTALL_DIR}/ssh_host_rsa_key", "host_key_file": "${INSTALL_DIR}/ssh_host_rsa_key",
"quiet": false, "quiet": false,
"admin_dir": "${INSTALL_DIR}/admin", "admin_dir": "${INSTALL_DIR}/admin",
@@ -333,33 +500,105 @@ EOF
chmod 600 "$INSTALL_DIR/xray_config.json" chmod 600 "$INSTALL_DIR/xray_config.json"
info " VLESS UUID: ${UUID}" info " VLESS UUID: ${UUID}"
# ── 9. Systemd service ─────────────────────────────────────────────────────── # ── 9. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
info "[9/9] Creating systemd service '${SERVICE_NAME}'…" info "[9/10] Configuring DNSTT DNS redirect (UDP 53 -> 5300)…"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF 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] [Unit]
Description=SSH Panel + Xray-core Server Description=SSH Panel DNSTT DNS redirect (UDP 53 to 5300)
After=network.target postgresql.service After=network.target
Wants=postgresql.service Before=sshpanel.service
[Service] [Service]
Type=simple Type=oneshot
WorkingDirectory=${INSTALL_DIR} ExecStart=/usr/local/sbin/sshpanel-dnstt-redirect.sh
EnvironmentFile=${INSTALL_DIR}/.env RemainAfterExit=yes
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
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload "$SYSTEMCTL_BIN" daemon-reload
systemctl enable "$SERVICE_NAME" "$SYSTEMCTL_BIN" enable --now sshpanel-dnstt-redirect.service || warn "DNSTT DNS redirect service failed; check: journalctl -u sshpanel-dnstt-redirect -e"
systemctl restart "$SERVICE_NAME" 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 sleep 2
echo "" echo ""
@@ -371,6 +610,7 @@ echo -e " Server IP : ${YELLOW}${SERVER_IP}${NC}"
echo -e " SSH ports : 80, 8080 (HTTP-injected SSH)" echo -e " SSH ports : 80, 8080 (HTTP-injected SSH)"
echo -e " VLESS port : 10086" echo -e " VLESS port : 10086"
echo -e " VLESS UUID : ${YELLOW}${UUID}${NC}" echo -e " VLESS UUID : ${YELLOW}${UUID}${NC}"
echo -e " DNSTT DNS : UDP 53 redirects to local UDP 5300"
echo "" echo ""
echo -e " Admin panel : ${YELLOW}http://${SERVER_IP}:9090${NC}" echo -e " Admin panel : ${YELLOW}http://${SERVER_IP}:9090${NC}"
echo -e " Admin login : ${YELLOW}admin${NC}" echo -e " Admin login : ${YELLOW}admin${NC}"
@@ -383,4 +623,4 @@ echo -e " tail -f ${INSTALL_DIR}/logs/panel.log"
echo "" echo ""
echo -e "${YELLOW}Save your admin login/password. The admin token is for API bearer-token access only.${NC}" echo -e "${YELLOW}Save your admin login/password. The admin token is for API bearer-token access only.${NC}"
echo "" echo ""
systemctl status "$SERVICE_NAME" --no-pager -l || true "$SYSTEMCTL_BIN" status "$SERVICE_NAME" --no-pager -l || true

514
main.go
View File

@@ -17,6 +17,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"sort" "sort"
"strconv" "strconv"
@@ -27,6 +28,7 @@ import (
_ "github.com/lib/pq" _ "github.com/lib/pq"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"runtime/debug"
) )
const ( const (
@@ -35,6 +37,9 @@ const (
tlsHandshakeTimeout = 15 * time.Second tlsHandshakeTimeout = 15 * time.Second
// Dial timeout for direct-tcpip backend connections. // Dial timeout for direct-tcpip backend connections.
directTCPIPDialTimeout = 10 * time.Second 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 ---------- // ---------- Config types ----------
@@ -65,9 +70,8 @@ type Config struct {
// "[::]:80", "[2001:db8::20]:8080". Empty slice means no // "[::]:80", "[2001:db8::20]:8080". Empty slice means no
// additional listeners. // additional listeners.
ExtraListen []string `json:"extra_listen"` ExtraListen []string `json:"extra_listen"`
// Optional: local-only raw SSH listener for other daemons (e.g. DNSTT upstream) // Legacy compatibility only. DragonCore no longer starts a local raw SSH listener.
// Set to "127.0.0.1:2222" or similar. Leave empty to disable. LocalSSHListen string `json:"local_ssh_listen,omitempty"`
LocalSSHListen string `json:"local_ssh_listen"`
HostKeyFile string `json:"host_key_file"` HostKeyFile string `json:"host_key_file"`
Quiet bool `json:"quiet"` Quiet bool `json:"quiet"`
@@ -76,6 +80,11 @@ type Config struct {
UserCount bool `json:"user_count"` 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 // NEW: Directory to serve the admin panel from
AdminDir string `json:"admin_dir"` AdminDir string `json:"admin_dir"`
@@ -279,9 +288,12 @@ func (m *UserManager) ReplaceAll(newUsers map[string]*UserState) {
m.users = newUsers m.users = newUsers
} }
// ReplaceAllPreserveRuntime replaces the user map while keeping runtime connection // ReplaceAllPreserveRuntime replaces the user map while keeping the same
// state (ActiveConns + conns) for users that already exist. // runtime UserState object for users that already exist. This is important:
// This prevents the admin panel from showing everyone as "offline" after a DB reload. // 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) { func (m *UserManager) ReplaceAllPreserveRuntime(newUsers map[string]*UserState) {
m.mu.Lock() m.mu.Lock()
old := m.users old := m.users
@@ -289,10 +301,17 @@ func (m *UserManager) ReplaceAllPreserveRuntime(newUsers map[string]*UserState)
for username, nu := range newUsers { for username, nu := range newUsers {
if ou, ok := old[username]; ok && ou != nil && nu != nil { if ou, ok := old[username]; ok && ou != nil && nu != nil {
ou.mu.Lock() ou.mu.Lock()
nu.ActiveConns = ou.ActiveConns ou.Cfg = nu.Cfg
// Preserve the live connection set so we can still disconnect correctly. ou.ExpiresAt = nu.ExpiresAt
nu.conns = ou.conns 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() ou.mu.Unlock()
newUsers[username] = ou
} }
} }
@@ -327,6 +346,9 @@ var (
userMgr = &UserManager{users: make(map[string]*UserState)} userMgr = &UserManager{users: make(map[string]*UserState)}
userCountEnabled bool userCountEnabled bool
sshIdleTimeoutMu sync.RWMutex
currentSSHIdleTimeout = defaultSSHIdleTimeout
displayMu sync.Mutex displayMu sync.Mutex
lastDisplayLen int lastDisplayLen int
@@ -355,13 +377,18 @@ func mbpsToBytesPerSec(mbps int) int64 {
return int64(mbps) * 1024 * 1024 / 8 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) { func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
if lim == nil { if lim == nil {
return io.Copy(dst, src) return io.Copy(dst, src)
} }
const bufSize = 32 * 1024 bufp := copyBufPool.Get().(*[]byte)
buf := make([]byte, bufSize) buf := *bufp
defer copyBufPool.Put(bufp)
ctx := context.Background() ctx := context.Background()
for { for {
@@ -396,6 +423,111 @@ func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written
return written, err 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) ---------- // ---------- Server stats (CPU + network interfaces) ----------
// per-interface stats returned by /api/stats // per-interface stats returned by /api/stats
@@ -420,6 +552,10 @@ type ifaceCounters struct {
TxBytes uint64 TxBytes uint64
} }
func isIgnoredInterface(iface string) bool {
return iface == "" || iface == "lo"
}
func getCurrentStats() StatsDTO { func getCurrentStats() StatsDTO {
statsMu.RLock() statsMu.RLock()
defer statsMu.RUnlock() defer statsMu.RUnlock()
@@ -432,6 +568,48 @@ func setCurrentStats(s StatsDTO) {
statsMu.Unlock() 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 { type IfaceTotals struct {
Iface string Iface string
TotalRxBytes uint64 TotalRxBytes uint64
@@ -439,6 +617,7 @@ type IfaceTotals struct {
LastKernelRxBytes uint64 LastKernelRxBytes uint64
LastKernelTxBytes uint64 LastKernelTxBytes uint64
UpdatedAt time.Time UpdatedAt time.Time
ResetAt time.Time
} }
type IfaceTotalsManager struct { 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 // 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". // goes backwards, it treats the new value as "delta since reset".
func (tm *IfaceTotalsManager) ApplyKernel(iface string, kRx, kTx uint64) (totalRx, totalTx uint64) { func (tm *IfaceTotalsManager) ApplyKernel(iface string, kRx, kTx uint64) (totalRx, totalTx uint64) {
if isIgnoredInterface(iface) {
return 0, 0
}
tm.mu.Lock() tm.mu.Lock()
defer tm.mu.Unlock() defer tm.mu.Unlock()
now := time.Now()
st, ok := tm.m[iface] st, ok := tm.m[iface]
if !ok { if !ok {
st = &IfaceTotals{Iface: iface} st = &IfaceTotals{Iface: iface, ResetAt: now}
tm.m[iface] = st 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 // RX
if st.LastKernelRxBytes == 0 && st.TotalRxBytes == 0 { 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.LastKernelTxBytes = kTx
st.UpdatedAt = time.Now() st.UpdatedAt = now
return st.TotalRxBytes, st.TotalTxBytes 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) { func (tm *IfaceTotalsManager) Load(rows []IfaceTotals) {
tm.mu.Lock() tm.mu.Lock()
defer tm.mu.Unlock() defer tm.mu.Unlock()
for _, r := range rows { for _, r := range rows {
if isIgnoredInterface(r.Iface) {
continue
}
cp := r // copy cp := r // copy
tm.m[r.Iface] = &cp tm.m[r.Iface] = &cp
} }
@@ -502,6 +729,9 @@ func (tm *IfaceTotalsManager) Snapshot() []IfaceTotals {
defer tm.mu.Unlock() defer tm.mu.Unlock()
out := make([]IfaceTotals, 0, len(tm.m)) out := make([]IfaceTotals, 0, len(tm.m))
for _, v := range tm.m { for _, v := range tm.m {
if v == nil || isIgnoredInterface(v.Iface) {
continue
}
out = append(out, *v) out = append(out, *v)
} }
return out return out
@@ -567,6 +797,9 @@ func startStatsCollector() {
dt := now.Sub(prevTime).Seconds() dt := now.Sub(prevTime).Seconds()
if netMap != nil { if netMap != nil {
for name, ctrs := range netMap { for name, ctrs := range netMap {
if isIgnoredInterface(name) {
continue
}
st := InterfaceStats{ st := InterfaceStats{
Name: name, Name: name,
} }
@@ -581,14 +814,27 @@ func startStatsCollector() {
} }
if prevNet != nil && dt > 0 { if prevNet != nil && dt > 0 {
if prev, ok := prevNet[name]; ok { if prev, ok := prevNet[name]; ok {
var rxDelta, txDelta uint64
if ctrs.RxBytes >= prev.RxBytes { if ctrs.RxBytes >= prev.RxBytes {
rxDelta := ctrs.RxBytes - prev.RxBytes rxDelta = ctrs.RxBytes - prev.RxBytes
st.RxMbps = float64(rxDelta*8) / dt / 1_000_000 } else {
// kernel counter reset or wrap
rxDelta = ctrs.RxBytes
} }
if ctrs.TxBytes >= prev.TxBytes { 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 st.TxMbps = float64(txDelta*8) / dt / 1_000_000
} }
if statsStore != nil && (rxDelta > 0 || txDelta > 0) {
addPendingIfaceUsage(name, rxDelta, txDelta)
}
} }
} }
interfaces = append(interfaces, st) interfaces = append(interfaces, st)
@@ -621,12 +867,18 @@ func startStatsCollector() {
Interfaces: interfaces, Interfaces: interfaces,
}) })
// Persist interface totals periodically (optional). // Persist interface totals and vnstat-style usage periodically (optional).
if flushTicker != nil && statsStore != nil && ifaceTotalsMgr != nil { if flushTicker != nil && statsStore != nil && ifaceTotalsMgr != nil {
select { select {
case <-flushTicker.C: case <-flushTicker.C:
ctx := context.Background() ctx := context.Background()
_ = statsStore.UpsertIfaceTotals(ctx, ifaceTotalsMgr.Snapshot()) _ = 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: default:
} }
} }
@@ -753,6 +1005,9 @@ func readNetDev() (map[string]ifaceCounters, error) {
continue continue
} }
iface := strings.TrimSpace(parts[0]) iface := strings.TrimSpace(parts[0])
if isIgnoredInterface(iface) {
continue
}
fields := strings.Fields(parts[1]) fields := strings.Fields(parts[1])
if len(fields) < 9 { if len(fields) < 9 {
continue continue
@@ -866,6 +1121,9 @@ func NewStore(dsn string) (*Store, error) {
if err := store.EnsureAdminUsersSchema(ctx); err != nil { if err := store.EnsureAdminUsersSchema(ctx); err != nil {
return nil, err return nil, err
} }
if err := store.EnsureManagedServersSchema(ctx); err != nil {
return nil, err
}
return store, nil return store, nil
} }
@@ -998,22 +1256,31 @@ func (s *Store) DeleteUser(ctx context.Context, username string) error {
// ---------- Optional persistence for interface totals ---------- // ---------- Optional persistence for interface totals ----------
func (s *Store) EnsureIfaceTotalsTable(ctx context.Context) error { func (s *Store) EnsureIfaceTotalsTable(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, ` stmts := []string{
CREATE TABLE IF NOT EXISTS ssh_iface_totals ( `CREATE TABLE IF NOT EXISTS ssh_iface_totals (
iface TEXT PRIMARY KEY, iface TEXT PRIMARY KEY,
total_rx_bytes BIGINT NOT NULL DEFAULT 0, total_rx_bytes BIGINT NOT NULL DEFAULT 0,
total_tx_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_rx_bytes BIGINT NOT NULL DEFAULT 0,
last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0, last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 err
} }
}
return nil
}
func (s *Store) LoadIfaceTotals(ctx context.Context) ([]IfaceTotals, error) { func (s *Store) LoadIfaceTotals(ctx context.Context) ([]IfaceTotals, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at SELECT iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at
FROM ssh_iface_totals`) FROM ssh_iface_totals
WHERE iface <> 'lo'`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1022,11 +1289,12 @@ func (s *Store) LoadIfaceTotals(ctx context.Context) ([]IfaceTotals, error) {
out := []IfaceTotals{} out := []IfaceTotals{}
for rows.Next() { for rows.Next() {
var r IfaceTotals var r IfaceTotals
var updated time.Time var updated, resetAt time.Time
if err := rows.Scan(&r.Iface, &r.TotalRxBytes, &r.TotalTxBytes, &r.LastKernelRxBytes, &r.LastKernelTxBytes, &updated); err != nil { if err := rows.Scan(&r.Iface, &r.TotalRxBytes, &r.TotalTxBytes, &r.LastKernelRxBytes, &r.LastKernelTxBytes, &updated, &resetAt); err != nil {
return nil, err return nil, err
} }
r.UpdatedAt = updated r.UpdatedAt = updated
r.ResetAt = resetAt
out = append(out, r) out = append(out, r)
} }
if err := rows.Err(); err != nil { 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. // Simple loop (small N: number of interfaces). Keeps CPU/DB overhead minimal.
for _, r := range rows { for _, r := range rows {
if isIgnoredInterface(r.Iface) {
continue
}
resetAt := r.ResetAt
if resetAt.IsZero() {
resetAt = time.Now()
}
_, err := s.db.ExecContext(ctx, ` _, 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) 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()) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
ON CONFLICT (iface) DO UPDATE ON CONFLICT (iface) DO UPDATE
SET total_rx_bytes = EXCLUDED.total_rx_bytes, SET total_rx_bytes = EXCLUDED.total_rx_bytes,
total_tx_bytes = EXCLUDED.total_tx_bytes, total_tx_bytes = EXCLUDED.total_tx_bytes,
last_kernel_rx_bytes = EXCLUDED.last_kernel_rx_bytes, last_kernel_rx_bytes = EXCLUDED.last_kernel_rx_bytes,
last_kernel_tx_bytes = EXCLUDED.last_kernel_tx_bytes, last_kernel_tx_bytes = EXCLUDED.last_kernel_tx_bytes,
updated_at = NOW()`, updated_at = NOW(),
r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes) reset_at = EXCLUDED.reset_at`,
r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes, resetAt)
if err != nil { if err != nil {
return err 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/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store)))) mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
// Superadmin-only: server stats + DNSTT // Server stats: visible to authenticated sessions; reset remains superadmin-only.
mux.Handle("/api/stats", saSession(http.HandlerFunc(handleStats))) 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", saSession(http.HandlerFunc(handleDnsttStats)))
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs))) 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/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store)))) mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
// Superadmin-only: Xray-core management // Master/slave server management. Superadmins can add slave nodes; all authenticated
mux.Handle("/api/xray/status", saSession(http.HandlerFunc(handleXrayStatus))) // 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/start", saSession(http.HandlerFunc(handleXrayStart)))
mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop))) mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop)))
mux.Handle("/api/xray/restart", saSession(http.HandlerFunc(handleXrayRestart))) 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/config", saSession(http.HandlerFunc(handleXrayConfig)))
mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs))) mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs)))
mux.Handle("/api/xray/inbounds", saSession(http.HandlerFunc(handleXrayInbounds))) mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", saSession(http.HandlerFunc(handleXrayClientAdd))) mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", saSession(http.HandlerFunc(handleXrayClientUpdate))) mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/remove", saSession(http.HandlerFunc(handleXrayClientRemove))) mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation // Superadmin-only: TLS certificate generation
mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned))) mux.Handle("/api/tls/generate-selfsigned", saSession(handleManagedProxyOrLocal(store, handleTLSGenerateSelfSigned)))
mux.Handle("/api/tls/letsencrypt", saSession(http.HandlerFunc(handleTLSLetsEncrypt))) mux.Handle("/api/tls/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt)))
mux.Handle("/api/tls/upload-pem", saSession(http.HandlerFunc(handleTLSUploadPEM))) mux.Handle("/api/tls/upload-pem", saSession(handleManagedProxyOrLocal(store, handleTLSUploadPEM)))
// Superadmin-only: DNSTT key management // Superadmin-only: DNSTT key management
mux.Handle("/api/dnstt/genkey", saSession(http.HandlerFunc(handleDnsttGenKey))) mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey)))
mux.Handle("/api/dnstt/pubkey", saSession(http.HandlerFunc(handleDnsttGetPubKey))) mux.Handle("/api/dnstt/pubkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGetPubKey)))
// Superadmin-only: server config (read/write config.json + live banner apply) // Superadmin-only: server config (read/write config.json + live banner apply)
mux.Handle("/api/server/config", saSession(http.HandlerFunc(handleServerConfig))) mux.Handle("/api/server/config", saSession(http.HandlerFunc(handleServerConfig)))
@@ -1152,6 +1441,7 @@ type UserDTO struct {
AllowStaticPassword bool `json:"allow_static_password"` AllowStaticPassword bool `json:"allow_static_password"`
TOTPEnabled bool `json:"totp_enabled"` TOTPEnabled bool `json:"totp_enabled"`
OwnerUsername string `json:"owner_username,omitempty"` OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
} }
func handleListUsers(w http.ResponseWriter, r *http.Request) { func handleListUsers(w http.ResponseWriter, r *http.Request) {
@@ -1161,11 +1451,19 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
} }
sess := sessionFromCtx(r.Context()) 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() states := userMgr.List()
out := make([]UserDTO, 0, len(states)) out := make([]UserDTO, 0, len(states))
for _, u := range states { for _, u := range states {
u.mu.Lock() u.mu.Lock()
c := u.ActiveConns c := len(u.conns)
u.ActiveConns = c
cfg := u.Cfg cfg := u.Cfg
expires := u.ExpiresAt expires := u.ExpiresAt
u.mu.Unlock() u.mu.Unlock()
@@ -1209,6 +1507,8 @@ type UserPayload struct {
TOTPWindow int `json:"totp_window"` TOTPWindow int `json:"totp_window"`
TOTPDigits int `json:"totp_digits"` TOTPDigits int `json:"totp_digits"`
AllowStaticPassword bool `json:"allow_static_password"` AllowStaticPassword bool `json:"allow_static_password"`
OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
} }
func handleCreateUser(store *Store) http.HandlerFunc { func handleCreateUser(store *Store) http.HandlerFunc {
@@ -1233,6 +1533,27 @@ func handleCreateUser(store *Store) http.HandlerFunc {
} }
ctx := r.Context() 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: // Decide what password to use:
// - if payload has non-empty password -> use it // - if payload has non-empty password -> use it
@@ -1278,11 +1599,13 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existsInDB) ).Scan(&existsInDB)
if !existsInDB { if !existsInDB {
owner, ok := adminUsers.get(sess.Username) 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) http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return return
} }
} }
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
ownerUsername = strings.TrimSpace(p.OwnerUsername)
} }
cfg := UserConfig{ cfg := UserConfig{
@@ -1332,6 +1655,23 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
} }
ctx := r.Context() 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 // Resellers may only delete their own users
sess := sessionFromCtx(ctx) sess := sessionFromCtx(ctx)
@@ -1364,6 +1704,9 @@ func handleStats(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
if proxyManagedServerFromRequest(w, r, statsStore, "/api/stats", nil, "") {
return
}
stats := getCurrentStats() stats := getCurrentStats()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stats) _ = json.NewEncoder(w).Encode(stats)
@@ -1536,18 +1879,20 @@ func publicKeyCallback(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissio
// ---------- Connection handling ---------- // ---------- Connection handling ----------
func handleConn(tcpConn net.Conn, config *ssh.ServerConfig) { 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. // 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 { if err != nil {
log.Println("ssh handshake failed:", err) log.Println("ssh handshake failed:", err)
return return
} }
// Clear deadlines after a successful handshake. // Clear deadlines after a successful handshake. Runtime cleanup is handled
_ = tcpConn.SetDeadline(time.Time{}) // by monitorSSHIdle, which checks traffic in both directions.
_ = trackedConn.SetDeadline(time.Time{})
username := sshConn.User() username := sshConn.User()
log.Printf("new SSH connection from %s as %s", sshConn.RemoteAddr(), username) 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 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() 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() u.mu.Unlock()
log.Printf("user %s exceeded max_connections (%d)", username, u.Cfg.MaxConnections) log.Printf("user %s exceeded max_connections (%d)", username, u.Cfg.MaxConnections)
sshConn.Close() sshConn.Close()
return return
} }
u.ActiveConns++
if u.conns == nil {
u.conns = make(map[*ssh.ServerConn]struct{})
}
u.conns[sshConn] = struct{}{} u.conns[sshConn] = struct{}{}
u.ActiveConns = len(u.conns)
u.mu.Unlock() u.mu.Unlock()
// Use per-user limit if set; otherwise fall back to the global config default. // 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() updateUserDisplay()
idleDone := make(chan struct{})
if idleTimeout := getSSHIdleTimeout(); idleTimeout > 0 {
go monitorSSHIdle(trackedConn, sshConn, username, idleTimeout, idleDone)
}
defer func() { defer func() {
close(idleDone)
u.mu.Lock() u.mu.Lock()
u.ActiveConns--
delete(u.conns, sshConn) delete(u.conns, sshConn)
u.ActiveConns = len(u.conns)
u.mu.Unlock() u.mu.Unlock()
updateUserDisplay() updateUserDisplay()
@@ -1727,7 +2081,8 @@ func updateUserDisplay() {
parts := make([]string, 0, len(userStates)) parts := make([]string, 0, len(userStates))
for _, u := range userStates { for _, u := range userStates {
u.mu.Lock() u.mu.Lock()
c := u.ActiveConns c := len(u.conns)
u.ActiveConns = c
name := u.Cfg.Username name := u.Cfg.Username
u.mu.Unlock() u.mu.Unlock()
parts = append(parts, fmt.Sprintf("%s: %d", name, c)) 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))) _, _ = raw.Write([]byte(fmt.Sprintf("HTTP/1.1 101 %s\r\n\r\n", status)))
skip200 := false 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. // Drain chained HTTP header blocks with a short rolling deadline so Peek/ReadBytes never stalls.
cleanWindow := 30 * time.Second cleanWindow := 30 * time.Second
@@ -2260,6 +2615,12 @@ func maybeHTTPStartPrefix(b []byte) bool {
} }
func main() { 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") configPath := flag.String("config", "config.json", "path to JSON config file")
quietFlag := flag.Bool("quiet", false, "override config and disable logs") quietFlag := flag.Bool("quiet", false, "override config and disable logs")
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)") userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
@@ -2318,6 +2679,9 @@ func main() {
} else { } else {
startXrayClientExpiryChecker(store) startXrayClientExpiryChecker(store)
} }
if err := store.EnsureIfaceUsageTables(ctx); err != nil {
log.Printf("vnstat usage tables disabled: %v", err)
}
if err := store.EnsureIfaceTotalsTable(ctx); err == nil { if err := store.EnsureIfaceTotalsTable(ctx); err == nil {
rows, err2 := store.LoadIfaceTotals(ctx) rows, err2 := store.LoadIfaceTotals(ctx)
if err2 == nil { if err2 == nil {
@@ -2339,6 +2703,7 @@ func main() {
} }
// start background collector for CPU + interface stats // start background collector for CPU + interface stats
primeCurrentStats()
startStatsCollector() startStatsCollector()
adminAddr := os.Getenv("ADMIN_HTTP_ADDR") adminAddr := os.Getenv("ADMIN_HTTP_ADDR")
@@ -2493,35 +2858,38 @@ func main() {
if quietLogs { if quietLogs {
log.SetOutput(io.Discard) 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) setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown)
setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout)
// Start the integrated DNSTT and UDPGW if configured.
startDNSTT(cfg.DNSTT, sshConfig)
startUDPGW(cfg.UDPGW)
// Initialise listener pools (used for initial startup and hot-reload alike). // Initialise listener pools (used for initial startup and hot-reload alike).
publicPool = newListenerPool(serveHTTP80) publicPool = newListenerPool(serveHTTP80)
localPool = newListenerPool(serveRawSSH)
tlsPool = newTLSListenerPool() 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). // Start public SSH listeners (listen + extra_listen).
publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...) publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...)
for _, e := range publicPool.Sync(publicAddrs) { for _, e := range publicPool.Sync(publicAddrs) {
log.Fatalf("failed to start listener: %v", e) log.Printf("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)
}
} }
// Start TLS forwarder listeners if configured. // Start TLS forwarder listeners if configured.
for _, e := range tlsPool.Sync(cfg.TLSForwarders) { 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. // 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() globalCfgMu.RUnlock()
portWarnings := normalizeRuntimePorts(&newCfg)
out, err := json.MarshalIndent(newCfg, "", " ") out, err := json.MarshalIndent(newCfg, "", " ")
if err != nil { if err != nil {
http.Error(w, "marshal error", http.StatusInternalServerError) http.Error(w, "marshal error", http.StatusInternalServerError)
@@ -110,8 +112,13 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
return return
} }
// Apply all changes live — no restart needed. // Apply all changes live and return health checks to the panel.
applyFullConfigReload(&newCfg) report := applyFullConfigReload(&newCfg)
if len(portWarnings) > 0 {
w.WriteHeader(http.StatusOK) 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 // startUDPGW starts the integrated UDP gateway if cfg is nonnil and
// cfg.Listen is nonempty. It applies default values to any zero // cfg.Listen is nonempty. It applies default values to any zero
// configuration fields and converts duration strings to time.Duration. // configuration fields and converts duration strings to time.Duration.
// The server runs in a goroutine; any fatal errors are logged and // The server runs in a goroutine; any fatal errors are logged and
// prevent the gateway from starting, but do not terminate the main // prevent the gateway from starting, but do not terminate the main
// process. // process.
func startUDPGW(cfg *UDPGWConfig) { func startUDPGW(cfg *UDPGWConfig) error {
if cfg == nil { if cfg == nil {
return return nil
} }
// Default the listen address to the standalone default (0.0.0.0:7400) if // Default the listen address to the standalone default (0.0.0.0:7400) if
// unspecified. This matches the behaviour of the original // unspecified. This matches the behaviour of the original
// badvpn-udpgw program, which listens on all interfaces by default. // badvpn-udpgw program, which listens on all interfaces by default.
listenAddr := cfg.Listen listenAddr := cfg.Listen
if listenAddr == "" { if listenAddr == "" {
listenAddr = "0.0.0.0:7400" listenAddr = defaultUDPGWListen
} }
cfg.Listen = listenAddr
// Apply defaults for numeric fields if zero. // Apply defaults for numeric fields if zero.
c := &internalUDPGWConfig{} c := &internalUDPGWConfig{}
c.listen = listenAddr c.listen = listenAddr
@@ -135,7 +142,7 @@ func startUDPGW(cfg *UDPGWConfig) {
ln, err := net.Listen("tcp", c.listen) ln, err := net.Listen("tcp", c.listen)
if err != nil { if err != nil {
log.Printf("udpgw: listen failed on %s: %v", c.listen, err) 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. // Register as the active listener so stopUDPGW can close it.
@@ -162,6 +169,7 @@ func startUDPGW(cfg *UDPGWConfig) {
go handleUDPGWClient(conn, c) go handleUDPGWClient(conn, c)
} }
}() }()
return nil
} }
// internalUDPGWConfig mirrors the exported UDPGWConfig but with // internalUDPGWConfig mirrors the exported UDPGWConfig but with

608
update.sh
View File

@@ -1,7 +1,21 @@
#!/bin/bash #!/bin/bash
# Update script for SSH Panel — updates the binary and admin panel in place. # Update script for DragonCoreSSH / SSH Panel.
# Preserves: .env, config.json, xray_config.json, SSH keys, database, certs. # Pulls the newest source from Git, builds the new binary, and updates the
# Usage: sudo bash update.sh # 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 set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
@@ -9,53 +23,255 @@ info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; } warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[x]${NC} $*"; exit 1; } error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
# ── config ──────────────────────────────────────────────────────────────────── # Config
INSTALL_DIR="/opt/sshpanel" INSTALL_DIR="${INSTALL_DIR:-/opt/sshpanel}"
SERVICE_NAME="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)" 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" [[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
echo -e "\n${GREEN}══════════════════════════════════════════${NC}" # Cross-distro helpers -------------------------------------------------------
echo -e "${GREEN} SSH Panel · Updater ${NC}" PKG_MANAGER=""
echo -e "${GREEN}══════════════════════════════════════════${NC}\n" 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 ────────────────────────────────────────────────────── require_systemd() {
info "[1/5] Pre-flight checks…" SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
if [[ -z "$SYSTEMCTL_BIN" ]]; then
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found — run install.sh first." error "systemd was not found. This updater supports Linux distributions that use systemd for services."
[[ -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
fi
fi fi
}
if $NEED_GO; then detect_pkg_manager() {
MACHINE=$(uname -m) if command -v apt-get >/dev/null 2>&1; then
case "$MACHINE" in PKG_MANAGER="apt"
x86_64) GOARCH="amd64" ;; elif command -v dnf >/dev/null 2>&1; then
aarch64) GOARCH="arm64" ;; PKG_MANAGER="dnf"
armv7l) GOARCH="armv6l" ;; elif command -v yum >/dev/null 2>&1; then
*) GOARCH="amd64" ;; 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 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" 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
}
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
}
echo -e "\n${GREEN}==========================================${NC}"
echo -e "${GREEN} DragonCoreSSH / SSH Panel Updater ${NC}"
echo -e "${GREEN}==========================================${NC}\n"
# Helpers
need_cmd() {
command -v "$1" >/dev/null 2>&1 || error "Required command not found: $1"
}
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
}
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 rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz tar -C /usr/local -xzf /tmp/go.tar.gz
rm -f /tmp/go.tar.gz rm -f /tmp/go.tar.gz
@@ -65,135 +281,311 @@ fi
export PATH=$PATH:/usr/local/go/bin export PATH=$PATH:/usr/local/go/bin
go version go version
}
# ── 3. Build new binary ─────────────────────────────────────────────────────── build_binary() {
info "[3/5] Building new sshpanel binary" info "[3/7] Building new sshpanel binary..."
cd "$SOURCE_DIR"
cd "$SCRIPT_DIR"
export GOPATH=/tmp/gopath_sshpanel export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_sshpanel export GOCACHE=/tmp/gocache_sshpanel
go mod download go mod download
go build -ldflags="-s -w" -o /tmp/sshpanel_new . go build -ldflags="-s -w" -o /tmp/sshpanel_new .
info " Build complete." info " Build complete."
}
# ── 4. Apply update ─────────────────────────────────────────────────────────── stop_service() {
info "[4/5] Applying update…" info "[4/7] Stopping service..."
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
# Stop the service "$SYSTEMCTL_BIN" stop "$SERVICE_NAME"
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
info " Stopping $SERVICE_NAME"
systemctl stop "$SERVICE_NAME"
RESTART_NEEDED=true RESTART_NEEDED=true
info " $SERVICE_NAME stopped."
else else
RESTART_NEEDED=false RESTART_NEEDED=false
warn " $SERVICE_NAME was not running."
fi 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
# Backup old binary
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak" cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
info " Old binary backed up to sshpanel.bak" info " Old binary backed up to $INSTALL_DIR/sshpanel.bak"
fi fi
# Replace binary
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel" mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
chmod +x "$INSTALL_DIR/sshpanel" chmod 755 "$INSTALL_DIR/sshpanel"
info " Binary updated." info " Binary updated."
# Update admin panel files rsync -a --delete "$SOURCE_DIR/admin/" "$INSTALL_DIR/admin/"
mkdir -p "$INSTALL_DIR/admin"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel updated." info " Admin panel updated."
# Ensure banner file exists (new in this version) copy_optional_script "update.sh" 700
if [[ ! -f "$INSTALL_DIR/banner.txt" ]]; then copy_optional_script "install.sh" 700
touch "$INSTALL_DIR/banner.txt" copy_optional_script "change_admin_password.sh" 700
info " Created banner.txt"
# 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 fi
# Ensure certs directory exists (new in this version) [[ -f "$INSTALL_DIR/banner.txt" ]] || touch "$INSTALL_DIR/banner.txt"
mkdir -p "$INSTALL_DIR/certs" }
# Patch config.json to add missing fields introduced in this version patch_configs() {
# without overwriting user-configured values. info "[6/7] Patching config files without overwriting user settings..."
CFG="$INSTALL_DIR/config.json"
if [[ -f "$CFG" ]]; then local cfg xcfg
# Add banner_file if not present cfg="$INSTALL_DIR/config.json"
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 xcfg="$INSTALL_DIR/xray_config.json"
python3 - "$CFG" << 'PYEOF'
if [[ -f "$cfg" ]]; then
python3 - "$cfg" <<'PYEOF'
import json, sys import json, sys
path = sys.argv[1] path = sys.argv[1]
try:
with open(path) as f: with open(path) as f:
d = json.load(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: if 'banner_file' not in d:
d['banner_file'] = '/opt/sshpanel/banner.txt' d['banner_file'] = '/opt/sshpanel/banner.txt'
changed = True
if 'local_ssh_listen' in d:
d.pop('local_ssh_listen', None)
changed = True
if changed:
with open(path, 'w') as f: with open(path, 'w') as f:
json.dump(d, f, indent=2) json.dump(d, f, indent=2)
f.write('\n')
PYEOF PYEOF
info " Added banner_file to config.json" info " config.json checked."
fi fi
# Fix routing: remove geoip:private rules that require geoip.dat from xray_config.json if [[ -f "$xcfg" ]] && grep -q '"geoip:private"' "$xcfg" 2>/dev/null; then
XCFG="$INSTALL_DIR/xray_config.json" python3 - "$xcfg" <<'PYEOF'
if [[ -f "$XCFG" ]]; then
if grep -q '"geoip:private"' "$XCFG" 2>/dev/null; then
python3 - "$XCFG" << 'PYEOF'
import json, sys import json, sys
path = sys.argv[1] path = sys.argv[1]
try:
with open(path) as f: with open(path) as f:
d = json.load(f) d = json.load(f)
except Exception as e:
print(f"[!] Could not parse {path}: {e}")
sys.exit(0)
routing = d.get('routing', {}) routing = d.get('routing', {})
rules = routing.get('rules', []) 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', [])] new_rules = [r for r in rules if 'geoip:private' not in r.get('ip', [])]
if new_rules != rules: if new_rules != rules:
if new_rules: if new_rules:
d['routing']['rules'] = new_rules d.setdefault('routing', {})['rules'] = new_rules
else: else:
d.pop('routing', None) d.pop('routing', None)
with open(path, 'w') as f: with open(path, 'w') as f:
json.dump(d, f, indent=2) json.dump(d, f, indent=2)
f.write('\n')
PYEOF PYEOF
info " Removed geoip:private routing rule from xray_config.json" info " Removed geoip:private routing rule from xray_config.json"
fi fi
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 fi
# ── 5. Restart service ──────────────────────────────────────────────────────── return 1
info "[5/5] Restarting service…" }
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
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
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 if $RESTART_NEEDED; then
systemctl start "$SERVICE_NAME" info " Starting $SERVICE_NAME after update..."
sleep 2
if systemctl is-active --quiet "$SERVICE_NAME"; then
info " $SERVICE_NAME is running."
else else
warn " $SERVICE_NAME failed to start — check logs:" warn " $SERVICE_NAME was not running before update; starting it now."
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"
exit 1
fi
else
warn " Service was not running; start it with: systemctl start $SERVICE_NAME"
fi fi
"$SYSTEMCTL_BIN" start "$SERVICE_NAME"
sleep 2
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 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
}
# 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 ""
echo -e "${GREEN}══════════════════════════════════════════${NC}" echo -e "${GREEN}==========================================${NC}"
echo -e "${GREEN} Update complete! ${NC}" echo -e "${GREEN} Update complete! ${NC}"
echo -e "${GREEN}══════════════════════════════════════════${NC}" echo -e "${GREEN}==========================================${NC}"
echo "" echo ""
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 " Logs : ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}" echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
echo ""
echo -e " Backup : ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}" echo -e " Backup : ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
echo "" echo ""
echo -e "${YELLOW}What was updated:${NC}" echo -e "${YELLOW}Updated:${NC}"
echo -e " sshpanel binary" echo -e " - sshpanel binary"
echo -e " Admin panel (admin/index.html)" echo -e " - Admin panel"
echo -e "${YELLOW}What was preserved:${NC}" echo -e " - update.sh / install.sh / helper scripts when available"
echo -e " • .env (DB credentials, tokens)" echo ""
echo -e " • config.json (your server settings)" echo -e "${YELLOW}Preserved:${NC}"
echo -e " • xray_config.json (your Xray settings)" echo -e " - .env"
echo -e " • SSH host keys" echo -e " - config.json"
echo -e " • All user data in PostgreSQL" echo -e " - xray_config.json"
echo -e " - SSH keys, certs, logs, database and users"
echo "" 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,30 +8,40 @@ import (
) )
// XrayClientMeta holds metadata stored in PostgreSQL for an Xray client. // 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 { type XrayClientMeta struct {
UUID string UUID string
Name string Name string
Email string Email string
InboundTag string InboundTag string
OwnerUsername string
ExpiresAt *time.Time ExpiresAt *time.Time
MaxConns int MaxConns int
CreatedAt time.Time CreatedAt time.Time
} }
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error { func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, ` stmts := []string{
CREATE TABLE IF NOT EXISTS xray_clients ( `CREATE TABLE IF NOT EXISTS xray_clients (
uuid TEXT PRIMARY KEY, uuid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
inbound_tag TEXT NOT NULL DEFAULT '', inbound_tag TEXT NOT NULL DEFAULT '',
owner_username TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
max_conns INT NOT NULL DEFAULT 0, max_conns INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`) )`,
`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 err
} }
}
return nil
}
func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error { func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error {
var expiresAt interface{} var expiresAt interface{}
@@ -39,15 +49,16 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro
expiresAt = *m.ExpiresAt expiresAt = *m.ExpiresAt
} }
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns) INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET ON CONFLICT (uuid) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
email = EXCLUDED.email, email = EXCLUDED.email,
inbound_tag = EXCLUDED.inbound_tag, 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, expires_at = EXCLUDED.expires_at,
max_conns = EXCLUDED.max_conns`, max_conns = EXCLUDED.max_conns`,
m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns) m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
return err return err
} }
@@ -55,9 +66,9 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime var expiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, ` 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). 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 { if err != nil {
return nil, err 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) { func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT uuid, name, email, inbound_tag, 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`) FROM xray_clients ORDER BY created_at DESC`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() 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 var out []*XrayClientMeta
for rows.Next() { for rows.Next() {
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime var expiresAt sql.NullTime
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &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 return nil, err
} }
if expiresAt.Valid { if expiresAt.Valid {
@@ -95,27 +138,49 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
rows, err := s.db.QueryContext(ctx, ` if store == nil || ownerUsername == "" {
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at return 0
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`) }
n, err := store.CountXrayClientsByOwner(ctx, ownerUsername)
if err != nil { if err != nil {
return nil, err log.Printf("count xray clients for %s: %v", ownerUsername, err)
return 0
} }
defer rows.Close() return n
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 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)
} }
out = append(out, m)
} }
return out, rows.Err()
} }
// startXrayClientExpiryChecker runs a background goroutine that removes expired // startXrayClientExpiryChecker runs a background goroutine that removes expired

File diff suppressed because it is too large Load Diff