diff --git a/README.md b/README.md index 204ceea..d126620 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/admin/index.html b/admin/index.html index 515ea92..f5e316a 100644 --- a/admin/index.html +++ b/admin/index.html @@ -131,6 +131,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55, +
@@ -533,6 +534,25 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
+ +
+
+
+
System Logs
+
+ + +
+
+
Select a log source and click Refresh.
+
Ready.
+
+
+
@@ -547,14 +567,10 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55, live
-
+
-
- - -
@@ -749,7 +765,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
- All changes apply live. Only host_key_file requires a restart. + All service changes apply live.
@@ -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; diff --git a/auth.go b/auth.go index 8369195..eb47f5b 100644 --- a/auth.go +++ b/auth.go @@ -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) diff --git a/change_admin_password.sh b/change_admin_password.sh new file mode 100644 index 0000000..24e4641 --- /dev/null +++ b/change_admin_password.sh @@ -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 </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." diff --git a/config_safety.go b/config_safety.go new file mode 100644 index 0000000..9e9a82f --- /dev/null +++ b/config_safety.go @@ -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() +} diff --git a/dnstt_integration.go b/dnstt_integration.go index 79f3d70..96a1dd1 100644 --- a/dnstt_integration.go +++ b/dnstt_integration.go @@ -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 diff --git a/hotreload.go b/hotreload.go index b2aa09c..956a554 100644 --- a/hotreload.go +++ b/hotreload.go @@ -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, ", ") } diff --git a/install.sh b/install.sh index 2662597..ce7ab3c 100644 --- a/install.sh +++ b/install.sh @@ -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" < 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" < 0 { + report.Warnings = append(portWarnings, report.Warnings...) + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(report) } diff --git a/system_logs_api.go b/system_logs_api.go new file mode 100644 index 0000000..c5df239 --- /dev/null +++ b/system_logs_api.go @@ -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 +} diff --git a/udpgw_integration.go b/udpgw_integration.go index b3aa035..a6541c9 100644 --- a/udpgw_integration.go +++ b/udpgw_integration.go @@ -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 // cfg.Listen is non‑empty. 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 diff --git a/update.sh b/update.sh index 5459844..dc33908 100644 --- a/update.sh +++ b/update.sh @@ -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 <