Fix test and online

This commit is contained in:
2026-05-29 10:22:24 -03:00
parent 096a8275be
commit 8c27a7f5d9
3 changed files with 382 additions and 32 deletions

296
main.go
View File

@@ -1,11 +1,9 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io/ioutil"
@@ -82,7 +80,7 @@ func main() {
if err := os.MkdirAll(cfg.DataDir, 0700); err != nil {
log.Fatalf("data dir: %v", err)
}
st, err := loadStore(filepath.Join(cfg.DataDir, "accounts.json"))
st, err := loadStore(filepath.Join(cfg.DataDir, "accounts.sqlite"))
if err != nil {
log.Fatalf("store: %v", err)
}
@@ -135,33 +133,171 @@ func loadConfig(path string) (Config, error) {
func loadStore(path string) (*Store, error) {
st := &Store{path: path, Accounts: map[string]Account{}}
b, err := ioutil.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return st, nil
}
if err != nil {
if err := initSQLiteStore(path); err != nil {
return nil, err
}
if len(bytes.TrimSpace(b)) == 0 {
return st, nil
}
if err := json.Unmarshal(b, st); err != nil {
if err := st.loadFromSQLite(); err != nil {
return nil, err
}
if st.Accounts == nil {
st.Accounts = map[string]Account{}
// Backward compatibility: migrate the old JSON store the first time this
// version runs, then keep SQLite as the source of truth.
legacyPath := filepath.Join(filepath.Dir(path), "accounts.json")
if len(st.Accounts) == 0 {
if b, err := ioutil.ReadFile(legacyPath); err == nil && len(strings.TrimSpace(string(b))) > 0 {
var old Store
if err := json.Unmarshal(b, &old); err == nil && len(old.Accounts) > 0 {
for k, v := range old.Accounts {
st.Accounts[k] = v
}
st.mu.Lock()
if err := st.saveLocked(); err != nil {
st.mu.Unlock()
return nil, err
}
st.mu.Unlock()
_ = os.Rename(legacyPath, legacyPath+".migrated")
}
}
}
st.path = path
return st, nil
}
func (s *Store) saveLocked() error {
tmp := s.path + ".tmp"
b, _ := json.MarshalIndent(s, "", " ")
if err := ioutil.WriteFile(tmp, b, 0600); err != nil {
func initSQLiteStore(path string) error {
if _, err := exec.LookPath("sqlite3"); err != nil {
return fmt.Errorf("sqlite3 is required for the bridge account store: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
return os.Rename(tmp, s.path)
schema := `
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS accounts (
username TEXT PRIMARY KEY,
password TEXT NOT NULL DEFAULT '',
max_connections INTEGER NOT NULL DEFAULT 0,
uuid TEXT NOT NULL DEFAULT '',
with_xray INTEGER NOT NULL DEFAULT 0,
expires_at_unix INTEGER NULL,
created_at_unix INTEGER NOT NULL DEFAULT 0
);
`
return sqliteExec(path, schema)
}
func sqliteExec(path, sql string) error {
cmd := exec.Command("sqlite3", path)
cmd.Stdin = strings.NewReader(sql)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("sqlite3: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func sqliteQuery(path, sql string) ([]string, error) {
cmd := exec.Command("sqlite3", "-separator", "\t", "-nullvalue", "", path, sql)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("sqlite3 query: %v: %s", err, strings.TrimSpace(string(out)))
}
text := strings.TrimRight(string(out), "\n")
if text == "" {
return nil, nil
}
return strings.Split(text, "\n"), nil
}
func sqlQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func sqliteNullableTime(t *time.Time) string {
if t == nil {
return "NULL"
}
return strconv.FormatInt(t.Unix(), 10)
}
func decodeHexField(s string) string {
b, err := hex.DecodeString(s)
if err != nil {
return ""
}
return string(b)
}
func (s *Store) loadFromSQLite() error {
rows, err := sqliteQuery(s.path, `SELECT hex(username), hex(password), max_connections, hex(uuid), with_xray, COALESCE(expires_at_unix,''), created_at_unix FROM accounts;`)
if err != nil {
return err
}
for _, row := range rows {
cols := strings.Split(row, "\t")
if len(cols) < 7 {
continue
}
createdUnix, _ := strconv.ParseInt(cols[6], 10, 64)
created := time.Unix(createdUnix, 0)
if createdUnix <= 0 {
created = time.Now()
}
var exp *time.Time
if cols[5] != "" {
if expUnix, err := strconv.ParseInt(cols[5], 10, 64); err == nil && expUnix > 0 {
t := time.Unix(expUnix, 0)
exp = &t
}
}
limit, _ := strconv.Atoi(cols[2])
withXray := cols[4] == "1"
username := decodeHexField(cols[0])
if username == "" {
continue
}
s.Accounts[username] = Account{
Username: username,
Password: decodeHexField(cols[1]),
MaxConnections: limit,
UUID: decodeHexField(cols[3]),
WithXray: withXray,
ExpiresAt: exp,
CreatedAt: created,
}
}
return nil
}
func (s *Store) saveLocked() error {
var b strings.Builder
b.WriteString("BEGIN IMMEDIATE;\nDELETE FROM accounts;\n")
for username, ac := range s.Accounts {
created := ac.CreatedAt
if created.IsZero() {
created = time.Now()
}
b.WriteString("INSERT INTO accounts(username,password,max_connections,uuid,with_xray,expires_at_unix,created_at_unix) VALUES(")
b.WriteString(sqlQuote(username))
b.WriteString(",")
b.WriteString(sqlQuote(ac.Password))
b.WriteString(",")
b.WriteString(strconv.Itoa(ac.MaxConnections))
b.WriteString(",")
b.WriteString(sqlQuote(ac.UUID))
b.WriteString(",")
if ac.WithXray {
b.WriteString("1")
} else {
b.WriteString("0")
}
b.WriteString(",")
b.WriteString(sqliteNullableTime(ac.ExpiresAt))
b.WriteString(",")
b.WriteString(strconv.FormatInt(created.Unix(), 10))
b.WriteString(");\n")
}
b.WriteString("COMMIT;\n")
return sqliteExec(s.path, b.String())
}
func randToken(n int) string { b := make([]byte, n); _, _ = rand.Read(b); return hex.EncodeToString(b) }
@@ -414,11 +550,9 @@ func (a *App) deleteSSH(username, uuid string) error {
if uuid != "" {
_ = removeXrayClientAll(uuid)
}
if _, err := user.Lookup(username); err == nil {
dummy, _ := passwordHash("disabled-dragoncore")
_ = exec.Command("usermod", "-p", dummy, username).Run()
_ = exec.Command("pkill", "-u", username).Run()
_ = exec.Command("userdel", "--force", username).Run()
if err := forceRemoveSystemUser(username); err != nil {
return err
}
_ = removeCompatUserFiles(username)
a.store.mu.Lock()
@@ -428,6 +562,55 @@ func (a *App) deleteSSH(username, uuid string) error {
return err
}
func forceRemoveSystemUser(username string) error {
if _, err := user.Lookup(username); err != nil {
return nil
}
// Linux user expiry has day granularity only. For minute/hour tests the
// bridge disables the password exactly at the stored SQLite expiry time,
// kills active SSH sessions, then deletes the OS user.
disableSystemPassword(username)
disconnectSSHUser(username)
var lastErr error
for i := 0; i < 5; i++ {
if _, err := user.Lookup(username); err == nil {
out, err := exec.Command("userdel", "--force", username).CombinedOutput()
if err != nil {
lastErr = fmt.Errorf("userdel: %v: %s", err, strings.TrimSpace(string(out)))
}
}
disconnectSSHUser(username)
time.Sleep(time.Duration(300+i*200) * time.Millisecond)
if _, err := user.Lookup(username); err != nil && activeSSHConnectionCount(username) == 0 {
return nil
}
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("unable to remove or disconnect user %s", username)
}
func disableSystemPassword(username string) {
randomPass := "dragoncore-expired-" + randToken(16)
hash, err := passwordHash(randomPass)
if err == nil && hash != "" {
_ = exec.Command("usermod", "-p", hash, username).Run()
}
// Lock as well, because the purpose is to prevent reconnect while removal is
// in progress. Existing sessions are killed separately below.
_ = exec.Command("usermod", "-L", username).Run()
}
func disconnectSSHUser(username string) {
_ = exec.Command("pkill", "-KILL", "-u", username).Run()
// The privileged OpenSSH process can still be owned by root, so -u username
// does not always catch it. Usernames are validated before this is called.
_ = exec.Command("pkill", "-KILL", "-f", "sshd: "+username).Run()
}
func passwordHash(password string) (string, error) {
out, err := exec.Command("openssl", "passwd", "-1", password).Output()
if err != nil {
@@ -484,7 +667,7 @@ func removeLinePrefix(path, prefix string) {
func (a *App) expiryLoop() {
for {
time.Sleep(60 * time.Second)
time.Sleep(10 * time.Second)
now := time.Now()
var expired []Account
a.store.mu.Lock()
@@ -506,13 +689,70 @@ func listSystemUsers(st *Store) []map[string]interface{} {
defer st.mu.Unlock()
out := make([]map[string]interface{}, 0, len(st.Accounts))
for _, ac := range st.Accounts {
out = append(out, map[string]interface{}{"username": ac.Username, "active_conns": activeProcCount(ac.Username), "max_connections": ac.MaxConnections, "expires_at": ac.ExpiresAt, "uuid": ac.UUID})
active := activeProcCount(ac.Username)
out = append(out, map[string]interface{}{
"username": ac.Username,
"active_conns": active,
"active_connections": active,
"max_connections": ac.MaxConnections,
"expires_at": ac.ExpiresAt,
"uuid": ac.UUID,
"online_source": "openssh",
})
}
sort.Slice(out, func(i, j int) bool { return fmt.Sprint(out[i]["username"]) < fmt.Sprint(out[j]["username"]) })
return out
}
func activeProcCount(username string) int {
out, err := exec.Command("pgrep", "-u", username).Output()
return activeSSHConnectionCount(username)
}
func dragonCoreInstalled() bool {
paths := []string{"/opt/dragoncore", "/opt/DragonCore", "/opt/DragonCore/menu.php"}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
func activeSSHConnectionCount(username string) int {
if username == "" {
return 0
}
// DragonCore installs have their own files, but this bridge is meant to be
// generic too. When /opt/dragoncore is not present we intentionally use the
// SSHPlus-compatible method: inspect the live OpenSSH processes per user.
_ = dragonCoreInstalled()
out, err := exec.Command("ps", "-eo", "args=").Output()
if err != nil {
return activeSSHByPgrep(username)
}
privNeedle := "sshd: " + username + " [priv]"
atNeedle := "sshd: " + username + "@"
privCount := 0
atCount := 0
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.Contains(line, privNeedle) {
privCount++
continue
}
if strings.Contains(line, atNeedle) {
atCount++
}
}
if privCount > 0 {
return privCount
}
return atCount
}
func activeSSHByPgrep(username string) int {
out, err := exec.Command("pgrep", "-u", username, "sshd").Output()
if err != nil {
return 0
}