feat: nlog, config hot-reload, and concurrency fixes
This commit is contained in:
@@ -22,3 +22,4 @@ Thumbs.db
|
|||||||
# Go
|
# Go
|
||||||
vendor/
|
vendor/
|
||||||
config.json
|
config.json
|
||||||
|
benchmark/
|
||||||
|
|||||||
+102
-45
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -46,36 +45,20 @@ func main() {
|
|||||||
// Apply runtime memory tuning before anything else allocates.
|
// Apply runtime memory tuning before anything else allocates.
|
||||||
applyRuntimeConfig(cfg.Runtime)
|
applyRuntimeConfig(cfg.Runtime)
|
||||||
|
|
||||||
nodes := cfg.ExpandNodes()
|
runWithReload(cfg, *configPath)
|
||||||
nlog.Core().Info(fmt.Sprintf("xboard-node %s starting, %d nodes", version, len(nodes)))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
sig := <-sigCh
|
|
||||||
nlog.Core().Info(fmt.Sprintf("received %v, shutting down...", sig))
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
// Force-exit on second signal or after timeout.
|
|
||||||
select {
|
|
||||||
case sig = <-sigCh:
|
|
||||||
slog.Warn("received second signal, forcing exit", "signal", sig)
|
|
||||||
os.Exit(1)
|
|
||||||
case <-time.After(15 * time.Second):
|
|
||||||
slog.Error("shutdown timed out after 15s, forcing exit")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
// Optional health-check endpoint for container orchestrators.
|
// runWithReload restarts all node services when the config file changes.
|
||||||
if cfg.HealthPort > 0 {
|
func runWithReload(initialCfg *config.Config, configPath string) {
|
||||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.HealthPort))
|
var healthSrv *http.Server
|
||||||
|
var healthPort int
|
||||||
|
startHealth := func(port int) {
|
||||||
|
if port <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to start health check listener", "port", cfg.HealthPort, "error", err)
|
nlog.Core().Error("failed to start health check listener", "port", port, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -84,44 +67,118 @@ func main() {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
healthSrv = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||||
|
healthPort = port
|
||||||
go func() {
|
go func() {
|
||||||
nlog.Core().Debug(fmt.Sprintf("health check listening on :%d", cfg.HealthPort))
|
nlog.Core().Debug(fmt.Sprintf("health check listening on :%d", port))
|
||||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
if err := healthSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||||
slog.Warn("health check server stopped", "error", err)
|
nlog.Core().Warn("health check server stopped", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
srv.Close()
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run one service per node. If any node fails, cancel all others.
|
startHealth(initialCfg.HealthPort)
|
||||||
|
defer func() {
|
||||||
|
if healthSrv != nil {
|
||||||
|
healthSrv.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for cfg := initialCfg; ; {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
reloadCh := make(chan *config.Config, 1)
|
||||||
|
|
||||||
|
watcher, err := config.WatchConfig(ctx, configPath, func(newCfg *config.Config) {
|
||||||
|
select {
|
||||||
|
case reloadCh <- newCfg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
nlog.Core().Warn("config watcher unavailable, hot-reload disabled", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case sig := <-sigCh:
|
||||||
|
nlog.Core().Info(fmt.Sprintf("received %v, shutting down...", sig))
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sig = <-sigCh:
|
||||||
|
nlog.Core().Warn("received second signal, forcing exit", "signal", sig)
|
||||||
|
os.Exit(1)
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
nlog.Core().Error("shutdown timed out after 15s, forcing exit")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if cfg.HealthPort != healthPort {
|
||||||
|
if healthSrv != nil {
|
||||||
|
healthSrv.Close()
|
||||||
|
healthSrv = nil
|
||||||
|
}
|
||||||
|
startHealth(cfg.HealthPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := cfg.ExpandNodes()
|
||||||
|
nlog.Core().Info(fmt.Sprintf("xboard-node %s starting, %d nodes", version, len(nodes)))
|
||||||
|
|
||||||
errCh := make(chan error, len(nodes))
|
errCh := make(chan error, len(nodes))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, nodeCfg := range nodes {
|
for _, nodeCfg := range nodes {
|
||||||
nodeCfg := nodeCfg // capture
|
nodeCfg := nodeCfg
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
svc := service.New(nodeCfg)
|
svc := service.New(nodeCfg)
|
||||||
if err := svc.Run(ctx); err != nil {
|
if err := svc.Run(ctx); err != nil {
|
||||||
slog.Error("node service exited with error",
|
nlog.Core().Error("node service exited with error",
|
||||||
"node_id", nodeCfg.Panel.NodeID, "error", err)
|
"node_id", nodeCfg.Panel.NodeID, "error", err)
|
||||||
errCh <- err
|
errCh <- err
|
||||||
cancel() // bring down all other nodes
|
cancel()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
doneCh := make(chan struct{})
|
||||||
close(errCh)
|
go func() { wg.Wait(); close(doneCh) }()
|
||||||
|
|
||||||
|
var newCfg *config.Config
|
||||||
|
select {
|
||||||
|
case newCfg = <-reloadCh:
|
||||||
|
nlog.Core().Info("config changed, restarting all services...")
|
||||||
|
cancel()
|
||||||
|
<-doneCh
|
||||||
|
case <-doneCh:
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.Stop(sigCh)
|
||||||
|
if watcher != nil {
|
||||||
|
watcher.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if newCfg == nil {
|
||||||
|
close(errCh)
|
||||||
if err := <-errCh; err != nil {
|
if err := <-errCh; err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
nlog.Core().Info("stopped")
|
nlog.Core().Info("stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config.InitLogger(newCfg.Log)
|
||||||
|
applyRuntimeConfig(newCfg.Runtime)
|
||||||
|
cfg = newCfg
|
||||||
|
nlog.Core().Info("reload complete, services restarting with new config")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyRuntimeConfig wires up Go runtime memory limits from the config file.
|
// applyRuntimeConfig wires up Go runtime memory limits from the config file.
|
||||||
@@ -133,17 +190,17 @@ func applyRuntimeConfig(rt config.RuntimeConfig) {
|
|||||||
// GOGC
|
// GOGC
|
||||||
if rt.GoGCPercent > 0 {
|
if rt.GoGCPercent > 0 {
|
||||||
prev := debug.SetGCPercent(rt.GoGCPercent)
|
prev := debug.SetGCPercent(rt.GoGCPercent)
|
||||||
slog.Info("runtime: GOGC set", "gogc", rt.GoGCPercent, "prev", prev)
|
nlog.Core().Info("runtime: GOGC set", "gogc", rt.GoGCPercent, "prev", prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GOMEMLIMIT — parse human-readable size string (e.g. "30MiB")
|
// GOMEMLIMIT — parse human-readable size string (e.g. "30MiB")
|
||||||
if rt.GoMemLimit != "" {
|
if rt.GoMemLimit != "" {
|
||||||
limit, err := parseMemLimit(rt.GoMemLimit)
|
limit, err := parseMemLimit(rt.GoMemLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("runtime: invalid gomemlimit, ignoring", "value", rt.GoMemLimit, "error", err)
|
nlog.Core().Warn("runtime: invalid gomemlimit, ignoring", "value", rt.GoMemLimit, "error", err)
|
||||||
} else {
|
} else {
|
||||||
prev := debug.SetMemoryLimit(limit)
|
prev := debug.SetMemoryLimit(limit)
|
||||||
slog.Info("runtime: GOMEMLIMIT set",
|
nlog.Core().Info("runtime: GOMEMLIMIT set",
|
||||||
"limit", rt.GoMemLimit,
|
"limit", rt.GoMemLimit,
|
||||||
"bytes", limit,
|
"bytes", limit,
|
||||||
"prev_bytes", prev,
|
"prev_bytes", prev,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/sagernet/sing-box v1.13.2
|
github.com/sagernet/sing-box v1.13.2
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2
|
github.com/shirou/gopsutil/v4 v4.26.2
|
||||||
github.com/xtls/xray-core v1.260206.0
|
github.com/xtls/xray-core v1.260206.0
|
||||||
|
golang.org/x/term v0.40.0
|
||||||
golang.org/x/time v0.12.0
|
golang.org/x/time v0.12.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -36,7 +37,7 @@ require (
|
|||||||
github.com/ebitengine/purego v0.10.0 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
github.com/enfein/mieru/v3 v3.29.0 // indirect
|
github.com/enfein/mieru/v3 v3.29.0 // indirect
|
||||||
github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
|
github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
github.com/gaissmai/bart v0.18.0 // indirect
|
github.com/gaissmai/bart v0.18.0 // indirect
|
||||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect
|
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect
|
||||||
@@ -161,7 +162,6 @@ require (
|
|||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvW
|
|||||||
github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc=
|
github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
|
github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
|
||||||
|
|||||||
+12
-12
@@ -9,7 +9,6 @@ import (
|
|||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -23,6 +22,7 @@ import (
|
|||||||
"github.com/libdns/cloudflare"
|
"github.com/libdns/cloudflare"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager handles TLS certificate lifecycle.
|
// Manager handles TLS certificate lifecycle.
|
||||||
@@ -171,7 +171,7 @@ func (m *Manager) startFile() error {
|
|||||||
if _, err := os.Stat(m.cfg.KeyFile); err != nil {
|
if _, err := os.Stat(m.cfg.KeyFile); err != nil {
|
||||||
return fmt.Errorf("key file: %w", err)
|
return fmt.Errorf("key file: %w", err)
|
||||||
}
|
}
|
||||||
slog.Debug("using manual TLS certificates", "cert", m.certFile, "key", m.keyFile)
|
nlog.Core().Debug("using manual TLS certificates", "cert", m.certFile, "key", m.keyFile)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ func (m *Manager) startFile() error {
|
|||||||
func (m *Manager) startSelfSigned() error {
|
func (m *Manager) startSelfSigned() error {
|
||||||
// If files already exist and are valid, skip regeneration.
|
// If files already exist and are valid, skip regeneration.
|
||||||
if fileExists(m.certFile) && fileExists(m.keyFile) {
|
if fileExists(m.certFile) && fileExists(m.keyFile) {
|
||||||
slog.Debug("self-signed cert exists, reusing", "cert", m.certFile)
|
nlog.Core().Debug("self-signed cert exists, reusing", "cert", m.certFile)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ func (m *Manager) startSelfSigned() error {
|
|||||||
return fmt.Errorf("write key: %w", err)
|
return fmt.Errorf("write key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("self-signed certificate generated",
|
nlog.Core().Info("self-signed certificate generated",
|
||||||
"domain", domain, "cert", m.certFile, "valid_years", 10)
|
"domain", domain, "cert", m.certFile, "valid_years", 10)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ func (m *Manager) startContent() error {
|
|||||||
if err := atomicWriteFile(m.keyFile, []byte(m.cfg.KeyContent), 0o600); err != nil {
|
if err := atomicWriteFile(m.keyFile, []byte(m.cfg.KeyContent), 0o600); err != nil {
|
||||||
return fmt.Errorf("write key content: %w", err)
|
return fmt.Errorf("write key content: %w", err)
|
||||||
}
|
}
|
||||||
slog.Info("TLS certificate written from panel content", "cert", m.certFile)
|
nlog.Core().Info("TLS certificate written from panel content", "cert", m.certFile)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
|
|||||||
// Guard: certmagic's background goroutines must only be started once.
|
// Guard: certmagic's background goroutines must only be started once.
|
||||||
// A process restart is required to change ACME settings at runtime.
|
// A process restart is required to change ACME settings at runtime.
|
||||||
if m.acmeStarted {
|
if m.acmeStarted {
|
||||||
slog.Info("cert: ACME already active, skipping re-init (restart to change ACME settings)")
|
nlog.Core().Info("cert: ACME already active, skipping re-init (restart to change ACME settings)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,32 +389,32 @@ func (m *Manager) syncCertFiles(ctx context.Context, storage certmagic.Storage,
|
|||||||
certKey, _ := data["certificate_path"].(string)
|
certKey, _ := data["certificate_path"].(string)
|
||||||
keyKey, _ := data["private_key_path"].(string)
|
keyKey, _ := data["private_key_path"].(string)
|
||||||
if certKey == "" || keyKey == "" {
|
if certKey == "" || keyKey == "" {
|
||||||
slog.Warn("cert_obtained event missing storage paths", "data", data)
|
nlog.Core().Warn("cert_obtained event missing storage paths", "data", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
certPEM, err := storage.Load(ctx, certKey)
|
certPEM, err := storage.Load(ctx, certKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load cert from storage", "error", err)
|
nlog.Core().Error("failed to load cert from storage", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keyPEM, err := storage.Load(ctx, keyKey)
|
keyPEM, err := storage.Load(ctx, keyKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load key from storage", "error", err)
|
nlog.Core().Error("failed to load key from storage", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil {
|
if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil {
|
||||||
slog.Error("failed to write cert file", "path", m.certFile, "error", err)
|
nlog.Core().Error("failed to write cert file", "path", m.certFile, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil {
|
if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil {
|
||||||
slog.Error("failed to write key file", "path", m.keyFile, "error", err)
|
nlog.Core().Error("failed to write key file", "path", m.keyFile, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renewal, _ := data["renewal"].(bool)
|
renewal, _ := data["renewal"].(bool)
|
||||||
slog.Info("TLS certificate synced", "domain", m.cfg.Domain, "renewal", renewal,
|
nlog.Core().Info("TLS certificate synced", "domain", m.cfg.Domain, "renewal", renewal,
|
||||||
"cert", m.certFile)
|
"cert", m.certFile)
|
||||||
m.renewed.Store(true)
|
m.renewed.Store(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
|
"golang.org/x/term"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -331,40 +333,47 @@ func (c *Config) ExpandNodes() []*Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InitLogger(cfg LogConfig) {
|
func InitLogger(cfg LogConfig) {
|
||||||
var level slog.Level
|
var minLevel slog.Level
|
||||||
switch cfg.Level {
|
switch cfg.Level {
|
||||||
case "debug":
|
case "debug":
|
||||||
level = slog.LevelDebug
|
minLevel = slog.LevelDebug
|
||||||
case "warn":
|
case "warn":
|
||||||
level = slog.LevelWarn
|
minLevel = slog.LevelWarn
|
||||||
case "error":
|
case "error":
|
||||||
level = slog.LevelError
|
minLevel = slog.LevelError
|
||||||
default:
|
default:
|
||||||
level = slog.LevelInfo
|
minLevel = slog.LevelInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
var w io.Writer
|
var w io.Writer
|
||||||
|
useColor := false
|
||||||
switch cfg.Output {
|
switch cfg.Output {
|
||||||
case "stdout", "":
|
case "stdout", "":
|
||||||
w = os.Stdout
|
w = os.Stdout
|
||||||
|
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
case "stderr":
|
case "stderr":
|
||||||
w = os.Stderr
|
w = os.Stderr
|
||||||
|
useColor = term.IsTerminal(int(os.Stderr.Fd()))
|
||||||
default:
|
default:
|
||||||
dir := filepath.Dir(cfg.Output)
|
dir := filepath.Dir(cfg.Output)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
slog.Error("failed to create log dir, falling back to stdout", "error", err)
|
fmt.Fprintf(os.Stderr, "failed to create log dir, falling back to stdout: %v\n", err)
|
||||||
w = os.Stdout
|
w = os.Stdout
|
||||||
|
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
} else {
|
} else {
|
||||||
f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to open log file, falling back to stdout", "error", err)
|
fmt.Fprintf(os.Stderr, "failed to open log file, falling back to stdout: %v\n", err)
|
||||||
w = os.Stdout
|
w = os.Stdout
|
||||||
|
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
} else {
|
} else {
|
||||||
w = f
|
w = f
|
||||||
|
useColor = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := slog.NewTextHandler(w, &slog.HandlerOptions{Level: level})
|
nlog.Init(w, minLevel, useColor)
|
||||||
slog.SetDefault(slog.New(handler))
|
// Application logging goes through nlog; silence slog.Default for stray library use.
|
||||||
|
slog.SetDefault(slog.New(slog.DiscardHandler))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watcher reloads the config file on change (debounced) and calls onChange.
|
||||||
|
type Watcher struct {
|
||||||
|
path string
|
||||||
|
debounce time.Duration
|
||||||
|
onChange func(*Config)
|
||||||
|
watcher *fsnotify.Watcher
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopped atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchConfig watches path; invalid reloads are logged and ignored.
|
||||||
|
// Stop via Stop() or cancel ctx.
|
||||||
|
func WatchConfig(ctx context.Context, path string, onChange func(*Config)) (*Watcher, error) {
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fsw, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch parent dir so rename/atomic saves are visible.
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := fsw.Add(dir); err != nil {
|
||||||
|
fsw.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &Watcher{
|
||||||
|
path: absPath,
|
||||||
|
debounce: 1 * time.Second,
|
||||||
|
onChange: onChange,
|
||||||
|
watcher: fsw,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go w.loop(ctx)
|
||||||
|
nlog.Core().Info("config watcher started", "path", absPath)
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) loop(ctx context.Context) {
|
||||||
|
var timer *time.Timer
|
||||||
|
defer func() {
|
||||||
|
if timer != nil {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
w.watcher.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-w.stopCh:
|
||||||
|
return
|
||||||
|
|
||||||
|
case ev, ok := <-w.watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if filepath.Clean(ev.Name) != w.path {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ev.Has(fsnotify.Write) && !ev.Has(fsnotify.Create) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if timer == nil {
|
||||||
|
timer = time.AfterFunc(w.debounce, w.reload)
|
||||||
|
} else {
|
||||||
|
timer.Reset(w.debounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
case err, ok := <-w.watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nlog.Core().Warn("config watcher error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) reload() {
|
||||||
|
if w.stopped.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := Load(w.path)
|
||||||
|
if err != nil {
|
||||||
|
nlog.Core().Error("config reload failed, keeping current config", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nlog.Core().Info("config reloaded successfully")
|
||||||
|
w.onChange(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) Stop() {
|
||||||
|
w.stopped.Store(true)
|
||||||
|
w.stopOnce.Do(func() { close(w.stopCh) })
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ package kernel
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ func LoadCustomConfig(path string) (map[string]any, error) {
|
|||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
slog.Warn("custom config file not found, skipping", "path", path)
|
nlog.Core().Warn("custom config file not found, skipping", "path", path)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read custom config %s: %w", path, err)
|
return nil, fmt.Errorf("read custom config %s: %w", path, err)
|
||||||
@@ -43,7 +43,7 @@ func LoadCustomConfig(path string) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("loaded custom config", "path", path, "keys", mapKeys(result))
|
nlog.Core().Info("loaded custom config", "path", path, "keys", mapKeys(result))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package geodata
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -49,7 +50,7 @@ func Ensure(dir string, needGeoIP, needGeoSite bool, kernelType string) error {
|
|||||||
var firstErr error
|
var firstErr error
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
if err := ensureFile(dir, f.name, f.url); err != nil {
|
if err := ensureFile(dir, f.name, f.url); err != nil {
|
||||||
slog.Warn("geo database auto-download failed", "file", f.name, "error", err)
|
nlog.Core().Warn("geo database auto-download failed", "file", f.name, "error", err)
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
@@ -63,12 +64,12 @@ func ensureFile(dir, name, url string) error {
|
|||||||
if _, err := os.Stat(dst); err == nil {
|
if _, err := os.Stat(dst); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
slog.Info("geo database missing, downloading automatically", "file", name, "url", url)
|
nlog.Core().Info("geo database missing, downloading automatically", "file", name, "url", url)
|
||||||
if err := atomicDownload(dst, url); err != nil {
|
if err := atomicDownload(dst, url); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if fi, err := os.Stat(dst); err == nil {
|
if fi, err := os.Stat(dst); err == nil {
|
||||||
slog.Info("geo database ready", "file", name, "size_kb", fi.Size()/1024)
|
nlog.Core().Info("geo database ready", "file", name, "size_kb", fi.Size()/1024)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package singbox
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net"
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -11,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/panel"
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
@@ -251,7 +251,7 @@ func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M {
|
|||||||
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
|
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
|
||||||
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load custom sing-box config", "error", err)
|
nlog.Core().Error("failed to load custom sing-box config", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if custom == nil {
|
if custom == nil {
|
||||||
@@ -427,7 +427,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
|||||||
base["users"] = userList
|
base["users"] = userList
|
||||||
|
|
||||||
if nc.Plugin != "" {
|
if nc.Plugin != "" {
|
||||||
slog.Warn("sing-box shadowsocks inbound does not support plugin, ignoring", "plugin", nc.Plugin)
|
nlog.Core().Warn("sing-box shadowsocks inbound does not support plugin, ignoring", "plugin", nc.Plugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package singbox
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"net"
|
"net"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -15,6 +14,8 @@ import (
|
|||||||
singM "github.com/sagernet/sing/common/metadata"
|
singM "github.com/sagernet/sing/common/metadata"
|
||||||
N "github.com/sagernet/sing/common/network"
|
N "github.com/sagernet/sing/common/network"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rateLimitThreshold = 64 * 1024
|
const rateLimitThreshold = 64 * 1024
|
||||||
@@ -171,7 +172,7 @@ func (t *ConnTracker) RoutedConnection(
|
|||||||
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
||||||
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
||||||
if t.checkDeviceGate(us, sourceIP, limit) {
|
if t.checkDeviceGate(us, sourceIP, limit) {
|
||||||
slog.Info("singbox: device limit gate-keep, rejecting connection",
|
nlog.Core().Info("singbox: device limit gate-keep, rejecting connection",
|
||||||
"user", uuid, "ip", sourceIP, "limit", limit)
|
"user", uuid, "ip", sourceIP, "limit", limit)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return conn
|
return conn
|
||||||
@@ -207,7 +208,7 @@ func (t *ConnTracker) RoutedConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoutedPacketConnection wraps a UDP PacketConn with per-user byte counting.
|
// RoutedPacketConnection wraps UDP with per-user counting (UDP not in connMap).
|
||||||
func (t *ConnTracker) RoutedPacketConnection(
|
func (t *ConnTracker) RoutedPacketConnection(
|
||||||
ctx context.Context, conn N.PacketConn,
|
ctx context.Context, conn N.PacketConn,
|
||||||
metadata adapter.InboundContext,
|
metadata adapter.InboundContext,
|
||||||
@@ -221,6 +222,17 @@ func (t *ConnTracker) RoutedPacketConnection(
|
|||||||
us := t.users[uid]
|
us := t.users[uid]
|
||||||
t.usersMu.RUnlock()
|
t.usersMu.RUnlock()
|
||||||
|
|
||||||
|
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
||||||
|
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
||||||
|
if t.checkDeviceGate(us, sourceIP, limit) {
|
||||||
|
nlog.Core().Info("singbox: device limit gate-keep, rejecting UDP connection",
|
||||||
|
"user", uuid, "ip", sourceIP, "limit", limit)
|
||||||
|
conn.Close()
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if us != nil {
|
if us != nil {
|
||||||
us.addConn(sourceIP)
|
us.addConn(sourceIP)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/panel"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFi
|
|||||||
return fmt.Errorf("marshal config: %w", err)
|
return fmt.Errorf("marshal config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("sing-box config generated", "len", len(data))
|
nlog.Core().Debug("sing-box config generated", "len", len(data))
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
ctx = include.Context(ctx)
|
ctx = include.Context(ctx)
|
||||||
@@ -147,7 +147,7 @@ func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFi
|
|||||||
go recycleOldBox(oldBox, oldCancel, oldCtx, oldTracker)
|
go recycleOldBox(oldBox, oldCancel, oldCtx, oldTracker)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("sing-box started", "users", len(users))
|
nlog.Core().Debug("sing-box started", "users", len(users))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ func recycleOldBox(oldBox *box.Box, oldCancel context.CancelFunc, oldCtx context
|
|||||||
if oldCancel != nil {
|
if oldCancel != nil {
|
||||||
oldCancel()
|
oldCancel()
|
||||||
}
|
}
|
||||||
slog.Debug("sing-box: old instance recycled")
|
nlog.Core().Debug("sing-box: old instance recycled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload hot-swaps the inbound users and routing rules without restarting the box.
|
// Reload hot-swaps the inbound users and routing rules without restarting the box.
|
||||||
@@ -213,9 +213,9 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
|
|||||||
|
|
||||||
// Update routing rules
|
// Update routing rules
|
||||||
if err := router.UpdateRules(opts.Route.Rules, opts.Route.RuleSet); err != nil {
|
if err := router.UpdateRules(opts.Route.Rules, opts.Route.RuleSet); err != nil {
|
||||||
slog.Debug("routing reload failed", "error", err)
|
nlog.Core().Debug("routing reload failed", "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("sing-box routing reloaded")
|
nlog.Core().Debug("sing-box routing reloaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
nopFactory := singLog.NewNOPFactory()
|
nopFactory := singLog.NewNOPFactory()
|
||||||
@@ -270,7 +270,7 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
|
nlog.Core().Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
|
|||||||
s.connTracker.SetUserMap(buildUserMap(users))
|
s.connTracker.SetUserMap(buildUserMap(users))
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("sing-box reloaded", "users", len(users))
|
nlog.Core().Debug("sing-box reloaded", "users", len(users))
|
||||||
s.users = users
|
s.users = users
|
||||||
s.nodeConfig = nodeConfig
|
s.nodeConfig = nodeConfig
|
||||||
s.certFile = certFile
|
s.certFile = certFile
|
||||||
@@ -559,7 +559,7 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Warn("incremental update failed, recreating inbound", "tag", tag, "error", err)
|
nlog.Core().Warn("incremental update failed, recreating inbound", "tag", tag, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = im.Remove(tag)
|
_ = im.Remove(tag)
|
||||||
@@ -573,7 +573,7 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
|
|||||||
s.connTracker.SetUserMap(buildUserMap(users))
|
s.connTracker.SetUserMap(buildUserMap(users))
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("sing-box users hot-swapped", "users", len(users))
|
nlog.Core().Debug("sing-box users hot-swapped", "users", len(users))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +594,7 @@ func (s *SingBox) CloseConnection(_ context.Context, connID string) error {
|
|||||||
return fmt.Errorf("not running")
|
return fmt.Errorf("not running")
|
||||||
}
|
}
|
||||||
if !ct.CloseByID(connID) {
|
if !ct.CloseByID(connID) {
|
||||||
slog.Debug("CloseConnection: connection not found (already closed?)", "id", connID)
|
nlog.Core().Debug("CloseConnection: connection not found (already closed?)", "id", connID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package xray
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/panel"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
|
|||||||
if inbound != nil {
|
if inbound != nil {
|
||||||
cfg["inbounds"] = []M{inbound}
|
cfg["inbounds"] = []M{inbound}
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("xray: unsupported protocol, no inbound configured — node will not accept connections",
|
nlog.Core().Warn("xray: unsupported protocol, no inbound configured — node will not accept connections",
|
||||||
"protocol", nc.Protocol,
|
"protocol", nc.Protocol,
|
||||||
"supported", "vmess, vless, trojan, shadowsocks, socks, http")
|
"supported", "vmess, vless, trojan, shadowsocks, socks, http")
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ func mergeRouteList(a, b []map[string]any) []map[string]any {
|
|||||||
func mergeCustomXray(cfg M, kcfg config.KernelConfig) {
|
func mergeCustomXray(cfg M, kcfg config.KernelConfig) {
|
||||||
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load custom xray config", "error", err)
|
nlog.Core().Error("failed to load custom xray config", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if custom == nil {
|
if custom == nil {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package xray
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,6 +17,8 @@ import (
|
|||||||
"github.com/xtls/xray-core/features/routing"
|
"github.com/xtls/xray-core/features/routing"
|
||||||
"github.com/xtls/xray-core/transport"
|
"github.com/xtls/xray-core/transport"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Access xray's internal config creator registry so we can replace the
|
// Access xray's internal config creator registry so we can replace the
|
||||||
@@ -54,7 +55,7 @@ func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{
|
|||||||
limitedIPs: make(map[string]map[string]int),
|
limitedIPs: make(map[string]map[string]int),
|
||||||
}
|
}
|
||||||
globalLimitDispatcher.Store(ld)
|
globalLimitDispatcher.Store(ld)
|
||||||
slog.Debug("xray: limit dispatcher installed")
|
nlog.Core().Debug("xray: limit dispatcher installed")
|
||||||
return ld, nil
|
return ld, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destina
|
|||||||
isTCP = dest.Network == net.Network_TCP
|
isTCP = dest.Network == net.Network_TCP
|
||||||
|
|
||||||
if d.checkDeviceLimit(email, sourceIP, isTCP) {
|
if d.checkDeviceLimit(email, sourceIP, isTCP) {
|
||||||
slog.Info("xray: device limit exceeded", "email", email, "ip", sourceIP)
|
nlog.Core().Info("xray: device limit exceeded", "email", email, "ip", sourceIP)
|
||||||
return "", "", false, errors.New("device limit exceeded for " + email)
|
return "", "", false, errors.New("device limit exceeded for " + email)
|
||||||
}
|
}
|
||||||
return email, sourceIP, isTCP, nil
|
return email, sourceIP, isTCP, nil
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -32,6 +31,7 @@ import (
|
|||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
|
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/panel"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
|
|||||||
|
|
||||||
x.updateDispatcherLimits(users)
|
x.updateDispatcherLimits(users)
|
||||||
|
|
||||||
slog.Info("xray started",
|
nlog.Core().Info("xray started",
|
||||||
"users", len(users),
|
"users", len(users),
|
||||||
"protocol", nodeConfig.Protocol,
|
"protocol", nodeConfig.Protocol,
|
||||||
)
|
)
|
||||||
@@ -174,12 +174,12 @@ func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile
|
|||||||
if same {
|
if same {
|
||||||
x.users = users
|
x.users = users
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
slog.Debug("xray: limits updated, kernel configuration unchanged")
|
nlog.Core().Debug("xray: limits updated, kernel configuration unchanged")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
|
|
||||||
slog.Info("xray: configuration changed, performing full restart")
|
nlog.Core().Info("xray: configuration changed, performing full restart")
|
||||||
return x.Start(nodeConfig, users, certFile, keyFile)
|
return x.Start(nodeConfig, users, certFile, keyFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,8 +220,10 @@ func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliv
|
|||||||
return traffic, aliveIPs, connCount, nil
|
return traffic, aliveIPs, connCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: read xray's native stats counters
|
// Fallback path: aggregateStats needs x.mu.
|
||||||
|
x.mu.Lock()
|
||||||
traffic, err = x.aggregateStats()
|
traffic, err = x.aggregateStats()
|
||||||
|
x.mu.Unlock()
|
||||||
return traffic, nil, 0, err
|
return traffic, nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +291,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
|||||||
// Protocol doesn't support UserManager → full restart
|
// Protocol doesn't support UserManager → full restart
|
||||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
slog.Debug("xray: AddUsers fallback to restart", "reason", err)
|
nlog.Core().Debug("xray: AddUsers fallback to restart", "reason", err)
|
||||||
if err := x.Start(nc, merged, cf, kf); err != nil {
|
if err := x.Start(nc, merged, cf, kf); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -305,11 +307,11 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
|||||||
for _, u := range toAdd {
|
for _, u := range toAdd {
|
||||||
mu, err := toMemoryUser(proto, nc, u)
|
mu, err := toMemoryUser(proto, nc, u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("xray: skip user, cannot build account", "user", u.ID, "error", err)
|
nlog.Core().Warn("xray: skip user, cannot build account", "user", u.ID, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := um.AddUser(ctx, mu); err != nil {
|
if err := um.AddUser(ctx, mu); err != nil {
|
||||||
slog.Warn("xray: AddUser failed", "user", u.ID, "error", err)
|
nlog.Core().Warn("xray: AddUser failed", "user", u.ID, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
added++
|
added++
|
||||||
@@ -321,7 +323,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
|||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(merged)
|
x.updateDispatcherLimits(merged)
|
||||||
|
|
||||||
slog.Info("xray: users added via UserManager", "added", added, "total", len(merged))
|
nlog.Core().Info("xray: users added via UserManager", "added", added, "total", len(merged))
|
||||||
return added, nil
|
return added, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +363,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
slog.Debug("xray: RemoveUsers fallback to restart", "reason", err)
|
nlog.Core().Debug("xray: RemoveUsers fallback to restart", "reason", err)
|
||||||
if err := x.Start(nc, kept, cf, kf); err != nil {
|
if err := x.Start(nc, kept, cf, kf); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -374,7 +376,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
|||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
email := userEmail(u.ID)
|
email := userEmail(u.ID)
|
||||||
if err := um.RemoveUser(ctx, email); err != nil {
|
if err := um.RemoveUser(ctx, email); err != nil {
|
||||||
slog.Debug("xray: RemoveUser skipped", "user", u.ID, "error", err)
|
nlog.Core().Debug("xray: RemoveUser skipped", "user", u.ID, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
actualRemoved++
|
actualRemoved++
|
||||||
@@ -385,7 +387,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
|||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(kept)
|
x.updateDispatcherLimits(kept)
|
||||||
|
|
||||||
slog.Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
|
nlog.Core().Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
|
||||||
return actualRemoved, nil
|
return actualRemoved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +408,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
|||||||
x.users = users
|
x.users = users
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(users)
|
x.updateDispatcherLimits(users)
|
||||||
slog.Info("xray: limits updated, kernel unchanged")
|
nlog.Core().Info("xray: limits updated, kernel unchanged")
|
||||||
return 0, 0, nil
|
return 0, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +417,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
|||||||
// Protocol doesn't support UserManager → full restart
|
// Protocol doesn't support UserManager → full restart
|
||||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
slog.Debug("xray: UpdateUsers fallback to restart", "reason", umErr)
|
nlog.Core().Debug("xray: UpdateUsers fallback to restart", "reason", umErr)
|
||||||
if err = x.Start(nc, users, cf, kf); err != nil {
|
if err = x.Start(nc, users, cf, kf); err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
@@ -432,17 +434,17 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
|||||||
for _, u := range toRemove {
|
for _, u := range toRemove {
|
||||||
email := userEmail(u.ID)
|
email := userEmail(u.ID)
|
||||||
if err := um.RemoveUser(ctx, email); err != nil {
|
if err := um.RemoveUser(ctx, email); err != nil {
|
||||||
slog.Debug("xray: RemoveUser skipped in UpdateUsers", "user", u.ID, "error", err)
|
nlog.Core().Debug("xray: RemoveUser skipped in UpdateUsers", "user", u.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, u := range toAdd {
|
for _, u := range toAdd {
|
||||||
mu, err := toMemoryUser(proto, nc, u)
|
mu, err := toMemoryUser(proto, nc, u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("xray: skip user in UpdateUsers", "user", u.ID, "error", err)
|
nlog.Core().Warn("xray: skip user in UpdateUsers", "user", u.ID, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := um.AddUser(ctx, mu); err != nil {
|
if err := um.AddUser(ctx, mu); err != nil {
|
||||||
slog.Warn("xray: AddUser failed in UpdateUsers", "user", u.ID, "error", err)
|
nlog.Core().Warn("xray: AddUser failed in UpdateUsers", "user", u.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +453,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
|||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(users)
|
x.updateDispatcherLimits(users)
|
||||||
|
|
||||||
slog.Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
|
nlog.Core().Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +599,7 @@ func (x *Xray) ensureGeoData(nc *panel.NodeConfig) {
|
|||||||
}
|
}
|
||||||
dir := x.cfg.GeoDataDir
|
dir := x.cfg.GeoDataDir
|
||||||
if err := geodata.Ensure(dir, needIP, needSite, "xray"); err != nil {
|
if err := geodata.Ensure(dir, needIP, needSite, "xray"); err != nil {
|
||||||
slog.Warn("geo database unavailable", "error", err)
|
nlog.Core().Warn("geo database unavailable", "error", err)
|
||||||
}
|
}
|
||||||
os.Setenv("XRAY_LOCATION_ASSET", dir)
|
os.Setenv("XRAY_LOCATION_ASSET", dir)
|
||||||
}
|
}
|
||||||
@@ -609,7 +611,7 @@ func marshalConfig(cfg config.KernelConfig, nc *panel.NodeConfig, users []panel.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal config: %w", err)
|
return nil, fmt.Errorf("marshal config: %w", err)
|
||||||
}
|
}
|
||||||
slog.Debug("xray config generated", "len", len(data))
|
nlog.Core().Debug("xray config generated", "len", len(data))
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +627,7 @@ func startWithTimeout(inst *xrayCore.Instance, timeout time.Duration) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
|
go inst.Close()
|
||||||
return fmt.Errorf("start xray: timeout after %v", timeout)
|
return fmt.Errorf("start xray: timeout after %v", timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,7 +651,7 @@ func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) {
|
|||||||
if ld != nil {
|
if ld != nil {
|
||||||
ld.ResetConns()
|
ld.ResetConns()
|
||||||
}
|
}
|
||||||
slog.Debug("xray: old instance recycled")
|
nlog.Core().Debug("xray: old instance recycled")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ func (t *SpeedTracker) UpdateBuckets() {
|
|||||||
}
|
}
|
||||||
t.limiter.mu.RUnlock()
|
t.limiter.mu.RUnlock()
|
||||||
|
|
||||||
|
func() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
newUUIDMap := make(map[string]int, len(currentUsers))
|
newUUIDMap := make(map[string]int, len(currentUsers))
|
||||||
activeIDs := make(map[int]struct{}, len(currentUsers))
|
activeIDs := make(map[int]struct{}, len(currentUsers))
|
||||||
@@ -87,7 +89,6 @@ func (t *SpeedTracker) UpdateBuckets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove limiters for users who are no longer in the list
|
|
||||||
for id := range t.buckets {
|
for id := range t.buckets {
|
||||||
if _, ok := activeIDs[id]; !ok {
|
if _, ok := activeIDs[id]; !ok {
|
||||||
delete(t.buckets, id)
|
delete(t.buckets, id)
|
||||||
@@ -96,12 +97,7 @@ func (t *SpeedTracker) UpdateBuckets() {
|
|||||||
|
|
||||||
t.uuidMap = newUUIDMap
|
t.uuidMap = newUUIDMap
|
||||||
t.hasLimits.Store(len(t.buckets) > 0)
|
t.hasLimits.Store(len(t.buckets) > 0)
|
||||||
|
}()
|
||||||
// IMPORTANT: logFunc must run after Unlock. The callback installed in
|
|
||||||
// service.startKernel calls LimitedUserCount(), which needs RLock on t.mu.
|
|
||||||
// Calling it while this goroutine holds Lock causes a self-deadlock on
|
|
||||||
// sync.RWMutex (writer blocked forever waiting for itself to release).
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
if t.logFunc != nil {
|
if t.logFunc != nil {
|
||||||
t.logFunc("buckets updated")
|
t.logFunc("buckets updated")
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package monitor
|
package monitor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
@@ -50,7 +50,7 @@ func Collect() Status {
|
|||||||
if cpuPercent, err := cpu.Percent(0, false); err == nil && len(cpuPercent) > 0 {
|
if cpuPercent, err := cpu.Percent(0, false); err == nil && len(cpuPercent) > 0 {
|
||||||
s.CPU = cpuPercent[0]
|
s.CPU = cpuPercent[0]
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
slog.Debug("failed to get CPU usage", "error", err)
|
nlog.Core().Debug("failed to get CPU usage", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-core CPU usage (best-effort; safe if it fails).
|
// Per-core CPU usage (best-effort; safe if it fails).
|
||||||
|
|||||||
+71
-21
@@ -3,6 +3,7 @@ package nlog
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -33,8 +34,44 @@ var (
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
defaultNode string
|
defaultNode string
|
||||||
nodeLoggers = make(map[string]*NodeLog)
|
nodeLoggers = make(map[string]*NodeLog)
|
||||||
|
|
||||||
|
logMu sync.RWMutex
|
||||||
|
logWriter io.Writer = os.Stdout
|
||||||
|
logMin = slog.LevelInfo
|
||||||
|
logColor = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Init configures process-wide log output (called from config.InitLogger).
|
||||||
|
// w must be non-nil. minLevel drops messages below that severity.
|
||||||
|
// color enables ANSI coloring when true (typically TTY + stdout/stderr).
|
||||||
|
func Init(w io.Writer, minLevel slog.Level, color bool) {
|
||||||
|
if w == nil {
|
||||||
|
w = os.Stdout
|
||||||
|
}
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
logWriter = w
|
||||||
|
logMin = minLevel
|
||||||
|
logColor = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMsg appends alternating key-value pairs like slog: ("k", v, "k2", v2).
|
||||||
|
func formatMsg(msg string, args []any) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(msg)
|
||||||
|
for i := 0; i < len(args); i += 2 {
|
||||||
|
if i+1 < len(args) {
|
||||||
|
fmt.Fprintf(&b, " %v=%v", args[i], args[i+1])
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " %v", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// SetDefault sets the default node prefix for core-level logs.
|
// SetDefault sets the default node prefix for core-level logs.
|
||||||
func SetDefault(prefix string) {
|
func SetDefault(prefix string) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -128,48 +165,61 @@ func (nl *NodeLog) Error(msg string, args ...any) {
|
|||||||
logWithColor(slog.LevelError, nl.prefix, msg, args...)
|
logWithColor(slog.LevelError, nl.prefix, msg, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// logWithColor outputs a colored log line directly to stdout.
|
// logWithColor writes one line to the configured writer (see Init).
|
||||||
// Format: HH:MM:SS.mmm LEVEL [prefix] message
|
// Format: HH:MM:SS.mmm LEVEL [prefix] message [key=value ...]
|
||||||
func logWithColor(level slog.Level, prefix, msg string, args ...any) {
|
func logWithColor(level slog.Level, prefix, msg string, args ...any) {
|
||||||
// Time
|
logMu.RLock()
|
||||||
now := time.Now().Format("15:04:05.000")
|
out := logWriter
|
||||||
|
min := logMin
|
||||||
|
color := logColor
|
||||||
|
logMu.RUnlock()
|
||||||
|
|
||||||
// Level with color
|
if level < min {
|
||||||
var levelStr, levelColor string
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Format("15:04:05.000")
|
||||||
|
fullMsg := formatMsg(msg, args)
|
||||||
|
|
||||||
|
var levelStr string
|
||||||
switch level {
|
switch level {
|
||||||
case slog.LevelDebug:
|
case slog.LevelDebug:
|
||||||
levelStr = "DEBUG"
|
levelStr = "DEBUG"
|
||||||
levelColor = ColorGreen
|
|
||||||
case slog.LevelInfo:
|
case slog.LevelInfo:
|
||||||
levelStr = "INFO "
|
levelStr = "INFO "
|
||||||
levelColor = ColorWhite
|
|
||||||
case slog.LevelWarn:
|
case slog.LevelWarn:
|
||||||
levelStr = "WARN "
|
levelStr = "WARN "
|
||||||
levelColor = ColorYellow
|
|
||||||
case slog.LevelError:
|
case slog.LevelError:
|
||||||
levelStr = "ERROR"
|
levelStr = "ERROR"
|
||||||
levelColor = ColorRed
|
|
||||||
default:
|
default:
|
||||||
levelStr = "?????"
|
levelStr = "?????"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !color {
|
||||||
|
fmt.Fprintf(out, "%s %s [%s] %s\n", now, strings.TrimSpace(levelStr), prefix, fullMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var levelColor string
|
||||||
|
switch level {
|
||||||
|
case slog.LevelDebug:
|
||||||
|
levelColor = ColorGreen
|
||||||
|
case slog.LevelInfo:
|
||||||
|
levelColor = ColorWhite
|
||||||
|
case slog.LevelWarn:
|
||||||
|
levelColor = ColorYellow
|
||||||
|
case slog.LevelError:
|
||||||
|
levelColor = ColorRed
|
||||||
|
default:
|
||||||
levelColor = ColorWhite
|
levelColor = ColorWhite
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix with color
|
|
||||||
prefixColor := ColorCyan
|
prefixColor := ColorCyan
|
||||||
if prefix == "core" {
|
if prefix == "core" {
|
||||||
prefixColor = ColorBlue
|
prefixColor = ColorBlue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format message
|
fmt.Fprintf(out, "%s%s%s %s%s%s %s[%s]%s %s\n",
|
||||||
var fullMsg string
|
|
||||||
if len(args) == 0 {
|
|
||||||
fullMsg = msg
|
|
||||||
} else {
|
|
||||||
fullMsg = fmt.Sprintf("%s %v", msg, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output colored line
|
|
||||||
fmt.Fprintf(os.Stdout, "%s%s%s %s%s%s %s[%s]%s %s\n",
|
|
||||||
ColorGray, now, ColorReset,
|
ColorGray, now, ColorReset,
|
||||||
levelColor, levelStr, ColorReset,
|
levelColor, levelStr, ColorReset,
|
||||||
prefixColor, prefix, ColorReset,
|
prefixColor, prefix, ColorReset,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string)
|
|||||||
req.Header.Set("If-None-Match", ifNoneMatch)
|
req.Header.Set("If-None-Match", ifNoneMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("panel request", "method", method, "path", path)
|
nlog.Core().Debug("panel request", "method", method, "path", path)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -359,7 +359,7 @@ func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string)
|
|||||||
c.apiFailure.Add(1)
|
c.apiFailure.Add(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("panel response", "method", method, "path", path, "status", resp.StatusCode)
|
nlog.Core().Debug("panel response", "method", method, "path", path, "status", resp.StatusCode)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-19
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ func (w *WSClient) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("ws disconnected", "error", err)
|
nlog.Core().Warn("ws disconnected", "error", err)
|
||||||
if !wasConnected {
|
if !wasConnected {
|
||||||
w.notifyStatus(false)
|
w.notifyStatus(false)
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
q.Set("node_id", strconv.Itoa(w.nodeID))
|
q.Set("node_id", strconv.Itoa(w.nodeID))
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
slog.Debug("ws connecting", "url", u.Redacted())
|
nlog.Core().Debug("ws connecting", "url", u.Host)
|
||||||
|
|
||||||
dialer := websocket.Dialer{
|
dialer := websocket.Dialer{
|
||||||
HandshakeTimeout: 15 * time.Second,
|
HandshakeTimeout: 15 * time.Second,
|
||||||
@@ -176,7 +176,9 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
var errData struct {
|
var errData struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(firstMsg.Data, &errData)
|
if err := json.Unmarshal(firstMsg.Data, &errData); err != nil {
|
||||||
|
return fmt.Errorf("auth failed (unable to parse error: %v)", err)
|
||||||
|
}
|
||||||
return fmt.Errorf("auth failed: %s", errData.Message)
|
return fmt.Errorf("auth failed: %s", errData.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +215,8 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
case msgCh <- msg:
|
case msgCh <- msg:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
default:
|
||||||
|
nlog.Core().Warn("ws: message channel full, dropping message", "event", msg.Event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -242,7 +246,7 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
select {
|
select {
|
||||||
case writeCh <- wsMessage{Event: "pong"}:
|
case writeCh <- wsMessage{Event: "pong"}:
|
||||||
default:
|
default:
|
||||||
slog.Warn("ws write channel full, skipping pong")
|
nlog.Core().Warn("ws write channel full, skipping pong")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +262,7 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
select {
|
select {
|
||||||
case writeCh <- msg:
|
case writeCh <- msg:
|
||||||
default:
|
default:
|
||||||
slog.Warn("ws write channel full, skipping status push (network slow?)")
|
nlog.Core().Warn("ws write channel full, skipping status push (network slow?)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +280,10 @@ func (w *WSClient) handleMessage(msg wsMessage) {
|
|||||||
switch msg.Event {
|
switch msg.Event {
|
||||||
case "ping":
|
case "ping":
|
||||||
// Server ping — handled by the pong timer reset above
|
// Server ping — handled by the pong timer reset above
|
||||||
slog.Debug("ws received ping")
|
nlog.Core().Debug("ws received ping")
|
||||||
|
|
||||||
case "auth.success":
|
case "auth.success":
|
||||||
slog.Debug("ws auth confirmed")
|
nlog.Core().Debug("ws auth confirmed")
|
||||||
|
|
||||||
case WSEventSyncConfig:
|
case WSEventSyncConfig:
|
||||||
w.handleDataEvent(msg)
|
w.handleDataEvent(msg)
|
||||||
@@ -291,7 +295,7 @@ func (w *WSClient) handleMessage(msg wsMessage) {
|
|||||||
w.handleDataEvent(msg)
|
w.handleDataEvent(msg)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
slog.Debug("ws unknown event", "event", msg.Event)
|
nlog.Core().Debug("ws unknown event", "event", msg.Event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,44 +314,44 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
|||||||
|
|
||||||
switch msg.Event {
|
switch msg.Event {
|
||||||
case WSEventSyncConfig:
|
case WSEventSyncConfig:
|
||||||
slog.Debug("ws sync config event received")
|
nlog.Core().Debug("ws sync config event received")
|
||||||
var p syncConfigPayload
|
var p syncConfigPayload
|
||||||
if err := decodeData(msg.Data, &p); err != nil {
|
if err := decodeData(msg.Data, &p); err != nil {
|
||||||
slog.Warn("ws: cannot decode config payload", "error", err)
|
nlog.Core().Warn("ws: cannot decode config payload", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p.Config.Protocol == "" {
|
if p.Config.Protocol == "" {
|
||||||
slog.Warn("ws: config payload missing protocol")
|
nlog.Core().Warn("ws: config payload missing protocol")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.Config = &p.Config
|
event.Config = &p.Config
|
||||||
|
|
||||||
case WSEventSyncUsers:
|
case WSEventSyncUsers:
|
||||||
slog.Debug("ws sync users event received")
|
nlog.Core().Debug("ws sync users event received")
|
||||||
var p syncUsersPayload
|
var p syncUsersPayload
|
||||||
if err := decodeData(msg.Data, &p); err != nil {
|
if err := decodeData(msg.Data, &p); err != nil {
|
||||||
slog.Warn("ws: cannot decode users payload", "error", err)
|
nlog.Core().Warn("ws: cannot decode users payload", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(p.Users) == 0 {
|
if len(p.Users) == 0 {
|
||||||
slog.Warn("ws: users payload empty")
|
nlog.Core().Warn("ws: users payload empty")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.Users = p.Users
|
event.Users = p.Users
|
||||||
|
|
||||||
case WSEventSyncUserDelta:
|
case WSEventSyncUserDelta:
|
||||||
slog.Debug("ws sync user delta event received")
|
nlog.Core().Debug("ws sync user delta event received")
|
||||||
var p syncUserDeltaPayload
|
var p syncUserDeltaPayload
|
||||||
if err := decodeData(msg.Data, &p); err != nil {
|
if err := decodeData(msg.Data, &p); err != nil {
|
||||||
slog.Warn("ws: cannot decode user delta payload", "error", err)
|
nlog.Core().Warn("ws: cannot decode user delta payload", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p.Action == "" {
|
if p.Action == "" {
|
||||||
slog.Warn("ws: user delta payload missing action")
|
nlog.Core().Warn("ws: user delta payload missing action")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(p.Users) == 0 {
|
if len(p.Users) == 0 {
|
||||||
slog.Warn("ws: user delta payload has no users")
|
nlog.Core().Warn("ws: user delta payload has no users")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.DeltaAction = p.Action
|
event.DeltaAction = p.Action
|
||||||
|
|||||||
+82
-42
@@ -7,9 +7,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -66,6 +66,9 @@ type Service struct {
|
|||||||
wsStatusCh chan panel.WSStatusChange // receives WS connect/disconnect notifications
|
wsStatusCh chan panel.WSStatusChange // receives WS connect/disconnect notifications
|
||||||
wsCancel context.CancelFunc // cancels the WS client goroutine
|
wsCancel context.CancelFunc // cancels the WS client goroutine
|
||||||
wsDisconnectAt time.Time // when WS last disconnected (zero if connected)
|
wsDisconnectAt time.Time // when WS last disconnected (zero if connected)
|
||||||
|
|
||||||
|
// metricsMu: lastUsers, lastConfig, wsClient, wsDisconnectAt (buildMetrics vs main loop).
|
||||||
|
metricsMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// pullResult carries the outcome of an async pullViaAPI back to the main goroutine.
|
// pullResult carries the outcome of an async pullViaAPI back to the main goroutine.
|
||||||
@@ -79,10 +82,13 @@ type pullResult struct {
|
|||||||
|
|
||||||
// apiBackoff implements simple exponential backoff for API failures.
|
// apiBackoff implements simple exponential backoff for API failures.
|
||||||
type apiBackoff struct {
|
type apiBackoff struct {
|
||||||
|
mu sync.Mutex
|
||||||
skipRemaining int
|
skipRemaining int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *apiBackoff) shouldSkip() bool {
|
func (b *apiBackoff) shouldSkip() bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
if b.skipRemaining > 0 {
|
if b.skipRemaining > 0 {
|
||||||
b.skipRemaining--
|
b.skipRemaining--
|
||||||
return true
|
return true
|
||||||
@@ -90,8 +96,15 @@ func (b *apiBackoff) shouldSkip() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *apiBackoff) onSuccess() { b.skipRemaining = 0 }
|
func (b *apiBackoff) onSuccess() {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.skipRemaining = 0
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (b *apiBackoff) onFailure() {
|
func (b *apiBackoff) onFailure() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
if b.skipRemaining <= 0 {
|
if b.skipRemaining <= 0 {
|
||||||
b.skipRemaining = 1
|
b.skipRemaining = 1
|
||||||
} else if b.skipRemaining < 8 {
|
} else if b.skipRemaining < 8 {
|
||||||
@@ -110,7 +123,7 @@ func New(cfg *config.Config) *Service {
|
|||||||
case "xray":
|
case "xray":
|
||||||
k = xray.New(cfg.Kernel)
|
k = xray.New(cfg.Kernel)
|
||||||
default:
|
default:
|
||||||
slog.Error("unsupported kernel type, defaulting to sing-box", "type", cfg.Kernel.Type)
|
nlog.Core().Error("unsupported kernel type, defaulting to sing-box", "type", cfg.Kernel.Type)
|
||||||
k = singbox.New(cfg.Kernel)
|
k = singbox.New(cfg.Kernel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,14 +289,14 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
|||||||
s.limiter.UpdateUsers(users)
|
s.limiter.UpdateUsers(users)
|
||||||
s.speedTracker.UpdateBuckets()
|
s.speedTracker.UpdateBuckets()
|
||||||
|
|
||||||
slog.Info("handshake complete (V1 fallback)",
|
nlog.Core().Info("handshake complete (V1 fallback)",
|
||||||
"protocol", nodeConfig.Protocol,
|
"protocol", nodeConfig.Protocol,
|
||||||
"port", nodeConfig.ServerPort,
|
"port", nodeConfig.ServerPort,
|
||||||
"users", len(users),
|
"users", len(users),
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
slog.Warn("no users, kernel will not start until users are available")
|
nlog.Core().Warn("no users, kernel will not start until users are available")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +323,7 @@ func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig
|
|||||||
|
|
||||||
// Dynamic Log Level (Kernel)
|
// Dynamic Log Level (Kernel)
|
||||||
if nc.KernelLogLevel != "" && nc.KernelLogLevel != s.cfg.Kernel.LogLevel {
|
if nc.KernelLogLevel != "" && nc.KernelLogLevel != s.cfg.Kernel.LogLevel {
|
||||||
slog.Info("cert: kernel log level override", "old", s.cfg.Kernel.LogLevel, "new", nc.KernelLogLevel)
|
nlog.Core().Info("cert: kernel log level override", "old", s.cfg.Kernel.LogLevel, "new", nc.KernelLogLevel)
|
||||||
s.cfg.Kernel.LogLevel = nc.KernelLogLevel
|
s.cfg.Kernel.LogLevel = nc.KernelLogLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +334,7 @@ func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig
|
|||||||
|
|
||||||
// Legacy fields (deprecated: prefer cert_config)
|
// Legacy fields (deprecated: prefer cert_config)
|
||||||
if nc.AutoTLS != s.cfg.Cert.AutoTLS {
|
if nc.AutoTLS != s.cfg.Cert.AutoTLS {
|
||||||
slog.Info("cert: auto_tls policy changed (deprecated field)", "new", nc.AutoTLS)
|
nlog.Core().Info("cert: auto_tls policy changed (deprecated field)", "new", nc.AutoTLS)
|
||||||
s.cfg.Cert.AutoTLS = nc.AutoTLS
|
s.cfg.Cert.AutoTLS = nc.AutoTLS
|
||||||
}
|
}
|
||||||
if nc.Domain != "" && nc.Domain != s.cfg.Cert.Domain {
|
if nc.Domain != "" && nc.Domain != s.cfg.Cert.Domain {
|
||||||
@@ -351,7 +364,7 @@ func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool
|
|||||||
|
|
||||||
changed, err := s.cert.Reconfigure(ctx, newCfg)
|
changed, err := s.cert.Reconfigure(ctx, newCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to apply panel cert config", "mode", pc.CertMode, "error", err)
|
nlog.Core().Error("failed to apply panel cert config", "mode", pc.CertMode, "error", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
s.cfg.Cert = newCfg
|
s.cfg.Cert = newCfg
|
||||||
@@ -386,7 +399,7 @@ func (s *Service) newWSClient(wsURL string) *panel.WSClient {
|
|||||||
select {
|
select {
|
||||||
case s.wsEvents <- event:
|
case s.wsEvents <- event:
|
||||||
default:
|
default:
|
||||||
slog.Warn("ws event channel full, dropping event", "type", event.Type)
|
nlog.Core().Warn("ws event channel full, dropping event", "type", event.Type)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
func(status panel.WSStatusChange) {
|
func(status panel.WSStatusChange) {
|
||||||
@@ -410,7 +423,9 @@ func (s *Service) newWSClient(wsURL string) *panel.WSClient {
|
|||||||
// - On reconnect: clear disconnect timestamp, REST poll to catch missed events.
|
// - On reconnect: clear disconnect timestamp, REST poll to catch missed events.
|
||||||
func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChange) {
|
func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChange) {
|
||||||
if status.Connected {
|
if status.Connected {
|
||||||
|
s.metricsMu.Lock()
|
||||||
s.wsDisconnectAt = time.Time{}
|
s.wsDisconnectAt = time.Time{}
|
||||||
|
s.metricsMu.Unlock()
|
||||||
// Use nodeLog if available, otherwise core
|
// Use nodeLog if available, otherwise core
|
||||||
if s.nodeLog != nil {
|
if s.nodeLog != nil {
|
||||||
s.nodeLog.Info("ws connected")
|
s.nodeLog.Info("ws connected")
|
||||||
@@ -421,9 +436,11 @@ func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChang
|
|||||||
// any updates during the disconnection window.
|
// any updates during the disconnection window.
|
||||||
s.pullViaAPIAsync(ctx)
|
s.pullViaAPIAsync(ctx)
|
||||||
} else {
|
} else {
|
||||||
|
s.metricsMu.Lock()
|
||||||
if s.wsDisconnectAt.IsZero() {
|
if s.wsDisconnectAt.IsZero() {
|
||||||
s.wsDisconnectAt = time.Now()
|
s.wsDisconnectAt = time.Now()
|
||||||
}
|
}
|
||||||
|
s.metricsMu.Unlock()
|
||||||
if s.nodeLog != nil {
|
if s.nodeLog != nil {
|
||||||
s.nodeLog.Info("ws disconnected")
|
s.nodeLog.Info("ws disconnected")
|
||||||
} else {
|
} else {
|
||||||
@@ -447,10 +464,10 @@ func (s *Service) wsDiscovery(ctx context.Context) {
|
|||||||
|
|
||||||
if s.wsClient == nil {
|
if s.wsClient == nil {
|
||||||
needsCheck = true
|
needsCheck = true
|
||||||
slog.Debug("ws discovery: no WS client, checking if panel enabled WS")
|
nlog.Core().Debug("ws discovery: no WS client, checking if panel enabled WS")
|
||||||
} else if !s.wsDisconnectAt.IsZero() && time.Since(s.wsDisconnectAt) > 10*time.Minute {
|
} else if !s.wsDisconnectAt.IsZero() && time.Since(s.wsDisconnectAt) > 10*time.Minute {
|
||||||
needsCheck = true
|
needsCheck = true
|
||||||
slog.Debug("ws discovery: WS disconnected for >10min, re-checking WS config")
|
nlog.Core().Debug("ws discovery: WS disconnected for >10min, re-checking WS config")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !needsCheck {
|
if !needsCheck {
|
||||||
@@ -459,7 +476,7 @@ func (s *Service) wsDiscovery(ctx context.Context) {
|
|||||||
|
|
||||||
hs, err := s.panel.Handshake()
|
hs, err := s.panel.Handshake()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("ws discovery: handshake failed", "error", err)
|
nlog.Core().Debug("ws discovery: handshake failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,19 +485,24 @@ func (s *Service) wsDiscovery(ctx context.Context) {
|
|||||||
|
|
||||||
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
|
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
|
||||||
if s.wsClient == nil {
|
if s.wsClient == nil {
|
||||||
slog.Info("ws discovery: panel has WS enabled, creating WS client")
|
nlog.Core().Info("ws discovery: panel has WS enabled, creating WS client")
|
||||||
s.wsClient = s.newWSClient(hs.WebSocket.WSURL)
|
wsc := s.newWSClient(hs.WebSocket.WSURL)
|
||||||
|
s.metricsMu.Lock()
|
||||||
|
s.wsClient = wsc
|
||||||
s.wsDisconnectAt = time.Time{}
|
s.wsDisconnectAt = time.Time{}
|
||||||
|
s.metricsMu.Unlock()
|
||||||
s.startWSClient(ctx)
|
s.startWSClient(ctx)
|
||||||
}
|
}
|
||||||
} else if s.wsClient != nil {
|
} else if s.wsClient != nil {
|
||||||
slog.Info("ws discovery: panel disabled WS, switching to REST-only")
|
nlog.Core().Info("ws discovery: panel disabled WS, switching to REST-only")
|
||||||
if s.wsCancel != nil {
|
if s.wsCancel != nil {
|
||||||
s.wsCancel()
|
s.wsCancel()
|
||||||
}
|
}
|
||||||
|
s.metricsMu.Lock()
|
||||||
s.wsClient = nil
|
s.wsClient = nil
|
||||||
s.wsCancel = nil
|
|
||||||
s.wsDisconnectAt = time.Time{}
|
s.wsDisconnectAt = time.Time{}
|
||||||
|
s.metricsMu.Unlock()
|
||||||
|
s.wsCancel = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +522,9 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
|
|||||||
s.nodeLog = nlog.ForNode(event.Config.Protocol, event.Config.ServerPort)
|
s.nodeLog = nlog.ForNode(event.Config.Protocol, event.Config.ServerPort)
|
||||||
}
|
}
|
||||||
s.nodeLog.Info(fmt.Sprintf("config updated, %d users", len(event.Users)))
|
s.nodeLog.Info(fmt.Sprintf("config updated, %d users", len(event.Users)))
|
||||||
|
s.metricsMu.Lock()
|
||||||
s.lastConfig = event.Config
|
s.lastConfig = event.Config
|
||||||
|
s.metricsMu.Unlock()
|
||||||
s.lastConfigHash = newConfigHash
|
s.lastConfigHash = newConfigHash
|
||||||
s.applyRemoteOverrides(ctx, event.Config)
|
s.applyRemoteOverrides(ctx, event.Config)
|
||||||
s.applyChanges(ctx, true, false)
|
s.applyChanges(ctx, true, false)
|
||||||
@@ -536,12 +560,12 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
|
|||||||
// goroutine and sends the result to pullResults for the main goroutine to apply.
|
// goroutine and sends the result to pullResults for the main goroutine to apply.
|
||||||
func (s *Service) pullViaAPIAsync(ctx context.Context) {
|
func (s *Service) pullViaAPIAsync(ctx context.Context) {
|
||||||
if !s.pullActive.CompareAndSwap(false, true) {
|
if !s.pullActive.CompareAndSwap(false, true) {
|
||||||
slog.Debug("pull already in progress, skipping")
|
nlog.Core().Debug("pull already in progress, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.pullBackoff.shouldSkip() {
|
if s.pullBackoff.shouldSkip() {
|
||||||
slog.Debug("skipping pull due to backoff")
|
nlog.Core().Debug("skipping pull due to backoff")
|
||||||
s.pullActive.Store(false)
|
s.pullActive.Store(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -555,14 +579,14 @@ func (s *Service) pullViaAPIAsync(ctx context.Context) {
|
|||||||
|
|
||||||
config, err := s.panel.GetConfig()
|
config, err := s.panel.GetConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("poll config failed", "error", err)
|
nlog.Core().Error("poll config failed", "error", err)
|
||||||
s.pullBackoff.onFailure()
|
s.pullBackoff.onFailure()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := s.panel.GetUsers()
|
users, err := s.panel.GetUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("poll users failed", "error", err)
|
nlog.Core().Error("poll users failed", "error", err)
|
||||||
s.pullBackoff.onFailure()
|
s.pullBackoff.onFailure()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -609,7 +633,9 @@ func (s *Service) applyPullResult(ctx context.Context, result pullResult) {
|
|||||||
s.nodeLog = nlog.ForNode(result.config.Protocol, result.config.ServerPort)
|
s.nodeLog = nlog.ForNode(result.config.Protocol, result.config.ServerPort)
|
||||||
}
|
}
|
||||||
s.nodeLog.Info(fmt.Sprintf("config updated, %d users", len(s.lastUsers)))
|
s.nodeLog.Info(fmt.Sprintf("config updated, %d users", len(s.lastUsers)))
|
||||||
|
s.metricsMu.Lock()
|
||||||
s.lastConfig = result.config
|
s.lastConfig = result.config
|
||||||
|
s.metricsMu.Unlock()
|
||||||
s.lastConfigHash = result.configHash
|
s.lastConfigHash = result.configHash
|
||||||
if s.applyRemoteOverrides(ctx, result.config) {
|
if s.applyRemoteOverrides(ctx, result.config) {
|
||||||
configChanged = true
|
configChanged = true
|
||||||
@@ -641,7 +667,11 @@ func (s *Service) updateUserState(users []panel.User) {
|
|||||||
|
|
||||||
s.limiter.UpdateUsers(users)
|
s.limiter.UpdateUsers(users)
|
||||||
s.speedTracker.UpdateBuckets()
|
s.speedTracker.UpdateBuckets()
|
||||||
|
|
||||||
|
s.metricsMu.Lock()
|
||||||
s.lastUsers = users
|
s.lastUsers = users
|
||||||
|
s.metricsMu.Unlock()
|
||||||
|
|
||||||
s.lastUserHash = computeUserHash(users)
|
s.lastUserHash = computeUserHash(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +679,7 @@ func (s *Service) updateUserState(users []panel.User) {
|
|||||||
// records the successfully applied state. Returns false on error.
|
// records the successfully applied state. Returns false on error.
|
||||||
func (s *Service) startKernel(nc *panel.NodeConfig, users []panel.User) bool {
|
func (s *Service) startKernel(nc *panel.NodeConfig, users []panel.User) bool {
|
||||||
if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
||||||
slog.Error("failed to start kernel", "error", err)
|
nlog.Core().Error("failed to start kernel", "error", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,10 +715,6 @@ func (s *Service) ensureRunning() bool {
|
|||||||
// applyUserUpdate replaces the full user set and hot-swaps the kernel.
|
// applyUserUpdate replaces the full user set and hot-swaps the kernel.
|
||||||
// Called from WS sync.users and REST polling.
|
// Called from WS sync.users and REST polling.
|
||||||
func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHash string) {
|
func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHash string) {
|
||||||
// Update limiter/speed state first so even if kernel ops fail,
|
|
||||||
// the in-memory limits are already correct.
|
|
||||||
s.updateUserState(users)
|
|
||||||
|
|
||||||
if !s.ensureRunning() {
|
if !s.ensureRunning() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -699,23 +725,22 @@ func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHa
|
|||||||
s.startKernel(s.lastConfig, users)
|
s.startKernel(s.lastConfig, users)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.updateUserState(users)
|
||||||
if s.nodeLog != nil && (added > 0 || removed > 0) {
|
if s.nodeLog != nil && (added > 0 || removed > 0) {
|
||||||
s.nodeLog.Info(fmt.Sprintf("users updated: +%d -%d", added, removed))
|
s.nodeLog.Info(fmt.Sprintf("users updated: +%d -%d", added, removed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyUserDelta applies an incremental user change (add or remove) directly
|
// applyUserDelta applies an incremental user change (add or remove) directly
|
||||||
// via the kernel's atomic user API.
|
// via the kernel's atomic user API. Kernel updates run before updateUserState.
|
||||||
func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers []panel.User) {
|
func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers []panel.User) {
|
||||||
switch action {
|
switch action {
|
||||||
case "add":
|
case "add":
|
||||||
// Defensive check for empty or nil deltaUsers
|
// Defensive check for empty or nil deltaUsers
|
||||||
if deltaUsers == nil || len(deltaUsers) == 0 {
|
if deltaUsers == nil || len(deltaUsers) == 0 {
|
||||||
nlog.Core().Warn("add delta with empty users, skipping")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
merged := mergeUsers(s.lastUsers, deltaUsers)
|
merged := mergeUsers(s.lastUsers, deltaUsers)
|
||||||
s.updateUserState(merged)
|
|
||||||
|
|
||||||
if !s.ensureRunning() {
|
if !s.ensureRunning() {
|
||||||
return
|
return
|
||||||
@@ -723,22 +748,23 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
|
|||||||
|
|
||||||
added, err := s.kernel.AddUsers(deltaUsers)
|
added, err := s.kernel.AddUsers(deltaUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Warn(fmt.Sprintf("AddUsers failed: %v", err))
|
nlog.Core().Warn(fmt.Sprintf("AddUsers failed: %v, falling back to UpdateUsers", err))
|
||||||
if _, _, err := s.kernel.UpdateUsers(merged); err != nil {
|
if _, _, err := s.kernel.UpdateUsers(merged); err != nil {
|
||||||
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else if s.nodeLog != nil && added > 0 {
|
}
|
||||||
|
s.updateUserState(merged)
|
||||||
|
if s.nodeLog != nil && added > 0 {
|
||||||
s.nodeLog.Info(fmt.Sprintf("users added: +%d", added))
|
s.nodeLog.Info(fmt.Sprintf("users added: +%d", added))
|
||||||
}
|
}
|
||||||
|
|
||||||
case "remove":
|
case "remove":
|
||||||
// Defensive check for empty or nil deltaUsers
|
// Defensive check for empty or nil deltaUsers
|
||||||
if deltaUsers == nil || len(deltaUsers) == 0 {
|
if deltaUsers == nil || len(deltaUsers) == 0 {
|
||||||
nlog.Core().Warn("remove delta with empty users, skipping")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
filtered := subtractUsers(s.lastUsers, deltaUsers)
|
filtered := subtractUsers(s.lastUsers, deltaUsers)
|
||||||
s.updateUserState(filtered)
|
|
||||||
|
|
||||||
if !s.kernel.IsRunning() {
|
if !s.kernel.IsRunning() {
|
||||||
return
|
return
|
||||||
@@ -746,11 +772,14 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
|
|||||||
|
|
||||||
removed, err := s.kernel.RemoveUsers(deltaUsers)
|
removed, err := s.kernel.RemoveUsers(deltaUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Warn(fmt.Sprintf("RemoveUsers failed: %v", err))
|
nlog.Core().Warn(fmt.Sprintf("RemoveUsers failed: %v, falling back to UpdateUsers", err))
|
||||||
if _, _, err := s.kernel.UpdateUsers(filtered); err != nil {
|
if _, _, err := s.kernel.UpdateUsers(filtered); err != nil {
|
||||||
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else if s.nodeLog != nil && removed > 0 {
|
}
|
||||||
|
s.updateUserState(filtered)
|
||||||
|
if s.nodeLog != nil && removed > 0 {
|
||||||
s.nodeLog.Info(fmt.Sprintf("users removed: -%d", removed))
|
s.nodeLog.Info(fmt.Sprintf("users removed: -%d", removed))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,6 +815,12 @@ func mergeUsers(base, delta []panel.User) []panel.User {
|
|||||||
|
|
||||||
// subtractUsers returns base with all users in delta removed.
|
// subtractUsers returns base with all users in delta removed.
|
||||||
func subtractUsers(base, delta []panel.User) []panel.User {
|
func subtractUsers(base, delta []panel.User) []panel.User {
|
||||||
|
if base == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if delta == nil || len(delta) == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
removeSet := make(map[int]struct{}, len(delta))
|
removeSet := make(map[int]struct{}, len(delta))
|
||||||
for _, u := range delta {
|
for _, u := range delta {
|
||||||
removeSet[u.ID] = struct{}{}
|
removeSet[u.ID] = struct{}{}
|
||||||
@@ -839,7 +874,7 @@ func (s *Service) trackAndEnforce(ctx context.Context) {
|
|||||||
|
|
||||||
traffic, aliveIPs, connCount, err := s.kernel.GetUserTraffic(ctx)
|
traffic, aliveIPs, connCount, err := s.kernel.GetUserTraffic(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("get user traffic failed", "error", err)
|
nlog.Core().Debug("get user traffic failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,12 +894,12 @@ func (s *Service) trackAndEnforce(ctx context.Context) {
|
|||||||
// loop is never blocked by slow HTTP. Only one push runs at a time.
|
// loop is never blocked by slow HTTP. Only one push runs at a time.
|
||||||
func (s *Service) pushReportAsync() {
|
func (s *Service) pushReportAsync() {
|
||||||
if !s.pushActive.CompareAndSwap(false, true) {
|
if !s.pushActive.CompareAndSwap(false, true) {
|
||||||
slog.Debug("push already in progress, skipping")
|
nlog.Core().Debug("push already in progress, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.pushBackoff.shouldSkip() {
|
if s.pushBackoff.shouldSkip() {
|
||||||
slog.Debug("skipping report due to backoff")
|
nlog.Core().Debug("skipping report due to backoff")
|
||||||
s.pushActive.Store(false)
|
s.pushActive.Store(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -888,7 +923,7 @@ func (s *Service) pushReportAsync() {
|
|||||||
[2]uint64{status.DiskTotal, status.DiskUsed},
|
[2]uint64{status.DiskTotal, status.DiskUsed},
|
||||||
metrics,
|
metrics,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Error("failed to push report", "error", err)
|
nlog.Core().Error("failed to push report", "error", err)
|
||||||
if len(traffic) > 0 {
|
if len(traffic) > 0 {
|
||||||
s.tracker.RestoreTraffic(traffic)
|
s.tracker.RestoreTraffic(traffic)
|
||||||
}
|
}
|
||||||
@@ -921,7 +956,7 @@ func (s *Service) pushReportSync() {
|
|||||||
[2]uint64{status.DiskTotal, status.DiskUsed},
|
[2]uint64{status.DiskTotal, status.DiskUsed},
|
||||||
metrics,
|
metrics,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Error("failed to push final report", "error", err)
|
nlog.Core().Error("failed to push final report", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,6 +964,11 @@ func (s *Service) pushReportSync() {
|
|||||||
// This includes active connections, per-core CPU, GC stats, API call stats,
|
// This includes active connections, per-core CPU, GC stats, API call stats,
|
||||||
// WebSocket status, and limiter hit counts.
|
// WebSocket status, and limiter hit counts.
|
||||||
func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
|
func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
|
||||||
|
s.metricsMu.RLock()
|
||||||
|
lastUsers := s.lastUsers
|
||||||
|
wsClient := s.wsClient
|
||||||
|
s.metricsMu.RUnlock()
|
||||||
|
|
||||||
m := make(map[string]interface{})
|
m := make(map[string]interface{})
|
||||||
online := s.tracker.CurrentOnline()
|
online := s.tracker.CurrentOnline()
|
||||||
|
|
||||||
@@ -939,7 +979,7 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
|
|||||||
m["active_connections"] = s.tracker.ActiveConnections()
|
m["active_connections"] = s.tracker.ActiveConnections()
|
||||||
m["total_connections"] = s.tracker.TotalConnections()
|
m["total_connections"] = s.tracker.TotalConnections()
|
||||||
m["active_users"] = len(online)
|
m["active_users"] = len(online)
|
||||||
m["total_users"] = len(s.lastUsers)
|
m["total_users"] = len(lastUsers)
|
||||||
|
|
||||||
// Speed
|
// Speed
|
||||||
m["inbound_speed"] = s.tracker.InboundSpeed()
|
m["inbound_speed"] = s.tracker.InboundSpeed()
|
||||||
@@ -976,8 +1016,8 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket status.
|
// WebSocket status.
|
||||||
wsEnabled := s.wsClient != nil
|
wsEnabled := wsClient != nil
|
||||||
wsConnected := wsEnabled && s.wsClient.IsConnected()
|
wsConnected := wsEnabled && wsClient.IsConnected()
|
||||||
m["ws"] = map[string]interface{}{
|
m["ws"] = map[string]interface{}{
|
||||||
"enabled": wsEnabled,
|
"enabled": wsEnabled,
|
||||||
"connected": wsConnected,
|
"connected": wsConnected,
|
||||||
|
|||||||
@@ -203,17 +203,15 @@ func (t *Tracker) RestoreAliveIPs(data map[int][]string) {
|
|||||||
if ips == nil {
|
if ips == nil {
|
||||||
ips = make([]string, 0, len(ipList))
|
ips = make([]string, 0, len(ipList))
|
||||||
}
|
}
|
||||||
for _, ip := range ipList {
|
// Use map for O(n) dedup instead of O(n²) linear search
|
||||||
// Dedup check
|
existMap := make(map[string]struct{}, len(ips)+len(ipList))
|
||||||
found := false
|
|
||||||
for _, existing := range ips {
|
for _, existing := range ips {
|
||||||
if existing == ip {
|
existMap[existing] = struct{}{}
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
for _, ip := range ipList {
|
||||||
if !found {
|
if _, exists := existMap[ip]; !exists {
|
||||||
ips = append(ips, ip)
|
ips = append(ips, ip)
|
||||||
|
existMap[ip] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.aliveIPsBuf[uid] = ips
|
t.aliveIPsBuf[uid] = ips
|
||||||
|
|||||||
Reference in New Issue
Block a user