1622 lines
44 KiB
Go
1622 lines
44 KiB
Go
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"),
|
|
bearer,
|
|
}
|
|
for _, tok := range staticTokens {
|
|
if strings.TrimSpace(tok) != "" && strings.TrimSpace(tok) == a.cfg.Token {
|
|
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 + "/api/bridge_onlines.php"
|
|
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)
|
|
client := &http.Client{Timeout: 15 * 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 == "<nil>" || 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() }
|