refactor: optimize user management, fix speed limit hot-reload and improve performance

This commit is contained in:
xboard
2026-03-16 06:55:42 +08:00
parent 08795d2e04
commit 0975b45404
7 changed files with 1115 additions and 326 deletions
+216 -11
View File
@@ -29,10 +29,9 @@ const drainTimeout = 5 * time.Second
// SingBox implements kernel.Kernel by embedding sing-box as a Go library.
//
// Connection tracking is done entirely in-process via ConnTracker, which is
// registered as an adapter.ConnectionTracker with sing-box's router. This
// avoids the HTTP/JSON overhead of the Clash API (saves 5-15 MB at 500+
// concurrent connections).
// User operations (AddUsers/RemoveUsers/UpdateUsers) use sing-box's native
// UpdatableInbound interface, which hot-swaps user credentials without
// restarting listeners — zero connection disruption.
type SingBox struct {
cfg config.KernelConfig
@@ -41,9 +40,14 @@ type SingBox struct {
ctx context.Context
cancel context.CancelFunc
users []panel.User
nodeConfig *panel.NodeConfig
certFile string
keyFile string
// connTracker is our lightweight in-process byte/IP tracker.
// Created fresh on every ApplyConfig (full restart).
// Survives ReloadUsers (hot-swap) since live connections persist.
// Created fresh on every Start (full restart).
// Survives Reload (hot-swap) since live connections persist.
connTracker *ConnTracker
// speedLimitFunc resolves a user UUID to a *rate.Limiter.
@@ -51,7 +55,7 @@ type SingBox struct {
speedLimitFunc func(string) *rate.Limiter
// trackerRegistered prevents duplicate AppendTracker calls on the same
// Router instance during ReloadUsers. Reset to false on full restart.
// Router instance during Reload. Reset to false on full restart.
trackerRegistered bool
}
@@ -59,9 +63,18 @@ func New(cfg config.KernelConfig) *SingBox {
return &SingBox{cfg: cfg}
}
var _ kernel.Kernel = (*SingBox)(nil)
func (s *SingBox) Name() string { return "sing-box" }
func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
func (s *SingBox) Protocols() []string {
return []string{
"vmess", "vless", "trojan", "shadowsocks",
"hysteria2", "tuic", "naive", "socks", "http", "anytls",
}
}
func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -102,6 +115,10 @@ func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User,
s.box = instance
s.ctx = ctx
s.cancel = cancel
s.users = users
s.nodeConfig = nodeConfig
s.certFile = certFile
s.keyFile = keyFile
// Fresh tracker on full restart (all connections were terminated by s.stop).
s.connTracker = NewConnTracker(0)
@@ -113,7 +130,7 @@ func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User,
s.trackerRegistered = false
s.registerTracker(ctx)
slog.Info("sing-box started (in-process)", "users", len(users))
slog.Info("sing-box started", "users", len(users))
return nil
}
@@ -225,6 +242,10 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
}
slog.Info("sing-box reloaded (users and routes hot-swapped)", "users", len(users))
s.users = users
s.nodeConfig = nodeConfig
s.certFile = certFile
s.keyFile = keyFile
return nil
}
@@ -312,8 +333,192 @@ func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
}
}
// GetConnections returns a snapshot of all active and recently-closed
// connections directly from the in-process ConnTracker.
// ─── User management (non-disruptive) ───────────────────────────────────────
// AddUsers hot-swaps users into running inbounds. Zero connection disruption.
func (s *SingBox) AddUsers(users []panel.User) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.box == nil {
return 0, fmt.Errorf("not running")
}
existing := make(map[int]struct{}, len(s.users))
for _, u := range s.users {
existing[u.ID] = struct{}{}
}
var toAdd []panel.User
for _, u := range users {
if _, dup := existing[u.ID]; !dup {
toAdd = append(toAdd, u)
}
}
if len(toAdd) == 0 {
return 0, nil
}
merged := append(append([]panel.User{}, s.users...), toAdd...)
if err := s.reloadInboundsLocked(merged); err != nil {
return 0, err
}
s.users = merged
return len(toAdd), nil
}
// RemoveUsers hot-swaps users out of running inbounds. Zero connection disruption
// for remaining users.
func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.box == nil {
return 0, fmt.Errorf("not running")
}
removeSet := make(map[int]struct{}, len(users))
for _, u := range users {
removeSet[u.ID] = struct{}{}
}
var kept []panel.User
removed := 0
for _, u := range s.users {
if _, rm := removeSet[u.ID]; rm {
removed++
} else {
kept = append(kept, u)
}
}
if removed == 0 {
return 0, nil
}
if err := s.reloadInboundsLocked(kept); err != nil {
return 0, err
}
s.users = kept
return removed, nil
}
// UpdateUsers replaces the entire user set atomically via hot-swap.
func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.box == nil {
return 0, 0, fmt.Errorf("not running")
}
toAdd, toRemove := kernel.UserDiff(s.users, users)
added, removed = len(toAdd), len(toRemove)
if added == 0 && removed == 0 {
// Only limits may have changed — update tracker map.
if s.connTracker != nil {
s.connTracker.SetUserMap(buildUserMap(users))
}
s.users = users
return 0, 0, nil
}
if err = s.reloadInboundsLocked(users); err != nil {
return 0, 0, err
}
s.users = users
return
}
// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound.
// Must be called with s.mu held.
func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
opts, err := singJSON.UnmarshalExtendedContext[option.Options](s.ctx, data)
if err != nil {
return fmt.Errorf("parse options: %w", err)
}
im := service.FromContext[adapter.InboundManager](s.ctx)
if im == nil {
return fmt.Errorf("inbound manager not available")
}
router := service.FromContext[adapter.Router](s.ctx)
if router == nil {
return fmt.Errorf("router not available")
}
nopFactory := singLog.NewNOPFactory()
for _, inb := range opts.Inbounds {
tag := inb.Tag
if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type {
var err error
switch v := existing.(type) {
case adapter.UpdatableInbound[option.VMessUser]:
if opts, ok := inb.Options.(*option.VMessInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.VLESSUser]:
if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.TrojanUser]:
if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.Hysteria2User]:
if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableShadowsocksInbound:
if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok {
err = v.UpdateUsersByOptions(opts.Users)
}
case adapter.UpdatableInbound[option.TUICUser]:
if opts, ok := inb.Options.(*option.TUICInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.AnyTLSUser]:
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[auth.User]:
switch opts := inb.Options.(type) {
case *option.NaiveInboundOptions:
err = v.UpdateUsers(opts.Users)
case *option.SocksInboundOptions:
err = v.UpdateUsers(opts.Users)
case *option.HTTPMixedInboundOptions:
err = v.UpdateUsers(opts.Users)
}
}
if err == nil {
continue
}
slog.Warn("incremental update failed, recreating inbound", "tag", tag, "error", err)
}
_ = im.Remove(tag)
logger := nopFactory.NewLogger(fmt.Sprintf("inbound/%s[%s]", inb.Type, tag))
if err := im.Create(s.ctx, router, logger, tag, inb.Type, inb.Options); err != nil {
return fmt.Errorf("recreate inbound %s: %w", tag, err)
}
}
if s.connTracker != nil {
s.connTracker.SetUserMap(buildUserMap(users))
}
slog.Info("sing-box users hot-swapped", "users", len(users))
return nil
}
// ─── Observability ──────────────────────────────────────────────────────────
func (s *SingBox) GetConnections(_ context.Context) ([]kernel.Connection, error) {
ct := s.connTrackerSafe()
if ct == nil {