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,
Xray
Resellers
Stats
+ Logs
Server Config
@@ -533,6 +534,25 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
+
+
+
+
+
System Logs
+
+
+ Panel / system
+ DNSTT
+ Xray
+
+ Refresh
+
+
+
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
-
Extra Listen Addresses (one per line, e.g. 0.0.0.0:8080)
@@ -749,7 +765,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
Save Config
Reload
- 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 <