Compare commits
6 Commits
096a8275be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f26a83da3 | |||
| 6bee92ef4a | |||
| b581561a63 | |||
| 26777dbb89 | |||
| e857bdba67 | |||
| 8c27a7f5d9 |
@@ -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,18 +160,37 @@ 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"
|
||||
|
||||
USER_NAME="admin"
|
||||
PASSWORD="$(rand_hex 10)"
|
||||
TOKEN="$(rand_hex 24)"
|
||||
PANEL_URL=""
|
||||
PANEL_SERVER_ID="0"
|
||||
PANEL_SERVER_IP=""
|
||||
PANEL_PUSH_INTERVAL="60"
|
||||
|
||||
if [ -f "$CONFIG_DIR/config.json" ]; then
|
||||
echo "Existing config found: $CONFIG_DIR/config.json"
|
||||
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)"
|
||||
PANEL_URL="$(grep -o '"panel_url"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || echo '')"
|
||||
PANEL_SERVER_ID="$(grep -o '"panel_server_id"[[:space:]]*:[[:space:]]*[0-9]*' "$CONFIG_DIR/config.json" | head -1 | grep -o '[0-9]*$' || echo 0)"
|
||||
PANEL_SERVER_IP="$(grep -o '"panel_server_ip"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || echo '')"
|
||||
PANEL_PUSH_INTERVAL="$(grep -o '"panel_push_interval_seconds"[[:space:]]*:[[:space:]]*[0-9]*' "$CONFIG_DIR/config.json" | head -1 | grep -o '[0-9]*$' || echo 60)"
|
||||
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" <<EOF
|
||||
@@ -134,7 +199,11 @@ cat > "$CONFIG_DIR/config.json" <<EOF
|
||||
"username": "$USER_NAME",
|
||||
"password": "$PASSWORD",
|
||||
"token": "$TOKEN",
|
||||
"data_dir": "$CONFIG_DIR"
|
||||
"data_dir": "$CONFIG_DIR",
|
||||
"panel_url": "$PANEL_URL",
|
||||
"panel_server_id": $PANEL_SERVER_ID,
|
||||
"panel_server_ip": "$PANEL_SERVER_IP",
|
||||
"panel_push_interval_seconds": $PANEL_PUSH_INTERVAL
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$CONFIG_DIR/config.json"
|
||||
@@ -159,6 +228,12 @@ EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
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 need_cmd ufw && ufw status 2>/dev/null | grep -qi "Status: active"; then
|
||||
|
||||
765
main.go
765
main.go
@@ -2,10 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
@@ -26,11 +26,15 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Listen string `json:"listen"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
DataDir string `json:"data_dir"`
|
||||
PanelURL string `json:"panel_url"`
|
||||
PanelServerID int `json:"panel_server_id"`
|
||||
PanelServerIP string `json:"panel_server_ip"`
|
||||
PanelPushIntervalSeconds int `json:"panel_push_interval_seconds"`
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
@@ -50,10 +54,14 @@ type Store struct {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
sessions map[string]time.Time
|
||||
sessMu sync.Mutex
|
||||
store *Store
|
||||
cfg Config
|
||||
configPath string
|
||||
sessions map[string]time.Time
|
||||
sessMu sync.Mutex
|
||||
store *Store
|
||||
startedAt time.Time
|
||||
panelMu sync.Mutex
|
||||
lastPush time.Time
|
||||
}
|
||||
|
||||
var usernameRE = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,31}$`)
|
||||
@@ -78,23 +86,28 @@ func main() {
|
||||
if cfg.Password == "" || cfg.Token == "" {
|
||||
log.Fatalf("username/password/token must be configured in %s", *cfgPath)
|
||||
}
|
||||
if cfg.PanelPushIntervalSeconds <= 0 {
|
||||
cfg.PanelPushIntervalSeconds = 60
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
app := &App{cfg: cfg, sessions: map[string]time.Time{}, store: st}
|
||||
app := &App{cfg: cfg, configPath: *cfgPath, sessions: map[string]time.Time{}, store: st, startedAt: time.Now()}
|
||||
go app.expiryLoop()
|
||||
go app.onlinePushLoop()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", app.handleLegacyCommand)
|
||||
mux.HandleFunc("/api/auth/login", app.handleLogin)
|
||||
mux.Handle("/api/auth/me", app.auth(http.HandlerFunc(app.handleMe)))
|
||||
mux.Handle("/api/users", app.auth(http.HandlerFunc(app.handleUsers)))
|
||||
mux.Handle("/api/onlines", app.auth(http.HandlerFunc(app.handleOnlines)))
|
||||
mux.Handle("/api/users/create", app.auth(http.HandlerFunc(app.handleCreateUser)))
|
||||
mux.Handle("/api/users/delete", app.auth(http.HandlerFunc(app.handleDeleteUser)))
|
||||
mux.Handle("/api/dragonpanel/create", app.auth(http.HandlerFunc(app.handleDragonCreate)))
|
||||
@@ -135,33 +148,219 @@ 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 {
|
||||
if _, pyErr := exec.LookPath("python3"); pyErr != nil {
|
||||
return fmt.Errorf("sqlite account store requires sqlite3 or python3: sqlite3=%v python3=%v", err, pyErr)
|
||||
}
|
||||
}
|
||||
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
|
||||
);
|
||||
`
|
||||
if err := sqliteExec(path, schema); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteExec(path, sql string) error {
|
||||
if _, err := exec.LookPath("sqlite3"); err == nil {
|
||||
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
|
||||
}
|
||||
py := `import sqlite3, sys
|
||||
path = sys.argv[1]
|
||||
sql = sys.stdin.read()
|
||||
con = sqlite3.connect(path)
|
||||
try:
|
||||
con.executescript(sql)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
`
|
||||
cmd := exec.Command("python3", "-c", py, path)
|
||||
cmd.Stdin = strings.NewReader(sql)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("python sqlite exec: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteQuery(path, sql string) ([]string, error) {
|
||||
if _, err := exec.LookPath("sqlite3"); err == nil {
|
||||
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
|
||||
}
|
||||
py := `import sqlite3, sys
|
||||
path = sys.argv[1]
|
||||
sql = sys.stdin.read()
|
||||
con = sqlite3.connect(path)
|
||||
try:
|
||||
cur = con.execute(sql)
|
||||
for row in cur.fetchall():
|
||||
print("\t".join("" if v is None else str(v) for v in row))
|
||||
finally:
|
||||
con.close()
|
||||
`
|
||||
cmd := exec.Command("python3", "-c", py, path)
|
||||
cmd.Stdin = strings.NewReader(sql)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("python sqlite 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) }
|
||||
@@ -182,7 +381,9 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
errText(w, 400, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Username != a.cfg.Username || req.Password != a.cfg.Password {
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
if req.Username != a.cfg.Username || (req.Password != a.cfg.Password && req.Password != a.cfg.Token) {
|
||||
errText(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
@@ -195,11 +396,30 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) auth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Senha") == a.cfg.Token || strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") == a.cfg.Token {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
staticTokens := []string{
|
||||
r.Header.Get("Senha"),
|
||||
r.Header.Get("X-API-Token"),
|
||||
r.Header.Get("X-Bridge-Token"),
|
||||
r.Header.Get("X-Auth-Token"),
|
||||
r.Header.Get("X-Bridge-Password"),
|
||||
bearer,
|
||||
}
|
||||
for _, tok := range staticTokens {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok != "" && (tok == a.cfg.Token || tok == a.cfg.Password) {
|
||||
a.learnPanelFromRequest(r)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// A login session may be sent either as X-Session-Token or as a Bearer
|
||||
// token. Accept both so existing panels and direct curl tests work.
|
||||
tok := strings.TrimSpace(r.Header.Get("X-Session-Token"))
|
||||
if tok == "" {
|
||||
tok = bearer
|
||||
}
|
||||
tok := r.Header.Get("X-Session-Token")
|
||||
a.sessMu.Lock()
|
||||
exp, ok := a.sessions[tok]
|
||||
if ok && time.Now().After(exp) {
|
||||
@@ -211,6 +431,7 @@ func (a *App) auth(next http.Handler) http.Handler {
|
||||
errText(w, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
a.learnPanelFromRequest(r)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -228,6 +449,235 @@ func (a *App) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, users)
|
||||
}
|
||||
|
||||
func (a *App) handleOnlines(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
counts := collectOnlineCounts()
|
||||
users := make([]map[string]interface{}, 0, len(counts))
|
||||
total := 0
|
||||
for username, count := range counts {
|
||||
if count <= 0 {
|
||||
continue
|
||||
}
|
||||
users = append(users, map[string]interface{}{"username": username, "usuario": username, "quantidade": count, "active_conns": count})
|
||||
total += count
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool { return fmt.Sprint(users[i]["username"]) < fmt.Sprint(users[j]["username"]) })
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "total": total, "users": users})
|
||||
}
|
||||
|
||||
func normalizePanelURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(raw), "http://") && !strings.HasPrefix(strings.ToLower(raw), "https://") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(raw, "/")
|
||||
}
|
||||
|
||||
func (a *App) learnPanelFromRequest(r *http.Request) {
|
||||
panelURL := normalizePanelURL(r.Header.Get("X-Dragon-Panel-Url"))
|
||||
serverIP := strings.TrimSpace(r.Header.Get("X-Dragon-Panel-Server-Ip"))
|
||||
serverID, _ := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Dragon-Panel-Server-Id")))
|
||||
if panelURL == "" && serverID <= 0 && serverIP == "" {
|
||||
return
|
||||
}
|
||||
a.panelMu.Lock()
|
||||
changed := false
|
||||
if panelURL != "" && panelURL != a.cfg.PanelURL {
|
||||
a.cfg.PanelURL = panelURL
|
||||
changed = true
|
||||
}
|
||||
if serverID > 0 && serverID != a.cfg.PanelServerID {
|
||||
a.cfg.PanelServerID = serverID
|
||||
changed = true
|
||||
}
|
||||
if serverIP != "" && serverIP != a.cfg.PanelServerIP {
|
||||
a.cfg.PanelServerIP = serverIP
|
||||
changed = true
|
||||
}
|
||||
cfgCopy := a.cfg
|
||||
a.panelMu.Unlock()
|
||||
if changed {
|
||||
log.Printf("panel online push target set to %s server_id=%d server_ip=%s", cfgCopy.PanelURL, cfgCopy.PanelServerID, cfgCopy.PanelServerIP)
|
||||
a.saveConfig(cfgCopy)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) saveConfig(cfg Config) {
|
||||
if strings.TrimSpace(a.configPath) == "" {
|
||||
return
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := ioutil.WriteFile(a.configPath, append(b, '\n'), 0600); err != nil {
|
||||
log.Printf("saving bridge config failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) onlinePushLoop() {
|
||||
initialDelay := 10 * time.Second
|
||||
time.Sleep(initialDelay)
|
||||
for {
|
||||
interval := a.cfg.PanelPushIntervalSeconds
|
||||
if interval <= 0 {
|
||||
interval = 60
|
||||
}
|
||||
a.pushOnlineSnapshot()
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) pushOnlineSnapshot() {
|
||||
a.panelMu.Lock()
|
||||
panelURL := normalizePanelURL(a.cfg.PanelURL)
|
||||
serverID := a.cfg.PanelServerID
|
||||
serverIP := a.cfg.PanelServerIP
|
||||
a.panelMu.Unlock()
|
||||
if panelURL == "" {
|
||||
return
|
||||
}
|
||||
counts := collectOnlineCounts()
|
||||
users := make([]map[string]interface{}, 0, len(counts))
|
||||
total := 0
|
||||
for username, count := range counts {
|
||||
if count <= 0 {
|
||||
continue
|
||||
}
|
||||
users = append(users, map[string]interface{}{"usuario": username, "username": username, "quantidade": count})
|
||||
total += count
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool { return fmt.Sprint(users[i]["usuario"]) < fmt.Sprint(users[j]["usuario"]) })
|
||||
body := map[string]interface{}{
|
||||
"server_id": serverID,
|
||||
"server_ip": serverIP,
|
||||
"hostname": hostname(),
|
||||
"total": total,
|
||||
"onlines": users,
|
||||
"sent_at": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
endpoint := panelURL + "/brigdge_onlines"
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
// The panel validates this against the same bridge/module password/token that
|
||||
// was registered in the server form.
|
||||
req.Header.Set("Senha", a.cfg.Password)
|
||||
req.Header.Set("X-API-Token", a.cfg.Token)
|
||||
req.Header.Set("X-Bridge-Password", a.cfg.Password)
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.Token)
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("online push failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := ioutil.ReadAll(resp.Body)
|
||||
log.Printf("online push rejected: http=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
return
|
||||
}
|
||||
a.panelMu.Lock()
|
||||
a.lastPush = time.Now()
|
||||
a.panelMu.Unlock()
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
h, err := os.Hostname()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func collectOnlineCounts() map[string]int {
|
||||
counts := map[string]int{}
|
||||
commands := []string{
|
||||
`ps -ef | grep -oP "sshd: \K\w+(?= \[priv\])" || true`,
|
||||
`sed '/^10.8.0./d' /etc/openvpn/openvpn-status.log 2>/dev/null | grep 127.0.0.1 | awk -F',' '{print $1}' || true`,
|
||||
`printf 'status\n' | nc -q0 127.0.0.1 7505 2>/dev/null | grep -oP '.*?,\K.*?(?=,)' | sort | uniq | grep -v ':' || true`,
|
||||
`awk -v date="$(date -d '60 seconds ago' +'%Y/%m/%d %H:%M:%S')" '$0 > date && /email:/ { sub(/.*email: /, "", $0); sub(/@gmail\.com$/, "", $0); if (!seen[$0]++) print }' /var/log/v2ray/access.log 2>/dev/null || true`,
|
||||
}
|
||||
for _, cmd := range commands {
|
||||
for _, line := range runShellLines(cmd, 8*time.Second) {
|
||||
user := sanitizeOnlineUser(line)
|
||||
if user == "" {
|
||||
continue
|
||||
}
|
||||
counts[user]++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func runShellLines(command string, timeout time.Duration) []string {
|
||||
var cmd *exec.Cmd
|
||||
if timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
cmd = exec.CommandContext(ctx, "bash", "-lc", command)
|
||||
} else {
|
||||
cmd = exec.Command("bash", "-lc", command)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return splitLines(string(out))
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
parts := strings.Split(s, "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeOnlineUser(line string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return ""
|
||||
}
|
||||
badFragments := []string{
|
||||
"No such file or directory",
|
||||
"nc: port number invalid",
|
||||
"UNDEF",
|
||||
"unknown",
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if lower == "root" {
|
||||
return ""
|
||||
}
|
||||
for _, bad := range badFragments {
|
||||
if strings.Contains(line, bad) || strings.Contains(lower, strings.ToLower(bad)) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(line, " \t,;|&<>$`\\\"'") {
|
||||
return ""
|
||||
}
|
||||
if len(line) > 64 {
|
||||
return ""
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
@@ -238,8 +688,6 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
Password *string `json:"password"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
LimitUpMbps int `json:"limit_mbps_up"`
|
||||
LimitDownMbps int `json:"limit_mbps_down"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
errText(w, 400, "invalid json")
|
||||
@@ -284,6 +732,7 @@ func (a *App) handleDragonCreate(w http.ResponseWriter, r *http.Request) {
|
||||
Password string `json:"password"`
|
||||
UUID string `json:"uuid"`
|
||||
Days int `json:"days"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
WithXray bool `json:"with_xray"`
|
||||
@@ -296,6 +745,9 @@ func (a *App) handleDragonCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if p.Minutes > 0 {
|
||||
t := time.Now().Add(time.Duration(p.Minutes) * time.Minute)
|
||||
exp = &t
|
||||
} else if p.Hours > 0 {
|
||||
t := time.Now().Add(time.Duration(p.Hours) * time.Hour)
|
||||
exp = &t
|
||||
} else if p.Days > 0 {
|
||||
t := time.Now().AddDate(0, 0, p.Days)
|
||||
exp = &t
|
||||
@@ -370,15 +822,12 @@ func (a *App) createSSH(username, password string, limit int, expiresAt *time.Ti
|
||||
}
|
||||
args := []string{"-M", "-s", "/bin/false", "-p", hash}
|
||||
if expiresAt != nil {
|
||||
linuxExpiry := *expiresAt
|
||||
if linuxExpiry.Before(time.Now().Add(24 * time.Hour)) {
|
||||
linuxExpiry = time.Now().AddDate(0, 0, 2)
|
||||
}
|
||||
linuxExpiry := linuxExpiryForAccount(*expiresAt)
|
||||
args = append(args, "-e", linuxExpiry.Format("2006-01-02"))
|
||||
}
|
||||
args = append(args, username)
|
||||
if out, err := exec.Command("useradd", args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("useradd: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
if err := runUserAdd(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCompatUserFiles(username, password, limit); err != nil {
|
||||
log.Printf("compat files: %v", err)
|
||||
@@ -394,6 +843,80 @@ func (a *App) createSSH(username, password string, limit int, expiresAt *time.Ti
|
||||
return a.store.saveLocked()
|
||||
}
|
||||
|
||||
func linuxExpiryForAccount(expiresAt time.Time) time.Time {
|
||||
// Linux account expiry is date-only and cannot safely represent accounts that
|
||||
// expire in minutes or hours. For those short tests, keep the system account
|
||||
// valid for seven days and let the bridge SQLite expiry loop delete it at the
|
||||
// exact minute/hour.
|
||||
if expiresAt.Before(time.Now().Add(24 * time.Hour)) {
|
||||
return time.Now().AddDate(0, 0, 7)
|
||||
}
|
||||
return expiresAt
|
||||
}
|
||||
|
||||
func runUserAdd(args []string) error {
|
||||
username := ""
|
||||
if len(args) > 0 {
|
||||
username = args[len(args)-1]
|
||||
}
|
||||
|
||||
attempts := [][]string{}
|
||||
// DragonCore test usernames are intentionally short and can start with digits
|
||||
// like 820etl/897blb. Several distros reject those unless --badname is passed.
|
||||
// Try the permissive flags first for numeric-start names, then fall back to the
|
||||
// plain useradd call for distros that do not support those options.
|
||||
if username != "" && username[0] >= '0' && username[0] <= '9' {
|
||||
attempts = append(attempts,
|
||||
append([]string{"--badname"}, args...),
|
||||
append([]string{"--force-badname"}, args...),
|
||||
)
|
||||
}
|
||||
attempts = append(attempts, args)
|
||||
|
||||
var firstErr error
|
||||
var firstOut string
|
||||
var lastErr error
|
||||
var lastOut string
|
||||
for _, tryArgs := range attempts {
|
||||
out, err := exec.Command("useradd", tryArgs...).CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
firstOut = strings.TrimSpace(string(out))
|
||||
}
|
||||
lastErr = err
|
||||
lastOut = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// If the plain call failed because of username policy, retry permissive flags
|
||||
// even for non-numeric names. This keeps compatibility with strict distros.
|
||||
if firstErr != nil && len(attempts) == 1 {
|
||||
text := strings.ToLower(firstOut)
|
||||
nameRejected := strings.Contains(text, "invalid user name") ||
|
||||
strings.Contains(text, "invalid username") ||
|
||||
strings.Contains(text, "bad name") ||
|
||||
strings.Contains(text, "does not match")
|
||||
if nameRejected {
|
||||
for _, opt := range []string{"--badname", "--force-badname"} {
|
||||
retryArgs := append([]string{opt}, args...)
|
||||
out, err := exec.Command("useradd", retryArgs...).CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
lastOut = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("useradd: %v: %s", lastErr, lastOut)
|
||||
}
|
||||
return fmt.Errorf("useradd failed: %v: %s", firstErr, firstOut)
|
||||
}
|
||||
|
||||
func (a *App) deleteSSH(username, uuid string) error {
|
||||
if username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
@@ -414,11 +937,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 := removeSystemUser(username, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = removeCompatUserFiles(username)
|
||||
a.store.mu.Lock()
|
||||
@@ -428,6 +949,57 @@ func (a *App) deleteSSH(username, uuid string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func removeSystemUser(username string, randomizePassword bool) error {
|
||||
if _, err := user.Lookup(username); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Never randomize a password for normal delete/recreate/update paths. The
|
||||
// random password step is only for a live expiry event, and even that is
|
||||
// skipped for accounts that were already expired before the bridge booted.
|
||||
if randomizePassword {
|
||||
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 {
|
||||
@@ -482,9 +1054,32 @@ func removeLinePrefix(path, prefix string) {
|
||||
_ = ioutil.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0644)
|
||||
}
|
||||
|
||||
func (a *App) expireSSH(ac Account) error {
|
||||
if ac.Username == "" {
|
||||
return nil
|
||||
}
|
||||
if ac.UUID != "" {
|
||||
_ = removeXrayClientAll(ac.UUID)
|
||||
}
|
||||
|
||||
// If the account was already expired before this process started, this is a
|
||||
// boot/restart cleanup. Do not change its password during boot; just remove
|
||||
// and disconnect it. Live expiries after boot may randomize/lock first.
|
||||
randomizePassword := ac.ExpiresAt != nil && ac.ExpiresAt.After(a.startedAt)
|
||||
if err := removeSystemUser(ac.Username, randomizePassword); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = removeCompatUserFiles(ac.Username)
|
||||
a.store.mu.Lock()
|
||||
delete(a.store.Accounts, ac.Username)
|
||||
err := a.store.saveLocked()
|
||||
a.store.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -496,23 +1091,83 @@ func (a *App) expiryLoop() {
|
||||
a.store.mu.Unlock()
|
||||
for _, ac := range expired {
|
||||
log.Printf("expiring %s", ac.Username)
|
||||
_ = a.deleteSSH(ac.Username, ac.UUID)
|
||||
if err := a.expireSSH(ac); err != nil {
|
||||
log.Printf("expire %s failed: %v", ac.Username, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listSystemUsers(st *Store) []map[string]interface{} {
|
||||
counts := collectOnlineCounts()
|
||||
st.mu.Lock()
|
||||
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 := counts[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": "bridge-local",
|
||||
})
|
||||
}
|
||||
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 collectOnlineCounts()[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
|
||||
}
|
||||
@@ -525,6 +1180,14 @@ func activeProcCount(username string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func positiveAtoi(s string) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseTimeMaybe(s string) (*time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
|
||||
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