Fix Onlines

This commit is contained in:
2026-05-30 10:34:08 -03:00
parent b581561a63
commit 6bee92ef4a
2 changed files with 272 additions and 15 deletions

View File

@@ -167,12 +167,20 @@ chmod 755 "$BIN"
USER_NAME="admin"
PASSWORD="$(rand_hex 10)"
TOKEN="$(rand_hex 24)"
PANEL_URL=""
PANEL_SERVER_ID="0"
PANEL_SERVER_IP=""
PANEL_PUSH_INTERVAL="60"
if [ -f "$CONFIG_DIR/config.json" ]; then
echo "Existing config found: $CONFIG_DIR/config.json"
USER_NAME="$(grep -o '"username"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || echo admin)"
PASSWORD="$(grep -o '"password"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || rand_hex 10)"
TOKEN="$(grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || rand_hex 24)"
PANEL_URL="$(grep -o '"panel_url"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || echo '')"
PANEL_SERVER_ID="$(grep -o '"panel_server_id"[[:space:]]*:[[:space:]]*[0-9]*' "$CONFIG_DIR/config.json" | head -1 | grep -o '[0-9]*$' || echo 0)"
PANEL_SERVER_IP="$(grep -o '"panel_server_ip"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/config.json" | head -1 | cut -d '"' -f4 || echo '')"
PANEL_PUSH_INTERVAL="$(grep -o '"panel_push_interval_seconds"[[:space:]]*:[[:space:]]*[0-9]*' "$CONFIG_DIR/config.json" | head -1 | grep -o '[0-9]*$' || echo 60)"
EXISTING_PORT="$(extract_listen_port "$CONFIG_DIR/config.json" || true)"
if [ "$PORT_SET" = "0" ] && [ -n "$EXISTING_PORT" ]; then
PORT="$EXISTING_PORT"
@@ -191,7 +199,11 @@ cat > "$CONFIG_DIR/config.json" <<EOF
"username": "$USER_NAME",
"password": "$PASSWORD",
"token": "$TOKEN",
"data_dir": "$CONFIG_DIR"
"data_dir": "$CONFIG_DIR",
"panel_url": "$PANEL_URL",
"panel_server_id": $PANEL_SERVER_ID,
"panel_server_ip": "$PANEL_SERVER_IP",
"panel_push_interval_seconds": $PANEL_PUSH_INTERVAL
}
EOF
chmod 600 "$CONFIG_DIR/config.json"

273
main.go
View File

@@ -1,6 +1,8 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -24,11 +26,15 @@ import (
)
type Config struct {
Listen string `json:"listen"`
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
DataDir string `json:"data_dir"`
Listen string `json:"listen"`
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
DataDir string `json:"data_dir"`
PanelURL string `json:"panel_url"`
PanelServerID int `json:"panel_server_id"`
PanelServerIP string `json:"panel_server_ip"`
PanelPushIntervalSeconds int `json:"panel_push_interval_seconds"`
}
type Account struct {
@@ -48,11 +54,14 @@ type Store struct {
}
type App struct {
cfg Config
sessions map[string]time.Time
sessMu sync.Mutex
store *Store
startedAt time.Time
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}$`)
@@ -77,6 +86,9 @@ func main() {
if cfg.Password == "" || cfg.Token == "" {
log.Fatalf("username/password/token must be configured in %s", *cfgPath)
}
if cfg.PanelPushIntervalSeconds <= 0 {
cfg.PanelPushIntervalSeconds = 60
}
if err := os.MkdirAll(cfg.DataDir, 0700); err != nil {
log.Fatalf("data dir: %v", err)
@@ -86,14 +98,16 @@ func main() {
log.Fatalf("store: %v", err)
}
app := &App{cfg: cfg, sessions: map[string]time.Time{}, store: st, startedAt: time.Now()}
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)))
@@ -392,6 +406,7 @@ func (a *App) auth(next http.Handler) http.Handler {
}
for _, tok := range staticTokens {
if strings.TrimSpace(tok) != "" && strings.TrimSpace(tok) == a.cfg.Token {
a.learnPanelFromRequest(r)
next.ServeHTTP(w, r)
return
}
@@ -414,6 +429,7 @@ func (a *App) auth(next http.Handler) http.Handler {
errText(w, 401, "unauthorized")
return
}
a.learnPanelFromRequest(r)
next.ServeHTTP(w, r)
})
}
@@ -431,6 +447,234 @@ func (a *App) handleUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, users)
}
func (a *App) handleOnlines(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
counts := collectOnlineCounts()
users := make([]map[string]interface{}, 0, len(counts))
total := 0
for username, count := range counts {
if count <= 0 {
continue
}
users = append(users, map[string]interface{}{"username": username, "usuario": username, "quantidade": count, "active_conns": count})
total += count
}
sort.Slice(users, func(i, j int) bool { return fmt.Sprint(users[i]["username"]) < fmt.Sprint(users[j]["username"]) })
writeJSON(w, map[string]interface{}{"ok": true, "total": total, "users": users})
}
func normalizePanelURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.HasPrefix(strings.ToLower(raw), "http://") && !strings.HasPrefix(strings.ToLower(raw), "https://") {
return ""
}
return strings.TrimRight(raw, "/")
}
func (a *App) learnPanelFromRequest(r *http.Request) {
panelURL := normalizePanelURL(r.Header.Get("X-Dragon-Panel-Url"))
serverIP := strings.TrimSpace(r.Header.Get("X-Dragon-Panel-Server-Ip"))
serverID, _ := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Dragon-Panel-Server-Id")))
if panelURL == "" && serverID <= 0 && serverIP == "" {
return
}
a.panelMu.Lock()
changed := false
if panelURL != "" && panelURL != a.cfg.PanelURL {
a.cfg.PanelURL = panelURL
changed = true
}
if serverID > 0 && serverID != a.cfg.PanelServerID {
a.cfg.PanelServerID = serverID
changed = true
}
if serverIP != "" && serverIP != a.cfg.PanelServerIP {
a.cfg.PanelServerIP = serverIP
changed = true
}
cfgCopy := a.cfg
a.panelMu.Unlock()
if changed {
log.Printf("panel online push target set to %s server_id=%d server_ip=%s", cfgCopy.PanelURL, cfgCopy.PanelServerID, cfgCopy.PanelServerIP)
a.saveConfig(cfgCopy)
}
}
func (a *App) saveConfig(cfg Config) {
if strings.TrimSpace(a.configPath) == "" {
return
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return
}
if err := ioutil.WriteFile(a.configPath, append(b, '\n'), 0600); err != nil {
log.Printf("saving bridge config failed: %v", err)
}
}
func (a *App) onlinePushLoop() {
initialDelay := 10 * time.Second
time.Sleep(initialDelay)
for {
interval := a.cfg.PanelPushIntervalSeconds
if interval <= 0 {
interval = 60
}
a.pushOnlineSnapshot()
time.Sleep(time.Duration(interval) * time.Second)
}
}
func (a *App) pushOnlineSnapshot() {
a.panelMu.Lock()
panelURL := normalizePanelURL(a.cfg.PanelURL)
serverID := a.cfg.PanelServerID
serverIP := a.cfg.PanelServerIP
a.panelMu.Unlock()
if panelURL == "" {
return
}
counts := collectOnlineCounts()
users := make([]map[string]interface{}, 0, len(counts))
total := 0
for username, count := range counts {
if count <= 0 {
continue
}
users = append(users, map[string]interface{}{"usuario": username, "username": username, "quantidade": count})
total += count
}
sort.Slice(users, func(i, j int) bool { return fmt.Sprint(users[i]["usuario"]) < fmt.Sprint(users[j]["usuario"]) })
body := map[string]interface{}{
"server_id": serverID,
"server_ip": serverIP,
"hostname": hostname(),
"total": total,
"onlines": users,
"sent_at": time.Now().Format(time.RFC3339),
}
b, _ := json.Marshal(body)
endpoint := panelURL + "/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)
@@ -852,11 +1096,12 @@ func (a *App) expiryLoop() {
}
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 := activeProcCount(ac.Username)
active := counts[ac.Username]
out = append(out, map[string]interface{}{
"username": ac.Username,
"active_conns": active,
@@ -864,14 +1109,14 @@ func listSystemUsers(st *Store) []map[string]interface{} {
"max_connections": ac.MaxConnections,
"expires_at": ac.ExpiresAt,
"uuid": ac.UUID,
"online_source": "openssh",
"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 activeSSHConnectionCount(username)
return collectOnlineCounts()[username]
}
func dragonCoreInstalled() bool {