From 8c27a7f5d9a64c4b2922ac2238f730607f4ae07e Mon Sep 17 00:00:00 2001 From: penguinehis Date: Fri, 29 May 2026 10:22:24 -0300 Subject: [PATCH] Fix test and online --- install_bridge.sh | 71 ++++++++++- main.go | 296 +++++++++++++++++++++++++++++++++++++++++----- update_bridge.sh | 47 ++++++++ 3 files changed, 382 insertions(+), 32 deletions(-) create mode 100644 update_bridge.sh diff --git a/install_bridge.sh b/install_bridge.sh index a9d3c59..d62d90e 100644 --- a/install_bridge.sh +++ b/install_bridge.sh @@ -4,6 +4,7 @@ set -euo pipefail REPO_URL="${REPO_URL:-}" BRANCH="${BRANCH:-main}" PORT="${PORT:-6969}" +PORT_SET=0 OPEN_FIREWALL="${OPEN_FIREWALL:-yes}" INSTALL_DIR="/opt/dragoncore-bridge" CONFIG_DIR="/etc/dragoncore-bridge" @@ -14,7 +15,7 @@ while [ $# -gt 0 ]; do case "$1" in --repo) REPO_URL="$2"; shift 2 ;; --branch) BRANCH="$2"; shift 2 ;; - --port) PORT="$2"; shift 2 ;; + --port) PORT="$2"; PORT_SET=1; shift 2 ;; --open-firewall) OPEN_FIREWALL="$2"; shift 2 ;; *) echo "Unknown option: $1"; exit 1 ;; esac @@ -28,6 +29,51 @@ fi need_cmd() { command -v "$1" >/dev/null 2>&1; } rand_hex() { openssl rand -hex "$1"; } +extract_listen_port() { + cfg="$1" + [ -f "$cfg" ] || return 1 + listen="$(grep -o '"listen"[[:space:]]*:[[:space:]]*"[^"]*"' "$cfg" | head -1 | cut -d '"' -f4 || true)" + port="${listen##*:}" + case "$port" in + ''|*[!0-9]*) return 1 ;; + *) printf '%s\n' "$port" ;; + esac +} + +port_in_use() { + p="$1" + if need_cmd ss; then + ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${p}$" + return $? + fi + if need_cmd netstat; then + netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${p}$" + return $? + fi + if need_cmd lsof; then + lsof -iTCP:"$p" -sTCP:LISTEN -Pn >/dev/null 2>&1 + return $? + fi + return 1 +} + +choose_free_port() { + base="$1" + case "$base" in ''|*[!0-9]*) base=6969 ;; esac + i=0 + while [ "$i" -le 100 ]; do + cand=$((base + i)) + if ! port_in_use "$cand"; then + printf '%s\n' "$cand" + return 0 + fi + i=$((i + 1)) + done + echo "No free TCP port found from $base to $((base + 100))." >&2 + exit 1 +} + + # The bridge source is intentionally Go 1.13-compatible so old VPS images # such as Ubuntu 20.04 can compile it with their distro package. # If the distro Go is older than 1.13, install a known-good official Go. @@ -78,14 +124,14 @@ install_official_go() { export DEBIAN_FRONTEND=noninteractive if need_cmd apt-get; then apt-get update -y - apt-get install -y ca-certificates curl wget git openssl build-essential + apt-get install -y ca-certificates curl wget git openssl build-essential sqlite3 lsof iproute2 if ! need_cmd go; then apt-get install -y golang-go || true fi elif need_cmd yum; then - yum install -y ca-certificates curl wget git openssl gcc make golang || yum install -y ca-certificates curl wget git openssl gcc make + yum install -y ca-certificates curl wget git openssl gcc make golang sqlite lsof iproute || yum install -y ca-certificates curl wget git openssl gcc make sqlite lsof iproute elif need_cmd dnf; then - dnf install -y ca-certificates curl wget git openssl gcc make golang || dnf install -y ca-certificates curl wget git openssl gcc make + dnf install -y ca-certificates curl wget git openssl gcc make golang sqlite lsof iproute || dnf install -y ca-certificates curl wget git openssl gcc make sqlite lsof iproute fi if go_version_ok; then @@ -114,6 +160,7 @@ else fi cd "$INSTALL_DIR/src" +if need_cmd systemctl; then systemctl stop dragoncore-bridge 2>/dev/null || true; fi "$GO_BIN" build -trimpath -ldflags "-s -w" -o "$BIN" . chmod 755 "$BIN" @@ -126,6 +173,16 @@ if [ -f "$CONFIG_DIR/config.json" ]; then USER_NAME="$(grep -o '"username"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || echo admin)" PASSWORD="$(grep -o '"password"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || rand_hex 10)" TOKEN="$(grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || rand_hex 24)" + EXISTING_PORT="$(extract_listen_port "$CONFIG_DIR/config.json" || true)" + if [ "$PORT_SET" = "0" ] && [ -n "$EXISTING_PORT" ]; then + PORT="$EXISTING_PORT" + fi +fi + +REQUESTED_PORT="$PORT" +PORT="$(choose_free_port "$PORT")" +if [ "$PORT" != "$REQUESTED_PORT" ]; then + echo "Port $REQUESTED_PORT is already in use. Using free port $PORT instead." fi cat > "$CONFIG_DIR/config.json" <&2 + journalctl -u dragoncore-bridge -n 40 --no-pager >&2 || true + exit 1 +fi if [ "$OPEN_FIREWALL" = "yes" ] || [ "$OPEN_FIREWALL" = "true" ] || [ "$OPEN_FIREWALL" = "1" ]; then if need_cmd ufw && ufw status 2>/dev/null | grep -qi "Status: active"; then diff --git a/main.go b/main.go index 75e1929..a7a9800 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,9 @@ package main import ( - "bytes" "crypto/rand" "encoding/hex" "encoding/json" - "errors" "flag" "fmt" "io/ioutil" @@ -82,7 +80,7 @@ func main() { if err := os.MkdirAll(cfg.DataDir, 0700); err != nil { log.Fatalf("data dir: %v", err) } - st, err := loadStore(filepath.Join(cfg.DataDir, "accounts.json")) + st, err := loadStore(filepath.Join(cfg.DataDir, "accounts.sqlite")) if err != nil { log.Fatalf("store: %v", err) } @@ -135,33 +133,171 @@ func loadConfig(path string) (Config, error) { func loadStore(path string) (*Store, error) { st := &Store{path: path, Accounts: map[string]Account{}} - b, err := ioutil.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return st, nil - } - if err != nil { + if err := initSQLiteStore(path); err != nil { return nil, err } - if len(bytes.TrimSpace(b)) == 0 { - return st, nil - } - if err := json.Unmarshal(b, st); err != nil { + if err := st.loadFromSQLite(); err != nil { return nil, err } - if st.Accounts == nil { - st.Accounts = map[string]Account{} + + // Backward compatibility: migrate the old JSON store the first time this + // version runs, then keep SQLite as the source of truth. + legacyPath := filepath.Join(filepath.Dir(path), "accounts.json") + if len(st.Accounts) == 0 { + if b, err := ioutil.ReadFile(legacyPath); err == nil && len(strings.TrimSpace(string(b))) > 0 { + var old Store + if err := json.Unmarshal(b, &old); err == nil && len(old.Accounts) > 0 { + for k, v := range old.Accounts { + st.Accounts[k] = v + } + st.mu.Lock() + if err := st.saveLocked(); err != nil { + st.mu.Unlock() + return nil, err + } + st.mu.Unlock() + _ = os.Rename(legacyPath, legacyPath+".migrated") + } + } } - st.path = path return st, nil } -func (s *Store) saveLocked() error { - tmp := s.path + ".tmp" - b, _ := json.MarshalIndent(s, "", " ") - if err := ioutil.WriteFile(tmp, b, 0600); err != nil { +func initSQLiteStore(path string) error { + if _, err := exec.LookPath("sqlite3"); err != nil { + return fmt.Errorf("sqlite3 is required for the bridge account store: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } - return os.Rename(tmp, s.path) + schema := ` +PRAGMA journal_mode=WAL; +CREATE TABLE IF NOT EXISTS accounts ( + username TEXT PRIMARY KEY, + password TEXT NOT NULL DEFAULT '', + max_connections INTEGER NOT NULL DEFAULT 0, + uuid TEXT NOT NULL DEFAULT '', + with_xray INTEGER NOT NULL DEFAULT 0, + expires_at_unix INTEGER NULL, + created_at_unix INTEGER NOT NULL DEFAULT 0 +); +` + return sqliteExec(path, schema) +} + +func sqliteExec(path, sql string) error { + cmd := exec.Command("sqlite3", path) + cmd.Stdin = strings.NewReader(sql) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("sqlite3: %v: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func sqliteQuery(path, sql string) ([]string, error) { + cmd := exec.Command("sqlite3", "-separator", "\t", "-nullvalue", "", path, sql) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("sqlite3 query: %v: %s", err, strings.TrimSpace(string(out))) + } + text := strings.TrimRight(string(out), "\n") + if text == "" { + return nil, nil + } + return strings.Split(text, "\n"), nil +} + +func sqlQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +func sqliteNullableTime(t *time.Time) string { + if t == nil { + return "NULL" + } + return strconv.FormatInt(t.Unix(), 10) +} + +func decodeHexField(s string) string { + b, err := hex.DecodeString(s) + if err != nil { + return "" + } + return string(b) +} + +func (s *Store) loadFromSQLite() error { + rows, err := sqliteQuery(s.path, `SELECT hex(username), hex(password), max_connections, hex(uuid), with_xray, COALESCE(expires_at_unix,''), created_at_unix FROM accounts;`) + if err != nil { + return err + } + for _, row := range rows { + cols := strings.Split(row, "\t") + if len(cols) < 7 { + continue + } + createdUnix, _ := strconv.ParseInt(cols[6], 10, 64) + created := time.Unix(createdUnix, 0) + if createdUnix <= 0 { + created = time.Now() + } + var exp *time.Time + if cols[5] != "" { + if expUnix, err := strconv.ParseInt(cols[5], 10, 64); err == nil && expUnix > 0 { + t := time.Unix(expUnix, 0) + exp = &t + } + } + limit, _ := strconv.Atoi(cols[2]) + withXray := cols[4] == "1" + username := decodeHexField(cols[0]) + if username == "" { + continue + } + s.Accounts[username] = Account{ + Username: username, + Password: decodeHexField(cols[1]), + MaxConnections: limit, + UUID: decodeHexField(cols[3]), + WithXray: withXray, + ExpiresAt: exp, + CreatedAt: created, + } + } + return nil +} + +func (s *Store) saveLocked() error { + var b strings.Builder + b.WriteString("BEGIN IMMEDIATE;\nDELETE FROM accounts;\n") + for username, ac := range s.Accounts { + created := ac.CreatedAt + if created.IsZero() { + created = time.Now() + } + b.WriteString("INSERT INTO accounts(username,password,max_connections,uuid,with_xray,expires_at_unix,created_at_unix) VALUES(") + b.WriteString(sqlQuote(username)) + b.WriteString(",") + b.WriteString(sqlQuote(ac.Password)) + b.WriteString(",") + b.WriteString(strconv.Itoa(ac.MaxConnections)) + b.WriteString(",") + b.WriteString(sqlQuote(ac.UUID)) + b.WriteString(",") + if ac.WithXray { + b.WriteString("1") + } else { + b.WriteString("0") + } + b.WriteString(",") + b.WriteString(sqliteNullableTime(ac.ExpiresAt)) + b.WriteString(",") + b.WriteString(strconv.FormatInt(created.Unix(), 10)) + b.WriteString(");\n") + } + b.WriteString("COMMIT;\n") + return sqliteExec(s.path, b.String()) } func randToken(n int) string { b := make([]byte, n); _, _ = rand.Read(b); return hex.EncodeToString(b) } @@ -414,11 +550,9 @@ func (a *App) deleteSSH(username, uuid string) error { if uuid != "" { _ = removeXrayClientAll(uuid) } - if _, err := user.Lookup(username); err == nil { - dummy, _ := passwordHash("disabled-dragoncore") - _ = exec.Command("usermod", "-p", dummy, username).Run() - _ = exec.Command("pkill", "-u", username).Run() - _ = exec.Command("userdel", "--force", username).Run() + + if err := forceRemoveSystemUser(username); err != nil { + return err } _ = removeCompatUserFiles(username) a.store.mu.Lock() @@ -428,6 +562,55 @@ func (a *App) deleteSSH(username, uuid string) error { return err } +func forceRemoveSystemUser(username string) error { + if _, err := user.Lookup(username); err != nil { + return nil + } + + // Linux user expiry has day granularity only. For minute/hour tests the + // bridge disables the password exactly at the stored SQLite expiry time, + // kills active SSH sessions, then deletes the OS user. + disableSystemPassword(username) + disconnectSSHUser(username) + + var lastErr error + for i := 0; i < 5; i++ { + if _, err := user.Lookup(username); err == nil { + out, err := exec.Command("userdel", "--force", username).CombinedOutput() + if err != nil { + lastErr = fmt.Errorf("userdel: %v: %s", err, strings.TrimSpace(string(out))) + } + } + disconnectSSHUser(username) + time.Sleep(time.Duration(300+i*200) * time.Millisecond) + if _, err := user.Lookup(username); err != nil && activeSSHConnectionCount(username) == 0 { + return nil + } + } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("unable to remove or disconnect user %s", username) +} + +func disableSystemPassword(username string) { + randomPass := "dragoncore-expired-" + randToken(16) + hash, err := passwordHash(randomPass) + if err == nil && hash != "" { + _ = exec.Command("usermod", "-p", hash, username).Run() + } + // Lock as well, because the purpose is to prevent reconnect while removal is + // in progress. Existing sessions are killed separately below. + _ = exec.Command("usermod", "-L", username).Run() +} + +func disconnectSSHUser(username string) { + _ = exec.Command("pkill", "-KILL", "-u", username).Run() + // The privileged OpenSSH process can still be owned by root, so -u username + // does not always catch it. Usernames are validated before this is called. + _ = exec.Command("pkill", "-KILL", "-f", "sshd: "+username).Run() +} + func passwordHash(password string) (string, error) { out, err := exec.Command("openssl", "passwd", "-1", password).Output() if err != nil { @@ -484,7 +667,7 @@ func removeLinePrefix(path, prefix string) { func (a *App) expiryLoop() { for { - time.Sleep(60 * time.Second) + time.Sleep(10 * time.Second) now := time.Now() var expired []Account a.store.mu.Lock() @@ -506,13 +689,70 @@ func listSystemUsers(st *Store) []map[string]interface{} { defer st.mu.Unlock() out := make([]map[string]interface{}, 0, len(st.Accounts)) for _, ac := range st.Accounts { - out = append(out, map[string]interface{}{"username": ac.Username, "active_conns": activeProcCount(ac.Username), "max_connections": ac.MaxConnections, "expires_at": ac.ExpiresAt, "uuid": ac.UUID}) + active := activeProcCount(ac.Username) + out = append(out, map[string]interface{}{ + "username": ac.Username, + "active_conns": active, + "active_connections": active, + "max_connections": ac.MaxConnections, + "expires_at": ac.ExpiresAt, + "uuid": ac.UUID, + "online_source": "openssh", + }) } sort.Slice(out, func(i, j int) bool { return fmt.Sprint(out[i]["username"]) < fmt.Sprint(out[j]["username"]) }) return out } func activeProcCount(username string) int { - out, err := exec.Command("pgrep", "-u", username).Output() + return activeSSHConnectionCount(username) +} + +func dragonCoreInstalled() bool { + paths := []string{"/opt/dragoncore", "/opt/DragonCore", "/opt/DragonCore/menu.php"} + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +func activeSSHConnectionCount(username string) int { + if username == "" { + return 0 + } + + // DragonCore installs have their own files, but this bridge is meant to be + // generic too. When /opt/dragoncore is not present we intentionally use the + // SSHPlus-compatible method: inspect the live OpenSSH processes per user. + _ = dragonCoreInstalled() + + out, err := exec.Command("ps", "-eo", "args=").Output() + if err != nil { + return activeSSHByPgrep(username) + } + privNeedle := "sshd: " + username + " [priv]" + atNeedle := "sshd: " + username + "@" + privCount := 0 + atCount := 0 + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, privNeedle) { + privCount++ + continue + } + if strings.Contains(line, atNeedle) { + atCount++ + } + } + if privCount > 0 { + return privCount + } + return atCount +} + +func activeSSHByPgrep(username string) int { + out, err := exec.Command("pgrep", "-u", username, "sshd").Output() if err != nil { return 0 } diff --git a/update_bridge.sh b/update_bridge.sh new file mode 100644 index 0000000..d7994ef --- /dev/null +++ b/update_bridge.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCore-Modules.git}" +BRANCH="${BRANCH:-main}" +CONFIG_DIR="/etc/dragoncore-bridge" + +if [ "$(id -u)" != "0" ]; then + echo "Run as root." + exit 1 +fi + +need_cmd() { command -v "$1" >/dev/null 2>&1; } + +# If this updater is executed from an extracted repository, use the local files. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/install_bridge.sh" ] && [ -f "$SCRIPT_DIR/main.go" ]; then + echo "Updating DragonCore Bridge from local source..." + bash "$SCRIPT_DIR/install_bridge.sh" "$@" + exit $? +fi + +export DEBIAN_FRONTEND=noninteractive +if need_cmd apt-get; then + apt-get update -y + apt-get install -y ca-certificates curl git +elif need_cmd yum; then + yum install -y ca-certificates curl git +elif need_cmd dnf; then + dnf install -y ca-certificates curl git +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +echo "Updating DragonCore Bridge from ${REPO_URL} (${BRANCH})..." +git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$TMP_DIR/src" + +# install_bridge.sh preserves existing username/password/token and existing port +# unless --port is explicitly supplied. It also migrates accounts.json to SQLite. +bash "$TMP_DIR/src/install_bridge.sh" "$@" + +if [ -f "$CONFIG_DIR/config.json" ]; then + echo + echo "Updated config: $CONFIG_DIR/config.json" +fi