Compare commits
26 Commits
e50c43c1bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cd9626db9 | |||
| 1479e6ac73 | |||
| f64f7fdc4d | |||
| 15859dc7f3 | |||
| 60cb2e3cdb | |||
| f1a587e00d | |||
| 1ad8b868ab | |||
| 67d56b2a76 | |||
| b66d194fa7 | |||
| 391db7708f | |||
| 603ae906a1 | |||
| 4a04ff79f0 | |||
| e00a7bd93c | |||
| 77a722d4ed | |||
| 03c43debf4 | |||
| 51aedfd3c7 | |||
| 3c7b02b8db | |||
| 3ddd934d9a | |||
| c74f6e2282 | |||
| 43482c88fa | |||
| 09f3959aa2 | |||
| 9b5f436a6e | |||
| d01fb919aa | |||
| 41aca3b7f3 | |||
| c1bb3c7a97 | |||
| f8fac513e3 |
300
README.md
300
README.md
@@ -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
629
admin/assets/app.css
Normal 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
2942
admin/assets/app.js
Normal file
File diff suppressed because it is too large
Load Diff
2020
admin/index.html
2020
admin/index.html
File diff suppressed because it is too large
Load Diff
1660
admin_script.js
Normal file
1660
admin_script.js
Normal file
File diff suppressed because it is too large
Load Diff
20
auth.go
20
auth.go
@@ -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
236
change_admin_password.sh
Normal 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."
|
||||||
@@ -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
146
config_safety.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
184
hotreload.go
184
hotreload.go
@@ -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, ", ")
|
||||||
}
|
}
|
||||||
|
|||||||
366
install.sh
366
install.sh
@@ -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
514
main.go
@@ -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
605
managed_servers.go
Normal 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
105
panel_log_limiter.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
report.Warnings = append(portWarnings, report.Warnings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(report)
|
||||||
}
|
}
|
||||||
|
|||||||
113
system_logs_api.go
Normal file
113
system_logs_api.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 non‑nil and
|
// startUDPGW starts the integrated UDP gateway if cfg is non‑nil and
|
||||||
// cfg.Listen is non‑empty. It applies default values to any zero
|
// cfg.Listen is non‑empty. 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
|
||||||
|
|||||||
680
update.sh
680
update.sh
@@ -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,191 +23,569 @@ 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
|
||||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||||
chmod +x /etc/profile.d/go.sh
|
chmod +x /etc/profile.d/go.sh
|
||||||
fi
|
|
||||||
|
|
||||||
export PATH=$PATH:/usr/local/go/bin
|
|
||||||
go version
|
|
||||||
|
|
||||||
# ── 3. Build new binary ───────────────────────────────────────────────────────
|
|
||||||
info "[3/5] Building new sshpanel binary…"
|
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
export GOPATH=/tmp/gopath_sshpanel
|
|
||||||
export GOCACHE=/tmp/gocache_sshpanel
|
|
||||||
go mod download
|
|
||||||
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
|
|
||||||
info " Build complete."
|
|
||||||
|
|
||||||
# ── 4. Apply update ───────────────────────────────────────────────────────────
|
|
||||||
info "[4/5] Applying update…"
|
|
||||||
|
|
||||||
# Stop the service
|
|
||||||
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
|
||||||
info " Stopping $SERVICE_NAME…"
|
|
||||||
systemctl stop "$SERVICE_NAME"
|
|
||||||
RESTART_NEEDED=true
|
|
||||||
else
|
|
||||||
RESTART_NEEDED=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Backup old binary
|
|
||||||
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
|
|
||||||
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
|
|
||||||
info " Old binary backed up to sshpanel.bak"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Replace binary
|
|
||||||
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
|
|
||||||
chmod +x "$INSTALL_DIR/sshpanel"
|
|
||||||
info " Binary updated."
|
|
||||||
|
|
||||||
# Update admin panel files
|
|
||||||
mkdir -p "$INSTALL_DIR/admin"
|
|
||||||
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
|
|
||||||
info " Admin panel updated."
|
|
||||||
|
|
||||||
# Ensure banner file exists (new in this version)
|
|
||||||
if [[ ! -f "$INSTALL_DIR/banner.txt" ]]; then
|
|
||||||
touch "$INSTALL_DIR/banner.txt"
|
|
||||||
info " Created banner.txt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure certs directory exists (new in this version)
|
|
||||||
mkdir -p "$INSTALL_DIR/certs"
|
|
||||||
|
|
||||||
# Patch config.json to add missing fields introduced in this version
|
|
||||||
# without overwriting user-configured values.
|
|
||||||
CFG="$INSTALL_DIR/config.json"
|
|
||||||
if [[ -f "$CFG" ]]; then
|
|
||||||
# Add banner_file if not present
|
|
||||||
if ! python3 -c "import json,sys; d=json.load(open('$CFG')); sys.exit(0 if 'banner_file' in d else 1)" 2>/dev/null; then
|
|
||||||
python3 - "$CFG" << 'PYEOF'
|
|
||||||
import json, sys
|
|
||||||
path = sys.argv[1]
|
|
||||||
with open(path) as f:
|
|
||||||
d = json.load(f)
|
|
||||||
if 'banner_file' not in d:
|
|
||||||
d['banner_file'] = '/opt/sshpanel/banner.txt'
|
|
||||||
with open(path, 'w') as f:
|
|
||||||
json.dump(d, f, indent=2)
|
|
||||||
PYEOF
|
|
||||||
info " Added banner_file to config.json"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fix routing: remove geoip:private rules that require geoip.dat from xray_config.json
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
XCFG="$INSTALL_DIR/xray_config.json"
|
go version
|
||||||
if [[ -f "$XCFG" ]]; then
|
}
|
||||||
if grep -q '"geoip:private"' "$XCFG" 2>/dev/null; then
|
|
||||||
python3 - "$XCFG" << 'PYEOF'
|
build_binary() {
|
||||||
|
info "[3/7] Building new sshpanel binary..."
|
||||||
|
cd "$SOURCE_DIR"
|
||||||
|
export GOPATH=/tmp/gopath_sshpanel
|
||||||
|
export GOCACHE=/tmp/gocache_sshpanel
|
||||||
|
go mod download
|
||||||
|
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
|
||||||
|
info " Build complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_service() {
|
||||||
|
info "[4/7] Stopping service..."
|
||||||
|
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||||
|
"$SYSTEMCTL_BIN" stop "$SERVICE_NAME"
|
||||||
|
RESTART_NEEDED=true
|
||||||
|
info " $SERVICE_NAME stopped."
|
||||||
|
else
|
||||||
|
RESTART_NEEDED=false
|
||||||
|
warn " $SERVICE_NAME was not running."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_optional_script() {
|
||||||
|
local name mode
|
||||||
|
name="$1"
|
||||||
|
mode="$2"
|
||||||
|
if [[ -f "$SOURCE_DIR/$name" ]]; then
|
||||||
|
cp "$SOURCE_DIR/$name" "$INSTALL_DIR/$name"
|
||||||
|
chmod "$mode" "$INSTALL_DIR/$name"
|
||||||
|
info " Updated $name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_update() {
|
||||||
|
info "[5/7] Applying update..."
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/logs" "$INSTALL_DIR/certs"
|
||||||
|
ensure_log_tmpfs_mount
|
||||||
|
|
||||||
|
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
|
||||||
|
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
|
||||||
|
info " Old binary backed up to $INSTALL_DIR/sshpanel.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
|
||||||
|
chmod 755 "$INSTALL_DIR/sshpanel"
|
||||||
|
info " Binary updated."
|
||||||
|
|
||||||
|
rsync -a --delete "$SOURCE_DIR/admin/" "$INSTALL_DIR/admin/"
|
||||||
|
info " Admin panel updated."
|
||||||
|
|
||||||
|
copy_optional_script "update.sh" 700
|
||||||
|
copy_optional_script "install.sh" 700
|
||||||
|
copy_optional_script "change_admin_password.sh" 700
|
||||||
|
|
||||||
|
# Keep a local copy of the latest source for easier support and future updates.
|
||||||
|
if [[ "$SOURCE_DIR" != "$SOURCE_CACHE_DIR" ]]; then
|
||||||
|
rm -rf "$SOURCE_CACHE_DIR"
|
||||||
|
mkdir -p "$SOURCE_CACHE_DIR"
|
||||||
|
rsync -a --delete --exclude '.git' "$SOURCE_DIR/" "$SOURCE_CACHE_DIR/"
|
||||||
|
info " Source files copied to $SOURCE_CACHE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f "$INSTALL_DIR/banner.txt" ]] || touch "$INSTALL_DIR/banner.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_configs() {
|
||||||
|
info "[6/7] Patching config files without overwriting user settings..."
|
||||||
|
|
||||||
|
local cfg xcfg
|
||||||
|
cfg="$INSTALL_DIR/config.json"
|
||||||
|
xcfg="$INSTALL_DIR/xray_config.json"
|
||||||
|
|
||||||
|
if [[ -f "$cfg" ]]; then
|
||||||
|
python3 - "$cfg" <<'PYEOF'
|
||||||
import json, sys
|
import json, sys
|
||||||
path = sys.argv[1]
|
path = sys.argv[1]
|
||||||
with open(path) as f:
|
try:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
json.dump(d, f, indent=2)
|
||||||
|
f.write('\n')
|
||||||
|
PYEOF
|
||||||
|
info " config.json checked."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$xcfg" ]] && grep -q '"geoip:private"' "$xcfg" 2>/dev/null; then
|
||||||
|
python3 - "$xcfg" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Could not parse {path}: {e}")
|
||||||
|
sys.exit(0)
|
||||||
routing = d.get('routing', {})
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
write_sshpanel_systemd_override() {
|
||||||
|
local include_dnstt_redirect="${1:-false}"
|
||||||
|
|
||||||
|
mkdir -p /etc/systemd/system/sshpanel.service.d
|
||||||
|
{
|
||||||
|
echo "[Unit]"
|
||||||
|
if [[ "$include_dnstt_redirect" == "true" ]]; then
|
||||||
|
echo "Wants=sshpanel-dnstt-redirect.service"
|
||||||
|
echo "After=local-fs.target sshpanel-dnstt-redirect.service"
|
||||||
|
else
|
||||||
|
echo "After=local-fs.target"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "[Service]"
|
||||||
|
echo "Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log"
|
||||||
|
echo "Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}"
|
||||||
|
echo "ExecStartPre="
|
||||||
|
echo "ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs"
|
||||||
|
echo "ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'"
|
||||||
|
echo "ExecStartPre=${SH_BIN} -c '${TOUCH_BIN} ${INSTALL_DIR}/logs/panel.log && ${CHMOD_BIN} 0644 ${INSTALL_DIR}/logs/panel.log || true'"
|
||||||
|
echo "StandardOutput=journal"
|
||||||
|
echo "StandardError=journal"
|
||||||
|
} > /etc/systemd/system/sshpanel.service.d/override.conf
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dnstt_redirect() {
|
||||||
|
if ! dnstt_redirect_is_enabled; then
|
||||||
|
warn " sshpanel-dnstt-redirect is disabled or removed; update will not recreate or enable it."
|
||||||
|
write_sshpanel_systemd_override false
|
||||||
|
"$SYSTEMCTL_BIN" daemon-reload
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
info " Ensuring DNSTT DNS redirect service exists..."
|
||||||
|
cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}"
|
||||||
|
DNSTT_PORT="${DNSTT_PORT:-5300}"
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
rm -f /etc/resolv.conf
|
||||||
|
printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf
|
||||||
|
|
||||||
|
if command -v ufw >/dev/null 2>&1; then
|
||||||
|
ufw allow 53/udp >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
||||||
|
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
|
||||||
|
firewall-cmd --reload >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 5. Restart service ────────────────────────────────────────────────────────
|
add_iptables_rule() {
|
||||||
info "[5/5] Restarting service…"
|
local bin="$1" chain="$2"
|
||||||
|
"$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \
|
||||||
|
|| "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
if $RESTART_NEEDED; then
|
if command -v iptables >/dev/null 2>&1; then
|
||||||
systemctl start "$SERVICE_NAME"
|
add_iptables_rule iptables PREROUTING
|
||||||
|
fi
|
||||||
|
if command -v ip6tables >/dev/null 2>&1; then
|
||||||
|
add_iptables_rule ip6tables PREROUTING || true
|
||||||
|
fi
|
||||||
|
if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
|
||||||
|
nft add table inet sshpanel_nat 2>/dev/null || true
|
||||||
|
nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true
|
||||||
|
nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \
|
||||||
|
|| nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT"
|
||||||
|
fi
|
||||||
|
EOS
|
||||||
|
chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF2'
|
||||||
|
[Unit]
|
||||||
|
Description=SSH Panel DNSTT DNS redirect (UDP 53 to 5300)
|
||||||
|
After=network.target
|
||||||
|
Before=sshpanel.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/sshpanel-dnstt-redirect.sh
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF2
|
||||||
|
|
||||||
|
write_sshpanel_systemd_override true
|
||||||
|
|
||||||
|
"$SYSTEMCTL_BIN" daemon-reload
|
||||||
|
"$SYSTEMCTL_BIN" enable --now sshpanel-dnstt-redirect.service || warn "DNSTT redirect service failed. Check: journalctl -u sshpanel-dnstt-redirect -e"
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_service() {
|
||||||
|
info "[7/7] Restarting service..."
|
||||||
|
ensure_dnstt_redirect
|
||||||
|
|
||||||
|
if $RESTART_NEEDED; then
|
||||||
|
info " Starting $SERVICE_NAME after update..."
|
||||||
|
else
|
||||||
|
warn " $SERVICE_NAME was not running before update; starting it now."
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$SYSTEMCTL_BIN" start "$SERVICE_NAME"
|
||||||
sleep 2
|
sleep 2
|
||||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE_NAME"; then
|
||||||
info " $SERVICE_NAME is running."
|
info " $SERVICE_NAME is running."
|
||||||
else
|
else
|
||||||
warn " $SERVICE_NAME failed to start — check logs:"
|
warn " $SERVICE_NAME failed to start. Check logs:"
|
||||||
warn " journalctl -u $SERVICE_NAME -n 30 --no-pager"
|
warn " journalctl -u $SERVICE_NAME -n 50 --no-pager"
|
||||||
warn " You can restore the old binary:"
|
if [[ -f "$INSTALL_DIR/sshpanel.bak" ]]; then
|
||||||
warn " mv $INSTALL_DIR/sshpanel.bak $INSTALL_DIR/sshpanel && systemctl start $SERVICE_NAME"
|
warn " Restore command:"
|
||||||
|
warn " cp $INSTALL_DIR/sshpanel.bak $INSTALL_DIR/sshpanel && systemctl start $SERVICE_NAME"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
}
|
||||||
warn " Service was not running; start it with: systemctl start $SERVICE_NAME"
|
|
||||||
fi
|
# Pre-flight
|
||||||
|
info "[0/7] Pre-flight checks..."
|
||||||
|
require_systemd
|
||||||
|
detect_pkg_manager
|
||||||
|
set_update_deps
|
||||||
|
ensure_update_dependencies
|
||||||
|
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found. Run install.sh first."
|
||||||
|
[[ -f "$INSTALL_DIR/.env" ]] || error "$INSTALL_DIR/.env not found. Run install.sh first."
|
||||||
|
need_cmd python3
|
||||||
|
need_cmd rsync
|
||||||
|
need_cmd git
|
||||||
|
need_cmd wget
|
||||||
|
|
||||||
|
info " Install dir : $INSTALL_DIR"
|
||||||
|
info " Cache dir : $SOURCE_CACHE_DIR"
|
||||||
|
info " Package manager : $PKG_MANAGER"
|
||||||
|
info " Service manager : systemd"
|
||||||
|
|
||||||
|
prepare_source_from_git
|
||||||
|
install_go_if_needed
|
||||||
|
build_binary
|
||||||
|
stop_service
|
||||||
|
apply_update
|
||||||
|
patch_configs
|
||||||
|
restart_service
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
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 " Logs: ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
|
echo -e " Updated from: ${YELLOW}${REPO_URL}${NC}"
|
||||||
|
echo -e " Source ref : ${YELLOW}${UPDATE_REF}${NC}"
|
||||||
|
echo -e " Source cache: ${YELLOW}${SOURCE_CACHE_DIR}${NC}"
|
||||||
|
echo -e " Logs : ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
|
||||||
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
|
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
|
||||||
|
echo -e " Backup : ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " Backup: ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
|
echo -e "${YELLOW}Updated:${NC}"
|
||||||
|
echo -e " - sshpanel binary"
|
||||||
|
echo -e " - Admin panel"
|
||||||
|
echo -e " - update.sh / install.sh / helper scripts when available"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}What was updated:${NC}"
|
echo -e "${YELLOW}Preserved:${NC}"
|
||||||
echo -e " • sshpanel binary"
|
echo -e " - .env"
|
||||||
echo -e " • Admin panel (admin/index.html)"
|
echo -e " - config.json"
|
||||||
echo -e "${YELLOW}What was preserved:${NC}"
|
echo -e " - xray_config.json"
|
||||||
echo -e " • .env (DB credentials, tokens)"
|
echo -e " - SSH keys, certs, logs, database and users"
|
||||||
echo -e " • config.json (your server settings)"
|
|
||||||
echo -e " • xray_config.json (your Xray settings)"
|
|
||||||
echo -e " • SSH host keys"
|
|
||||||
echo -e " • All user data in PostgreSQL"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
366
vnstat_api.go
Normal file
366
vnstat_api.go
Normal 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
|
||||||
|
}
|
||||||
121
xray_clients.go
121
xray_clients.go
@@ -8,29 +8,39 @@ 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 {
|
||||||
@@ -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{}
|
func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||||
var expiresAt sql.NullTime
|
return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
|
||||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
|
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
|
||||||
|
if store == nil || ownerUsername == "" {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if expiresAt.Valid {
|
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||||
m.ExpiresAt = &expiresAt.Time
|
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
|
||||||
|
|||||||
1162
xray_integration.go
1162
xray_integration.go
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user