package main import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "flag" "fmt" "io/ioutil" "log" "net" "net/http" "os" "os/exec" "os/user" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "syscall" "time" ) type Config struct { 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 { Username string `json:"username"` Password string `json:"password,omitempty"` MaxConnections int `json:"max_connections"` UUID string `json:"uuid,omitempty"` WithXray bool `json:"with_xray"` ExpiresAt *time.Time `json:"expires_at,omitempty"` CreatedAt time.Time `json:"created_at"` } type Store struct { mu sync.Mutex path string Accounts map[string]Account `json:"accounts"` } type App struct { 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}$`) func main() { cfgPath := flag.String("config", getenv("DRAGON_BRIDGE_CONFIG", "/etc/dragoncore-bridge/config.json"), "config file") flag.Parse() cfg, err := loadConfig(*cfgPath) if err != nil { log.Fatalf("config: %v", err) } if cfg.Listen == "" { cfg.Listen = ":6969" } if cfg.DataDir == "" { cfg.DataDir = "/etc/dragoncore-bridge" } if cfg.Username == "" { cfg.Username = "admin" } 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.sqlite")) if err != nil { log.Fatalf("store: %v", err) } 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))) mux.Handle("/api/dragonpanel/delete", app.auth(http.HandlerFunc(app.handleDragonDelete))) mux.Handle("/api/dragonpanel/sync", app.auth(http.HandlerFunc(app.handleDragonSync))) mux.Handle("/api/stats", app.auth(http.HandlerFunc(app.handleStats))) mux.Handle("/api/system/reboot", app.auth(http.HandlerFunc(app.handleReboot))) mux.Handle("/api/system/restart-ssh", app.auth(http.HandlerFunc(app.handleRestartSSH))) mux.Handle("/api/system/cleanup", app.auth(http.HandlerFunc(app.handleCleanup))) mux.Handle("/api/xray/fix", app.auth(http.HandlerFunc(app.handleXrayFix))) mux.Handle("/api/xray/inbounds", app.auth(http.HandlerFunc(app.handleXrayInbounds))) mux.Handle("/api/xray/clients/add", app.auth(http.HandlerFunc(app.handleXrayClientAdd))) mux.Handle("/api/xray/clients/remove", app.auth(http.HandlerFunc(app.handleXrayClientRemove))) log.Printf("DragonCore bridge listening on %s", cfg.Listen) srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 10 * time.Second} log.Fatal(srv.ListenAndServe()) } func getenv(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func loadConfig(path string) (Config, error) { b, err := ioutil.ReadFile(path) if err != nil { return Config{}, err } var c Config if err := json.Unmarshal(b, &c); err != nil { return Config{}, err } return c, nil } func loadStore(path string) (*Store, error) { st := &Store{path: path, Accounts: map[string]Account{}} if err := initSQLiteStore(path); err != nil { return nil, err } if err := st.loadFromSQLite(); err != nil { return nil, err } // 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") } } } return st, 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 } 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) } func writeJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func errText(w http.ResponseWriter, code int, msg string) { http.Error(w, msg, code) } func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ Username, Password string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { errText(w, 400, "invalid json") return } 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 } tok := randToken(24) a.sessMu.Lock() a.sessions[tok] = time.Now().Add(12 * time.Hour) a.sessMu.Unlock() writeJSON(w, map[string]interface{}{"token": tok, "username": a.cfg.Username, "role": "admin"}) } func (a *App) auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 } a.sessMu.Lock() exp, ok := a.sessions[tok] if ok && time.Now().After(exp) { delete(a.sessions, tok) ok = false } a.sessMu.Unlock() if !ok { errText(w, 401, "unauthorized") return } a.learnPanelFromRequest(r) next.ServeHTTP(w, r) }) } func (a *App) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"username": a.cfg.Username, "role": "admin"}) } func (a *App) handleUsers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } users := listSystemUsers(a.store) 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) return } var p struct { Username string `json:"username"` Password *string `json:"password"` MaxConnections int `json:"max_connections"` ExpiresAt string `json:"expires_at"` } if err := json.NewDecoder(r.Body).Decode(&p); err != nil { errText(w, 400, "invalid json") return } pass := "" if p.Password != nil { pass = *p.Password } exp, _ := parseTimeMaybe(p.ExpiresAt) if err := a.createSSH(p.Username, pass, p.MaxConnections, exp, "", false); err != nil { errText(w, 400, err.Error()) return } w.WriteHeader(http.StatusCreated) } func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { w.WriteHeader(http.StatusMethodNotAllowed) return } u := r.URL.Query().Get("username") if u == "" { errText(w, 400, "username required") return } if err := a.deleteSSH(u, ""); err != nil { errText(w, 400, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (a *App) handleDragonCreate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var p struct { Username string `json:"username"` 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"` } if err := json.NewDecoder(r.Body).Decode(&p); err != nil { errText(w, 400, "invalid json") return } var exp *time.Time 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 } if err := a.createSSH(p.Username, p.Password, p.MaxConnections, exp, p.UUID, p.WithXray); err != nil { errText(w, 400, err.Error()) return } writeJSON(w, map[string]interface{}{"ok": true}) } func (a *App) handleDragonDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodDelete { w.WriteHeader(http.StatusMethodNotAllowed) return } var p struct{ Username, UUID string } if r.Method == http.MethodDelete { p.Username = r.URL.Query().Get("username") p.UUID = r.URL.Query().Get("uuid") } else { _ = json.NewDecoder(r.Body).Decode(&p) } if err := a.deleteSSH(p.Username, p.UUID); err != nil { errText(w, 400, err.Error()) return } writeJSON(w, map[string]interface{}{"ok": true}) } func (a *App) handleDragonSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { Accounts []struct { Username string `json:"username"` Password string `json:"password"` ExpiresAt string `json:"expires_at"` UUID string `json:"uuid"` MaxConnections int `json:"max_connections"` } `json:"accounts"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { errText(w, 400, "invalid json") return } ok, fail := 0, []string{} for _, ac := range req.Accounts { exp, _ := parseTimeMaybe(ac.ExpiresAt) if err := a.createSSH(ac.Username, ac.Password, ac.MaxConnections, exp, ac.UUID, ac.UUID != ""); err != nil { fail = append(fail, ac.Username+":"+err.Error()) } else { ok++ } } writeJSON(w, map[string]interface{}{"ok": ok, "fail": fail}) } func (a *App) createSSH(username, password string, limit int, expiresAt *time.Time, uuid string, withXray bool) error { if !usernameRE.MatchString(username) { return fmt.Errorf("invalid username") } if password == "" { return fmt.Errorf("password required") } _ = a.deleteSSH(username, "") hash, err := passwordHash(password) if err != nil { return err } args := []string{"-M", "-s", "/bin/false", "-p", hash} if expiresAt != nil { linuxExpiry := linuxExpiryForAccount(*expiresAt) args = append(args, "-e", linuxExpiry.Format("2006-01-02")) } args = append(args, username) if err := runUserAdd(args); err != nil { return err } if err := writeCompatUserFiles(username, password, limit); err != nil { log.Printf("compat files: %v", err) } if withXray && uuid != "" { if err := addXrayClientAll(uuid, username); err != nil { return err } } a.store.mu.Lock() defer a.store.mu.Unlock() a.store.Accounts[username] = Account{Username: username, Password: password, MaxConnections: limit, UUID: uuid, WithXray: withXray, ExpiresAt: expiresAt, CreatedAt: time.Now()} 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") } if username == "root" { return fmt.Errorf("refusing to delete root") } if !usernameRE.MatchString(username) { return fmt.Errorf("invalid username") } if uuid == "" { a.store.mu.Lock() if ac, ok := a.store.Accounts[username]; ok { uuid = ac.UUID } a.store.mu.Unlock() } if uuid != "" { _ = removeXrayClientAll(uuid) } if err := removeSystemUser(username, false); err != nil { return err } _ = removeCompatUserFiles(username) a.store.mu.Lock() delete(a.store.Accounts, username) err := a.store.saveLocked() a.store.mu.Unlock() 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 { return "", fmt.Errorf("openssl passwd failed: %w", err) } return strings.TrimSpace(string(out)), nil } func writeCompatUserFiles(username, password string, limit int) error { _ = os.MkdirAll("/etc/SSHPlus/senha", 0755) _ = ioutil.WriteFile("/etc/SSHPlus/senha/"+username, []byte(password+"\n"), 0600) upsertLine("/root/usuarios.db", username, fmt.Sprintf("%s %d", username, limit)) if _, err := os.Stat("/opt/DragonCore/menu.php"); err == nil { _ = exec.Command("php", "/opt/DragonCore/menu.php", "deleteData", username).Run() _ = exec.Command("php", "/opt/DragonCore/menu.php", "insertData", username, password, strconv.Itoa(limit)).Run() } return nil } func removeCompatUserFiles(username string) error { _ = os.Remove("/etc/SSHPlus/senha/" + username) removeLinePrefix("/root/usuarios.db", username) if _, err := os.Stat("/opt/DragonCore/menu.php"); err == nil { _ = exec.Command("php", "/opt/DragonCore/menu.php", "deleteData", username).Run() } return nil } func upsertLine(path, prefix, line string) { removeLinePrefix(path, prefix) f, _ := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if f != nil { defer f.Close() _, _ = f.WriteString(line + "\n") } } func removeLinePrefix(path, prefix string) { b, err := ioutil.ReadFile(path) if err != nil { return } var out []string for _, l := range strings.Split(string(b), "\n") { if l == "" { continue } if strings.HasPrefix(l, prefix+" ") || l == prefix { continue } out = append(out, l) } _ = 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(10 * time.Second) now := time.Now() var expired []Account a.store.mu.Lock() for _, ac := range a.store.Accounts { if ac.ExpiresAt != nil && now.After(*ac.ExpiresAt) { expired = append(expired, ac) } } a.store.mu.Unlock() for _, ac := range expired { log.Printf("expiring %s", ac.Username) 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 { 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 { 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 } n := 0 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { if l != "" { n++ } } 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 == "" { return nil, nil } layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} for _, l := range layouts { if t, err := time.Parse(l, s); err == nil { return &t, nil } } return nil, fmt.Errorf("invalid time") } func xrayConfigPaths() []string { return []string{"/usr/local/etc/xray/config.json", "/etc/xray/config.json", "/etc/v2ray/config.json"} } func readJSONFile(path string) (map[string]interface{}, error) { b, err := ioutil.ReadFile(path) if err != nil { return nil, err } var m map[string]interface{} err = json.Unmarshal(b, &m) return m, err } func writeJSONFile(path string, m map[string]interface{}) error { b, err := json.MarshalIndent(m, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := ioutil.WriteFile(tmp, b, 0644); err != nil { return err } return os.Rename(tmp, path) } func addXrayClientAll(uuid, email string) error { changed := false for _, path := range xrayConfigPaths() { if _, err := os.Stat(path); err != nil { continue } cfg, err := readJSONFile(path) if err != nil { return err } inb, _ := cfg["inbounds"].([]interface{}) fileChanged := false for _, it := range inb { im, ok := it.(map[string]interface{}) if !ok || fmt.Sprint(im["protocol"]) != "vless" { continue } settings, _ := im["settings"].(map[string]interface{}) if settings == nil { settings = map[string]interface{}{} im["settings"] = settings } clients, _ := settings["clients"].([]interface{}) exists := false for _, c := range clients { cm, _ := c.(map[string]interface{}) if fmt.Sprint(cm["id"]) == uuid || fmt.Sprint(cm["email"]) == email { exists = true } } if !exists { settings["clients"] = append(clients, map[string]interface{}{"id": uuid, "alterId": 0, "email": email}) fileChanged = true } } if fileChanged { if err := writeJSONFile(path, cfg); err != nil { return err } changed = true } } if changed { restartXray() } return nil } func removeXrayClientAll(uuid string) error { changed := false for _, path := range xrayConfigPaths() { if _, err := os.Stat(path); err != nil { continue } cfg, err := readJSONFile(path) if err != nil { return err } inb, _ := cfg["inbounds"].([]interface{}) fileChanged := false for _, it := range inb { im, ok := it.(map[string]interface{}) if !ok || fmt.Sprint(im["protocol"]) != "vless" { continue } settings, _ := im["settings"].(map[string]interface{}) if settings == nil { continue } clients, _ := settings["clients"].([]interface{}) out := make([]interface{}, 0, len(clients)) for _, c := range clients { cm, _ := c.(map[string]interface{}) if fmt.Sprint(cm["id"]) == uuid { fileChanged = true continue } out = append(out, c) } settings["clients"] = out } if fileChanged { if err := writeJSONFile(path, cfg); err != nil { return err } changed = true } } if changed { restartXray() } return nil } func restartXray() { _ = exec.Command("systemctl", "restart", "xray").Run() _ = exec.Command("systemctl", "restart", "v2ray").Run() } func (a *App) handleXrayInbounds(w http.ResponseWriter, r *http.Request) { type Inb struct { Tag string `json:"tag"` Protocol string `json:"protocol"` Clients []map[string]interface{} `json:"clients"` } var out []Inb for _, path := range xrayConfigPaths() { cfg, err := readJSONFile(path) if err != nil { continue } inb, _ := cfg["inbounds"].([]interface{}) for i, it := range inb { im, _ := it.(map[string]interface{}) proto := fmt.Sprint(im["protocol"]) tag := fmt.Sprint(im["tag"]) if tag == "" || tag == "" { tag = fmt.Sprintf("%s-%d", proto, i) } settings, _ := im["settings"].(map[string]interface{}) clientsAny, _ := settings["clients"].([]interface{}) clients := []map[string]interface{}{} for _, c := range clientsAny { if cm, ok := c.(map[string]interface{}); ok { clients = append(clients, cm) } } out = append(out, Inb{Tag: tag, Protocol: proto, Clients: clients}) } } writeJSON(w, out) } func (a *App) handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { var p struct { UUID string `json:"uuid"` Email string `json:"email"` Name string `json:"name"` } _ = json.NewDecoder(r.Body).Decode(&p) if p.UUID == "" { errText(w, 400, "uuid required") return } email := p.Email if email == "" { email = p.Name } if email == "" { email = p.UUID } if err := addXrayClientAll(p.UUID, email); err != nil { errText(w, 400, err.Error()) return } w.WriteHeader(200) } func (a *App) handleXrayClientRemove(w http.ResponseWriter, r *http.Request) { uuid := r.URL.Query().Get("uuid") if uuid == "" { errText(w, 400, "uuid required") return } if err := removeXrayClientAll(uuid); err != nil { errText(w, 400, err.Error()) return } w.WriteHeader(204) } func (a *App) handleStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, collectStats()) } type cpuSample struct{ idle, total uint64 } func readCPU() cpuSample { b, _ := ioutil.ReadFile("/proc/stat") fields := strings.Fields(strings.SplitN(string(b), "\n", 2)[0]) var vals []uint64 for _, f := range fields[1:] { v, _ := strconv.ParseUint(f, 10, 64) vals = append(vals, v) } var total uint64 for _, v := range vals { total += v } idle := uint64(0) if len(vals) > 3 { idle = vals[3] } return cpuSample{idle, total} } func collectStats() map[string]interface{} { c1 := readCPU() time.Sleep(150 * time.Millisecond) c2 := readCPU() cpu := 0.0 if c2.total > c1.total { cpu = 100 * (1 - float64(c2.idle-c1.idle)/float64(c2.total-c1.total)) } mem := readMem() return map[string]interface{}{"cpu_percent": cpu, "mem_total_bytes": mem["total"], "mem_used_bytes": mem["used"], "mem_avail_bytes": mem["avail"], "mem_percent": mem["percent"], "interfaces": readNet()} } func readMem() map[string]interface{} { b, _ := ioutil.ReadFile("/proc/meminfo") vals := map[string]uint64{} for _, l := range strings.Split(string(b), "\n") { f := strings.Fields(l) if len(f) >= 2 { v, _ := strconv.ParseUint(f[1], 10, 64) vals[strings.TrimSuffix(f[0], ":")] = v * 1024 } } total := vals["MemTotal"] avail := vals["MemAvailable"] used := uint64(0) pct := 0.0 if total > 0 { used = total - avail pct = 100 * float64(used) / float64(total) } return map[string]interface{}{"total": total, "used": used, "avail": avail, "percent": pct} } func readNet() []map[string]interface{} { b, _ := ioutil.ReadFile("/proc/net/dev") var out []map[string]interface{} for _, l := range strings.Split(string(b), "\n") { if !strings.Contains(l, ":") { continue } parts := strings.SplitN(l, ":", 2) name := strings.TrimSpace(parts[0]) if name == "lo" || name == "" { continue } f := strings.Fields(parts[1]) if len(f) >= 16 { rx, _ := strconv.ParseUint(f[0], 10, 64) tx, _ := strconv.ParseUint(f[8], 10, 64) out = append(out, map[string]interface{}{"name": name, "rx_bytes": rx, "tx_bytes": tx}) } } return out } func (a *App) handleReboot(w http.ResponseWriter, r *http.Request) { go exec.Command("shutdown", "-r", "+1", "DragonCore bridge reboot requested").Run() writeJSON(w, map[string]interface{}{"ok": true}) } func (a *App) handleRestartSSH(w http.ResponseWriter, r *http.Request) { _ = exec.Command("systemctl", "restart", "ssh").Run() _ = exec.Command("systemctl", "restart", "sshd").Run() writeJSON(w, map[string]interface{}{"ok": true}) } func (a *App) handleCleanup(w http.ResponseWriter, r *http.Request) { _ = exec.Command("sh", "-c", "apt-get clean 2>/dev/null; journalctl --vacuum-time=3d 2>/dev/null; rm -rf /tmp/* 2>/dev/null").Run() writeJSON(w, map[string]interface{}{"ok": true}) } func (a *App) handleXrayFix(w http.ResponseWriter, r *http.Request) { restartXray() writeJSON(w, map[string]interface{}{"ok": true}) } func (a *App) handleLegacyCommand(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } if r.Method != http.MethodPost { _, _ = w.Write([]byte("DragonCore Bridge API")) return } if r.Header.Get("Senha") != a.cfg.Token { errText(w, 401, "Nao autorizado") return } _ = r.ParseForm() cmd := r.FormValue("comando") msg, err := a.runLegacyDragonCommand(cmd) if err != nil { _, _ = w.Write([]byte(err.Error())) return } _, _ = w.Write([]byte(msg)) } func shellFields(s string) []string { var out []string var cur strings.Builder quote := rune(0) esc := false for _, r := range s { if esc { cur.WriteRune(r) esc = false continue } if r == '\\' { esc = true continue } if quote != 0 { if r == quote { quote = 0 } else { cur.WriteRune(r) } continue } if r == '\'' || r == '"' { quote = r continue } if r == ' ' || r == '\t' || r == '\n' { if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() } continue } cur.WriteRune(r) } if cur.Len() > 0 { out = append(out, cur.String()) } return out } func (a *App) runLegacyDragonCommand(cmd string) (string, error) { f := shellFields(cmd) if len(f) > 0 && strings.Contains(f[0], "dragonmodule") { f = f[1:] } if len(f) == 0 { return "", fmt.Errorf("empty command") } switch f[0] { case "createssh": if len(f) < 5 { return "", fmt.Errorf("bad createssh") } days, _ := strconv.Atoi(f[3]) lim, _ := strconv.Atoi(f[4]) t := time.Now().AddDate(0, 0, days) return "CRIADOCOMSUCESSO", a.createSSH(f[1], f[2], lim, &t, "", false) case "createsshteste": if len(f) < 5 { return "", fmt.Errorf("bad createsshteste") } mins, _ := strconv.Atoi(f[3]) lim, _ := strconv.Atoi(f[4]) t := time.Now().Add(time.Duration(mins) * time.Minute) return "CRIADOCOMSUCESSO", a.createSSH(f[1], f[2], lim, &t, "", false) case "v2rayadd": if len(f) < 6 { return "", fmt.Errorf("bad v2rayadd") } days, _ := strconv.Atoi(f[4]) lim, _ := strconv.Atoi(f[5]) t := time.Now().AddDate(0, 0, days) return "1\nCRIADOCOMSUCESSO", a.createSSH(f[2], f[3], lim, &t, f[1], true) case "v2rayaddteste": if len(f) < 6 { return "", fmt.Errorf("bad v2rayaddteste") } mins, _ := strconv.Atoi(f[4]) lim, _ := strconv.Atoi(f[5]) t := time.Now().Add(time.Duration(mins) * time.Minute) return "1\nCRIADOCOMSUCESSO", a.createSSH(f[2], f[3], lim, &t, f[1], true) case "removessh": if len(f) < 2 { return "", fmt.Errorf("bad removessh") } return "90Cbp1PK1ExPingu", a.deleteSSH(f[1], "") case "v2raydel": if len(f) < 3 { return "", fmt.Errorf("bad v2raydel") } return "90Cbp1PK1ExPingu", a.deleteSSH(f[2], f[1]) default: return "", fmt.Errorf("unsupported command") } } func localIP() string { ifaces, _ := net.Interfaces() for _, i := range ifaces { addrs, _ := i.Addrs() for _, a := range addrs { ipnet, ok := a.(*net.IPNet) if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { return ipnet.IP.String() } } } return "127.0.0.1" } func dropPrivilegesNotUsed() { _ = syscall.Getuid() }