Fix test and online
This commit is contained in:
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
REPO_URL="${REPO_URL:-}"
|
REPO_URL="${REPO_URL:-}"
|
||||||
BRANCH="${BRANCH:-main}"
|
BRANCH="${BRANCH:-main}"
|
||||||
PORT="${PORT:-6969}"
|
PORT="${PORT:-6969}"
|
||||||
|
PORT_SET=0
|
||||||
OPEN_FIREWALL="${OPEN_FIREWALL:-yes}"
|
OPEN_FIREWALL="${OPEN_FIREWALL:-yes}"
|
||||||
INSTALL_DIR="/opt/dragoncore-bridge"
|
INSTALL_DIR="/opt/dragoncore-bridge"
|
||||||
CONFIG_DIR="/etc/dragoncore-bridge"
|
CONFIG_DIR="/etc/dragoncore-bridge"
|
||||||
@@ -14,7 +15,7 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--repo) REPO_URL="$2"; shift 2 ;;
|
--repo) REPO_URL="$2"; shift 2 ;;
|
||||||
--branch) BRANCH="$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 ;;
|
--open-firewall) OPEN_FIREWALL="$2"; shift 2 ;;
|
||||||
*) echo "Unknown option: $1"; exit 1 ;;
|
*) echo "Unknown option: $1"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -28,6 +29,51 @@ fi
|
|||||||
need_cmd() { command -v "$1" >/dev/null 2>&1; }
|
need_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||||
rand_hex() { openssl rand -hex "$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
|
# 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.
|
# 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.
|
# 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
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
if need_cmd apt-get; then
|
if need_cmd apt-get; then
|
||||||
apt-get update -y
|
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
|
if ! need_cmd go; then
|
||||||
apt-get install -y golang-go || true
|
apt-get install -y golang-go || true
|
||||||
fi
|
fi
|
||||||
elif need_cmd yum; then
|
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
|
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
|
fi
|
||||||
|
|
||||||
if go_version_ok; then
|
if go_version_ok; then
|
||||||
@@ -114,6 +160,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$INSTALL_DIR/src"
|
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" .
|
"$GO_BIN" build -trimpath -ldflags "-s -w" -o "$BIN" .
|
||||||
chmod 755 "$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)"
|
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)"
|
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)"
|
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
|
fi
|
||||||
|
|
||||||
cat > "$CONFIG_DIR/config.json" <<EOF
|
cat > "$CONFIG_DIR/config.json" <<EOF
|
||||||
@@ -159,6 +216,12 @@ EOF
|
|||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now dragoncore-bridge
|
systemctl enable --now dragoncore-bridge
|
||||||
|
sleep 1
|
||||||
|
if ! systemctl is-active --quiet dragoncore-bridge; then
|
||||||
|
echo "dragoncore-bridge failed to start. Last logs:" >&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 [ "$OPEN_FIREWALL" = "yes" ] || [ "$OPEN_FIREWALL" = "true" ] || [ "$OPEN_FIREWALL" = "1" ]; then
|
||||||
if need_cmd ufw && ufw status 2>/dev/null | grep -qi "Status: active"; then
|
if need_cmd ufw && ufw status 2>/dev/null | grep -qi "Status: active"; then
|
||||||
|
|||||||
296
main.go
296
main.go
@@ -1,11 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
@@ -82,7 +80,7 @@ func main() {
|
|||||||
if err := os.MkdirAll(cfg.DataDir, 0700); err != nil {
|
if err := os.MkdirAll(cfg.DataDir, 0700); err != nil {
|
||||||
log.Fatalf("data dir: %v", err)
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("store: %v", err)
|
log.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
@@ -135,33 +133,171 @@ func loadConfig(path string) (Config, error) {
|
|||||||
|
|
||||||
func loadStore(path string) (*Store, error) {
|
func loadStore(path string) (*Store, error) {
|
||||||
st := &Store{path: path, Accounts: map[string]Account{}}
|
st := &Store{path: path, Accounts: map[string]Account{}}
|
||||||
b, err := ioutil.ReadFile(path)
|
if err := initSQLiteStore(path); err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(bytes.TrimSpace(b)) == 0 {
|
if err := st.loadFromSQLite(); err != nil {
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, st); err != nil {
|
|
||||||
return nil, err
|
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
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) saveLocked() error {
|
func initSQLiteStore(path string) error {
|
||||||
tmp := s.path + ".tmp"
|
if _, err := exec.LookPath("sqlite3"); err != nil {
|
||||||
b, _ := json.MarshalIndent(s, "", " ")
|
return fmt.Errorf("sqlite3 is required for the bridge account store: %w", err)
|
||||||
if err := ioutil.WriteFile(tmp, b, 0600); err != nil {
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||||
return err
|
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) }
|
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 != "" {
|
if uuid != "" {
|
||||||
_ = removeXrayClientAll(uuid)
|
_ = removeXrayClientAll(uuid)
|
||||||
}
|
}
|
||||||
if _, err := user.Lookup(username); err == nil {
|
|
||||||
dummy, _ := passwordHash("disabled-dragoncore")
|
if err := forceRemoveSystemUser(username); err != nil {
|
||||||
_ = exec.Command("usermod", "-p", dummy, username).Run()
|
return err
|
||||||
_ = exec.Command("pkill", "-u", username).Run()
|
|
||||||
_ = exec.Command("userdel", "--force", username).Run()
|
|
||||||
}
|
}
|
||||||
_ = removeCompatUserFiles(username)
|
_ = removeCompatUserFiles(username)
|
||||||
a.store.mu.Lock()
|
a.store.mu.Lock()
|
||||||
@@ -428,6 +562,55 @@ func (a *App) deleteSSH(username, uuid string) error {
|
|||||||
return err
|
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) {
|
func passwordHash(password string) (string, error) {
|
||||||
out, err := exec.Command("openssl", "passwd", "-1", password).Output()
|
out, err := exec.Command("openssl", "passwd", "-1", password).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -484,7 +667,7 @@ func removeLinePrefix(path, prefix string) {
|
|||||||
|
|
||||||
func (a *App) expiryLoop() {
|
func (a *App) expiryLoop() {
|
||||||
for {
|
for {
|
||||||
time.Sleep(60 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var expired []Account
|
var expired []Account
|
||||||
a.store.mu.Lock()
|
a.store.mu.Lock()
|
||||||
@@ -506,13 +689,70 @@ func listSystemUsers(st *Store) []map[string]interface{} {
|
|||||||
defer st.mu.Unlock()
|
defer st.mu.Unlock()
|
||||||
out := make([]map[string]interface{}, 0, len(st.Accounts))
|
out := make([]map[string]interface{}, 0, len(st.Accounts))
|
||||||
for _, ac := range 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"]) })
|
sort.Slice(out, func(i, j int) bool { return fmt.Sprint(out[i]["username"]) < fmt.Sprint(out[j]["username"]) })
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
func activeProcCount(username string) int {
|
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 {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
47
update_bridge.sh
Normal file
47
update_bridge.sh
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user