Safe Update

This commit is contained in:
2026-05-02 23:20:13 -03:00
parent 41aca3b7f3
commit d01fb919aa
13 changed files with 1083 additions and 98 deletions

View File

@@ -12,6 +12,8 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
- 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
@@ -104,6 +106,8 @@ Admin token
```text
80 SSH com HTTP Injection
8080 SSH extra com HTTP Injection
53/udp DNS público para DNSTT, redirecionado para 5300/udp
5300/udp DNSTT interno
9090 Painel web + API pública /check
10086 Xray VLESS
10087 Xray VMess
@@ -115,11 +119,34 @@ Libere no firewall apenas as portas que você realmente usa. Exemplo com `ufw`:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 8080/tcp
sudo ufw allow 53/udp
sudo ufw allow 9090/tcp
sudo ufw allow 10086/tcp
sudo ufw allow 10087/tcp
```
### DNSTT na porta DNS 53
O instalador cria o serviço `sshpanel-dnstt-redirect.service`, que libera a porta 53 removendo o `systemd-resolved` quando ele existe, fixa `/etc/resolv.conf` com `1.1.1.1` e adiciona uma regra NAT para redirecionar DNS UDP público da porta `53` para o DNSTT em `5300`.
Comandos manuais equivalentes em sistemas com `iptables`:
```bash
sudo systemctl disable --now systemd-resolved.service || true
sudo rm -f /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
sudo iptables -t nat -C PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300 2>/dev/null \
|| sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300
```
Verificar o redirect:
```bash
systemctl status sshpanel-dnstt-redirect --no-pager -l
sudo iptables -t nat -S PREROUTING | grep 5300
```
### Comandos úteis
Ver status do serviço:
@@ -146,6 +173,28 @@ Reiniciar serviço:
systemctl restart sshpanel
```
### Trocar senha perdida do admin
Se o dono perdeu a senha do painel, acesse o servidor como `root` e execute:
```bash
sudo bash /opt/sshpanel/change_admin_password.sh
```
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
Entre na pasta do projeto atualizado e execute:
@@ -250,6 +299,8 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
- 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
@@ -342,6 +393,8 @@ Admin token
```text
80 SSH with HTTP Injection
8080 Extra SSH with HTTP Injection
53/udp Public DNS for DNSTT, redirected to 5300/udp
5300/udp Internal DNSTT listener
9090 Web panel + public /check API
10086 Xray VLESS
10087 Xray VMess
@@ -353,11 +406,34 @@ Open only the ports that you actually use. Example with `ufw`:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 8080/tcp
sudo ufw allow 53/udp
sudo ufw allow 9090/tcp
sudo ufw allow 10086/tcp
sudo ufw allow 10087/tcp
```
### DNSTT on DNS port 53
The installer creates `sshpanel-dnstt-redirect.service`. It frees port 53 by stopping `systemd-resolved` when present, writes `/etc/resolv.conf` with `1.1.1.1`, and adds a NAT rule that redirects public UDP DNS traffic from port `53` to DNSTT on `5300`.
Equivalent manual commands on systems with `iptables`:
```bash
sudo systemctl disable --now systemd-resolved.service || true
sudo rm -f /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
sudo iptables -t nat -C PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300 2>/dev/null \
|| sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 5300
```
Check the redirect:
```bash
systemctl status sshpanel-dnstt-redirect --no-pager -l
sudo iptables -t nat -S PREROUTING | grep 5300
```
### Useful commands
Check service status:
@@ -384,6 +460,28 @@ Restart service:
systemctl restart sshpanel
```
### Reset lost admin password
If the owner loses the web panel password, access the server as `root` and run:
```bash
sudo bash /opt/sshpanel/change_admin_password.sh
```
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.
### Update
Enter the updated source-code folder and run:

View File

@@ -131,6 +131,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
<button class="tab-btn superadmin-only hidden" data-tab="xray">Xray</button>
<button class="tab-btn superadmin-only hidden" data-tab="resellers">Resellers</button>
<button class="tab-btn superadmin-only hidden" data-tab="stats">Stats</button>
<button class="tab-btn superadmin-only hidden" data-tab="logs">Logs</button>
<button class="tab-btn superadmin-only hidden" data-tab="server">Server Config</button>
</nav>
<div class="hright">
@@ -533,6 +534,25 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
</div>
</div><!-- /tab-stats -->
<!-- ═══════════ Logs Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-logs">
<div class="card">
<div class="card-hdr">
<div class="card-title">System Logs</div>
<div class="form-actions" style="margin-top:0">
<select id="logSource" class="btn-ghost" style="border-radius:999px;padding:4px 8px;background:rgba(15,23,42,.9);color:var(--text);border:1px solid rgba(55,65,81,.9);font-size:.7rem;">
<option value="panel">Panel / system</option>
<option value="dnstt">DNSTT</option>
<option value="xray">Xray</option>
</select>
<button class="btn btn-sm" type="button" onclick="loadSystemLogs()">Refresh</button>
</div>
</div>
<pre class="log-box" id="systemLogBox" style="max-height:430px;min-height:260px;">Select a log source and click Refresh.</pre>
<div class="statusbar"><span id="systemLogStatus">Ready.</span></div>
</div>
</div><!-- /tab-logs -->
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-server">
@@ -547,14 +567,10 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
<span class="chip green">live</span>
</div>
<div class="form-grid">
<div class="field">
<div class="field" style="grid-column:1/-1">
<label>Main Listen (SSH / HTTP)</label>
<input type="text" id="cfgListen" placeholder="0.0.0.0:80"/>
</div>
<div class="field">
<label>Local SSH Listen</label>
<input type="text" id="cfgLocalSSH" placeholder="127.0.0.1:2222 (blank = off)"/>
</div>
</div>
<div class="field" style="margin-top:6px">
<label>Extra Listen Addresses <span class="hint">(one per line, e.g. 0.0.0.0:8080)</span></label>
@@ -749,7 +765,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
<div class="form-actions" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);">
<button class="btn" onclick="saveServerConfig()">Save Config</button>
<button class="btn btn-ghost" onclick="loadServerConfig()">Reload</button>
<span id="srvCfgStatus" class="hint">All changes apply live. Only <strong>host_key_file</strong> requires a restart.</span>
<span id="srvCfgStatus" class="hint">All service changes apply live.</span>
</div>
</div><!-- /tab-server -->
@@ -1535,6 +1551,29 @@ async function loadStats() {
} catch (e) { if (e.message==="auth") doAuthError(); }
}
// ─── Logs ─────────────────────────────────────────────────────────────────────
document.querySelector("[data-tab='logs']")?.addEventListener("click", loadSystemLogs);
document.getElementById("logSource")?.addEventListener("change", loadSystemLogs);
async function loadSystemLogs() {
const box = document.getElementById("systemLogBox");
const st = document.getElementById("systemLogStatus");
const source = document.getElementById("logSource")?.value || "panel";
st.textContent = "Loading…";
try {
const res = await api(`/api/system/logs?source=${encodeURIComponent(source)}&lines=500`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
const lines = Array.isArray(data.lines) ? data.lines : [];
box.textContent = lines.length ? lines.join("\n") : "No log lines yet.";
box.scrollTop = box.scrollHeight;
st.textContent = `${data.source || source} logs${data.path ? " · " + data.path : ""} · ${lines.length} lines · ` + new Date().toLocaleTimeString();
} catch (e) {
if (e.message === "auth") doAuthError();
else st.textContent = "Error: " + e.message;
}
}
// ─── Server Config ────────────────────────────────────────────────────────────
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
@@ -1559,7 +1598,6 @@ async function loadServerConfig() {
// Network
document.getElementById("cfgListen").value = c.listen || "";
document.getElementById("cfgLocalSSH").value = c.local_ssh_listen || "";
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
// SSH / general
@@ -1620,7 +1658,6 @@ async function saveServerConfig() {
const cfg = {
listen: document.getElementById("cfgListen").value.trim(),
extra_listen: extraLines,
local_ssh_listen: document.getElementById("cfgLocalSSH").value.trim(),
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
admin_dir: "/opt/sshpanel/admin",
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
@@ -1654,7 +1691,15 @@ async function saveServerConfig() {
try {
const res = await api("/api/server/config", { method: "POST", body: JSON.stringify(cfg) });
if (!res.ok) throw new Error(await res.text());
st.textContent = "Saved. Banner applied live — other changes need a server restart.";
const report = await res.json().catch(() => null);
const warnings = report?.warnings || [];
const bad = Object.entries(report?.services || {}).filter(([_, v]) => v?.enabled && !v?.running);
if (warnings.length || bad.length) {
const badText = bad.map(([name, v]) => `${name}: ${v.error || "not running"}`).join(" | ");
st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | ");
} else {
st.textContent = "Saved and applied live.";
}
} catch (e) {
if (e.message === "auth") doAuthError();
else st.textContent = "Error: " + e.message;

View File

@@ -536,10 +536,10 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
}
if s.Role == RoleReseller {
if u, ok := adminUsers.get(s.Username); ok {
resp["max_users"] = u.MaxUsers
resp["max_users"] = u.MaxUsers
resp["used_users"] = countOwnedUsers(s.Username)
resp["expires_at"] = u.ExpiresAt
resp["is_active"] = u.IsActive
resp["is_active"] = u.IsActive
}
}
w.Header().Set("Content-Type", "application/json")
@@ -633,8 +633,8 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
if p.Password != "" {
u.PasswordHash = hashAdminPassword(p.Password)
}
u.MaxUsers = p.MaxUsers
u.IsActive = p.IsActive
u.MaxUsers = p.MaxUsers
u.IsActive = p.IsActive
u.ExpiresAt = nil
if p.ExpiresAt != "" {
t, err := time.Parse(time.RFC3339, p.ExpiresAt)

236
change_admin_password.sh Normal file
View File

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

146
config_safety.go Normal file
View File

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

View File

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

View File

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

View File

@@ -35,23 +35,23 @@ case "$OS_ID" in
ubuntu|debian|linuxmint)
PKG_UPDATE="apt-get update -qq"
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y"
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl"
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl iptables nftables"
;;
centos|rhel|rocky|almalinux)
PKG_UPDATE="yum makecache -q"
PKG_INSTALL="yum install -y"
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl"
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl iptables nftables"
;;
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"
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl iptables nftables"
;;
*)
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"
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl iptables nftables"
;;
esac
info " OS: $OS_ID"
@@ -107,6 +107,11 @@ go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" .
info " Binary: $INSTALL_DIR/sshpanel"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel copied"
if [[ -f "$SCRIPT_DIR/change_admin_password.sh" ]]; then
cp "$SCRIPT_DIR/change_admin_password.sh" "$INSTALL_DIR/change_admin_password.sh"
chmod 700 "$INSTALL_DIR/change_admin_password.sh"
info " Admin password recovery script copied"
fi
# ── 6. Xray binary ──────────────────────────────────────────────────────────
info "[6/9] Downloading Xray-core…"
@@ -230,7 +235,7 @@ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};
info " PostgreSQL database '${DB_NAME}' ready"
# ── 8. Config files ──────────────────────────────────────────────────────────
info "[8/9] Generating config files…"
info "[8/10] Generating config files…"
# Admin token
ADMIN_TOKEN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48 || true)
@@ -281,7 +286,6 @@ cat > "$INSTALL_DIR/config.json" <<EOF
{
"listen": "0.0.0.0:80",
"extra_listen": ["0.0.0.0:8080"],
"local_ssh_listen": "127.0.0.1:2222",
"host_key_file": "${INSTALL_DIR}/ssh_host_rsa_key",
"quiet": false,
"admin_dir": "${INSTALL_DIR}/admin",
@@ -333,18 +337,86 @@ EOF
chmod 600 "$INSTALL_DIR/xray_config.json"
info " VLESS UUID: ${UUID}"
# ── 9. Systemd service ───────────────────────────────────────────────────────
info "[9/9] Creating systemd service '${SERVICE_NAME}'…"
# ── 9. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
info "[9/10] Configuring DNSTT DNS redirect (UDP 53 -> 5300)…"
cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS'
#!/bin/bash
set -euo pipefail
DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}"
DNSTT_PORT="${DNSTT_PORT:-5300}"
# Free port 53 on systemd-resolved based systems and keep outbound DNS working.
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true
fi
rm -f /etc/resolv.conf
printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf
# Open DNS/UDP in common Linux firewalls when they are active.
if command -v ufw >/dev/null 2>&1; then
ufw allow 53/udp >/dev/null 2>&1 || true
fi
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
add_iptables_rule() {
local bin="$1" chain="$2"
"$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \
|| "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT"
}
if command -v iptables >/dev/null 2>&1; then
add_iptables_rule iptables PREROUTING
fi
if command -v ip6tables >/dev/null 2>&1; then
add_iptables_rule ip6tables PREROUTING || true
fi
# Fallback for minimal systems where only nft is present.
if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
nft add table inet sshpanel_nat 2>/dev/null || true
nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true
nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \
|| nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT"
fi
EOS
chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh
cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF'
[Unit]
Description=SSH Panel 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
EOF
systemctl daemon-reload
systemctl enable --now sshpanel-dnstt-redirect.service || warn "DNSTT DNS redirect service failed; check: journalctl -u sshpanel-dnstt-redirect -e"
info " DNSTT DNS redirect installed: UDP 53 -> 5300"
# ── 10. Systemd service ──────────────────────────────────────────────────────
info "[10/10] Creating systemd service '${SERVICE_NAME}'…"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=SSH Panel + Xray-core Server
After=network.target postgresql.service
Wants=postgresql.service
After=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
ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
Restart=always
RestartSec=5
@@ -371,6 +443,7 @@ echo -e " Server IP : ${YELLOW}${SERVER_IP}${NC}"
echo -e " SSH ports : 80, 8080 (HTTP-injected SSH)"
echo -e " VLESS port : 10086"
echo -e " VLESS UUID : ${YELLOW}${UUID}${NC}"
echo -e " DNSTT DNS : UDP 53 redirects to local UDP 5300"
echo ""
echo -e " Admin panel : ${YELLOW}http://${SERVER_IP}:9090${NC}"
echo -e " Admin login : ${YELLOW}admin${NC}"

35
main.go
View File

@@ -65,9 +65,8 @@ type Config struct {
// "[::]:80", "[2001:db8::20]:8080". Empty slice means no
// additional listeners.
ExtraListen []string `json:"extra_listen"`
// Optional: local-only raw SSH listener for other daemons (e.g. DNSTT upstream)
// Set to "127.0.0.1:2222" or similar. Leave empty to disable.
LocalSSHListen string `json:"local_ssh_listen"`
// Legacy compatibility only. DragonCore no longer starts a local raw SSH listener.
LocalSSHListen string `json:"local_ssh_listen,omitempty"`
HostKeyFile string `json:"host_key_file"`
Quiet bool `json:"quiet"`
@@ -1088,6 +1087,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
// Superadmin-only: server stats + DNSTT
mux.Handle("/api/stats", saSession(http.HandlerFunc(handleStats)))
mux.Handle("/api/system/logs", saSession(http.HandlerFunc(handleSystemLogs)))
mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats)))
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
@@ -2497,31 +2497,32 @@ func main() {
// Initialise default per-connection bandwidth limits.
setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown)
// 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).
publicPool = newListenerPool(serveHTTP80)
localPool = newListenerPool(serveRawSSH)
tlsPool = newTLSListenerPool()
for _, msg := range normalizeRuntimePorts(cfg) {
log.Printf("startup config fallback: %s", msg)
}
// Start the integrated DNSTT and UDPGW if configured. Startup errors are logged
// but do not crash the panel; the admin UI exposes the logs and service status.
if err := startDNSTT(cfg.DNSTT, sshConfig); err != nil {
log.Printf("dnstt auto-start failed: %v", err)
}
if err := startUDPGW(cfg.UDPGW); err != nil {
log.Printf("udpgw auto-start failed: %v", err)
}
// Start public SSH listeners (listen + extra_listen).
publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...)
for _, e := range publicPool.Sync(publicAddrs) {
log.Fatalf("failed to start listener: %v", e)
}
// Start local raw SSH listener if configured.
if cfg.LocalSSHListen != "" {
for _, e := range localPool.Sync([]string{cfg.LocalSSHListen}) {
log.Fatalf("failed to start local SSH listener: %v", e)
}
log.Printf("failed to start listener: %v", e)
}
// Start TLS forwarder listeners if configured.
for _, e := range tlsPool.Sync(cfg.TLSForwarders) {
log.Fatalf("failed to start TLS listener: %v", e)
log.Printf("failed to start TLS listener: %v", e)
}
// Print user counts once at startup.

View File

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

116
system_logs_api.go Normal file
View File

@@ -0,0 +1,116 @@
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 := strings.TrimSpace(os.Getenv("PANEL_LOG_FILE"))
if path == "" {
path = defaultPanelLogFile
}
resp.Path = path
lines, err := tailTextFile(path, limit)
if err != nil {
lines = []string{"unable to read " + path + ": " + err.Error()}
}
resp.Lines = lines
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func tailTextFile(path string, limit int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if limit <= 0 {
limit = 300
}
ring := make([]string, limit)
count := 0
scanner := bufio.NewScanner(f)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
ring[count%limit] = scanner.Text()
count++
}
if err := scanner.Err(); err != nil {
return nil, err
}
if count == 0 {
return []string{}, nil
}
outLen := count
if outLen > limit {
outLen = limit
}
out := make([]string, 0, outLen)
start := 0
if count > limit {
start = count % limit
}
for i := 0; i < outLen; i++ {
out = append(out, ring[(start+i)%limit])
}
return out, nil
}
func limitLines(lines []string, limit int) []string {
if len(lines) == 0 {
return []string{}
}
if limit <= 0 || len(lines) <= limit {
out := make([]string, len(lines))
copy(out, lines)
return out
}
out := make([]string, limit)
copy(out, lines[len(lines)-limit:])
return out
}

View File

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

View File

@@ -23,7 +23,7 @@ echo -e "${GREEN} SSH Panel · Updater ${NC}"
echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
# ── 1. Pre-flight checks ──────────────────────────────────────────────────────
info "[1/5] Pre-flight checks…"
info "[1/6] Pre-flight checks…"
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found — run install.sh first."
[[ -f "$INSTALL_DIR/.env" ]] || error "$INSTALL_DIR/.env not found — run install.sh first."
@@ -34,7 +34,7 @@ info " Source dir : $SCRIPT_DIR"
info " Go version : $GO_VERSION"
# ── 2. Go toolchain ───────────────────────────────────────────────────────────
info "[2/5] Checking Go toolchain…"
info "[2/6] Checking Go toolchain…"
NEED_GO=true
if command -v go &>/dev/null; then
@@ -67,7 +67,7 @@ export PATH=$PATH:/usr/local/go/bin
go version
# ── 3. Build new binary ───────────────────────────────────────────────────────
info "[3/5] Building new sshpanel binary…"
info "[3/6] Building new sshpanel binary…"
cd "$SCRIPT_DIR"
export GOPATH=/tmp/gopath_sshpanel
@@ -77,7 +77,7 @@ go build -ldflags="-s -w" -o /tmp/sshpanel_new .
info " Build complete."
# ── 4. Apply update ───────────────────────────────────────────────────────────
info "[4/5] Applying update…"
info "[4/6] Applying update…"
# Stop the service
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
@@ -103,6 +103,11 @@ info " Binary updated."
mkdir -p "$INSTALL_DIR/admin"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel updated."
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 updated."
fi
# Ensure banner file exists (new in this version)
if [[ ! -f "$INSTALL_DIR/banner.txt" ]]; then
@@ -132,6 +137,18 @@ PYEOF
info " Added banner_file to config.json"
fi
# Remove legacy local_ssh_listen. DragonCore now handles DNSTT in-process.
python3 - "$CFG" << 'PYEOF'
import json, sys
path = sys.argv[1]
with open(path) as f:
d = json.load(f)
changed = d.pop('local_ssh_listen', None) is not None
if changed:
with open(path, 'w') as f:
json.dump(d, f, indent=2)
PYEOF
# Fix routing: remove geoip:private rules that require geoip.dat from xray_config.json
XCFG="$INSTALL_DIR/xray_config.json"
if [[ -f "$XCFG" ]]; then
@@ -158,8 +175,72 @@ PYEOF
fi
fi
# ── 5. Restart service ────────────────────────────────────────────────────────
info "[5/5] Restarting service…"
# ── 5. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
info "[5/6] Ensuring DNSTT DNS redirect (UDP 53 -> 5300)…"
cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS'
#!/bin/bash
set -euo pipefail
DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}"
DNSTT_PORT="${DNSTT_PORT:-5300}"
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true
fi
rm -f /etc/resolv.conf
printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf
if command -v ufw >/dev/null 2>&1; then
ufw allow 53/udp >/dev/null 2>&1 || true
fi
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
add_iptables_rule() {
local bin="$1" chain="$2"
"$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \
|| "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT"
}
if command -v iptables >/dev/null 2>&1; then
add_iptables_rule iptables PREROUTING
fi
if command -v ip6tables >/dev/null 2>&1; then
add_iptables_rule ip6tables PREROUTING || true
fi
if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
nft add table inet sshpanel_nat 2>/dev/null || true
nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true
nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \
|| nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT"
fi
EOS
chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh
cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF'
[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
EOF
mkdir -p /etc/systemd/system/sshpanel.service.d
cat > /etc/systemd/system/sshpanel.service.d/override.conf <<EOF
[Unit]
Wants=sshpanel-dnstt-redirect.service
After=sshpanel-dnstt-redirect.service
[Service]
Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log
EOF
systemctl daemon-reload
systemctl enable --now sshpanel-dnstt-redirect.service || warn "DNSTT DNS redirect service failed; check: journalctl -u sshpanel-dnstt-redirect -e"
# ── 6. Restart service ────────────────────────────────────────────────────────
info "[6/6] Restarting service…"
if $RESTART_NEEDED; then
systemctl start "$SERVICE_NAME"