feat: nlog, config hot-reload, and concurrency fixes

This commit is contained in:
xboard
2026-03-24 23:12:55 +08:00
parent 191e58b9ee
commit 7e3ad7cb23
22 changed files with 572 additions and 281 deletions
+3 -3
View File
@@ -3,10 +3,10 @@ package kernel
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"github.com/cedar2025/xboard-node/internal/nlog"
"gopkg.in/yaml.v3"
)
@@ -20,7 +20,7 @@ func LoadCustomConfig(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
if err != nil {
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, 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
}
+5 -4
View File
@@ -3,11 +3,12 @@ package geodata
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/cedar2025/xboard-node/internal/nlog"
)
const (
@@ -49,7 +50,7 @@ func Ensure(dir string, needGeoIP, needGeoSite bool, kernelType string) error {
var firstErr error
for _, f := range files {
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 {
firstErr = err
}
@@ -63,12 +64,12 @@ func ensureFile(dir, name, url string) error {
if _, err := os.Stat(dst); err == 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 {
return err
}
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
}
+3 -3
View File
@@ -3,7 +3,6 @@ package singbox
import (
"encoding/base64"
"fmt"
"log/slog"
"net"
"path/filepath"
"strconv"
@@ -11,6 +10,7 @@ import (
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/cedar2025/xboard-node/internal/panel"
"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) {
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
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
}
if custom == nil {
@@ -427,7 +427,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
base["users"] = userList
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
+22 -10
View File
@@ -3,7 +3,6 @@ package singbox
import (
"context"
"io"
"log/slog"
"net"
"sort"
"sync"
@@ -15,6 +14,8 @@ import (
singM "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/time/rate"
"github.com/cedar2025/xboard-node/internal/nlog"
)
const rateLimitThreshold = 64 * 1024
@@ -171,7 +172,7 @@ func (t *ConnTracker) RoutedConnection(
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
if limit, hasLimit := (*dlf)(uuid); hasLimit {
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)
conn.Close()
return conn
@@ -197,17 +198,17 @@ func (t *ConnTracker) RoutedConnection(
}
return &trackedConn{
Conn: conn,
tracker: t,
us: us,
connID: connID,
Conn: conn,
tracker: t,
us: us,
connID: connID,
sourceIP: sourceIP,
limiter: lim,
ctx: ctx,
limiter: lim,
ctx: ctx,
}
}
// 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(
ctx context.Context, conn N.PacketConn,
metadata adapter.InboundContext,
@@ -221,6 +222,17 @@ func (t *ConnTracker) RoutedPacketConnection(
us := t.users[uid]
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 {
us.addConn(sourceIP)
}
@@ -372,7 +384,7 @@ func (t *ConnTracker) removeConnRef(connID string) {
type trackedConn struct {
net.Conn
tracker *ConnTracker
us *userStats // per-user stats (upload/download atomics + IP tracking)
us *userStats // per-user stats (upload/download atomics + IP tracking)
connID string
sourceIP string
limiter *rate.Limiter
+11 -11
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
@@ -20,6 +19,7 @@ import (
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/nlog"
"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)
}
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 = include.Context(ctx)
@@ -147,7 +147,7 @@ func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFi
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
}
@@ -176,7 +176,7 @@ func recycleOldBox(oldBox *box.Box, oldCancel context.CancelFunc, oldCtx context
if oldCancel != nil {
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.
@@ -213,9 +213,9 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
// Update routing rules
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 {
slog.Debug("sing-box routing reloaded")
nlog.Core().Debug("sing-box routing reloaded")
}
nopFactory := singLog.NewNOPFactory()
@@ -270,7 +270,7 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
if err == nil {
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))
}
slog.Debug("sing-box reloaded", "users", len(users))
nlog.Core().Debug("sing-box reloaded", "users", len(users))
s.users = users
s.nodeConfig = nodeConfig
s.certFile = certFile
@@ -559,7 +559,7 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
if err == nil {
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)
@@ -573,7 +573,7 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
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
}
@@ -594,7 +594,7 @@ func (s *SingBox) CloseConnection(_ context.Context, connID string) error {
return fmt.Errorf("not running")
}
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
}
+3 -3
View File
@@ -3,11 +3,11 @@ package xray
import (
"encoding/base64"
"fmt"
"log/slog"
"strings"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/nlog"
"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 {
cfg["inbounds"] = []M{inbound}
} 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,
"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) {
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
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
}
if custom == nil {
+4 -3
View File
@@ -3,7 +3,6 @@ package xray
import (
"context"
"errors"
"log/slog"
"reflect"
"sort"
"sync"
@@ -18,6 +17,8 @@ import (
"github.com/xtls/xray-core/features/routing"
"github.com/xtls/xray-core/transport"
"golang.org/x/time/rate"
"github.com/cedar2025/xboard-node/internal/nlog"
)
// 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),
}
globalLimitDispatcher.Store(ld)
slog.Debug("xray: limit dispatcher installed")
nlog.Core().Debug("xray: limit dispatcher installed")
return ld, nil
}
@@ -158,7 +159,7 @@ func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destina
isTCP = dest.Network == net.Network_TCP
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 email, sourceIP, isTCP, nil
+24 -21
View File
@@ -6,7 +6,6 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"sync"
@@ -32,6 +31,7 @@ import (
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
"github.com/cedar2025/xboard-node/internal/nlog"
"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)
slog.Info("xray started",
nlog.Core().Info("xray started",
"users", len(users),
"protocol", nodeConfig.Protocol,
)
@@ -174,12 +174,12 @@ func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile
if same {
x.users = users
x.mu.Unlock()
slog.Debug("xray: limits updated, kernel configuration unchanged")
nlog.Core().Debug("xray: limits updated, kernel configuration unchanged")
return nil
}
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)
}
@@ -220,8 +220,10 @@ func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliv
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()
x.mu.Unlock()
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
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
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 {
return 0, err
}
@@ -305,11 +307,11 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
for _, u := range toAdd {
mu, err := toMemoryUser(proto, nc, u)
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
}
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
}
added++
@@ -321,7 +323,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
x.mu.Unlock()
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
}
@@ -361,7 +363,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
if err != nil {
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
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 {
return 0, err
}
@@ -374,7 +376,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
for _, u := range users {
email := userEmail(u.ID)
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
}
actualRemoved++
@@ -385,7 +387,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
x.mu.Unlock()
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
}
@@ -406,7 +408,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
x.users = users
x.mu.Unlock()
x.updateDispatcherLimits(users)
slog.Info("xray: limits updated, kernel unchanged")
nlog.Core().Info("xray: limits updated, kernel unchanged")
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
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
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 {
return 0, 0, err
}
@@ -432,17 +434,17 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
for _, u := range toRemove {
email := userEmail(u.ID)
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 {
mu, err := toMemoryUser(proto, nc, u)
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
}
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.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
}
@@ -597,7 +599,7 @@ func (x *Xray) ensureGeoData(nc *panel.NodeConfig) {
}
dir := x.cfg.GeoDataDir
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)
}
@@ -609,7 +611,7 @@ func marshalConfig(cfg config.KernelConfig, nc *panel.NodeConfig, users []panel.
if err != nil {
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
}
@@ -625,6 +627,7 @@ func startWithTimeout(inst *xrayCore.Instance, timeout time.Duration) error {
}
return nil
case <-time.After(timeout):
go inst.Close()
return fmt.Errorf("start xray: timeout after %v", timeout)
}
}
@@ -648,7 +651,7 @@ func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) {
if ld != nil {
ld.ResetConns()
}
slog.Debug("xray: old instance recycled")
nlog.Core().Debug("xray: old instance recycled")
}()
}