From 598b6b531b62a97836c3f500a33aa3932be34909 Mon Sep 17 00:00:00 2001 From: xboard Date: Sat, 11 Apr 2026 05:36:59 +0800 Subject: [PATCH] feat: add standalone controlplane support --- .gitignore | 2 + README.md | 24 +- config.yml.example | 76 +++-- go.mod | 3 +- go.sum | 4 +- internal/config/config.go | 34 +- internal/config/config_test.go | 56 ++++ internal/config/standalone.go | 129 ++++++++ internal/controlplane/local.go | 56 ++++ internal/controlplane/local_test.go | 32 ++ internal/controlplane/panel.go | 161 +++++++++ internal/controlplane/types.go | 85 +++++ internal/kernel/geo.go | 6 +- internal/kernel/kernel.go | 33 +- internal/kernel/singbox/config.go | 46 +-- internal/kernel/singbox/config_test.go | 109 ++++--- internal/kernel/singbox/singbox.go | 45 ++- internal/kernel/singbox/singbox_test.go | 204 ++++++++++++ internal/kernel/xray/config.go | 30 +- internal/kernel/xray/config_test.go | 32 +- internal/kernel/xray/dispatcher.go | 188 ++--------- internal/kernel/xray/dispatcher_test.go | 133 ++++---- internal/kernel/xray/xray.go | 132 +++++--- internal/kernel/xray/xray_test.go | 278 ++++++++++++++++ internal/limiter/limiter.go | 10 +- internal/limiter/limiter_test.go | 22 +- internal/limiter/speedtracker.go | 4 +- internal/limiter/speedtracker_test.go | 14 +- internal/model/panel.go | 224 +++++++++++++ internal/model/standalone.go | 82 +++++ internal/model/standalone_test.go | 49 +++ internal/model/types.go | 132 ++++++++ internal/service/service.go | 414 ++++++++++-------------- internal/service/service_test.go | 241 ++++++++++++++ 34 files changed, 2383 insertions(+), 707 deletions(-) create mode 100644 internal/config/standalone.go create mode 100644 internal/controlplane/local.go create mode 100644 internal/controlplane/local_test.go create mode 100644 internal/controlplane/panel.go create mode 100644 internal/controlplane/types.go create mode 100644 internal/kernel/singbox/singbox_test.go create mode 100644 internal/kernel/xray/xray_test.go create mode 100644 internal/model/panel.go create mode 100644 internal/model/standalone.go create mode 100644 internal/model/standalone_test.go create mode 100644 internal/model/types.go create mode 100644 internal/service/service_test.go diff --git a/.gitignore b/.gitignore index 584e3ea..f205547 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ Thumbs.db vendor/ config.json benchmark/ +third_party +bench-results \ No newline at end of file diff --git a/README.md b/README.md index dbe1ba2..8292a98 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,20 @@ Dedicated node backend for [Xboard](https://github.com/cedar2025/Xboard). Fully > **Disclaimer**: This project is for educational and learning purposes only. +## Overview + +| Item | Description | +| --- | --- | +| Role | Xboard-compatible node backend | +| Kernels | `sing-box` (default), `xray-core` | +| Protocols | V2Ray family, Trojan, Shadowsocks, Hysteria2, TUIC, Naive | +| Modes | Panel-managed mode, `standalone` mode | +| Sync | WebSocket push, REST polling/report fallback | +| User controls | Per-user speed limit, device limit, alive IP tracking | +| Runtime ops | Hot user add/remove/update | +| Reporting | Traffic, online/alive-IP state, CPU, memory, swap, disk, connection count | +| Deployment | Single Go service, Docker, Docker Compose | + ## Install ### Docker @@ -24,7 +38,6 @@ git clone -b compose --depth 1 https://github.com/cedar2025/xboard-node.git cd xboard-node ``` - **2. Edit local config** ```bash @@ -38,14 +51,6 @@ vim config/config.yml docker compose up -d ``` -## Features - -- **Kernels**: sing-box (default) / Xray-core. -- **Protocols**: Full coverage (V2Ray, Trojan, SS, Hysteria2, TUIC, Naive). -- **Speed**: Kernel-level rate limiting. -- **Sync**: Real-time WebSocket + REST fallback. -- **Dev**: Single Go binary. - ## Configuration `config.yml`: @@ -59,4 +64,3 @@ panel: ## License MPL-2.0. - diff --git a/config.yml.example b/config.yml.example index abfd4b7..4d89875 100644 --- a/config.yml.example +++ b/config.yml.example @@ -2,90 +2,86 @@ # Copy this file to config.yml and modify as needed # ══════════════════════════════════════════════════════════════════════════════ -# MINIMAL CONFIGURATION (Recommended) -# Most settings (Node ID, Kernel, TLS, Rules) can be managed via the Panel. +# PANEL MODE (Default) +# Connect to an Xboard panel for config / users / reporting. # ══════════════════════════════════════════════════════════════════════════════ -# Panel connection settings panel: - # Xboard panel URL (required) + # Xboard panel URL (required in panel mode) url: "https://panel.example.com" - # Server communication token (required, must match panel settings) + # Server communication token (required in panel mode) token: "your-server-token" # Node ID as configured in the panel node_id: 1 -# Kernel settings (optional defaults) +# Kernel settings (used by both panel mode and standalone mode) kernel: # Kernel type: "singbox" (default) or "xray" type: "singbox" # Directory to store generated configs (default: /etc/xboard-node) config_dir: "/etc/xboard-node" - # Kernel log level: debug/info/warn/error (default: warn, can be overridden by panel) + # Kernel log level: debug/info/warn/error (default: warn) log_level: "warn" -# TLS certificate settings (optional, can be managed by panel) +# TLS certificate settings # cert_mode: "http" | "dns" | "self" | "file" | "none" -# http - ACME HTTP-01 challenge (requires port 80 open to internet) -# dns - ACME DNS-01 challenge (supports wildcard certs, requires dns_provider) -# self - self-signed certificate (valid 10 years, no external dependency) -# file - manually specify cert_file / key_file -# none - no TLS (default) cert: cert_mode: "none" - # Domain for ACME or self-signed cert # domain: "node.example.com" - # Email for ACME registration # email: "admin@example.com" - - # ── HTTP-01 mode ── - # http_port: 80 # local port for ACME challenge (default: 80) - - # ── DNS-01 mode ── - # dns_provider: "cloudflare" # supported: cloudflare, alidns - # dns_env: # provider-specific credentials + # http_port: 80 + # dns_provider: "cloudflare" + # dns_env: # CF_API_TOKEN: "your-cloudflare-api-token" - - # ── File mode ── # cert_file: "/etc/ssl/certs/cert.pem" # key_file: "/etc/ssl/private/key.pem" - - # Storage directory for auto certs (default: {config_dir}/certs) # cert_dir: "/etc/xboard-node/certs" -# Logging settings log: - # Node log level: debug/info/warn/error (default: info) level: "info" - # Log output: stdout/stderr or file path (default: stdout) output: "stdout" -# ══════════════════════════════════════════════════════════════════════════════ -# ADVANCED / MULTI-NODE MODE -# Run multiple nodes with one configuration file. -# Each node gets its own isolated working directory. -# ══════════════════════════════════════════════════════════════════════════════ +# Standalone mode runs the node entirely from local config and never contacts +# the panel. When enabled, panel.* is ignored. +# +# standalone: +# enabled: true +# node: +# protocol: "vless" +# listen_ip: "::" +# server_port: 8443 +# network: "ws" +# tls: 1 +# host: "node.example.com" +# server_name: "node.example.com" +# network_settings: +# path: "/app" +# host: "node.example.com" +# routes: +# - match: ["geoip:private"] +# action: "direct" +# users: +# - id: 1 +# uuid: "11111111-1111-1111-1111-111111111111" +# speed_limit: 0 +# device_limit: 0 + +# Advanced / multi-node panel mode # When `nodes:` is present, the top-level `panel.node_id` is ignored. # # nodes: # - node_id: 1 -# # - node_id: 2 -# # Optional overrides per node -# kernel_type: "xray" # cert: # auto_tls: true # domain: "node2.example.com" -# # - node_id: 3 # kernel: # config_dir: "/etc/xboard-node/custom-node-3" # Runtime memory tuning (optional) # runtime: -# # Soft memory limit (e.g. "30MiB", "512MiB") # gomemlimit: "" -# # GOGC target percentage (default: 100) # gogc: 0 # Health check endpoint for Docker/K8s (optional, 0 = disabled) diff --git a/go.mod b/go.mod index a22d7bf..418ca23 100644 --- a/go.mod +++ b/go.mod @@ -177,5 +177,4 @@ require ( ) replace github.com/sagernet/sing-box => github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295 - -// replace github.com/sagernet/sing-box => ./third_party/sing-box +replace github.com/xtls/xray-core => github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965 diff --git a/go.sum b/go.sum index ed8a050..09e1816 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2 github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965 h1:fuJYmFZ1N58sIA9LqzOcEhJrxeWsvBKPOR4mvZLtWig= +github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60= github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295 h1:bmtrfItcjIJ/jGfz17Ic1WBzjPP3aBdAGgagL8X5IPQ= github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295/go.mod h1:DTyWqWrjh/DqOHynCUgMhW8fgrkctleficRKwQmh668= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -323,8 +325,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE= -github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= diff --git a/internal/config/config.go b/internal/config/config.go index b2c168c..830f307 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,8 @@ type Config struct { Log LogConfig `yaml:"log"` Runtime RuntimeConfig `yaml:"runtime"` WS WSConfig `yaml:"ws"` + // Standalone enables a local-only node that never contacts the panel. + Standalone *StandaloneConfig `yaml:"standalone,omitempty"` // HealthPort enables a lightweight HTTP health-check endpoint on the // given port (e.g. 65530). 0 = disabled (default). HealthPort int `yaml:"health_port"` @@ -278,19 +280,25 @@ func (c *Config) setDefaults() { } func (c *Config) validate() error { - if c.Panel.URL == "" { - return fmt.Errorf("panel.url is required") - } - if c.Panel.Token == "" { - return fmt.Errorf("panel.token is required") - } - // In multi-node mode panel.node_id is optional; validate each NodeEntry instead. - if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 { - return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)") - } - for i, n := range c.Nodes { - if n.NodeID <= 0 { - return fmt.Errorf("nodes[%d].node_id must be positive", i) + if c.IsStandalone() { + if err := c.validateStandalone(); err != nil { + return err + } + } else { + if c.Panel.URL == "" { + return fmt.Errorf("panel.url is required") + } + if c.Panel.Token == "" { + return fmt.Errorf("panel.token is required") + } + // In multi-node mode panel.node_id is optional; validate each NodeEntry instead. + if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 { + return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)") + } + for i, n := range c.Nodes { + if n.NodeID <= 0 { + return fmt.Errorf("nodes[%d].node_id must be positive", i) + } } } switch c.Kernel.Type { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5d44110..7849a51 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -267,3 +267,59 @@ node: t.Errorf("pull_interval: got %d", cfg.Node.PullInterval) } } + +func TestLoad_StandaloneConfig(t *testing.T) { + path := writeTemp(t, ` +standalone: + enabled: true + node: + protocol: "vless" + server_port: 8443 + network: "ws" + tls: 1 + network_settings: + path: "/ws" + users: + - id: 1 + uuid: "11111111-1111-1111-1111-111111111111" +kernel: + type: xray +cert: + cert_mode: none +`) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.IsStandalone() { + t.Fatal("expected standalone mode to be enabled") + } + if cfg.Standalone.Node.Protocol != "vless" { + t.Errorf("standalone.node.protocol: got %q", cfg.Standalone.Node.Protocol) + } + if cfg.Standalone.Node.ServerPort != 8443 { + t.Errorf("standalone.node.server_port: got %d", cfg.Standalone.Node.ServerPort) + } + if len(cfg.Standalone.Users) != 1 { + t.Fatalf("standalone.users: got %d", len(cfg.Standalone.Users)) + } + if cfg.Standalone.Users[0].UUID != "11111111-1111-1111-1111-111111111111" { + t.Errorf("standalone.users[0].uuid: got %q", cfg.Standalone.Users[0].UUID) + } +} + +func TestLoad_StandaloneRequiresUsers(t *testing.T) { + path := writeTemp(t, ` +standalone: + enabled: true + node: + protocol: "trojan" + server_port: 443 +kernel: + type: singbox +`) + _, err := Load(path) + if err == nil { + t.Fatal("expected error for standalone mode without users") + } +} diff --git a/internal/config/standalone.go b/internal/config/standalone.go new file mode 100644 index 0000000..3ccf8e9 --- /dev/null +++ b/internal/config/standalone.go @@ -0,0 +1,129 @@ +package config + +import "fmt" + +// StandaloneConfig describes a locally-managed node that runs without panel +// handshake, polling, reporting, or WebSocket sync. +type StandaloneConfig struct { + Enabled bool `yaml:"enabled"` + Node StandaloneNodeConfig `yaml:"node"` + Users []StandaloneUser `yaml:"users"` +} + +// StandaloneNodeConfig captures the subset of panel node config that is needed +// to generate a working local inbound. +type StandaloneNodeConfig struct { + Protocol string `yaml:"protocol"` + ListenIP string `yaml:"listen_ip,omitempty"` + + ServerPort int `yaml:"server_port"` + Network string `yaml:"network,omitempty"` + + NetworkSettings map[string]any `yaml:"network_settings,omitempty"` + Routes []StandaloneRouteRule `yaml:"routes,omitempty"` + + Cipher string `yaml:"cipher,omitempty"` + Plugin string `yaml:"plugin,omitempty"` + PluginOpt string `yaml:"plugin_opts,omitempty"` + ServerKey string `yaml:"server_key,omitempty"` + + TLS int `yaml:"tls,omitempty"` + Flow string `yaml:"flow,omitempty"` + Decryption string `yaml:"decryption,omitempty"` + TLSSettings map[string]any `yaml:"tls_settings,omitempty"` + + Host string `yaml:"host,omitempty"` + ServerName string `yaml:"server_name,omitempty"` + + Version int `yaml:"version,omitempty"` + UpMbps int `yaml:"up_mbps,omitempty"` + DownMbps int `yaml:"down_mbps,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ObfsPassword string `yaml:"obfs_password,omitempty"` + + CongestionControl string `yaml:"congestion_control,omitempty"` + PaddingScheme string `yaml:"padding_scheme,omitempty"` + Transport string `yaml:"transport,omitempty"` + TrafficPattern string `yaml:"traffic_pattern,omitempty"` + + Multiplex *StandaloneMultiplexConfig `yaml:"multiplex,omitempty"` + AcceptProxyProtocol bool `yaml:"accept_proxy_protocol,omitempty"` +} + +type StandaloneRouteRule struct { + ID int `yaml:"id,omitempty"` + Match []string `yaml:"match,omitempty"` + Action string `yaml:"action,omitempty"` + ActionValue string `yaml:"action_value,omitempty"` +} + +type StandaloneMultiplexConfig struct { + Enabled bool `yaml:"enabled"` + Protocol string `yaml:"protocol,omitempty"` + MaxConnections int `yaml:"max_connections,omitempty"` + MinStreams int `yaml:"min_streams,omitempty"` + MaxStreams int `yaml:"max_streams,omitempty"` + Padding bool `yaml:"padding,omitempty"` + Brutal *StandaloneBrutalConfig `yaml:"brutal,omitempty"` +} + +type StandaloneBrutalConfig struct { + Enabled bool `yaml:"enabled"` + UpMbps int `yaml:"up_mbps,omitempty"` + DownMbps int `yaml:"down_mbps,omitempty"` +} + +type StandaloneUser struct { + ID int `yaml:"id"` + UUID string `yaml:"uuid"` + SpeedLimit int `yaml:"speed_limit,omitempty"` + DeviceLimit int `yaml:"device_limit,omitempty"` +} + +func (c *Config) IsStandalone() bool { + return c.Standalone != nil && c.Standalone.Enabled +} + +func (c *Config) validateStandalone() error { + if c.Standalone == nil { + return fmt.Errorf("standalone config is required when standalone mode is enabled") + } + if len(c.Nodes) > 0 { + return fmt.Errorf("standalone mode does not support top-level 'nodes'") + } + + node := c.Standalone.Node + if node.Protocol == "" { + return fmt.Errorf("standalone.node.protocol is required") + } + if node.ServerPort <= 0 || node.ServerPort > 65535 { + return fmt.Errorf("standalone.node.server_port must be between 1 and 65535") + } + if node.TLS < 0 || node.TLS > 2 { + return fmt.Errorf("standalone.node.tls must be 0 (off), 1 (tls), or 2 (reality)") + } + if len(c.Standalone.Users) == 0 { + return fmt.Errorf("standalone.users must not be empty") + } + + ids := make(map[int]struct{}, len(c.Standalone.Users)) + uuids := make(map[string]struct{}, len(c.Standalone.Users)) + for i, user := range c.Standalone.Users { + if user.ID <= 0 { + return fmt.Errorf("standalone.users[%d].id must be positive", i) + } + if user.UUID == "" { + return fmt.Errorf("standalone.users[%d].uuid is required", i) + } + if _, exists := ids[user.ID]; exists { + return fmt.Errorf("standalone.users[%d].id duplicates %d", i, user.ID) + } + if _, exists := uuids[user.UUID]; exists { + return fmt.Errorf("standalone.users[%d].uuid duplicates %q", i, user.UUID) + } + ids[user.ID] = struct{}{} + uuids[user.UUID] = struct{}{} + } + + return nil +} diff --git a/internal/controlplane/local.go b/internal/controlplane/local.go new file mode 100644 index 0000000..4ebd0bd --- /dev/null +++ b/internal/controlplane/local.go @@ -0,0 +1,56 @@ +package controlplane + +import ( + "context" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/model" +) + +type LocalControlPlane struct { + cfg *config.Config +} + +func NewLocalControlPlane(cfg *config.Config) *LocalControlPlane { return &LocalControlPlane{cfg: cfg} } +func (l *LocalControlPlane) SupportsPolling() bool { return false } +func (l *LocalControlPlane) SupportsDiscovery() bool { return false } +func (l *LocalControlPlane) SupportsReporting() bool { return false } +func (l *LocalControlPlane) SupportsDeviceReports() bool { return false } + +func (l *LocalControlPlane) Initial(ctx context.Context, _ func() map[string]interface{}, _ chan<- Event, _ chan<- StatusChange) (Bootstrap, error) { + select { + case <-ctx.Done(): + return Bootstrap{}, ctx.Err() + default: + } + return Bootstrap{ + PushInterval: l.cfg.Node.PushInterval, + PullInterval: l.cfg.Node.PullInterval, + Config: model.NodeSpecFromStandalone(l.cfg), + Users: model.UserSpecsFromStandalone(l.cfg), + }, nil +} + +func (l *LocalControlPlane) Poll(ctx context.Context) (Snapshot, error) { + select { + case <-ctx.Done(): + return Snapshot{}, ctx.Err() + default: + return Snapshot{}, nil + } +} + +func (l *LocalControlPlane) Discover(ctx context.Context, _ func() map[string]interface{}, _ chan<- Event, _ chan<- StatusChange) (PushClient, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + return nil, nil + } +} + +func (l *LocalControlPlane) Report(payload ReportPayload) error { _ = payload; return nil } +func (l *LocalControlPlane) ReportDevices(push PushClient, devices map[int][]string) { + _, _ = push, devices +} +func (l *LocalControlPlane) Metrics() APIMetrics { return APIMetrics{} } diff --git a/internal/controlplane/local_test.go b/internal/controlplane/local_test.go new file mode 100644 index 0000000..95ce566 --- /dev/null +++ b/internal/controlplane/local_test.go @@ -0,0 +1,32 @@ +package controlplane + +import ( + "context" + "testing" + + "github.com/cedar2025/xboard-node/internal/config" +) + +func TestLocalControlPlaneInitial(t *testing.T) { + cp := NewLocalControlPlane(&config.Config{ + Kernel: config.KernelConfig{Type: "singbox", LogLevel: "warn"}, + Standalone: &config.StandaloneConfig{ + Enabled: true, + Node: config.StandaloneNodeConfig{Protocol: "shadowsocks", ServerPort: 8388, Cipher: "aes-128-gcm"}, + Users: []config.StandaloneUser{{ID: 1, UUID: "secret"}}, + }, + }) + bootstrap, err := cp.Initial(context.Background(), nil, nil, nil) + if err != nil { + t.Fatalf("Initial: %v", err) + } + if bootstrap.Push != nil { + t.Fatal("local control plane should not create push client") + } + if bootstrap.Config == nil || bootstrap.Config.Protocol != "shadowsocks" { + t.Fatalf("config: %#v", bootstrap.Config) + } + if len(bootstrap.Users) != 1 || bootstrap.Users[0].UUID != "secret" { + t.Fatalf("users: %#v", bootstrap.Users) + } +} diff --git a/internal/controlplane/panel.go b/internal/controlplane/panel.go new file mode 100644 index 0000000..128e422 --- /dev/null +++ b/internal/controlplane/panel.go @@ -0,0 +1,161 @@ +package controlplane + +import ( + "context" + "fmt" + "time" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/model" + "github.com/cedar2025/xboard-node/internal/nlog" + "github.com/cedar2025/xboard-node/internal/panel" +) + +type PanelControlPlane struct { + cfg config.PanelConfig + wsCfg config.WSConfig + client *panel.Client +} + +type panelPushClient struct { + inner *panel.WSClient +} + +func NewPanelControlPlane(panelCfg config.PanelConfig, wsCfg config.WSConfig) *PanelControlPlane { + return &PanelControlPlane{ + cfg: panelCfg, + wsCfg: wsCfg, + client: panel.NewClient(panelCfg), + } +} + +func (p *PanelControlPlane) SupportsPolling() bool { return true } +func (p *PanelControlPlane) SupportsDiscovery() bool { return true } +func (p *PanelControlPlane) SupportsReporting() bool { return true } +func (p *PanelControlPlane) SupportsDeviceReports() bool { return true } + +func (p *PanelControlPlane) Initial(ctx context.Context, metricsFn func() map[string]interface{}, events chan<- Event, statuses chan<- StatusChange) (Bootstrap, error) { + hs, err := p.client.Handshake() + if err != nil { + return Bootstrap{}, fmt.Errorf("handshake: %w", err) + } + + bootstrap := Bootstrap{PushInterval: hs.Settings.PushInterval, PullInterval: hs.Settings.PullInterval} + if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" { + bootstrap.Push = p.newPushClient(metricsFn, events, statuses, hs.WebSocket.WSURL) + return bootstrap, nil + } + + nlog.Core().Info("websocket disabled, using REST API") + configSnapshot, err := p.client.GetConfig() + if err != nil { + return Bootstrap{}, fmt.Errorf("initial config fetch: %w", err) + } + if configSnapshot == nil { + return Bootstrap{}, fmt.Errorf("initial config is nil") + } + users, err := p.client.GetUsers() + if err != nil { + return Bootstrap{}, fmt.Errorf("initial user fetch: %w", err) + } + bootstrap.Config = model.NodeSpecFromPanel(configSnapshot) + bootstrap.Users = model.UserSpecsFromPanel(users) + return bootstrap, nil +} + +func (p *PanelControlPlane) Poll(ctx context.Context) (Snapshot, error) { + configSnapshot, err := p.client.GetConfig() + if err != nil { + return Snapshot{}, fmt.Errorf("poll config: %w", err) + } + users, err := p.client.GetUsers() + if err != nil { + return Snapshot{}, fmt.Errorf("poll users: %w", err) + } + select { + case <-ctx.Done(): + return Snapshot{}, ctx.Err() + default: + } + return Snapshot{Config: model.NodeSpecFromPanel(configSnapshot), Users: model.UserSpecsFromPanel(users)}, nil +} + +func (p *PanelControlPlane) Discover(ctx context.Context, metricsFn func() map[string]interface{}, events chan<- Event, statuses chan<- StatusChange) (PushClient, error) { + hs, err := p.client.Handshake() + if err != nil { + return nil, fmt.Errorf("handshake: %w", err) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" { + return p.newPushClient(metricsFn, events, statuses, hs.WebSocket.WSURL), nil + } + return nil, nil +} + +func (p *PanelControlPlane) Report(payload ReportPayload) error { + return p.client.Report(payload.Traffic, payload.Alive, payload.Online, payload.CPU, payload.Mem, payload.Swap, payload.Disk, payload.Metrics) +} + +func (p *PanelControlPlane) ReportDevices(push PushClient, devices map[int][]string) { + if push != nil { + push.SendDeviceReport(devices) + } +} + +func (p *PanelControlPlane) Metrics() APIMetrics { + api := p.client.SnapshotMetrics() + return APIMetrics{Success: api.Success, Failure: api.Failure} +} + +func (p *PanelControlPlane) newPushClient(metricsFn func() map[string]interface{}, events chan<- Event, statuses chan<- StatusChange, wsURL string) PushClient { + cfg := panel.WSClientConfig{ + StatusInterval: time.Duration(p.wsCfg.StatusInterval) * time.Second, + HandshakeTimeout: time.Duration(p.wsCfg.HandshakeTimeout) * time.Second, + BackoffInitial: time.Duration(p.wsCfg.BackoffInitial) * time.Second, + BackoffMax: time.Duration(p.wsCfg.BackoffMax) * time.Second, + } + inner := panel.NewWSClient( + wsURL, + p.cfg.Token, + p.cfg.NodeID, + cfg, + func(event panel.WSEvent) { + translated := translateWSEvent(event) + select { + case events <- translated: + default: + nlog.Core().Warn("ws event channel full, dropping event", "type", translated.Type) + } + }, + func(status panel.WSStatusChange) { + select { + case statuses <- StatusChange{Connected: status.Connected}: + default: + } + }, + metricsFn, + ) + return &panelPushClient{inner: inner} +} + +func translateWSEvent(event panel.WSEvent) Event { + translated := Event{Type: EventType(event.Type), DeltaAction: event.DeltaAction, DeviceUsers: event.DeviceUsers} + if event.Config != nil { + translated.Config = model.NodeSpecFromPanel(event.Config) + } + if event.Users != nil { + translated.Users = model.UserSpecsFromPanel(event.Users) + } + if event.DeltaUsers != nil { + translated.DeltaUsers = model.UserSpecsFromPanel(event.DeltaUsers) + } + return translated +} + +func (p *panelPushClient) Run(ctx context.Context) { p.inner.Run(ctx) } +func (p *panelPushClient) IsConnected() bool { return p.inner.IsConnected() } +func (p *panelPushClient) SendDeviceReport(devices map[int][]string) { p.inner.SendDeviceReport(devices) } diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go new file mode 100644 index 0000000..5f1a486 --- /dev/null +++ b/internal/controlplane/types.go @@ -0,0 +1,85 @@ +package controlplane + +import ( + "context" + + "github.com/cedar2025/xboard-node/internal/model" +) + +type EventType string + +const ( + EventSyncConfig EventType = "sync.config" + EventSyncUsers EventType = "sync.users" + EventSyncUserDelta EventType = "sync.user.delta" + EventSyncDevices EventType = "sync.devices" +) + +type Event struct { + Type EventType + Config *model.NodeSpec + Users []model.UserSpec + DeltaAction string + DeltaUsers []model.UserSpec + DeviceUsers map[int][]string +} + +type StatusChange struct { + Connected bool +} + +type APIMetrics struct { + Success uint64 + Failure uint64 +} + +type Bootstrap struct { + PushInterval int + PullInterval int + Push PushClient + Config *model.NodeSpec + Users []model.UserSpec +} + +type Snapshot struct { + Config *model.NodeSpec + Users []model.UserSpec +} + +type ReportPayload struct { + Traffic map[int][2]int64 + Alive map[int][]string + Online map[int]int + CPU float64 + Mem [2]uint64 + Swap [2]uint64 + Disk [2]uint64 + Metrics map[string]interface{} +} + +type PushClient interface { + Run(ctx context.Context) + IsConnected() bool + SendDeviceReport(devices map[int][]string) +} + +type Source interface { + Initial(ctx context.Context, metricsFn func() map[string]interface{}, events chan<- Event, statuses chan<- StatusChange) (Bootstrap, error) + Poll(ctx context.Context) (Snapshot, error) + Discover(ctx context.Context, metricsFn func() map[string]interface{}, events chan<- Event, statuses chan<- StatusChange) (PushClient, error) + Metrics() APIMetrics + SupportsPolling() bool + SupportsDiscovery() bool +} + +type Sink interface { + Report(payload ReportPayload) error + ReportDevices(push PushClient, devices map[int][]string) + SupportsReporting() bool + SupportsDeviceReports() bool +} + +type ControlPlane interface { + Source + Sink +} diff --git a/internal/kernel/geo.go b/internal/kernel/geo.go index 4684242..65b496a 100644 --- a/internal/kernel/geo.go +++ b/internal/kernel/geo.go @@ -3,11 +3,11 @@ package kernel import ( "strings" - "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/model" ) // NeedsGeoIP returns true when any panel route rule contains a "geoip:" match entry. -func NeedsGeoIP(routes []panel.RouteRule) bool { +func NeedsGeoIP(routes []model.RouteRule) bool { for _, r := range routes { for _, m := range r.Match { if strings.HasPrefix(m, "geoip:") { @@ -19,7 +19,7 @@ func NeedsGeoIP(routes []panel.RouteRule) bool { } // NeedsGeoSite returns true when any panel route rule contains a "geosite:" match entry. -func NeedsGeoSite(routes []panel.RouteRule) bool { +func NeedsGeoSite(routes []model.RouteRule) bool { for _, r := range routes { for _, m := range r.Match { if strings.HasPrefix(m, "geosite:") { diff --git a/internal/kernel/kernel.go b/internal/kernel/kernel.go index 7cef7fd..1efc554 100644 --- a/internal/kernel/kernel.go +++ b/internal/kernel/kernel.go @@ -7,10 +7,19 @@ import ( "fmt" "sort" - "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/model" "golang.org/x/time/rate" ) +type Capabilities struct { + PerUserSpeedLimit bool + DeviceLimit bool + BuiltInTrafficStats bool + AliveIPTracking bool + ForceCloseConnection bool + ForceCloseUser bool +} + // Kernel is the interface for proxy kernel backends (sing-box, xray, etc.). // // The interface is split into lifecycle, user management, and observability @@ -25,12 +34,14 @@ type Kernel interface { Name() string // Protocols returns the protocol names this kernel supports. Protocols() []string + // Capabilities returns the explicit runtime semantics supported by the kernel. + Capabilities() Capabilities // ─── Lifecycle ────────────────────────────────────────────────────── // Start initialises the kernel with the given node config and initial // user set, binds listeners, and begins accepting connections. // Calling Start on an already-running kernel stops the old instance first. - Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error + Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error // Stop gracefully shuts down the kernel, draining active connections. Stop() // IsRunning returns whether the kernel is currently accepting connections. @@ -38,21 +49,21 @@ type Kernel interface { // Reload re-generates the full config and hot-swaps listeners/routes. // Use this when port, protocol, or TLS settings change. // Existing connections MAY be briefly interrupted. - Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error + Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error // ─── User management (non-disruptive) ─────────────────────────────── // AddUsers registers new users with the running kernel. // Existing connections are unaffected. Returns the count actually added // (duplicates are silently skipped). - AddUsers(users []panel.User) (added int, err error) + AddUsers(users []model.UserSpec) (added int, err error) // RemoveUsers deregisters users from the running kernel. // Active connections of removed users are terminated. Returns the count // actually removed (unknown IDs are silently skipped). - RemoveUsers(users []panel.User) (removed int, err error) + RemoveUsers(users []model.UserSpec) (removed int, err error) // UpdateUsers replaces the entire user set atomically. // This is equivalent to computing the diff and calling Add/Remove, but // may be more efficient for bulk changes. Returns (added, removed) counts. - UpdateUsers(users []panel.User) (added, removed int, err error) + UpdateUsers(users []model.UserSpec) (added, removed int, err error) // ─── Observability ────────────────────────────────────────────────── // GetUserTraffic returns per-user cumulative traffic counters and @@ -81,11 +92,11 @@ type Kernel interface { // ComputeHash returns a hash of config + user identities that would // require a kernel restart/reconstruction if changed. -func ComputeHash(nc *panel.NodeConfig, users []panel.User) string { +func ComputeHash(nc *model.NodeSpec, users []model.UserSpec) string { h := sha256.New() configData, _ := json.Marshal(nc) h.Write(configData) - sorted := make([]panel.User, len(users)) + sorted := make([]model.UserSpec, len(users)) copy(sorted, users) sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) for _, u := range sorted { @@ -97,12 +108,12 @@ func ComputeHash(nc *panel.NodeConfig, users []panel.User) string { // UserDiff computes which users to add and which to remove when transitioning // from oldUsers to newUsers. This is a pure helper used by callers; kernels // may also use it internally. -func UserDiff(oldUsers, newUsers []panel.User) (toAdd, toRemove []panel.User) { - oldMap := make(map[int]panel.User, len(oldUsers)) +func UserDiff(oldUsers, newUsers []model.UserSpec) (toAdd, toRemove []model.UserSpec) { + oldMap := make(map[int]model.UserSpec, len(oldUsers)) for _, u := range oldUsers { oldMap[u.ID] = u } - newMap := make(map[int]panel.User, len(newUsers)) + newMap := make(map[int]model.UserSpec, len(newUsers)) for _, u := range newUsers { newMap[u.ID] = u } diff --git a/internal/kernel/singbox/config.go b/internal/kernel/singbox/config.go index 365f2b7..a69a58d 100644 --- a/internal/kernel/singbox/config.go +++ b/internal/kernel/singbox/config.go @@ -10,15 +10,15 @@ import ( "github.com/cedar2025/xboard-node/internal/config" "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/model" "github.com/cedar2025/xboard-node/internal/nlog" - "github.com/cedar2025/xboard-node/internal/panel" "github.com/go-viper/mapstructure/v2" ) // M is a shorthand for building JSON-like maps type M = map[string]interface{} -func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { var outbounds []M tags := make(map[string]bool) @@ -80,7 +80,7 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U // outboundConfigToSingbox converts a structured OutboundConfig (from the panel) // into a sing-box outbound object. sing-box uses a flat layout where all // protocol-specific fields sit at the top level alongside "type" and "tag". -func outboundConfigToSingbox(oc panel.OutboundConfig) M { +func outboundConfigToSingbox(oc model.OutboundConfig) M { m := M{ "type": oc.Protocol, "tag": oc.Tag, @@ -144,7 +144,7 @@ func mergeRouteList(a, b []map[string]any) []map[string]any { return res } -func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M { +func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M { var rules []M // Custom Routes (Panel-pushed or Local) go FIRST to have highest priority @@ -326,7 +326,7 @@ func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) { } } -func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base := M{ "tag": nc.Protocol + "-in", "listen": "::", @@ -361,7 +361,7 @@ func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile st } } -func buildMieru(base M, nc *panel.NodeConfig, users []panel.User) M { +func buildMieru(base M, nc *model.NodeSpec, users []model.UserSpec) M { base["type"] = "mieru" if nc.Transport != "" { base["transport"] = nc.Transport @@ -392,7 +392,7 @@ var ss2022Methods = map[string]ss2022Config{ "2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32}, } -func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M { +func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M { base["type"] = "shadowsocks" base["method"] = nc.Cipher @@ -433,7 +433,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M { return base } -func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base["type"] = "vmess" userList := make([]M, 0, len(users)) @@ -459,7 +459,7 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF return base } -func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { if nc.Decryption != "" && nc.Decryption != "none" { nlog.Core().Warn("sing-box does not support VLESS encryption (decryption), use xray kernel for this feature") } @@ -494,7 +494,7 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF return base } -func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base["type"] = "trojan" userList := make([]M, len(users)) @@ -533,7 +533,7 @@ func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, key return base } -func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { if nc.Version == 2 { base["type"] = "hysteria2" @@ -584,7 +584,7 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k return base } -func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base["type"] = "tuic" userList := make([]M, 0, len(users)) @@ -614,7 +614,7 @@ func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi return base } -func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base["type"] = "anytls" userList := make([]M, 0, len(users)) @@ -627,7 +627,7 @@ func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, key base["users"] = userList if nc.PaddingScheme != "" { - base["padding_scheme"] = string(nc.PaddingScheme) + base["padding_scheme"] = nc.PaddingScheme } if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil { @@ -638,7 +638,7 @@ func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, key return base } -func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base["type"] = "naive" userList := make([]M, 0, len(users)) @@ -658,7 +658,7 @@ func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF return base } -func buildSocks(base M, users []panel.User) M { +func buildSocks(base M, users []model.UserSpec) M { base["type"] = "socks" userList := make([]M, 0, len(users)) @@ -672,7 +672,7 @@ func buildSocks(base M, users []panel.User) M { return base } -func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base["type"] = "http" userList := make([]M, 0, len(users)) @@ -693,7 +693,7 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi return base } -func applyTransport(base M, nc *panel.NodeConfig) { +func applyTransport(base M, nc *model.NodeSpec) { if nc.Network == "" || nc.Network == "tcp" { return } @@ -723,6 +723,8 @@ func applyTransport(base M, nc *panel.NodeConfig) { case "grpc": if v, ok := nc.NetworkSettings["serviceName"]; ok { transport["service_name"] = v + } else if v, ok := nc.NetworkSettings["service_name"]; ok { + transport["service_name"] = v } case "httpupgrade": if v, ok := nc.NetworkSettings["path"]; ok { @@ -749,7 +751,7 @@ func applyTransport(base M, nc *panel.NodeConfig) { // If either path is missing, it returns nil: the inbound should run without a TLS layer // (e.g. TLS terminated at nginx/CDN while the panel still shows tls=1). // Sing-box does not accept a magic "self-signed" path — it tries to open that name as a file. -func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M { +func buildTLSConfig(nc *model.NodeSpec, certFile, keyFile string) M { if certFile == "" || keyFile == "" { return nil } @@ -779,7 +781,7 @@ func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M { return tls } -func buildRealityConfig(nc *panel.NodeConfig) M { +func buildRealityConfig(nc *model.NodeSpec) M { tls := M{"enabled": true} if nc.TLSSettings == nil { return tls @@ -845,7 +847,7 @@ func buildRealityConfig(nc *panel.NodeConfig) M { return tls } -func applyMultiplex(base M, nc *panel.NodeConfig) { +func applyMultiplex(base M, nc *model.NodeSpec) { if nc.Multiplex == nil || !nc.Multiplex.Enabled { return } @@ -873,7 +875,7 @@ func applyMultiplex(base M, nc *panel.NodeConfig) { base["multiplex"] = mux } -func applyProxyProtocol(base M, nc *panel.NodeConfig) { +func applyProxyProtocol(base M, nc *model.NodeSpec) { // if !nc.GetProxyProtocol() { // return // } diff --git a/internal/kernel/singbox/config_test.go b/internal/kernel/singbox/config_test.go index 24255db..7f7ac33 100644 --- a/internal/kernel/singbox/config_test.go +++ b/internal/kernel/singbox/config_test.go @@ -5,23 +5,35 @@ import ( "testing" "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/model" "github.com/cedar2025/xboard-node/internal/panel" ) -var testUsers = []panel.User{ +var testUsersPanel = []panel.User{ {ID: 1, UUID: "aaaaaaaa-1111-2222-3333-444444444444", SpeedLimit: 0, DeviceLimit: 0}, {ID: 2, UUID: "bbbbbbbb-5555-6666-7777-888888888888", SpeedLimit: 3, DeviceLimit: 2}, } // --- Shadowsocks --- +var testUsers = model.UserSpecsFromPanel(testUsersPanel) + +func testNodeSpec(nc *panel.NodeConfig) *model.NodeSpec { return model.NodeSpecFromPanel(nc) } + +func testRouteRules(r []panel.RouteRule) []model.RouteRule { + if r == nil { + return nil + } + return model.NodeSpecFromPanel(&panel.NodeConfig{Routes: r}).Routes +} + func TestBuildInbound_Shadowsocks(t *testing.T) { nc := &panel.NodeConfig{ Protocol: "shadowsocks", ServerPort: 111, Cipher: "aes-128-gcm", } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") assertMapValue(t, inbound, "type", "shadowsocks") assertMapValue(t, inbound, "method", "aes-128-gcm") assertMapValue(t, inbound, "listen_port", 111) @@ -41,7 +53,7 @@ func TestBuildInbound_Shadowsocks2022(t *testing.T) { Cipher: "2022-blake3-aes-128-gcm", ServerKey: "base64serverkey==", } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm") assertMapValue(t, inbound, "password", "base64serverkey==") } @@ -53,7 +65,7 @@ func TestBuildInbound_VMess(t *testing.T) { Protocol: "vmess", ServerPort: 443, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") assertMapValue(t, inbound, "type", "vmess") assertMapValue(t, inbound, "tag", "vmess-in") @@ -69,7 +81,7 @@ func TestBuildInbound_VMess_WithTLS(t *testing.T) { TLS: 1, ServerName: "example.com", } - inbound := buildInbound(nc, testUsers, "/path/cert.pem", "/path/key.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/path/cert.pem", "/path/key.pem") tls := inbound["tls"].(M) assertMapValue(t, tls, "enabled", true) assertMapValue(t, tls, "server_name", "example.com") @@ -86,7 +98,7 @@ func TestBuildInbound_VMess_TLS_PanelOn_NoCertFiles(t *testing.T) { TLS: 1, ServerName: "example.com", } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") if _, exists := inbound["tls"]; exists { t.Fatal("expected no tls block when cert/key paths are empty (nginx offload)") } @@ -98,7 +110,7 @@ func TestBuildInbound_VMess_NoTLS(t *testing.T) { ServerPort: 80, TLS: 0, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") if _, exists := inbound["tls"]; exists { t.Error("should not have TLS when tls=0") } @@ -116,7 +128,7 @@ func TestBuildInbound_VMess_WithWebSocket(t *testing.T) { }, }, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") transport := inbound["transport"].(M) assertMapValue(t, transport, "type", "ws") assertMapValue(t, transport, "path", "/ws") @@ -131,12 +143,27 @@ func TestBuildInbound_VMess_WithGRPC(t *testing.T) { "serviceName": "mygrpc", }, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") transport := inbound["transport"].(M) assertMapValue(t, transport, "type", "grpc") assertMapValue(t, transport, "service_name", "mygrpc") } +func TestBuildInbound_VMess_WithGRPCSnakeCaseServiceName(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "vmess", + ServerPort: 443, + Network: "grpc", + NetworkSettings: map[string]interface{}{ + "service_name": "mygrpc-snake", + }, + } + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") + transport := inbound["transport"].(M) + assertMapValue(t, transport, "type", "grpc") + assertMapValue(t, transport, "service_name", "mygrpc-snake") +} + func TestBuildInbound_VMess_WithH2(t *testing.T) { nc := &panel.NodeConfig{ Protocol: "vmess", @@ -147,7 +174,7 @@ func TestBuildInbound_VMess_WithH2(t *testing.T) { "host": "example.com", }, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") transport := inbound["transport"].(M) assertMapValue(t, transport, "type", "http") assertMapValue(t, transport, "path", "/h2path") @@ -163,7 +190,7 @@ func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) { "host": "example.com", }, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") transport := inbound["transport"].(M) assertMapValue(t, transport, "type", "httpupgrade") assertMapValue(t, transport, "path", "/upgrade") @@ -177,7 +204,7 @@ func TestBuildInbound_VLESS(t *testing.T) { Protocol: "vless", ServerPort: 443, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") assertMapValue(t, inbound, "type", "vless") users := inbound["users"].([]M) @@ -190,7 +217,7 @@ func TestBuildInbound_VLESS_WithFlow(t *testing.T) { ServerPort: 443, Flow: "xtls-rprx-vision", } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") users := inbound["users"].([]M) assertMapValue(t, users[0], "flow", "xtls-rprx-vision") } @@ -207,7 +234,7 @@ func TestBuildInbound_VLESS_Reality(t *testing.T) { "server_name": "www.example.com", }, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") tls := inbound["tls"].(M) assertMapValue(t, tls, "enabled", true) @@ -231,7 +258,7 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) { "dest": "example.com", }, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") reality := inbound["tls"].(M)["reality"].(M) ids := reality["short_id"].([]string) if len(ids) != 2 { @@ -246,7 +273,7 @@ func TestBuildInbound_Trojan(t *testing.T) { Protocol: "trojan", ServerPort: 443, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") assertMapValue(t, inbound, "type", "trojan") users := inbound["users"].([]M) @@ -259,7 +286,7 @@ func TestBuildInbound_Trojan_NoTLS(t *testing.T) { ServerPort: 80, TLS: 0, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") // Without certificate paths there is no TLS layer; sing-box trojan needs certs or Reality. if _, exists := inbound["tls"]; exists { t.Fatal("expected no tls when cert paths are empty and tls=0") @@ -273,7 +300,7 @@ func TestBuildInbound_Trojan_WithTLS(t *testing.T) { TLS: 1, ServerName: "example.com", } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") tls := inbound["tls"].(M) assertMapValue(t, tls, "enabled", true) } @@ -286,7 +313,7 @@ func TestBuildInbound_Hysteria2(t *testing.T) { ServerPort: 444, Version: 2, } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") assertMapValue(t, inbound, "type", "hysteria2") users := inbound["users"].([]M) @@ -304,7 +331,7 @@ func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) { Obfs: "salamander", ObfsPassword: "secret", } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") obfs := inbound["obfs"].(M) assertMapValue(t, obfs, "type", "salamander") assertMapValue(t, obfs, "password", "secret") @@ -318,7 +345,7 @@ func TestBuildInbound_Hysteria1(t *testing.T) { UpMbps: 100, DownMbps: 200, } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") assertMapValue(t, inbound, "type", "hysteria") assertMapValue(t, inbound, "up_mbps", 100) assertMapValue(t, inbound, "down_mbps", 200) @@ -335,7 +362,7 @@ func TestBuildInbound_TUIC(t *testing.T) { ServerPort: 555, CongestionControl: "bbr", } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") assertMapValue(t, inbound, "type", "tuic") assertMapValue(t, inbound, "congestion_control", "bbr") @@ -352,7 +379,7 @@ func TestBuildInbound_AnyTLS(t *testing.T) { ServerPort: 443, PaddingScheme: "stop=8\n0=30-30", } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") assertMapValue(t, inbound, "type", "anytls") assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30") @@ -365,7 +392,7 @@ func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) { Protocol: "anytls", ServerPort: 443, } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") if _, exists := inbound["padding_scheme"]; exists { t.Error("should not have padding_scheme when empty") } @@ -379,7 +406,7 @@ func TestBuildInbound_Naive(t *testing.T) { ServerPort: 443, TLS: 1, } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") assertMapValue(t, inbound, "type", "naive") users := inbound["users"].([]M) @@ -397,7 +424,7 @@ func TestBuildInbound_Socks(t *testing.T) { Protocol: "socks", ServerPort: 1080, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") assertMapValue(t, inbound, "type", "socks") users := inbound["users"].([]M) @@ -416,7 +443,7 @@ func TestBuildInbound_HTTP(t *testing.T) { Protocol: "http", ServerPort: 8080, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") assertMapValue(t, inbound, "type", "http") users := inbound["users"].([]M) @@ -429,7 +456,7 @@ func TestBuildInbound_HTTP_WithTLS(t *testing.T) { ServerPort: 443, TLS: 1, } - inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem") tls := inbound["tls"].(M) assertMapValue(t, tls, "enabled", true) } @@ -441,7 +468,7 @@ func TestBuildInbound_Unknown(t *testing.T) { Protocol: "unknown-proto", ServerPort: 999, } - inbound := buildInbound(nc, testUsers, "", "") + inbound := buildInbound(testNodeSpec(nc), testUsers, "", "") if inbound != nil { t.Errorf("unknown protocol should return nil, got %v", inbound) } @@ -456,7 +483,7 @@ func TestBuildConfig(t *testing.T) { ServerPort: 111, Cipher: "aes-128-gcm", } - cfg := buildConfig(kcfg, nc, testUsers, "", "") + cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "") data, err := json.Marshal(cfg) if err != nil { @@ -500,7 +527,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) { }, } - cfg := buildConfig(kcfg, nc, testUsers, "", "") + cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "") outbounds := cfg["outbounds"].([]M) // We have 2 overrides in input, so we should have exactly 2 outbounds total @@ -553,7 +580,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) { for _, tc := range protocols { t.Run(tc.name, func(t *testing.T) { - cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, tc.nc, testUsers, "/c.pem", "/k.pem") + cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, testNodeSpec(tc.nc), testUsers, "/c.pem", "/k.pem") data, err := json.Marshal(cfg) if err != nil { t.Fatalf("marshal %s: %v", tc.name, err) @@ -586,7 +613,7 @@ func TestBuildRoutes_WithCustomRules(t *testing.T) { {ID: 2, Match: []string{"10.0.0.0/8"}, Action: "block"}, {ID: 3, Match: []string{"allowed.com"}, Action: "direct"}, } - route := buildRoutes(rules, nil) + route := buildRoutes(testRouteRules(rules), nil) allRules := route["rules"].([]M) if len(allRules) != 5 { @@ -611,7 +638,7 @@ func TestBuildRoutes_MultiMatch(t *testing.T) { {ID: 1, Match: []string{"*.evil.com", "bad.org", "192.168.1.0/24"}, Action: "block"}, {ID: 2, Match: []string{"*.bypass.com"}, Action: "direct"}, } - route := buildRoutes(rules, nil) + route := buildRoutes(testRouteRules(rules), nil) allRules := route["rules"].([]M) // 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5 @@ -645,7 +672,7 @@ func TestBuildRoutes_MultiMatch(t *testing.T) { func TestBuildTLSConfig_WithCert(t *testing.T) { nc := &panel.NodeConfig{ServerName: "example.com"} - tls := buildTLSConfig(nc, "/cert.pem", "/key.pem") + tls := buildTLSConfig(testNodeSpec(nc), "/cert.pem", "/key.pem") assertMapValue(t, tls, "enabled", true) assertMapValue(t, tls, "server_name", "example.com") assertMapValue(t, tls, "certificate_path", "/cert.pem") @@ -654,7 +681,7 @@ func TestBuildTLSConfig_WithCert(t *testing.T) { func TestBuildTLSConfig_NoCert(t *testing.T) { nc := &panel.NodeConfig{ServerName: "example.com"} - tls := buildTLSConfig(nc, "", "") + tls := buildTLSConfig(testNodeSpec(nc), "", "") if tls != nil { t.Fatalf("expected nil tls config when cert paths are empty, got %+v", tls) } @@ -662,7 +689,7 @@ func TestBuildTLSConfig_NoCert(t *testing.T) { func TestBuildTLSConfig_FallbackToHost(t *testing.T) { nc := &panel.NodeConfig{Host: "fallback.com"} - tls := buildTLSConfig(nc, "/c.pem", "/k.pem") + tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem") assertMapValue(t, tls, "server_name", "fallback.com") } @@ -673,7 +700,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) { "server_name": "override.com", }, } - tls := buildTLSConfig(nc, "/c.pem", "/k.pem") + tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem") assertMapValue(t, tls, "server_name", "override.com") } @@ -682,7 +709,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) { func TestApplyTransport_TCP(t *testing.T) { base := M{} nc := &panel.NodeConfig{Network: "tcp"} - applyTransport(base, nc) + applyTransport(base, testNodeSpec(nc)) if _, exists := base["transport"]; exists { t.Error("tcp should not add transport") } @@ -691,7 +718,7 @@ func TestApplyTransport_TCP(t *testing.T) { func TestApplyTransport_Empty(t *testing.T) { base := M{} nc := &panel.NodeConfig{Network: ""} - applyTransport(base, nc) + applyTransport(base, testNodeSpec(nc)) if _, exists := base["transport"]; exists { t.Error("empty network should not add transport") } @@ -707,7 +734,7 @@ func TestApplyTransport_WS_MaxEarlyData(t *testing.T) { "early_data_header_name": "Sec-WebSocket-Protocol", }, } - applyTransport(base, nc) + applyTransport(base, testNodeSpec(nc)) transport := base["transport"].(M) assertMapValue(t, transport, "max_early_data", 2048) assertMapValue(t, transport, "early_data_header_name", "Sec-WebSocket-Protocol") diff --git a/internal/kernel/singbox/singbox.go b/internal/kernel/singbox/singbox.go index 5040117..c5d5eb7 100644 --- a/internal/kernel/singbox/singbox.go +++ b/internal/kernel/singbox/singbox.go @@ -19,8 +19,8 @@ import ( "github.com/cedar2025/xboard-node/internal/config" "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/model" "github.com/cedar2025/xboard-node/internal/nlog" - "github.com/cedar2025/xboard-node/internal/panel" ) // drainTimeout is how long stop() waits for in-flight connections to finish @@ -40,8 +40,8 @@ type SingBox struct { ctx context.Context cancel context.CancelFunc - users []panel.User - nodeConfig *panel.NodeConfig + users []model.UserSpec + nodeConfig *model.NodeSpec certFile string keyFile string @@ -71,6 +71,17 @@ var _ kernel.Kernel = (*SingBox)(nil) func (s *SingBox) Name() string { return "sing-box" } +func (s *SingBox) Capabilities() kernel.Capabilities { + return kernel.Capabilities{ + PerUserSpeedLimit: true, + DeviceLimit: true, + BuiltInTrafficStats: false, + AliveIPTracking: true, + ForceCloseConnection: false, + ForceCloseUser: true, + } +} + func (s *SingBox) Protocols() []string { return []string{ "vmess", "vless", "trojan", "shadowsocks", @@ -78,7 +89,7 @@ func (s *SingBox) Protocols() []string { } } -func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { +func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { s.mu.Lock() defer s.mu.Unlock() @@ -182,7 +193,7 @@ func recycleOldBox(oldBox *box.Box, oldCancel context.CancelFunc, oldCtx context // Reload hot-swaps the inbound users and routing rules without restarting the box. // Routes, outbounds, and the connTracker stay alive so in-flight connections // continue to be tracked correctly. -func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { +func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { s.mu.Lock() defer s.mu.Unlock() @@ -257,6 +268,10 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok { err = v.UpdateUsers(opts.Users) } + case adapter.UpdatableInbound[option.MieruUser]: + if opts, ok := inb.Options.(*option.MieruInboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } case adapter.UpdatableInbound[auth.User]: switch opts := inb.Options.(type) { case *option.NaiveInboundOptions: @@ -413,7 +428,7 @@ func (s *SingBox) ClearGlobalDevices() { // ─── User management (non-disruptive) ─────────────────────────────────────── // AddUsers hot-swaps users into running inbounds. Zero connection disruption. -func (s *SingBox) AddUsers(users []panel.User) (int, error) { +func (s *SingBox) AddUsers(users []model.UserSpec) (int, error) { s.mu.Lock() defer s.mu.Unlock() @@ -425,7 +440,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) { for _, u := range s.users { existing[u.ID] = struct{}{} } - var toAdd []panel.User + var toAdd []model.UserSpec for _, u := range users { if _, dup := existing[u.ID]; !dup { toAdd = append(toAdd, u) @@ -435,7 +450,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) { return 0, nil } - merged := append(append([]panel.User{}, s.users...), toAdd...) + merged := append(append([]model.UserSpec{}, s.users...), toAdd...) if err := s.reloadInboundsLocked(merged); err != nil { return 0, err } @@ -445,7 +460,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) { // RemoveUsers hot-swaps users out of running inbounds. Zero connection disruption // for remaining users. -func (s *SingBox) RemoveUsers(users []panel.User) (int, error) { +func (s *SingBox) RemoveUsers(users []model.UserSpec) (int, error) { s.mu.Lock() defer s.mu.Unlock() @@ -457,7 +472,7 @@ func (s *SingBox) RemoveUsers(users []panel.User) (int, error) { for _, u := range users { removeSet[u.ID] = struct{}{} } - var kept []panel.User + var kept []model.UserSpec removed := 0 for _, u := range s.users { if _, rm := removeSet[u.ID]; rm { @@ -478,7 +493,7 @@ func (s *SingBox) RemoveUsers(users []panel.User) (int, error) { } // UpdateUsers replaces the entire user set atomically via hot-swap. -func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error) { +func (s *SingBox) UpdateUsers(users []model.UserSpec) (added, removed int, err error) { s.mu.Lock() defer s.mu.Unlock() @@ -507,7 +522,7 @@ func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error // reloadInboundsLocked hot-swaps inbound users using UpdatableInbound. // Must be called with s.mu held. -func (s *SingBox) reloadInboundsLocked(users []panel.User) error { +func (s *SingBox) reloadInboundsLocked(users []model.UserSpec) error { cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile) data, err := json.Marshal(cfgMap) if err != nil { @@ -564,6 +579,10 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error { if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok { err = v.UpdateUsers(opts.Users) } + case adapter.UpdatableInbound[option.MieruUser]: + if opts, ok := inb.Options.(*option.MieruInboundOptions); ok { + err = v.UpdateUsers(opts.Users) + } case adapter.UpdatableInbound[auth.User]: switch opts := inb.Options.(type) { case *option.NaiveInboundOptions: @@ -630,7 +649,7 @@ func (s *SingBox) CloseUserConnections(_ context.Context, uuid string) error { // buildUserMap creates a UUID→userID mapping used by ConnTracker to attribute // connections to the correct user. All sing-box protocols use the user's UUID // as the inbound name/username, so this covers every protocol. -func buildUserMap(users []panel.User) map[string]int { +func buildUserMap(users []model.UserSpec) map[string]int { m := make(map[string]int, len(users)) for _, u := range users { m[u.UUID] = u.ID diff --git a/internal/kernel/singbox/singbox_test.go b/internal/kernel/singbox/singbox_test.go new file mode 100644 index 0000000..5d720ec --- /dev/null +++ b/internal/kernel/singbox/singbox_test.go @@ -0,0 +1,204 @@ +package singbox + +import ( + "context" + "errors" + "net" + "net/netip" + "testing" + "time" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/sagernet/sing-box/adapter" + singM "github.com/sagernet/sing/common/metadata" + "golang.org/x/time/rate" +) + +func TestSingBoxCapabilities(t *testing.T) { + s := New(config.KernelConfig{Type: "sing-box"}) + caps := s.Capabilities() + if !caps.PerUserSpeedLimit || !caps.DeviceLimit || !caps.AliveIPTracking || !caps.ForceCloseUser { + t.Fatalf("unexpected sing-box capabilities: %+v", caps) + } + if caps.BuiltInTrafficStats || caps.ForceCloseConnection { + t.Fatalf("unexpected sing-box capabilities: %+v", caps) + } +} + + + +type testConn struct { + closed bool + reads [][]byte + writes [][]byte +} + +func (c *testConn) Read(b []byte) (int, error) { + if len(c.reads) == 0 { + return 0, errors.New("eof") + } + chunk := c.reads[0] + c.reads = c.reads[1:] + n := copy(b, chunk) + return n, nil +} + +func (c *testConn) Write(b []byte) (int, error) { + cp := append([]byte(nil), b...) + c.writes = append(c.writes, cp) + return len(b), nil +} + +func (c *testConn) Close() error { c.closed = true; return nil } +func (c *testConn) LocalAddr() net.Addr { return &net.TCPAddr{} } +func (c *testConn) RemoteAddr() net.Addr { return &net.TCPAddr{} } +func (c *testConn) SetDeadline(time.Time) error { return nil } +func (c *testConn) SetReadDeadline(time.Time) error { return nil } +func (c *testConn) SetWriteDeadline(time.Time) error { return nil } + +func testInboundContext(uuid, ip string) adapter.InboundContext { + return adapter.InboundContext{ + User: uuid, + Source: singM.Socksaddr{Addr: netip.MustParseAddr(ip)}, + } +} + +func TestConnTrackerRoutedConnectionTracksTrafficAndAliveIPs(t *testing.T) { + tracker := NewConnTracker(0) + tracker.SetUserMap(map[string]int{"uuid-1": 1}) + base := &testConn{reads: [][]byte{[]byte("hello")}} + + wrapped := tracker.RoutedConnection(context.Background(), base, testInboundContext("uuid-1", "1.1.1.1"), nil, nil) + + buf := make([]byte, 16) + n, err := wrapped.Read(buf) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if n != 5 { + t.Fatalf("Read() bytes = %d, want 5", n) + } + if _, err := wrapped.Write([]byte("bye")); err != nil { + t.Fatalf("Write() error = %v", err) + } + + traffic, aliveIPs, connCount := tracker.GetUserTraffic() + if got := traffic[1]; got != [2]int64{5, 3} { + t.Fatalf("traffic[1] = %v, want [5 3]", got) + } + if !aliveIPs[1]["1.1.1.1"] { + t.Fatal("expected alive IP to include source address") + } + if connCount != 1 { + t.Fatalf("connCount = %d, want 1", connCount) + } + + if err := wrapped.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + _, aliveIPs, connCount = tracker.GetUserTraffic() + if aliveIPs[1] != nil { + t.Fatalf("aliveIPs after close = %v, want nil", aliveIPs[1]) + } + if connCount != 0 { + t.Fatalf("connCount after close = %d, want 0", connCount) + } +} + +func TestConnTrackerRoutedConnectionRejectsWhenDeviceLimitExceeded(t *testing.T) { + tracker := NewConnTracker(0) + tracker.SetUserMap(map[string]int{"uuid-1": 1}) + tracker.SetDeviceLimitFunc(func(uuid string) (int, bool) { + if uuid != "uuid-1" { + return 0, false + } + return 1, true + }) + + first := &testConn{} + wrapped1 := tracker.RoutedConnection(context.Background(), first, testInboundContext("uuid-1", "1.1.1.1"), nil, nil) + if wrapped1 == first { + t.Fatal("expected first connection to be wrapped") + } + + second := &testConn{} + wrapped2 := tracker.RoutedConnection(context.Background(), second, testInboundContext("uuid-1", "2.2.2.2"), nil, nil) + if wrapped2 != second { + t.Fatal("expected rejected connection to be returned unwrapped") + } + if !second.closed { + t.Fatal("expected rejected connection to be closed") + } +} + +func TestConnTrackerCheckDeviceGateMergesFreshGlobalDevices(t *testing.T) { + tracker := NewConnTracker(0) + tracker.SetUserMap(map[string]int{"uuid-1": 1}) + us := tracker.users[1] + us.addConn("1.1.1.1") + tracker.UpdateGlobalDevices(map[int][]string{1: {"9.9.9.9"}}) + + if tracker.checkDeviceGate(us, 1, "2.2.2.2", 2) { + t.Fatal("expected lexicographically earlier candidate to remain allowed") + } + if !tracker.checkDeviceGate(us, 1, "99.99.99.99", 2) { + t.Fatal("expected merged local+global device state to reject lexicographically later third device") + } + if tracker.checkDeviceGate(us, 1, "1.1.1.1", 2) { + t.Fatal("existing local IP should still be allowed") + } + if tracker.checkDeviceGate(us, 1, "9.9.9.9", 2) { + t.Fatal("existing global IP should still be allowed") + } +} + +func TestConnTrackerCloseByIDClosesTrackedConnection(t *testing.T) { + tracker := NewConnTracker(0) + tracker.SetUserMap(map[string]int{"uuid-1": 1}) + base := &testConn{} + wrapped := tracker.RoutedConnection(context.Background(), base, testInboundContext("uuid-1", "1.1.1.1"), nil, nil) + tracked, ok := wrapped.(*trackedConn) + if !ok { + t.Fatalf("wrapped type = %T, want *trackedConn", wrapped) + } + if !tracker.CloseByID(tracked.connID) { + t.Fatal("CloseByID() = false, want true") + } + if !base.closed { + t.Fatal("expected underlying connection to be closed") + } +} + +func TestConnTrackerRateLimitHonorsContextCancellation(t *testing.T) { + tracker := NewConnTracker(0) + tracker.SetUserMap(map[string]int{"uuid-1": 1}) + tracker.SetSpeedLimitFunc(func(uuid string) *rate.Limiter { + if uuid != "uuid-1" { + return nil + } + return rate.NewLimiter(rate.Limit(1), 1) + }) + + readCtx, cancelRead := context.WithCancel(context.Background()) + readBase := &testConn{reads: [][]byte{[]byte("a"), []byte("a")}} + readTracked := tracker.RoutedConnection(readCtx, readBase, testInboundContext("uuid-1", "1.1.1.1"), nil, nil).(*trackedConn) + buf := make([]byte, 8) + if n, err := readTracked.Read(buf); err != nil || n != 1 { + t.Fatalf("first Read() = (%d, %v), want (1, nil)", n, err) + } + cancelRead() + if n, err := readTracked.Read(buf); !errors.Is(err, context.Canceled) || n != 1 { + t.Fatalf("second Read() = (%d, %v), want (1, context canceled)", n, err) + } + + writeCtx, cancelWrite := context.WithCancel(context.Background()) + writeBase := &testConn{} + writeTracked := tracker.RoutedConnection(writeCtx, writeBase, testInboundContext("uuid-1", "1.1.1.2"), nil, nil).(*trackedConn) + if n, err := writeTracked.Write([]byte("a")); err != nil || n != 1 { + t.Fatalf("first Write() = (%d, %v), want (1, nil)", n, err) + } + cancelWrite() + if n, err := writeTracked.Write([]byte("a")); !errors.Is(err, context.Canceled) || n != 0 { + t.Fatalf("second Write() = (%d, %v), want (0, context canceled)", n, err) + } +} diff --git a/internal/kernel/xray/config.go b/internal/kernel/xray/config.go index 6f67c1b..b96ada3 100644 --- a/internal/kernel/xray/config.go +++ b/internal/kernel/xray/config.go @@ -7,14 +7,14 @@ import ( "github.com/cedar2025/xboard-node/internal/config" "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/model" "github.com/cedar2025/xboard-node/internal/nlog" - "github.com/cedar2025/xboard-node/internal/panel" ) // M is a shorthand for building JSON-like maps type M = map[string]interface{} -func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { var outbounds []M tags := make(map[string]bool) @@ -85,7 +85,7 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U // outboundConfigToXray converts a structured OutboundConfig (from the panel) // into an Xray outbound object. Xray uses a nested layout where protocol- // specific fields go inside a "settings" key, and chain proxying uses "proxySettings". -func outboundConfigToXray(oc panel.OutboundConfig) M { +func outboundConfigToXray(oc model.OutboundConfig) M { m := M{ "protocol": oc.Protocol, "tag": oc.Tag, @@ -195,7 +195,7 @@ func xrayLogLevel(singboxLevel string) string { } } -func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { listenAddr := "::" if nc.ListenIP != "" { listenAddr = nc.ListenIP @@ -238,7 +238,7 @@ func userEmail(userID int) string { return fmt.Sprintf("user@%d", userID) } -func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { clients := make([]M, 0, len(users)) for _, u := range users { clients = append(clients, M{ @@ -253,7 +253,7 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF return base } -func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { clients := make([]M, 0, len(users)) for _, u := range users { client := M{ @@ -278,7 +278,7 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF return base } -func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { clients := make([]M, len(users)) for i := range users { u := &users[i] @@ -314,7 +314,7 @@ var ss2022Methods = map[string]ss2022Config{ "2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32}, } -func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M { +func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M { ss2022, isSS2022 := ss2022Methods[nc.Cipher] clients := make([]M, 0, len(users)) @@ -360,7 +360,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M { return base } -func buildSocks(base M, users []panel.User) M { +func buildSocks(base M, users []model.UserSpec) M { base["protocol"] = "socks" accounts := make([]M, 0, len(users)) for _, u := range users { @@ -378,7 +378,7 @@ func buildSocks(base M, users []panel.User) M { return base } -func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { base["protocol"] = "http" accounts := make([]M, 0, len(users)) for _, u := range users { @@ -399,7 +399,7 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi } // buildHysteria creates a Hysteria v2 inbound for xray-core. -func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { +func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M { if nc.Version != 2 { nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound") return nil @@ -458,7 +458,7 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k return base } -func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) { +func applyStreamSettings(base M, nc *model.NodeSpec, certFile, keyFile string) { ss := M{} // Network / transport @@ -497,6 +497,8 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) if nc.NetworkSettings != nil { if v, ok := nc.NetworkSettings["serviceName"]; ok { grpcSettings["serviceName"] = v + } else if v, ok := nc.NetworkSettings["service_name"]; ok { + grpcSettings["serviceName"] = v } } ss["grpcSettings"] = grpcSettings @@ -577,7 +579,7 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) base["streamSettings"] = ss } -func buildRealitySettings(nc *panel.NodeConfig) M { +func buildRealitySettings(nc *model.NodeSpec) M { reality := M{"show": false} if nc.TLSSettings == nil { @@ -614,7 +616,7 @@ func buildRealitySettings(nc *panel.NodeConfig) M { return reality } -func buildRouting(rules []panel.RouteRule, customRules []map[string]any) M { +func buildRouting(rules []model.RouteRule, customRules []map[string]any) M { var xrayRules []M // Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority diff --git a/internal/kernel/xray/config_test.go b/internal/kernel/xray/config_test.go index 398a2c3..700c38f 100644 --- a/internal/kernel/xray/config_test.go +++ b/internal/kernel/xray/config_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/model" "github.com/cedar2025/xboard-node/internal/panel" ) @@ -13,11 +14,22 @@ var testKernelCfg = config.KernelConfig{ LogLevel: "warn", } -var testUsers = []panel.User{ +var testUsersPanel = []panel.User{ {ID: 1, UUID: "279d4f89-3a2c-488d-a67c-2d39a72acdde"}, {ID: 5, UUID: "4d5965c8-a60c-452a-a943-af83ec0bb0db"}, } +var testUsers = model.UserSpecsFromPanel(testUsersPanel) + +func testNodeSpec(nc *panel.NodeConfig) *model.NodeSpec { return model.NodeSpecFromPanel(nc) } + +func testRouteRules(r []panel.RouteRule) []model.RouteRule { + if r == nil { + return nil + } + return model.NodeSpecFromPanel(&panel.NodeConfig{Routes: r}).Routes +} + func TestBuildConfig_OutboundPriority(t *testing.T) { kcfg := config.KernelConfig{ LogLevel: "info", @@ -34,7 +46,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) { }, } - cfg := buildConfig(kcfg, nc, testUsers, "", "") + cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "") outbounds := cfg["outbounds"].([]M) // Since we overrode both 'direct' and 'block', the result should contain @@ -149,7 +161,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) { for _, tc := range protocols { t.Run(tc.name, func(t *testing.T) { - cfg := buildConfig(testKernelCfg, &tc.nc, testUsers, "/cert.pem", "/key.pem") + cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, "/cert.pem", "/key.pem") data, err := json.Marshal(cfg) if err != nil { @@ -182,7 +194,7 @@ func TestBuildConfig_VMess_Users(t *testing.T) { Protocol: "vmess", ServerPort: 10086, } - cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "") data, _ := json.Marshal(cfg) var parsed map[string]interface{} @@ -222,7 +234,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) { "server_name": "example.com", }, } - cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "") data, _ := json.Marshal(cfg) var parsed map[string]interface{} @@ -275,7 +287,7 @@ func TestBuildRouting_WithRules(t *testing.T) { }, } - routing := buildRouting(rules, nil) + routing := buildRouting(testRouteRules(rules), nil) xrayRules := routing["rules"].([]M) // 1 default + 2 domain rules + 1 IP rule = 4 @@ -337,7 +349,7 @@ func TestBuildConfig_StatsEnabled(t *testing.T) { Protocol: "vmess", ServerPort: 10086, } - cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "") data, _ := json.Marshal(cfg) var parsed map[string]interface{} @@ -367,7 +379,7 @@ func TestBuildConfig_Shadowsocks_MultiUserTraditional(t *testing.T) { ServerPort: 8388, Cipher: "aes-128-gcm", } - cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "") data, _ := json.Marshal(cfg) var parsed map[string]interface{} @@ -400,7 +412,7 @@ func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) { Cipher: "2022-blake3-aes-128-gcm", ServerKey: "server-key", } - cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "") data, _ := json.Marshal(cfg) var parsed map[string]interface{} @@ -424,7 +436,7 @@ func TestBuildConfig_SocksStats(t *testing.T) { Protocol: "socks", ServerPort: 1080, } - cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "") data, _ := json.Marshal(cfg) var parsed map[string]interface{} diff --git a/internal/kernel/xray/dispatcher.go b/internal/kernel/xray/dispatcher.go index 1fad02b..18ebe32 100644 --- a/internal/kernel/xray/dispatcher.go +++ b/internal/kernel/xray/dispatcher.go @@ -16,7 +16,6 @@ import ( "github.com/xtls/xray-core/common/session" "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" ) @@ -59,13 +58,13 @@ func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{ return ld, nil } -// LimitDispatcher wraps xray's DefaultDispatcher to add per-user device -// limit enforcement, speed limiting, and per-user traffic counting. +// LimitDispatcher wraps xray's DefaultDispatcher to enforce per-user +// admission checks before a request is dispatched into xray-core. // -// Architecture: traffic bytes are accumulated in per-user atomic counters -// (not per-connection maps). IP tracking uses sync.Map for unlimited users -// (lock-free) and RWMutex-protected map for limited users (deterministic). -// This means GetUserTraffic() is O(users), not O(connections). +// It intentionally does NOT mutate transport.Link.Reader/Writer. Xray's +// mux/XUDP close path requires the original concrete *pipe.Reader to remain +// intact, so the dispatcher is limited to gate-keeping and safe connection +// lifecycle bookkeeping. type LimitDispatcher struct { inner interface{} // original DefaultDispatcher (Feature + Dispatcher) innerDisp routing.Dispatcher // same object, typed as Dispatcher @@ -75,43 +74,32 @@ type LimitDispatcher struct { mu sync.RWMutex limitedIPs map[string]map[string]int // email → sourceIP → refcount deviceLimits map[string]int // email → max devices - speedLimits map[string]int // email → Mbps emailToUID map[string]int // email → panel user ID // unlimitedIPs: users without device limit — sync.Map for lock-free access. - // Each entry is *ipCounter{count atomic.Int64, ips sync.Map}. + // Each entry is *ipCounter{ips sync.Map}. unlimitedIPs sync.Map // email → *ipCounter - speedBuckets sync.Map // email → *rate.Limiter - - // Per-user traffic counters — lock-free atomics for the data path. - userTraffic sync.Map // email → *userTrafficCounter - - connCount atomic.Int64 // total active connections (for metrics) + connCount atomic.Int64 // total active connections tracked by dispatcher } // ipCounter tracks IPs for unlimited users without any lock. type ipCounter struct { - count atomic.Int64 // total TCP connections (for connCount reporting) - ips sync.Map // sourceIP → *atomic.Int64 (refcount) + ips sync.Map // sourceIP → *atomic.Int64 (refcount) } // aliveIPs returns a snapshot of distinct IPs. func (ic *ipCounter) aliveIPs() map[string]bool { result := make(map[string]bool) ic.ips.Range(func(key, _ interface{}) bool { - result[key.(string)] = true + if rv, ok := ic.ips.Load(key); ok && rv.(*atomic.Int64).Load() > 0 { + result[key.(string)] = true + } return true }) return result } -// userTrafficCounter holds per-user cumulative traffic counters. -type userTrafficCounter struct { - upload atomic.Int64 - download atomic.Int64 -} - // ─── routing.Dispatcher ────────────────────────────────────────────────────── func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (*transport.Link, error) { @@ -129,7 +117,7 @@ func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (* } if email != "" { - d.wrapLink(ctx, link, email, sourceIP, isTCP) + d.trackLink(link, email, sourceIP, isTCP) } return link, nil } @@ -141,7 +129,7 @@ func (d *LimitDispatcher) DispatchLink(ctx context.Context, dest net.Destination } if email != "" { - d.wrapLink(ctx, link, email, sourceIP, isTCP) + d.trackLink(link, email, sourceIP, isTCP) } return d.innerDisp.DispatchLink(ctx, dest, link) } @@ -165,13 +153,10 @@ func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destina return email, sourceIP, isTCP, nil } -// wrapLink instruments a transport.Link with per-user byte counting, -// rate limiting, and lifecycle tracking. Must only be called when email != "". -func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, email, sourceIP string, isTCP bool) { - // Get or create per-user traffic counter - v, _ := d.userTraffic.LoadOrStore(email, &userTrafficCounter{}) - utc := v.(*userTrafficCounter) - +// trackLink records connection lifecycle without mutating xray-core owned +// transport primitives. This keeps mux/XUDP compatible while still allowing +// the dispatcher to release device-limit state when the link closes. +func (d *LimitDispatcher) trackLink(link *transport.Link, email, sourceIP string, isTCP bool) { d.connCount.Add(1) onClose := func() { @@ -181,19 +166,8 @@ func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, em d.connCount.Add(-1) } - limiter := d.getBucket(email) - - link.Reader = &statsCloseReader{ - Reader: link.Reader, - counter: &utc.upload, - limiter: limiter, - ctx: ctx, - } - link.Writer = &statsCloseWriter{ + link.Writer = &closeTrackingWriter{ Writer: link.Writer, - counter: &utc.download, - limiter: limiter, - ctx: ctx, onClose: onClose, } } @@ -218,19 +192,12 @@ func (d *LimitDispatcher) Close() error { // ─── Limit management (called by Xray kernel) ────────────────────────────── -func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, speedLimits map[string]int) { +func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, _ map[string]int) { d.mu.Lock() d.emailToUID = emailToUID d.deviceLimits = deviceLimits - d.speedLimits = speedLimits d.mu.Unlock() - d.speedBuckets.Range(func(key, _ interface{}) bool { - if _, ok := speedLimits[key.(string)]; !ok { - d.speedBuckets.Delete(key) - } - return true - }) } func (d *LimitDispatcher) ResetConns() { @@ -244,21 +211,12 @@ func (d *LimitDispatcher) ResetConns() { return true }) - d.userTraffic.Range(func(key, _ interface{}) bool { - d.userTraffic.Delete(key) - return true - }) d.connCount.Store(0) - - d.speedBuckets.Range(func(key, _ interface{}) bool { - d.speedBuckets.Delete(key) - return true - }) } -// GetUserTraffic returns per-user cumulative traffic and alive IPs. -// O(users), not O(connections). -func (d *LimitDispatcher) GetUserTraffic() (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int) { +// GetConnectionState returns dispatcher-tracked alive IPs and connection count. +// Traffic bytes are intentionally left to xray's built-in stats pipeline. +func (d *LimitDispatcher) GetConnectionState() (aliveIPs map[int]map[string]bool, connCount int) { d.mu.RLock() emailToUID := d.emailToUID limitedIPs := d.limitedIPs @@ -302,22 +260,6 @@ func (d *LimitDispatcher) GetUserTraffic() (traffic map[int][2]int64, aliveIPs m return true }) - traffic = make(map[int][2]int64) - d.userTraffic.Range(func(key, value interface{}) bool { - email := key.(string) - utc := value.(*userTrafficCounter) - uid := emailToUID[email] - if uid == 0 { - return true - } - up := utc.upload.Load() - down := utc.download.Load() - if up > 0 || down > 0 { - traffic[uid] = [2]int64{up, down} - } - return true - }) - connCount = int(d.connCount.Load()) return } @@ -337,7 +279,6 @@ func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) b if isTCP { v, _ := d.unlimitedIPs.LoadOrStore(email, &ipCounter{}) ic := v.(*ipCounter) - ic.count.Add(1) // Increment IP refcount atomically. rv, _ := ic.ips.LoadOrStore(sourceIP, &atomic.Int64{}) @@ -445,93 +386,20 @@ func (d *LimitDispatcher) delConn(email, sourceIP string) { } } -func (d *LimitDispatcher) getBucket(email string) *rate.Limiter { - d.mu.RLock() - mbps, ok := d.speedLimits[email] - d.mu.RUnlock() - if !ok || mbps <= 0 { - return nil - } - bytesPerSec := int(mbps) * 1_000_000 / 8 - burst := bytesPerSec - if burst < 64*1024 { - burst = 64 * 1024 - } - v, loaded := d.speedBuckets.LoadOrStore(email, rate.NewLimiter(rate.Limit(bytesPerSec), burst)) - lim := v.(*rate.Limiter) - if loaded { - // Update existing limiter in case speed limit changed. - lim.SetLimit(rate.Limit(bytesPerSec)) - lim.SetBurst(burst) - } - return lim -} - -// ─── I/O wrappers ─────────────────────────────────────────────────────────── - -const xrayRateLimitThreshold = 64 * 1024 - -type statsCloseReader struct { - buf.Reader - counter *atomic.Int64 - limiter *rate.Limiter - ctx context.Context - pendingTokens atomic.Int64 -} - -func (r *statsCloseReader) ReadMultiBuffer() (buf.MultiBuffer, error) { - mb, err := r.Reader.ReadMultiBuffer() - if n := int64(mb.Len()); n > 0 { - r.counter.Add(n) - if r.limiter != nil { - if pending := r.pendingTokens.Add(n); pending >= xrayRateLimitThreshold { - tokens := int(r.pendingTokens.Swap(0)) - if burst := r.limiter.Burst(); tokens > burst { - tokens = burst - } - _ = r.limiter.WaitN(r.ctx, tokens) - } - } - } - return mb, err -} - -func (r *statsCloseReader) Close() error { return common.Close(r.Reader) } -func (r *statsCloseReader) Interrupt() { common.Interrupt(r.Reader) } - -type statsCloseWriter struct { +type closeTrackingWriter struct { buf.Writer - counter *atomic.Int64 - limiter *rate.Limiter - ctx context.Context - onClose func() - closed atomic.Bool - pendingTokens atomic.Int64 + onClose func() + closed atomic.Bool } -func (w *statsCloseWriter) WriteMultiBuffer(mb buf.MultiBuffer) error { - n := int64(mb.Len()) - if w.limiter != nil { - if pending := w.pendingTokens.Add(n); pending >= xrayRateLimitThreshold { - tokens := int(w.pendingTokens.Swap(0)) - if burst := w.limiter.Burst(); tokens > burst { - tokens = burst - } - _ = w.limiter.WaitN(w.ctx, tokens) - } - } - w.counter.Add(n) - return w.Writer.WriteMultiBuffer(mb) -} - -func (w *statsCloseWriter) Close() error { +func (w *closeTrackingWriter) Close() error { if w.closed.CompareAndSwap(false, true) { w.onClose() } return common.Close(w.Writer) } -func (w *statsCloseWriter) Interrupt() { +func (w *closeTrackingWriter) Interrupt() { if w.closed.CompareAndSwap(false, true) { w.onClose() } diff --git a/internal/kernel/xray/dispatcher_test.go b/internal/kernel/xray/dispatcher_test.go index a4a9b91..1b39fe8 100644 --- a/internal/kernel/xray/dispatcher_test.go +++ b/internal/kernel/xray/dispatcher_test.go @@ -4,7 +4,9 @@ import ( "sync/atomic" "testing" - "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/model" + "github.com/xtls/xray-core/common/buf" + "github.com/xtls/xray-core/transport" ) func newTestDispatcher() *LimitDispatcher { @@ -13,10 +15,14 @@ func newTestDispatcher() *LimitDispatcher { } } +type nopReader struct{} + +func (nopReader) ReadMultiBuffer() (buf.MultiBuffer, error) { return nil, nil } + func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) { ld := newTestDispatcher() - users := []panel.User{ + users := []model.UserSpec{ {ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0}, {ID: 2, UUID: "uuid-2", DeviceLimit: 0, SpeedLimit: 10}, } @@ -93,75 +99,35 @@ func TestLimitDispatcher_DelConn(t *testing.T) { } } -func TestLimitDispatcher_SpeedBucket(t *testing.T) { - ld := newTestDispatcher() - - email1 := userEmail(1) - email2 := userEmail(2) - - speedLimits := map[string]int{email1: 10} // 10 Mbps - ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, speedLimits) - - // User 1 has speed limit — should get a limiter - limiter := ld.getBucket(email1) - if limiter == nil { - t.Error("user with speed limit should get a limiter") - } - - // Same user should get the same limiter (cached) - limiter2 := ld.getBucket(email1) - if limiter != limiter2 { - t.Error("same user should get cached limiter") - } - - // User 2 has no speed limit — should get nil - limiter3 := ld.getBucket(email2) - if limiter3 != nil { - t.Error("user without speed limit should get nil") - } -} - -func TestLimitDispatcher_GetUserTraffic(t *testing.T) { +func TestLimitDispatcher_GetConnectionState(t *testing.T) { ld := newTestDispatcher() email1 := userEmail(1) email2 := userEmail(2) ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil) - // Simulate traffic by storing counters directly - tc1 := &userTrafficCounter{} - tc1.upload.Store(1000) - tc1.download.Store(2000) - ld.userTraffic.Store(email1, tc1) - - tc2 := &userTrafficCounter{} - tc2.upload.Store(500) - tc2.download.Store(800) - ld.userTraffic.Store(email2, tc2) - - // Simulate IPs (use unlimitedIPs for users without device limit) ic1 := &ipCounter{} - ic1.ips.Store("1.1.1.1", &atomic.Int64{}) - ic1.ips.Store("2.2.2.2", &atomic.Int64{}) + r1 := &atomic.Int64{} + r1.Store(1) + ic1.ips.Store("1.1.1.1", r1) + r2 := &atomic.Int64{} + r2.Store(1) + ic1.ips.Store("2.2.2.2", r2) ld.unlimitedIPs.Store(email1, ic1) ic2 := &ipCounter{} - ic2.ips.Store("3.3.3.3", &atomic.Int64{}) + r3 := &atomic.Int64{} + r3.Store(1) + ic2.ips.Store("3.3.3.3", r3) ld.unlimitedIPs.Store(email2, ic2) ld.connCount.Store(5) - traffic, aliveIPs, connCount := ld.GetUserTraffic() + aliveIPs, connCount := ld.GetConnectionState() if connCount != 5 { t.Errorf("expected connCount=5, got %d", connCount) } - if traffic[1] != [2]int64{1000, 2000} { - t.Errorf("user 1 traffic: got %v", traffic[1]) - } - if traffic[2] != [2]int64{500, 800} { - t.Errorf("user 2 traffic: got %v", traffic[2]) - } if len(aliveIPs[1]) != 2 { t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1])) } @@ -173,11 +139,9 @@ func TestLimitDispatcher_GetUserTraffic(t *testing.T) { func TestLimitDispatcher_ResetConns(t *testing.T) { ld := newTestDispatcher() - // Add some state ld.mu.Lock() ld.limitedIPs["user@1"] = map[string]int{"1.1.1.1": 1} ld.mu.Unlock() - ld.userTraffic.Store("user@1", &userTrafficCounter{}) ld.connCount.Store(3) ld.ResetConns() @@ -189,16 +153,6 @@ func TestLimitDispatcher_ResetConns(t *testing.T) { t.Error("limitedIPs should be empty after reset") } - // Verify userTraffic is cleared - count := 0 - ld.userTraffic.Range(func(_, _ interface{}) bool { - count++ - return true - }) - if count != 0 { - t.Error("userTraffic should be empty after reset") - } - if ld.connCount.Load() != 0 { t.Error("connCount should be 0 after reset") } @@ -230,3 +184,52 @@ func TestLimitDispatcher_UnlimitedUserFastPath(t *testing.T) { t.Error("should have tracked some IPs") } } + + +func TestLimitDispatcher_TrackLinkPreservesReader(t *testing.T) { + ld := newTestDispatcher() + email := userEmail(1) + ld.UpdateLimits(map[string]int{email: 1}, map[string]int{email: 1}, nil) + + origReader := nopReader{} + origWriter := &closeTrackingWriter{Writer: buf.Discard, onClose: func() {}} + link := &transport.Link{Reader: origReader, Writer: origWriter} + + ld.trackLink(link, email, "1.1.1.1", true) + + if link.Reader != origReader { + t.Fatal("trackLink must not replace link.Reader") + } + if link.Writer == origWriter { + t.Fatal("trackLink should wrap link.Writer for lifecycle callbacks") + } +} + +func TestLimitDispatcher_CloseTrackingWriterReleasesConn(t *testing.T) { + ld := newTestDispatcher() + email := userEmail(1) + ld.UpdateLimits(map[string]int{email: 1}, map[string]int{email: 1}, nil) + if ld.checkDeviceLimit(email, "1.1.1.1", true) { + t.Fatal("first connection should be allowed") + } + + link := &transport.Link{Reader: nopReader{}, Writer: buf.Discard} + ld.trackLink(link, email, "1.1.1.1", true) + + if got := ld.connCount.Load(); got != 1 { + t.Fatalf("expected connCount=1 after tracking, got %d", got) + } + cw, ok := link.Writer.(*closeTrackingWriter) + if !ok { + t.Fatal("expected closeTrackingWriter wrapper") + } + if err := cw.Close(); err != nil { + t.Fatalf("closeTrackingWriter.Close() error = %v", err) + } + if got := ld.connCount.Load(); got != 0 { + t.Fatalf("expected connCount=0 after close, got %d", got) + } + if ld.checkDeviceLimit(email, "2.2.2.2", true) { + t.Fatal("device slot should be released after writer close") + } +} diff --git a/internal/kernel/xray/xray.go b/internal/kernel/xray/xray.go index d49166c..607ae7e 100644 --- a/internal/kernel/xray/xray.go +++ b/internal/kernel/xray/xray.go @@ -17,6 +17,7 @@ import ( xrayCore "github.com/xtls/xray-core/core" "github.com/xtls/xray-core/features/inbound" "github.com/xtls/xray-core/features/stats" + featurebandwidth "github.com/xtls/xray-core/features/bandwidth" "github.com/xtls/xray-core/infra/conf/serial" xrayProxy "github.com/xtls/xray-core/proxy" "github.com/xtls/xray-core/proxy/shadowsocks" @@ -32,7 +33,7 @@ import ( "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" + "github.com/cedar2025/xboard-node/internal/model" ) const ( @@ -59,14 +60,15 @@ type Xray struct { mu sync.Mutex instance *xrayCore.Instance limitDispatcher *LimitDispatcher - users []panel.User - nodeConfig *panel.NodeConfig + users []model.UserSpec + nodeConfig *model.NodeSpec certFile string keyFile string protocol string inboundTag string lastKernelHash string cumTraffic map[int][2]int64 + speedLimitFunc func(string) *rate.Limiter // running is set after a successful Start and cleared before shutdown. // Atomic so IsRunning / GetConnections never block. @@ -82,6 +84,17 @@ func New(cfg config.KernelConfig) *Xray { func (x *Xray) Name() string { return "xray" } +func (x *Xray) Capabilities() kernel.Capabilities { + return kernel.Capabilities{ + PerUserSpeedLimit: true, + DeviceLimit: true, + BuiltInTrafficStats: true, + AliveIPTracking: true, + ForceCloseConnection: false, + ForceCloseUser: false, + } +} + func (x *Xray) Protocols() []string { return []string{ "vmess", "vless", "trojan", "shadowsocks", @@ -102,7 +115,7 @@ func (x *Xray) Protocols() []string { // Phase 3 – StartNew: instance.Start (no lock, potentially slow) // Phase 4 – Swap: store new, extract old (brief kernel lock) // Phase 5 – RecycleOld: close old in background (non-blocking) -func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { +func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { // ── Phase 1: Build config (no shared state) ───────────────────────── x.ensureGeoData(nodeConfig) @@ -152,6 +165,7 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, closeOld(old, oldLD) x.updateDispatcherLimits(users) + x.updateBandwidthLimits(users) nlog.Core().Info("xray started", "users", len(users), @@ -164,8 +178,9 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, // Since Xray does not support granular inbound reconstruction without an // instance restart for most transport/TLS settings, it triggers a full restart // if any kernel-affecting fields (hash mismatch) have changed. -func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { +func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { x.updateDispatcherLimits(users) + x.updateBandwidthLimits(users) newHash := kernel.ComputeHash(nodeConfig, users) @@ -204,8 +219,10 @@ func (x *Xray) IsRunning() bool { return x.running.Load() } // ─── Observability ────────────────────────────────────────────────────────── -// GetUserTraffic returns per-user cumulative traffic and alive IPs. -// Reads from LimitDispatcher's per-user atomic counters — O(users). +// GetUserTraffic returns per-user cumulative traffic from xray's built-in +// stats pipeline and connection state from the admission dispatcher when +// available. The dispatcher intentionally no longer wraps transport links, +// so traffic accounting must come from xray-core itself. func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int, err error) { if !x.running.Load() { return nil, nil, 0, nil @@ -213,18 +230,16 @@ func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliv x.mu.Lock() ld := x.limitDispatcher - x.mu.Unlock() - - if ld != nil { - traffic, aliveIPs, connCount = ld.GetUserTraffic() - return traffic, aliveIPs, connCount, nil - } - - // Fallback path: aggregateStats needs x.mu. - x.mu.Lock() traffic, err = x.aggregateStats() x.mu.Unlock() - return traffic, nil, 0, err + if err != nil { + return nil, nil, 0, err + } + + if ld != nil { + aliveIPs, connCount = ld.GetConnectionState() + } + return traffic, aliveIPs, connCount, nil } func (x *Xray) CloseConnection(_ context.Context, _ string) error { @@ -238,7 +253,15 @@ func (x *Xray) CloseUserConnections(_ context.Context, _ string) error { return nil } -func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {} +// SetSpeedLimitFunc wires xray's patched bandwidth feature to the shared +// per-user limiter callback used by the service layer. Unlike the old no-op +// behavior, xray now consumes this callback to support dynamic speed updates. +func (x *Xray) SetSpeedLimitFunc(fn func(string) *rate.Limiter) { + x.mu.Lock() + x.speedLimitFunc = fn + x.mu.Unlock() + x.updateBandwidthLimits(nil) +} // SetDeviceLimitFunc is a no-op for xray — device limits are already // gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time. @@ -260,7 +283,7 @@ func (x *Xray) ClearGlobalDevices() {} // Delta "add" events also carry property updates (speed/device limits) for // existing users, so this method always merges properties and refreshes the // dispatcher limits — even when no brand-new users need to be added. -func (x *Xray) AddUsers(users []panel.User) (int, error) { +func (x *Xray) AddUsers(users []model.UserSpec) (int, error) { x.mu.Lock() if x.instance == nil { x.mu.Unlock() @@ -268,18 +291,18 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) { } // Merge: overwrite existing users' properties, collect truly new ones. - userMap := make(map[int]panel.User, len(x.users)) + userMap := make(map[int]model.UserSpec, len(x.users)) for _, u := range x.users { userMap[u.ID] = u } - var toAdd []panel.User + var toAdd []model.UserSpec for _, u := range users { if _, exists := userMap[u.ID]; !exists { toAdd = append(toAdd, u) } userMap[u.ID] = u // always overwrite properties } - merged := make([]panel.User, 0, len(userMap)) + merged := make([]model.UserSpec, 0, len(userMap)) for _, u := range userMap { merged = append(merged, u) } @@ -289,6 +312,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) { x.users = merged x.mu.Unlock() x.updateDispatcherLimits(merged) + x.updateBandwidthLimits(merged) return 0, nil } @@ -328,6 +352,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) { x.users = merged x.mu.Unlock() x.updateDispatcherLimits(merged) + x.updateBandwidthLimits(merged) nlog.Core().Info("xray: users added via UserManager", "added", added, "total", len(merged)) return added, nil @@ -335,7 +360,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) { // RemoveUsers removes users from the running kernel via xray's UserManager API. // Truly hitless for supported protocols — remaining connections unaffected. -func (x *Xray) RemoveUsers(users []panel.User) (int, error) { +func (x *Xray) RemoveUsers(users []model.UserSpec) (int, error) { x.mu.Lock() if x.instance == nil { x.mu.Unlock() @@ -345,7 +370,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) { for _, u := range users { removeSet[u.ID] = struct{}{} } - var kept []panel.User + var kept []model.UserSpec removed := 0 for _, u := range x.users { if _, rm := removeSet[u.ID]; rm { @@ -392,6 +417,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) { x.users = kept x.mu.Unlock() x.updateDispatcherLimits(kept) + x.updateBandwidthLimits(kept) nlog.Core().Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept)) return actualRemoved, nil @@ -400,7 +426,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) { // UpdateUsers replaces the entire user set. If only speed/device limits // changed, updates the dispatcher without restarting. Otherwise uses // UserManager for hitless add/remove where supported. -func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) { +func (x *Xray) UpdateUsers(users []model.UserSpec) (added, removed int, err error) { x.mu.Lock() if x.instance == nil { x.mu.Unlock() @@ -414,6 +440,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) { x.users = users x.mu.Unlock() x.updateDispatcherLimits(users) + x.updateBandwidthLimits(users) nlog.Core().Info("xray: limits updated, kernel unchanged") return 0, 0, nil } @@ -458,6 +485,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) { x.users = users x.mu.Unlock() x.updateDispatcherLimits(users) + x.updateBandwidthLimits(users) nlog.Core().Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users)) return @@ -502,9 +530,9 @@ func (x *Xray) getUserManager() (xrayProxy.UserManager, error) { return um, nil } -// toMemoryUser converts a panel.User into an xray protocol.MemoryUser for the +// toMemoryUser converts a model.UserSpec into an xray protocol.MemoryUser for the // given protocol. Each protocol needs a different Account type. -func toMemoryUser(proto string, nc *panel.NodeConfig, u panel.User) (*protocol.MemoryUser, error) { +func toMemoryUser(proto string, nc *model.NodeSpec, u model.UserSpec) (*protocol.MemoryUser, error) { email := userEmail(u.ID) mu := &protocol.MemoryUser{Email: email, Level: 0} @@ -598,7 +626,7 @@ func hexEncode(dst, src []byte) { } // ensureGeoData downloads geo databases when routes reference geoip/geosite. -func (x *Xray) ensureGeoData(nc *panel.NodeConfig) { +func (x *Xray) ensureGeoData(nc *model.NodeSpec) { needIP, needSite := kernel.NeedsGeoIP(nc.Routes), kernel.NeedsGeoSite(nc.Routes) if !needIP && !needSite { return @@ -611,7 +639,7 @@ func (x *Xray) ensureGeoData(nc *panel.NodeConfig) { } // marshalConfig builds the xray JSON config and returns the raw bytes. -func marshalConfig(cfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) ([]byte, error) { +func marshalConfig(cfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) ([]byte, error) { cfgMap := buildConfig(cfg, nc, users, certFile, keyFile) data, err := json.MarshalIndent(cfgMap, "", " ") if err != nil { @@ -712,9 +740,44 @@ func (x *Xray) aggregateStats() (map[int][2]int64, error) { return traffic, nil } +// updateBandwidthLimits configures the patched xray-core bandwidth feature +// with per-user speed limits in bytes per second. +func (x *Xray) updateBandwidthLimits(users []model.UserSpec) { + x.mu.Lock() + inst := x.instance + fn := x.speedLimitFunc + currentUsers := x.users + x.mu.Unlock() + if inst == nil { + return + } + feat := inst.GetFeature(featurebandwidth.ManagerType()) + if feat == nil { + return + } + bm, ok := feat.(featurebandwidth.Manager) + if !ok { + return + } + bm.Reset() + if users == nil { + users = currentUsers + } + for _, u := range users { + email := userEmail(u.ID) + if fn != nil { + bm.SetUserLimiter(email, fn(u.UUID)) + continue + } + bps := int64(u.SpeedLimit) * 1_000_000 / 8 + bm.SetUserLimit(email, bps) + } +} + // updateDispatcherLimits configures the LimitDispatcher with per-user -// device and speed limits. -func (x *Xray) updateDispatcherLimits(users []panel.User) { +// device-limit admission metadata only. Speed limits are enforced by the +// patched xray-core bandwidth feature. +func (x *Xray) updateDispatcherLimits(users []model.UserSpec) { x.mu.Lock() ld := x.limitDispatcher x.mu.Unlock() @@ -724,7 +787,6 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) { emailToUID := make(map[string]int, len(users)*2) deviceLimits := make(map[string]int) - speedLimits := make(map[string]int) for _, u := range users { email := userEmail(u.ID) @@ -734,13 +796,9 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) { deviceLimits[email] = u.DeviceLimit deviceLimits[u.UUID] = u.DeviceLimit } - if u.SpeedLimit > 0 { - speedLimits[email] = u.SpeedLimit - speedLimits[u.UUID] = u.SpeedLimit - } } - ld.UpdateLimits(emailToUID, deviceLimits, speedLimits) + ld.UpdateLimits(emailToUID, deviceLimits, nil) } // xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture diff --git a/internal/kernel/xray/xray_test.go b/internal/kernel/xray/xray_test.go new file mode 100644 index 0000000..1ce3d5e --- /dev/null +++ b/internal/kernel/xray/xray_test.go @@ -0,0 +1,278 @@ +package xray + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/model" + appstats "github.com/xtls/xray-core/app/stats" + xrayCore "github.com/xtls/xray-core/core" + featurebandwidth "github.com/xtls/xray-core/features/bandwidth" + xraystats "github.com/xtls/xray-core/features/stats" + "golang.org/x/time/rate" +) + +func newStatsManager(t *testing.T) xraystats.Manager { + t.Helper() + mgr, err := appstats.NewManager(context.Background(), &appstats.Config{}) + if err != nil { + t.Fatalf("stats.NewManager() error = %v", err) + } + return mgr +} + +func mustRegisterCounter(t *testing.T, mgr xraystats.Manager, name string) xraystats.Counter { + t.Helper() + counter, err := mgr.RegisterCounter(name) + if err != nil { + t.Fatalf("RegisterCounter(%q) error = %v", name, err) + } + return counter +} + +func counterName(userID int, direction string) string { + return fmt.Sprintf("user>>>%s>>>traffic>>>%s", userEmail(userID), direction) +} + +func newStatsBackedXray(t *testing.T, users []model.UserSpec, ld *LimitDispatcher) (*Xray, xraystats.Manager) { + t.Helper() + mgr := newStatsManager(t) + inst := new(xrayCore.Instance) + if err := inst.AddFeature(mgr); err != nil { + t.Fatalf("Instance.AddFeature(stats) error = %v", err) + } + x := New(config.KernelConfig{Type: "xray"}) + x.instance = inst + x.limitDispatcher = ld + x.users = users + x.cumTraffic = make(map[int][2]int64) + x.running.Store(true) + return x, mgr +} + +func TestXrayGetUserTrafficUsesBuiltInStatsAndDispatcherState(t *testing.T) { + users := []model.UserSpec{ + {ID: 1, UUID: "uuid-1"}, + {ID: 2, UUID: "uuid-2"}, + } + + ld := newTestDispatcher() + email1 := userEmail(1) + email2 := userEmail(2) + ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil) + ld.mu.Lock() + ld.limitedIPs[email1] = map[string]int{"1.1.1.1": 1} + ld.mu.Unlock() + ref := &atomic.Int64{} + ref.Store(1) + ic := &ipCounter{} + ic.ips.Store("2.2.2.2", ref) + ld.unlimitedIPs.Store(email2, ic) + ld.connCount.Store(7) + + x, mgr := newStatsBackedXray(t, users, ld) + up1 := mustRegisterCounter(t, mgr, counterName(1, "uplink")) + down1 := mustRegisterCounter(t, mgr, counterName(1, "downlink")) + up2 := mustRegisterCounter(t, mgr, counterName(2, "uplink")) + down2 := mustRegisterCounter(t, mgr, counterName(2, "downlink")) + up1.Add(100) + down1.Add(200) + up2.Add(50) + down2.Add(80) + + traffic, aliveIPs, connCount, err := x.GetUserTraffic(context.Background()) + if err != nil { + t.Fatalf("GetUserTraffic() error = %v", err) + } + if connCount != 7 { + t.Fatalf("connCount = %d, want 7", connCount) + } + if got := traffic[1]; got != [2]int64{100, 200} { + t.Fatalf("traffic[1] = %v, want [100 200]", got) + } + if got := traffic[2]; got != [2]int64{50, 80} { + t.Fatalf("traffic[2] = %v, want [50 80]", got) + } + if !aliveIPs[1]["1.1.1.1"] { + t.Fatal("expected limited-user IP to be reported") + } + if !aliveIPs[2]["2.2.2.2"] { + t.Fatal("expected unlimited-user IP to be reported") + } + + up1.Add(30) + down1.Add(40) + traffic, _, _, err = x.GetUserTraffic(context.Background()) + if err != nil { + t.Fatalf("second GetUserTraffic() error = %v", err) + } + if got := traffic[1]; got != [2]int64{130, 240} { + t.Fatalf("traffic[1] after second poll = %v, want [130 240]", got) + } + if got := traffic[2]; got != [2]int64{50, 80} { + t.Fatalf("traffic[2] after second poll = %v, want [50 80]", got) + } +} + +func TestXrayUpdateDispatcherLimitsPropagatesDeviceMetadata(t *testing.T) { + x := New(config.KernelConfig{Type: "xray"}) + ld := newTestDispatcher() + x.limitDispatcher = ld + + users := []model.UserSpec{ + {ID: 7, UUID: "uuid-7", DeviceLimit: 2, SpeedLimit: 12}, + {ID: 9, UUID: "uuid-9", DeviceLimit: 0, SpeedLimit: 0}, + } + + x.updateDispatcherLimits(users) + + if got := ld.deviceLimits[userEmail(7)]; got != 2 { + t.Fatalf("deviceLimits[email] = %d, want 2", got) + } + if got := ld.deviceLimits["uuid-7"]; got != 2 { + t.Fatalf("deviceLimits[uuid] = %d, want 2", got) + } + if _, ok := ld.deviceLimits[userEmail(9)]; ok { + t.Fatal("unexpected device limit entry for unlimited user") + } +} + +func TestXraySetSpeedLimitFuncUsesPatchedCorePath(t *testing.T) { + x := New(config.KernelConfig{Type: "xray"}) + called := false + + x.SetSpeedLimitFunc(func(string) *rate.Limiter { + called = true + return rate.NewLimiter(rate.Limit(1), 1) + }) + + if called { + t.Fatal("patched xray path should not consult external speed-limit callbacks") + } + + mu, err := toMemoryUser("vless", &model.NodeSpec{Protocol: "vless"}, model.UserSpec{ID: 1, UUID: "11111111-1111-1111-1111-111111111111", SpeedLimit: 8}) + if err != nil { + t.Fatalf("toMemoryUser() error = %v", err) + } + if mu.Level != 0 { + t.Fatalf("MemoryUser.Level = %d, want 0", mu.Level) + } +} + + +func TestXrayCapabilities(t *testing.T) { + x := New(config.KernelConfig{Type: "xray"}) + caps := x.Capabilities() + if !caps.PerUserSpeedLimit || !caps.DeviceLimit || !caps.BuiltInTrafficStats || !caps.AliveIPTracking { + t.Fatalf("unexpected positive xray capabilities: %+v", caps) + } + if caps.ForceCloseConnection || caps.ForceCloseUser { + t.Fatalf("unexpected force-close xray capabilities: %+v", caps) + } +} + + +func TestXrayUpdateBandwidthLimitsWritesPatchedCoreFeature(t *testing.T) { + inst := new(xrayCore.Instance) + bm := featurebandwidth.New() + if err := inst.AddFeature(bm); err != nil { + t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err) + } + x := New(config.KernelConfig{Type: "xray"}) + x.instance = inst + x.updateBandwidthLimits([]model.UserSpec{{ID: 1, UUID: "uuid-1", SpeedLimit: 8}}) + lim := bm.GetUserLimiter(userEmail(1)) + if lim == nil { + t.Fatal("expected patched bandwidth feature to receive user limiter") + } +} + + +func TestXrayUpdateBandwidthLimitsUsesSpeedLimitFunc(t *testing.T) { + inst := new(xrayCore.Instance) + bm := featurebandwidth.New() + if err := inst.AddFeature(bm); err != nil { + t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err) + } + x := New(config.KernelConfig{Type: "xray"}) + x.instance = inst + x.users = []model.UserSpec{{ID: 1, UUID: "uuid-1", SpeedLimit: 8}} + shared := rate.NewLimiter(7, 7) + x.SetSpeedLimitFunc(func(uuid string) *rate.Limiter { + if uuid != "uuid-1" { + t.Fatalf("unexpected uuid: %s", uuid) + } + return shared + }) + lim := bm.GetUserLimiter(userEmail(1)) + if lim != shared { + t.Fatal("expected patched bandwidth feature to consume shared limiter from callback") + } +} + + +func TestXrayUpdateBandwidthLimitsFallsBackToUserSpeed(t *testing.T) { + inst := new(xrayCore.Instance) + bm := featurebandwidth.New() + if err := inst.AddFeature(bm); err != nil { + t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err) + } + x := New(config.KernelConfig{Type: "xray"}) + x.instance = inst + x.users = []model.UserSpec{{ID: 2, UUID: "uuid-2", SpeedLimit: 16}} + x.updateBandwidthLimits(nil) + if bm.GetUserLimiter(userEmail(2)) == nil { + t.Fatal("expected fallback limiter derived from user speed") + } +} + + +func TestXrayUpdateUsersLimitOnlyRefreshesDispatcherAndBandwidth(t *testing.T) { + x := New(config.KernelConfig{Type: "xray"}) + ld := newTestDispatcher() + inst := new(xrayCore.Instance) + bm := featurebandwidth.New() + if err := inst.AddFeature(bm); err != nil { + t.Fatalf("Instance.AddFeature(bandwidth) error = %v", err) + } + x.instance = inst + x.limitDispatcher = ld + x.users = []model.UserSpec{{ID: 1, UUID: "uuid-1", DeviceLimit: 1, SpeedLimit: 8}} + + added, removed, err := x.UpdateUsers([]model.UserSpec{{ID: 1, UUID: "uuid-1", DeviceLimit: 3, SpeedLimit: 16}}) + if err != nil { + t.Fatalf("UpdateUsers() error = %v", err) + } + if added != 0 || removed != 0 { + t.Fatalf("UpdateUsers() counts = (%d, %d), want (0, 0)", added, removed) + } + if got := ld.deviceLimits[userEmail(1)]; got != 3 { + t.Fatalf("device limit after refresh = %d, want 3", got) + } + if bm.GetUserLimiter(userEmail(1)) == nil { + t.Fatal("expected bandwidth limiter to be refreshed for unchanged user set") + } +} + +func TestXrayRemoveUsersStopsKernelWhenLastUserRemoved(t *testing.T) { + x := New(config.KernelConfig{Type: "xray"}) + x.instance = new(xrayCore.Instance) + ld := newTestDispatcher() + x.limitDispatcher = ld + x.users = []model.UserSpec{{ID: 1, UUID: "uuid-1"}} + x.running.Store(true) + + removed, err := x.RemoveUsers([]model.UserSpec{{ID: 1, UUID: "uuid-1"}}) + if err != nil { + t.Fatalf("RemoveUsers() error = %v", err) + } + if removed != 1 { + t.Fatalf("removed = %d, want 1", removed) + } + if x.IsRunning() { + t.Fatal("expected xray to stop when last user is removed") + } +} diff --git a/internal/limiter/limiter.go b/internal/limiter/limiter.go index c9c14dc..bf46ef6 100644 --- a/internal/limiter/limiter.go +++ b/internal/limiter/limiter.go @@ -4,13 +4,13 @@ import ( "sync" "sync/atomic" - "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/model" ) // Limiter enforces per-user device limits and detects removed users. type Limiter struct { mu sync.RWMutex - users map[int]panel.User + users map[int]model.UserSpec // uuidDeviceLimit provides O(1) UUID→device-limit lookup for the // kernel gate-keeping hot path (called per new connection). @@ -21,18 +21,18 @@ type Limiter struct { func New() *Limiter { return &Limiter{ - users: make(map[int]panel.User), + users: make(map[int]model.UserSpec), uuidDeviceLimit: make(map[string]int), } } // UpdateUsers refreshes the user limit configuration and returns // the IDs of users that were present before but are now removed. -func (l *Limiter) UpdateUsers(users []panel.User) []int { +func (l *Limiter) UpdateUsers(users []model.UserSpec) []int { l.mu.Lock() defer l.mu.Unlock() - newUsers := make(map[int]panel.User, len(users)) + newUsers := make(map[int]model.UserSpec, len(users)) for _, u := range users { newUsers[u.ID] = u } diff --git a/internal/limiter/limiter_test.go b/internal/limiter/limiter_test.go index 519fe7f..82d6157 100644 --- a/internal/limiter/limiter_test.go +++ b/internal/limiter/limiter_test.go @@ -4,7 +4,7 @@ import ( "sort" "testing" - "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/model" ) func TestNew(t *testing.T) { @@ -16,7 +16,7 @@ func TestNew(t *testing.T) { func TestUpdateUsers(t *testing.T) { l := New() - users := []panel.User{ + users := []model.UserSpec{ {ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 2}, {ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0}, } @@ -29,14 +29,14 @@ func TestUpdateUsers(t *testing.T) { func TestUpdateUsers_DetectsRemoved(t *testing.T) { l := New() - l.UpdateUsers([]panel.User{ + l.UpdateUsers([]model.UserSpec{ {ID: 1, UUID: "u1"}, {ID: 2, UUID: "u2"}, {ID: 3, UUID: "u3"}, }) // Remove user 2 - removed := l.UpdateUsers([]panel.User{ + removed := l.UpdateUsers([]model.UserSpec{ {ID: 1, UUID: "u1"}, {ID: 3, UUID: "u3"}, }) @@ -48,14 +48,14 @@ func TestUpdateUsers_DetectsRemoved(t *testing.T) { func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) { l := New() - l.UpdateUsers([]panel.User{ + l.UpdateUsers([]model.UserSpec{ {ID: 1, UUID: "u1"}, {ID: 2, UUID: "u2"}, {ID: 3, UUID: "u3"}, }) // Remove users 1 and 3 - removed := l.UpdateUsers([]panel.User{ + removed := l.UpdateUsers([]model.UserSpec{ {ID: 2, UUID: "u2"}, }) @@ -67,12 +67,12 @@ func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) { func TestUpdateUsers_NoRemovals(t *testing.T) { l := New() - l.UpdateUsers([]panel.User{ + l.UpdateUsers([]model.UserSpec{ {ID: 1, UUID: "u1"}, }) // Add user, keep existing - removed := l.UpdateUsers([]panel.User{ + removed := l.UpdateUsers([]model.UserSpec{ {ID: 1, UUID: "u1"}, {ID: 2, UUID: "u2"}, }) @@ -84,10 +84,10 @@ func TestUpdateUsers_NoRemovals(t *testing.T) { func TestUpdateUsers_ReplacesAll(t *testing.T) { l := New() - l.UpdateUsers([]panel.User{{ID: 1, DeviceLimit: 1}}) + l.UpdateUsers([]model.UserSpec{{ID: 1, DeviceLimit: 1}}) // Replace with different users - removed := l.UpdateUsers([]panel.User{{ID: 2, DeviceLimit: 1}}) + removed := l.UpdateUsers([]model.UserSpec{{ID: 2, DeviceLimit: 1}}) if len(removed) != 1 || removed[0] != 1 { t.Errorf("expected removed=[1], got %v", removed) @@ -96,7 +96,7 @@ func TestUpdateUsers_ReplacesAll(t *testing.T) { func TestGetDeviceLimitByUUID(t *testing.T) { l := New() - l.UpdateUsers([]panel.User{ + l.UpdateUsers([]model.UserSpec{ {ID: 1, UUID: "u1", DeviceLimit: 3}, {ID: 2, UUID: "u2", DeviceLimit: 0}, }) diff --git a/internal/limiter/speedtracker.go b/internal/limiter/speedtracker.go index 1dee5e8..ddb6f2a 100644 --- a/internal/limiter/speedtracker.go +++ b/internal/limiter/speedtracker.go @@ -4,7 +4,7 @@ import ( "sync" "sync/atomic" - "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/model" "golang.org/x/time/rate" ) @@ -45,7 +45,7 @@ func (t *SpeedTracker) SetLogCallback(f SpeedTrackerLogCallback) { // UpdateBuckets updates the UUID→userID mapping and syncs existing limiters. func (t *SpeedTracker) UpdateBuckets() { - currentUsers := make([]panel.User, 0, 32) + currentUsers := make([]model.UserSpec, 0, 32) t.limiter.mu.RLock() for _, u := range t.limiter.users { currentUsers = append(currentUsers, u) diff --git a/internal/limiter/speedtracker_test.go b/internal/limiter/speedtracker_test.go index 6871f8c..fcb2d29 100644 --- a/internal/limiter/speedtracker_test.go +++ b/internal/limiter/speedtracker_test.go @@ -4,14 +4,14 @@ import ( "testing" "time" - "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/model" ) func TestSpeedTracker_UpdateBuckets(t *testing.T) { l := New() st := NewSpeedTracker(l) - users := []panel.User{ + users := []model.UserSpec{ {ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, // 3 Mbps {ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0}, // unlimited {ID: 3, UUID: "u3", SpeedLimit: 10, DeviceLimit: 2}, // 10 Mbps @@ -60,7 +60,7 @@ func TestSpeedTracker_UpdateBuckets_PreservesExisting(t *testing.T) { l := New() st := NewSpeedTracker(l) - users := []panel.User{ + users := []model.UserSpec{ {ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, } l.UpdateUsers(users) @@ -72,7 +72,7 @@ func TestSpeedTracker_UpdateBuckets_PreservesExisting(t *testing.T) { } // Update same user with different speed — should reuse same *rate.Limiter pointer - users2 := []panel.User{ + users2 := []model.UserSpec{ {ID: 1, UUID: "u1", SpeedLimit: 5, DeviceLimit: 0}, } l.UpdateUsers(users2) @@ -97,7 +97,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) { l := New() st := NewSpeedTracker(l) - users := []panel.User{ + users := []model.UserSpec{ {ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, {ID: 2, UUID: "u2", SpeedLimit: 5, DeviceLimit: 0}, } @@ -109,7 +109,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) { } // Remove user 2 - users2 := []panel.User{ + users2 := []model.UserSpec{ {ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, } l.UpdateUsers(users2) @@ -128,7 +128,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) { func TestSpeedTracker_UpdateBuckets_LogCallbackMayCallLimitedUserCount(t *testing.T) { l := New() st := NewSpeedTracker(l) - l.UpdateUsers([]panel.User{{ID: 1, UUID: "u1", SpeedLimit: 1}}) + l.UpdateUsers([]model.UserSpec{{ID: 1, UUID: "u1", SpeedLimit: 1}}) st.SetLogCallback(func(msg string) { _ = st.LimitedUserCount() }) diff --git a/internal/model/panel.go b/internal/model/panel.go new file mode 100644 index 0000000..d08ba7a --- /dev/null +++ b/internal/model/panel.go @@ -0,0 +1,224 @@ +package model + +import ( + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/panel" +) + +func NodeSpecFromPanel(nc *panel.NodeConfig) *NodeSpec { + if nc == nil { + return nil + } + + var certCfg *config.CertConfig + if nc.CertConfig != nil { + certCfg = &config.CertConfig{ + CertMode: nc.CertConfig.CertMode, + Domain: nc.CertConfig.Domain, + Email: nc.CertConfig.Email, + DNSProvider: nc.CertConfig.DNSProvider, + DNSEnv: nc.CertConfig.DNSEnv, + HTTPPort: nc.CertConfig.HTTPPort, + CertFile: nc.CertConfig.CertFile, + KeyFile: nc.CertConfig.KeyFile, + CertContent: nc.CertConfig.CertContent, + KeyContent: nc.CertConfig.KeyContent, + } + } + + var multiplex *MultiplexConfig + if nc.Multiplex != nil { + multiplex = &MultiplexConfig{ + Enabled: nc.Multiplex.Enabled, + Protocol: nc.Multiplex.Protocol, + MaxConnections: nc.Multiplex.MaxConnections, + MinStreams: nc.Multiplex.MinStreams, + MaxStreams: nc.Multiplex.MaxStreams, + Padding: nc.Multiplex.Padding, + } + if nc.Multiplex.Brutal != nil { + multiplex.Brutal = &BrutalConfig{ + Enabled: nc.Multiplex.Brutal.Enabled, + UpMbps: nc.Multiplex.Brutal.UpMbps, + DownMbps: nc.Multiplex.Brutal.DownMbps, + } + } + } + + routes := make([]RouteRule, 0, len(nc.Routes)) + for _, route := range nc.Routes { + routes = append(routes, RouteRule{ + ID: route.ID, + Match: cloneStringSlice(route.Match), + Action: route.Action, + ActionValue: route.ActionValue, + }) + } + + outbounds := make([]OutboundConfig, 0, len(nc.CustomOutbounds)) + for _, outbound := range nc.CustomOutbounds { + outbounds = append(outbounds, OutboundConfig{ + Tag: outbound.Tag, + Protocol: outbound.Protocol, + Settings: cloneAnyMap(outbound.Settings), + ProxyTag: outbound.ProxyTag, + }) + } + + return &NodeSpec{ + Protocol: nc.Protocol, + ListenIP: nc.ListenIP, + ServerPort: nc.ServerPort, + Network: nc.Network, + NetworkSettings: cloneAnyMap(nc.NetworkSettings), + Routes: routes, + KernelType: nc.KernelType, + KernelLogLevel: nc.KernelLogLevel, + CustomOutbounds: outbounds, + CustomRoutes: cloneMapSlice(nc.CustomRoutes), + CertConfig: certCfg, + AutoTLS: nc.AutoTLS, + Domain: nc.Domain, + Cipher: nc.Cipher, + Plugin: nc.Plugin, + PluginOpt: nc.PluginOpt, + ServerKey: nc.ServerKey, + TLS: nc.TLS, + Flow: nc.Flow, + Decryption: nc.Decryption, + TLSSettings: cloneAnyMap(nc.TLSSettings), + Host: nc.Host, + ServerName: nc.ServerName, + Version: nc.Version, + UpMbps: nc.UpMbps, + DownMbps: nc.DownMbps, + Obfs: nc.Obfs, + ObfsPassword: nc.ObfsPassword, + CongestionControl: nc.CongestionControl, + PaddingScheme: string(nc.PaddingScheme), + Transport: nc.Transport, + TrafficPattern: nc.TrafficPattern, + Multiplex: multiplex, + AcceptProxyProtocol: nc.AcceptProxyProtocol, + } +} + +func UserSpecsFromPanel(users []panel.User) []UserSpec { + if users == nil { + return nil + } + out := make([]UserSpec, 0, len(users)) + for _, user := range users { + out = append(out, UserSpec{ID: user.ID, UUID: user.UUID, SpeedLimit: user.SpeedLimit, DeviceLimit: user.DeviceLimit}) + } + return out +} + +func (n *NodeSpec) ToPanel() *panel.NodeConfig { + if n == nil { + return nil + } + + var certCfg *panel.CertConfig + if n.CertConfig != nil { + certCfg = &panel.CertConfig{ + CertMode: n.CertConfig.CertMode, + Domain: n.CertConfig.Domain, + Email: n.CertConfig.Email, + DNSProvider: n.CertConfig.DNSProvider, + DNSEnv: n.CertConfig.DNSEnv, + HTTPPort: n.CertConfig.HTTPPort, + CertFile: n.CertConfig.CertFile, + KeyFile: n.CertConfig.KeyFile, + CertContent: n.CertConfig.CertContent, + KeyContent: n.CertConfig.KeyContent, + } + } + + var multiplex *panel.MultiplexConfig + if n.Multiplex != nil { + multiplex = &panel.MultiplexConfig{ + Enabled: n.Multiplex.Enabled, + Protocol: n.Multiplex.Protocol, + MaxConnections: n.Multiplex.MaxConnections, + MinStreams: n.Multiplex.MinStreams, + MaxStreams: n.Multiplex.MaxStreams, + Padding: n.Multiplex.Padding, + } + if n.Multiplex.Brutal != nil { + multiplex.Brutal = &panel.BrutalConfig{ + Enabled: n.Multiplex.Brutal.Enabled, + UpMbps: n.Multiplex.Brutal.UpMbps, + DownMbps: n.Multiplex.Brutal.DownMbps, + } + } + } + + routes := make([]panel.RouteRule, 0, len(n.Routes)) + for _, route := range n.Routes { + routes = append(routes, panel.RouteRule{ + ID: route.ID, + Match: cloneStringSlice(route.Match), + Action: route.Action, + ActionValue: route.ActionValue, + }) + } + + outbounds := make([]panel.OutboundConfig, 0, len(n.CustomOutbounds)) + for _, outbound := range n.CustomOutbounds { + outbounds = append(outbounds, panel.OutboundConfig{ + Tag: outbound.Tag, + Protocol: outbound.Protocol, + Settings: cloneAnyMap(outbound.Settings), + ProxyTag: outbound.ProxyTag, + }) + } + + return &panel.NodeConfig{ + Protocol: n.Protocol, + ListenIP: n.ListenIP, + ServerPort: n.ServerPort, + Network: n.Network, + NetworkSettings: cloneAnyMap(n.NetworkSettings), + Routes: routes, + KernelType: n.KernelType, + KernelLogLevel: n.KernelLogLevel, + CustomOutbounds: outbounds, + CustomRoutes: cloneMapSlice(n.CustomRoutes), + CertConfig: certCfg, + AutoTLS: n.AutoTLS, + Domain: n.Domain, + Cipher: n.Cipher, + Plugin: n.Plugin, + PluginOpt: n.PluginOpt, + ServerKey: n.ServerKey, + TLS: n.TLS, + Flow: n.Flow, + Decryption: n.Decryption, + TLSSettings: cloneAnyMap(n.TLSSettings), + Host: n.Host, + ServerName: n.ServerName, + Version: n.Version, + UpMbps: n.UpMbps, + DownMbps: n.DownMbps, + Obfs: n.Obfs, + ObfsPassword: n.ObfsPassword, + CongestionControl: n.CongestionControl, + PaddingScheme: panel.StringOrArray(n.PaddingScheme), + Transport: n.Transport, + TrafficPattern: n.TrafficPattern, + Multiplex: multiplex, + AcceptProxyProtocol: n.AcceptProxyProtocol, + } +} + +func UserSpecsToPanel(users []UserSpec) []panel.User { + if users == nil { + return nil + } + out := make([]panel.User, 0, len(users)) + for _, user := range users { + out = append(out, panel.User{ID: user.ID, UUID: user.UUID, SpeedLimit: user.SpeedLimit, DeviceLimit: user.DeviceLimit}) + } + return out +} diff --git a/internal/model/standalone.go b/internal/model/standalone.go new file mode 100644 index 0000000..a9d8e48 --- /dev/null +++ b/internal/model/standalone.go @@ -0,0 +1,82 @@ +package model + +import "github.com/cedar2025/xboard-node/internal/config" + +func NodeSpecFromStandalone(cfg *config.Config) *NodeSpec { + sc := cfg.Standalone + if sc == nil { + return nil + } + + routes := make([]RouteRule, 0, len(sc.Node.Routes)) + for _, route := range sc.Node.Routes { + routes = append(routes, RouteRule{ + ID: route.ID, + Match: cloneStringSlice(route.Match), + Action: route.Action, + ActionValue: route.ActionValue, + }) + } + + var multiplex *MultiplexConfig + if sc.Node.Multiplex != nil { + multiplex = &MultiplexConfig{ + Enabled: sc.Node.Multiplex.Enabled, + Protocol: sc.Node.Multiplex.Protocol, + MaxConnections: sc.Node.Multiplex.MaxConnections, + MinStreams: sc.Node.Multiplex.MinStreams, + MaxStreams: sc.Node.Multiplex.MaxStreams, + Padding: sc.Node.Multiplex.Padding, + } + if sc.Node.Multiplex.Brutal != nil { + multiplex.Brutal = &BrutalConfig{ + Enabled: sc.Node.Multiplex.Brutal.Enabled, + UpMbps: sc.Node.Multiplex.Brutal.UpMbps, + DownMbps: sc.Node.Multiplex.Brutal.DownMbps, + } + } + } + + return &NodeSpec{ + Protocol: sc.Node.Protocol, + ListenIP: sc.Node.ListenIP, + ServerPort: sc.Node.ServerPort, + Network: sc.Node.Network, + NetworkSettings: cloneAnyMap(sc.Node.NetworkSettings), + Routes: routes, + KernelType: cfg.Kernel.Type, + KernelLogLevel: cfg.Kernel.LogLevel, + Cipher: sc.Node.Cipher, + Plugin: sc.Node.Plugin, + PluginOpt: sc.Node.PluginOpt, + ServerKey: sc.Node.ServerKey, + TLS: sc.Node.TLS, + Flow: sc.Node.Flow, + Decryption: sc.Node.Decryption, + TLSSettings: cloneAnyMap(sc.Node.TLSSettings), + Host: sc.Node.Host, + ServerName: sc.Node.ServerName, + Version: sc.Node.Version, + UpMbps: sc.Node.UpMbps, + DownMbps: sc.Node.DownMbps, + Obfs: sc.Node.Obfs, + ObfsPassword: sc.Node.ObfsPassword, + CongestionControl: sc.Node.CongestionControl, + PaddingScheme: sc.Node.PaddingScheme, + Transport: sc.Node.Transport, + TrafficPattern: sc.Node.TrafficPattern, + Multiplex: multiplex, + AcceptProxyProtocol: sc.Node.AcceptProxyProtocol, + } +} + +func UserSpecsFromStandalone(cfg *config.Config) []UserSpec { + if cfg.Standalone == nil { + return nil + } + users := make([]UserSpec, 0, len(cfg.Standalone.Users)) + for _, user := range cfg.Standalone.Users { + users = append(users, UserSpec{ID: user.ID, UUID: user.UUID, SpeedLimit: user.SpeedLimit, DeviceLimit: user.DeviceLimit}) + } + return users +} diff --git a/internal/model/standalone_test.go b/internal/model/standalone_test.go new file mode 100644 index 0000000..39fb454 --- /dev/null +++ b/internal/model/standalone_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "testing" + + "github.com/cedar2025/xboard-node/internal/config" +) + +func TestNodeSpecFromStandalone(t *testing.T) { + cfg := &config.Config{ + Kernel: config.KernelConfig{Type: "xray", LogLevel: "info"}, + Standalone: &config.StandaloneConfig{ + Enabled: true, + Node: config.StandaloneNodeConfig{ + Protocol: "vless", + ListenIP: "::", + ServerPort: 8443, + Network: "ws", + NetworkSettings: map[string]any{"path": "/ws"}, + TLS: 1, + Host: "node.example.com", + ServerName: "node.example.com", + AcceptProxyProtocol: true, + PaddingScheme: "stop=8\n0=30-30", + Routes: []config.StandaloneRouteRule{{ID: 1, Match: []string{"example.com"}, Action: "direct"}}, + Multiplex: &config.StandaloneMultiplexConfig{Enabled: true, Protocol: "smux", MaxConnections: 4}, + }, + Users: []config.StandaloneUser{{ID: 7, UUID: "11111111-1111-1111-1111-111111111111", SpeedLimit: 50, DeviceLimit: 2}}, + }, + } + + nc := NodeSpecFromStandalone(cfg) + users := UserSpecsFromStandalone(cfg) + if nc.Protocol != "vless" || nc.ServerPort != 8443 { + t.Fatalf("unexpected node spec: %#v", nc) + } + if nc.NetworkSettings["path"] != "/ws" { + t.Fatalf("network_settings.path: got %v", nc.NetworkSettings["path"]) + } + if nc.PaddingScheme != "stop=8\n0=30-30" { + t.Fatalf("padding_scheme: got %q", nc.PaddingScheme) + } + if nc.Multiplex == nil || !nc.Multiplex.Enabled { + t.Fatal("expected multiplex config to be copied") + } + if len(users) != 1 || users[0].DeviceLimit != 2 { + t.Fatalf("users: %#v", users) + } +} diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 0000000..aab9c8d --- /dev/null +++ b/internal/model/types.go @@ -0,0 +1,132 @@ +package model + +import "github.com/cedar2025/xboard-node/internal/config" + +type NodeSpec struct { + Protocol string + ListenIP string + ServerPort int + Network string + NetworkSettings map[string]any + Routes []RouteRule + + KernelType string + KernelLogLevel string + CustomOutbounds []OutboundConfig + CustomRoutes []map[string]any + CertConfig *config.CertConfig + AutoTLS bool + Domain string + + Cipher string + Plugin string + PluginOpt string + ServerKey string + + TLS int + Flow string + Decryption string + TLSSettings map[string]any + + Host string + ServerName string + + Version int + UpMbps int + DownMbps int + Obfs string + ObfsPassword string + + CongestionControl string + PaddingScheme string + Transport string + TrafficPattern string + + Multiplex *MultiplexConfig + AcceptProxyProtocol bool +} + +type OutboundConfig struct { + Tag string + Protocol string + Settings map[string]any + ProxyTag string +} + +type RouteRule struct { + ID int + Match []string + Action string + ActionValue string +} + +type MultiplexConfig struct { + Enabled bool + Protocol string + MaxConnections int + MinStreams int + MaxStreams int + Padding bool + Brutal *BrutalConfig +} + +type BrutalConfig struct { + Enabled bool + UpMbps int + DownMbps int +} + +type UserSpec struct { + ID int + UUID string + SpeedLimit int + DeviceLimit int +} + +func (n *NodeSpec) GetProxyProtocol() bool { + if n == nil { + return false + } + if n.AcceptProxyProtocol { + return true + } + if n.NetworkSettings != nil { + if v, ok := n.NetworkSettings["acceptProxyProtocol"]; ok { + if b, ok := v.(bool); ok { + return b + } + } + } + return false +} + +func cloneAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + out := make(map[string]any, len(src)) + for k, v := range src { + out[k] = v + } + return out +} + +func cloneMapSlice(src []map[string]any) []map[string]any { + if len(src) == 0 { + return nil + } + out := make([]map[string]any, 0, len(src)) + for _, item := range src { + out = append(out, cloneAnyMap(item)) + } + return out +} + +func cloneStringSlice(src []string) []string { + if len(src) == 0 { + return nil + } + out := make([]string, len(src)) + copy(out, src) + return out +} diff --git a/internal/service/service.go b/internal/service/service.go index bc5e3f3..87321ea 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -15,27 +15,29 @@ import ( "github.com/cedar2025/xboard-node/internal/cert" "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/controlplane" "github.com/cedar2025/xboard-node/internal/kernel" "github.com/cedar2025/xboard-node/internal/kernel/singbox" "github.com/cedar2025/xboard-node/internal/kernel/xray" "github.com/cedar2025/xboard-node/internal/limiter" + "github.com/cedar2025/xboard-node/internal/model" "github.com/cedar2025/xboard-node/internal/monitor" "github.com/cedar2025/xboard-node/internal/nlog" - "github.com/cedar2025/xboard-node/internal/panel" "github.com/cedar2025/xboard-node/internal/tracker" ) type Service struct { cfg *config.Config - panel *panel.Client + source controlplane.Source + sink controlplane.Sink kernel kernel.Kernel tracker *tracker.Tracker limiter *limiter.Limiter speedTracker *limiter.SpeedTracker cert *cert.Manager - lastConfig *panel.NodeConfig - lastUsers []panel.User + lastConfig *model.NodeSpec + lastUsers []model.UserSpec // nodeLog is the logger with node context for this service instance. nodeLog *nlog.NodeLog @@ -43,8 +45,8 @@ type Service struct { // appliedState tracks the configuration and users that are currently // successfully running in the kernel. appliedState struct { - Config *panel.NodeConfig - Users []panel.User + Config *model.NodeSpec + Users []model.UserSpec } pushInterval int // seconds @@ -61,9 +63,9 @@ type Service struct { // pullResults delivers async pullViaAPI results back to the main goroutine. pullResults chan pullResult - wsClient *panel.WSClient // WebSocket client (nil if WS not enabled) - wsEvents chan panel.WSEvent // receives data events from WS client - wsStatusCh chan panel.WSStatusChange // receives WS connect/disconnect notifications + wsClient controlplane.PushClient // Push client (nil if push is not enabled) + wsEvents chan controlplane.Event // receives data events from push transport + wsStatusCh chan controlplane.StatusChange // receives push connectivity notifications wsCancel context.CancelFunc // cancels the WS client goroutine wsDisconnectAt time.Time // when WS last disconnected (zero if connected) @@ -73,8 +75,8 @@ type Service struct { // pullResult carries the outcome of an async pullViaAPI back to the main goroutine. type pullResult struct { - config *panel.NodeConfig - users []panel.User + config *model.NodeSpec + users []model.UserSpec configHash string userHash string certChanged bool @@ -113,8 +115,7 @@ func (b *apiBackoff) onFailure() { } func New(cfg *config.Config) *Service { - panelClient := panel.NewClient(cfg.Panel) - certMgr := cert.NewManager(cfg.Cert) + certMgr := cert.NewManager(cfg.Cert) var k kernel.Kernel switch cfg.Kernel.Type { @@ -130,16 +131,24 @@ func New(cfg *config.Config) *Service { l := limiter.New() st := limiter.NewSpeedTracker(l) + var cp controlplane.ControlPlane + if cfg.IsStandalone() { + cp = controlplane.NewLocalControlPlane(cfg) + } else { + cp = controlplane.NewPanelControlPlane(cfg.Panel, cfg.WS) + } + return &Service{ cfg: cfg, - panel: panelClient, + source: cp, + sink: cp, kernel: k, tracker: tracker.New(), limiter: l, speedTracker: st, cert: certMgr, - wsEvents: make(chan panel.WSEvent, 16), - wsStatusCh: make(chan panel.WSStatusChange, 4), + wsEvents: make(chan controlplane.Event, 16), + wsStatusCh: make(chan controlplane.StatusChange, 4), pullResults: make(chan pullResult, 1), } } @@ -157,6 +166,7 @@ func (s *Service) Run(ctx context.Context) error { } defer s.kernel.Stop() + // Set up tickers trackTicker := time.NewTicker(time.Duration(s.cfg.Node.TrackInterval) * time.Second) pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second @@ -218,35 +228,18 @@ func (s *Service) Run(ctx context.Context) error { } func (s *Service) initialSetup(ctx context.Context) error { - // Register speed limit lookup with kernel unconditionally (before WS/V1 branch). - // This ensures speedLimitFunc is set regardless of which code path applies config. + // Register speed limit lookup with kernel unconditionally (before push/poll branch). s.kernel.SetSpeedLimitFunc(s.speedTracker.GetLimiter) s.kernel.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID) - var hs *panel.HandshakeResponse - var err error - // Retry loop for initial handshake - for attempt := 1; ; attempt++ { - hs, err = s.panel.Handshake() - if err != nil { - nlog.Core().Error(fmt.Sprintf("handshake failed (attempt %d): %v", attempt, err)) - if attempt >= 5 { - return fmt.Errorf("handshake failed after %d attempts: %w", attempt, err) - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Duration(attempt*5) * time.Second): - continue - } - } - break + bootstrap, err := s.source.Initial(ctx, s.wsMetrics, s.wsEvents, s.wsStatusCh) + if err != nil { + return err } - // Apply settings from panel handshake (Handshake provides default intervals) - if s.cfg.Node.PushInterval == 0 && hs.Settings.PushInterval > 0 { - s.pushInterval = hs.Settings.PushInterval + if s.cfg.Node.PushInterval == 0 && bootstrap.PushInterval > 0 { + s.pushInterval = bootstrap.PushInterval } else { s.pushInterval = s.cfg.Node.PushInterval } @@ -254,8 +247,8 @@ func (s *Service) initialSetup(ctx context.Context) error { s.pushInterval = 60 } - if s.cfg.Node.PullInterval == 0 && hs.Settings.PullInterval > 0 { - s.pullInterval = hs.Settings.PullInterval + if s.cfg.Node.PullInterval == 0 && bootstrap.PullInterval > 0 { + s.pullInterval = bootstrap.PullInterval } else { s.pullInterval = s.cfg.Node.PullInterval } @@ -263,65 +256,41 @@ func (s *Service) initialSetup(ctx context.Context) error { s.pullInterval = 60 } - // If WebSocket is enabled, skip initial V1 APIs fetch and wait for push - if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" { - s.wsClient = s.newWSClient(hs.WebSocket.WSURL) + if bootstrap.Push != nil { + s.wsClient = bootstrap.Push return nil } - - // Falls back to V1 APIs only if WebSocket is disabled in panel - nlog.Core().Info("websocket disabled, using REST API") - - // Fetch initial config and users via V1 APIs - nodeConfig, err := s.panel.GetConfig() - if err != nil { - return fmt.Errorf("initial config fetch: %w", err) - } - if nodeConfig == nil { + if bootstrap.Config == nil { return fmt.Errorf("initial config is nil") } - users, err := s.panel.GetUsers() - if err != nil { - return fmt.Errorf("initial user fetch: %w", err) - } + s.metricsMu.Lock() + s.lastConfig = bootstrap.Config + s.metricsMu.Unlock() + s.lastConfigHash = computeConfigHash(bootstrap.Config) + s.updateUserState(bootstrap.Users) - // Apply initial data - s.lastConfig = nodeConfig - s.lastUsers = users - s.lastUserHash = computeUserHash(users) - s.lastConfigHash = computeConfigHash(nodeConfig) - s.limiter.UpdateUsers(users) - s.speedTracker.UpdateBuckets() - - nlog.Core().Info("handshake complete (V1 fallback)", - "protocol", nodeConfig.Protocol, - "port", nodeConfig.ServerPort, - "users", len(users), + nlog.Core().Info("initial snapshot ready", + "protocol", bootstrap.Config.Protocol, + "port", bootstrap.Config.ServerPort, + "users", len(bootstrap.Users), ) - if len(users) == 0 { + if len(bootstrap.Users) == 0 { nlog.Core().Warn("no users, kernel will not start until users are available") return nil } - // Update service-level config overrides from remote NodeConfig if present - s.applyRemoteOverrides(ctx, nodeConfig) - - if err := s.kernel.Start(nodeConfig, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil { - return fmt.Errorf("start kernel: %w", err) + s.applyRemoteOverrides(ctx, bootstrap.Config) + if !s.startKernel(bootstrap.Config, bootstrap.Users) { + return fmt.Errorf("start kernel") } - - // record applied state on success - s.appliedState.Config = nodeConfig - s.appliedState.Users = users - return nil } // applyRemoteOverrides updates service-level settings (log level, cert config) // from the panel's NodeConfig. Returns true if cert paths changed (kernel restart needed). -func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig) bool { +func (s *Service) applyRemoteOverrides(ctx context.Context, nc *model.NodeSpec) bool { if nc == nil { return false } @@ -334,7 +303,7 @@ func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig // Certificate configuration from panel (panel-first: takes precedence over local config) if nc.CertConfig != nil { - return s.applyPanelCert(ctx, nc.CertConfig) + return s.applyNodeCert(ctx, nc.CertConfig) } // Legacy fields (deprecated: prefer cert_config) @@ -351,30 +320,21 @@ func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig // applyPanelCert converts a panel CertConfig into the local config format and // reconfigures the cert manager. Reports whether cert paths changed. -func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool { - newCfg := config.CertConfig{ - CertMode: pc.CertMode, - Domain: pc.Domain, - Email: pc.Email, - DNSProvider: pc.DNSProvider, - DNSEnv: pc.DNSEnv, - HTTPPort: pc.HTTPPort, - CertFile: pc.CertFile, - KeyFile: pc.KeyFile, - CertContent: pc.CertContent, - KeyContent: pc.KeyContent, - // Preserve local storage dir — only the operator controls where certs live. - CertDir: s.cfg.Cert.CertDir, - } - - changed, err := s.cert.Reconfigure(ctx, newCfg) - if err != nil { - nlog.Core().Error("failed to apply panel cert config", "mode", pc.CertMode, "error", err) +func (s *Service) applyNodeCert(ctx context.Context, newCfg *config.CertConfig) bool { + if newCfg == nil { return false } - s.cfg.Cert = newCfg + cfgCopy := *newCfg + cfgCopy.CertDir = s.cfg.Cert.CertDir + + changed, err := s.cert.Reconfigure(ctx, cfgCopy) + if err != nil { + nlog.Core().Error("failed to apply runtime cert config", "mode", cfgCopy.CertMode, "error", err) + return false + } + s.cfg.Cert = cfgCopy if changed { - msg := fmt.Sprintf("cert: paths updated from panel cert=%s key=%s", s.cert.CertFile(), s.cert.KeyFile()) + msg := fmt.Sprintf("cert: paths updated cert=%s key=%s", s.cert.CertFile(), s.cert.KeyFile()) if s.nodeLog != nil { s.nodeLog.Info(msg) } else { @@ -384,7 +344,7 @@ func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool return changed } -// startWSClient starts the WS client goroutine if a client is configured. +// startWSClient starts the push client goroutine if a client is configured. func (s *Service) startWSClient(ctx context.Context) { if s.wsClient == nil { return @@ -394,46 +354,19 @@ func (s *Service) startWSClient(ctx context.Context) { go s.wsClient.Run(wsCtx) } -// newWSClient creates a WSClient with standard event/status callbacks. -func (s *Service) newWSClient(wsURL string) *panel.WSClient { - wsCfg := panel.WSClientConfig{ - StatusInterval: time.Duration(s.cfg.WS.StatusInterval) * time.Second, - HandshakeTimeout: time.Duration(s.cfg.WS.HandshakeTimeout) * time.Second, - BackoffInitial: time.Duration(s.cfg.WS.BackoffInitial) * time.Second, - BackoffMax: time.Duration(s.cfg.WS.BackoffMax) * time.Second, - } - return panel.NewWSClient( - wsURL, - s.cfg.Panel.Token, - s.cfg.Panel.NodeID, - wsCfg, - func(event panel.WSEvent) { - select { - case s.wsEvents <- event: - default: - nlog.Core().Warn("ws event channel full, dropping event", "type", event.Type) - } - }, - func(status panel.WSStatusChange) { - select { - case s.wsStatusCh <- status: - default: - } - }, - func() map[string]interface{} { - status := monitor.Collect() - m := s.buildMetrics(status) - m["kernel_status"] = s.kernel.IsRunning() - return m - }, - ) +func (s *Service) wsMetrics() map[string]interface{} { + status := monitor.Collect() + m := s.buildMetrics(status) + m["kernel_status"] = s.kernel.IsRunning() + return m } // handleWSStatus reacts to WS connectivity changes. + // // - On disconnect: record timestamp, immediately REST poll. // - 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 controlplane.StatusChange) { if status.Connected { s.metricsMu.Lock() s.wsDisconnectAt = time.Time{} @@ -474,41 +407,42 @@ func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChang // If WS is now disabled, stop the WS client and switch to REST-only. // If WS config changed (different URL/channel), restart with new config. func (s *Service) wsDiscovery(ctx context.Context) { - needsCheck := false - - if s.wsClient == nil { - needsCheck = true - 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 { - needsCheck = true - nlog.Core().Debug("ws discovery: WS disconnected for >10min, re-checking WS config") + if !s.source.SupportsDiscovery() { + return } + needsCheck := false + if s.wsClient == nil { + needsCheck = true + nlog.Core().Debug("push discovery: no push client, checking if control plane enabled push") + } else if !s.wsDisconnectAt.IsZero() && time.Since(s.wsDisconnectAt) > 10*time.Minute { + needsCheck = true + nlog.Core().Debug("push discovery: push disconnected for >10min, re-checking") + } if !needsCheck { return } - hs, err := s.panel.Handshake() + pushClient, err := s.source.Discover(ctx, s.wsMetrics, s.wsEvents, s.wsStatusCh) if err != nil { - nlog.Core().Debug("ws discovery: handshake failed", "error", err) + nlog.Core().Debug("push discovery failed", "error", err) return } + if s.source.SupportsPolling() { + s.pullViaAPIAsync(ctx) + } - // Apply any latest config/user changes via dedicated APIs - s.pullViaAPIAsync(ctx) - - if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" { + if pushClient != nil { if s.wsClient == nil { - nlog.Core().Info("ws discovery: panel has WS enabled, creating WS client") - wsc := s.newWSClient(hs.WebSocket.WSURL) + nlog.Core().Info("push discovery: control plane enabled push, creating client") s.metricsMu.Lock() - s.wsClient = wsc + s.wsClient = pushClient s.wsDisconnectAt = time.Time{} s.metricsMu.Unlock() s.startWSClient(ctx) } } else if s.wsClient != nil { - nlog.Core().Info("ws discovery: panel disabled WS, switching to REST-only") + nlog.Core().Info("push discovery: control plane disabled push, switching to polling") if s.wsCancel != nil { s.wsCancel() } @@ -521,9 +455,9 @@ func (s *Service) wsDiscovery(ctx context.Context) { } // handleWSEvent processes data events received via WebSocket -func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) { +func (s *Service) handleWSEvent(ctx context.Context, event controlplane.Event) { switch event.Type { - case panel.WSEventSyncConfig: + case controlplane.EventSyncConfig: if event.Config == nil { return } @@ -543,7 +477,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) { s.applyRemoteOverrides(ctx, event.Config) s.applyChanges(ctx, true, false) - case panel.WSEventSyncUsers: + case controlplane.EventSyncUsers: if event.Users == nil { return } @@ -556,7 +490,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) { } s.applyUserUpdate(ctx, event.Users, newHash) - case panel.WSEventSyncUserDelta: + case controlplane.EventSyncUserDelta: if len(event.DeltaUsers) == 0 { return } @@ -565,7 +499,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) { } s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers) - case panel.WSEventSyncDevices: + case controlplane.EventSyncDevices: // Sync global device state if event.DeviceUsers != nil { s.kernel.UpdateGlobalDevices(event.DeviceUsers) @@ -579,55 +513,43 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) { // pullViaAPIAsync fetches config/users from the panel API in a background // goroutine and sends the result to pullResults for the main goroutine to apply. func (s *Service) pullViaAPIAsync(ctx context.Context) { + if !s.source.SupportsPolling() { + return + } if !s.pullActive.CompareAndSwap(false, true) { nlog.Core().Debug("pull already in progress, skipping") return } - if s.pullBackoff.shouldSkip() { nlog.Core().Debug("skipping pull due to backoff") s.pullActive.Store(false) return } - // Capture current hashes for comparison in the goroutine. currentConfigHash := s.lastConfigHash certChanged := s.cert.CertRenewed() go func() { defer s.pullActive.Store(false) - - config, err := s.panel.GetConfig() + snapshot, err := s.source.Poll(ctx) if err != nil { - nlog.Core().Error("poll config failed", "error", err) + nlog.Core().Error("poll control plane failed", "error", err) s.pullBackoff.onFailure() return } - - users, err := s.panel.GetUsers() - if err != nil { - nlog.Core().Error("poll users failed", "error", err) - s.pullBackoff.onFailure() - return - } - s.pullBackoff.onSuccess() - result := pullResult{ - config: config, - users: users, - certChanged: certChanged, + result := pullResult{certChanged: certChanged} + if snapshot.Config != nil { + result.config = snapshot.Config + result.configHash = computeConfigHash(snapshot.Config) + if result.configHash == currentConfigHash && !certChanged { + result.config = nil + } } - if config != nil { - result.configHash = computeConfigHash(config) - } - if users != nil { - result.userHash = computeUserHash(users) - } - - // Detect config change using captured hash. - if config != nil && result.configHash == currentConfigHash && !certChanged { - result.config = nil // signal no change + if snapshot.Users != nil { + result.users = snapshot.Users + result.userHash = computeUserHash(snapshot.Users) } select { @@ -679,25 +601,48 @@ func (s *Service) applyPullResult(ctx context.Context, result pullResult) { // ─── User state helpers ───────────────────────────────────────────────────── -func (s *Service) updateUserState(users []panel.User) { - // Defensive nil check +func (s *Service) updateUserState(users []model.UserSpec) { if users == nil { - users = []panel.User{} + users = []model.UserSpec{} } + _, _ = s.prepareUserState(users) +} + +func (s *Service) prepareUserState(users []model.UserSpec) (prevUsers []model.UserSpec, prevHash string) { + if users == nil { + users = []model.UserSpec{} + } + + s.metricsMu.RLock() + prevUsers = append([]model.UserSpec(nil), s.lastUsers...) + s.metricsMu.RUnlock() + prevHash = s.lastUserHash s.limiter.UpdateUsers(users) s.speedTracker.UpdateBuckets() s.metricsMu.Lock() - s.lastUsers = users + s.lastUsers = append([]model.UserSpec(nil), users...) s.metricsMu.Unlock() - s.lastUserHash = computeUserHash(users) + return prevUsers, prevHash +} + +func (s *Service) restoreUserState(users []model.UserSpec, hash string) { + if users == nil { + users = []model.UserSpec{} + } + s.limiter.UpdateUsers(users) + s.speedTracker.UpdateBuckets() + s.metricsMu.Lock() + s.lastUsers = append([]model.UserSpec(nil), users...) + s.metricsMu.Unlock() + s.lastUserHash = hash } // startKernel starts (or restarts) the kernel with the given config/users and // 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 *model.NodeSpec, users []model.UserSpec) bool { if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil { nlog.Core().Error("failed to start kernel", "error", err) return false @@ -734,18 +679,23 @@ func (s *Service) ensureRunning() bool { // applyUserUpdate replaces the full user set and hot-swaps the kernel. // 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 []model.UserSpec, newHash string) { if !s.ensureRunning() { return } + prevUsers, prevHash := s.prepareUserState(users) added, removed, err := s.kernel.UpdateUsers(users) if err != nil { nlog.Core().Warn(fmt.Sprintf("UpdateUsers failed, restarting kernel: %v", err)) - s.startKernel(s.lastConfig, users) + if !s.startKernel(s.lastConfig, users) { + s.restoreUserState(prevUsers, prevHash) + } return } - s.updateUserState(users) + if newHash != "" { + s.lastUserHash = newHash + } if s.nodeLog != nil && (added > 0 || removed > 0) { s.nodeLog.Info(fmt.Sprintf("users updated: +%d -%d", added, removed)) } @@ -753,7 +703,7 @@ func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHa // applyUserDelta applies an incremental user change (add or remove) directly // 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 []model.UserSpec) { switch action { case "add": // Defensive check for empty or nil deltaUsers @@ -769,21 +719,22 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers for _, delta := range deltaUsers { for _, old := range s.lastUsers { if old.ID == delta.ID && old.UUID != delta.UUID { - s.kernel.RemoveUsers([]panel.User{old}) + s.kernel.RemoveUsers([]model.UserSpec{old}) break } } } + prevUsers, prevHash := s.prepareUserState(merged) added, err := s.kernel.AddUsers(deltaUsers) if err != nil { nlog.Core().Warn(fmt.Sprintf("AddUsers failed: %v, falling back to UpdateUsers", err)) if _, _, err := s.kernel.UpdateUsers(merged); err != nil { nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err)) + s.restoreUserState(prevUsers, prevHash) return } } - s.updateUserState(merged) if s.nodeLog != nil && added > 0 { s.nodeLog.Info(fmt.Sprintf("users added: +%d", added)) } @@ -799,15 +750,16 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers return } + prevUsers, prevHash := s.prepareUserState(filtered) removed, err := s.kernel.RemoveUsers(deltaUsers) if err != nil { nlog.Core().Warn(fmt.Sprintf("RemoveUsers failed: %v, falling back to UpdateUsers", err)) if _, _, err := s.kernel.UpdateUsers(filtered); err != nil { nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err)) + s.restoreUserState(prevUsers, prevHash) return } } - s.updateUserState(filtered) if s.nodeLog != nil && removed > 0 { s.nodeLog.Info(fmt.Sprintf("users removed: -%d", removed)) } @@ -819,23 +771,23 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers // mergeUsers overlays deltaUsers onto base (keyed by ID). New users are // appended, existing users have their properties overwritten. -func mergeUsers(base, delta []panel.User) []panel.User { +func mergeUsers(base, delta []model.UserSpec) []model.UserSpec { // Handle nil slices if base == nil { - base = []panel.User{} + base = []model.UserSpec{} } if delta == nil { return base } - m := make(map[int]panel.User, len(base)) + m := make(map[int]model.UserSpec, len(base)) for _, u := range base { m[u.ID] = u } for _, u := range delta { m[u.ID] = u } - out := make([]panel.User, 0, len(m)) + out := make([]model.UserSpec, 0, len(m)) for _, u := range m { out = append(out, u) } @@ -843,7 +795,7 @@ func mergeUsers(base, delta []panel.User) []panel.User { } // subtractUsers returns base with all users in delta removed. -func subtractUsers(base, delta []panel.User) []panel.User { +func subtractUsers(base, delta []model.UserSpec) []model.UserSpec { if base == nil { return nil } @@ -854,7 +806,7 @@ func subtractUsers(base, delta []panel.User) []panel.User { for _, u := range delta { removeSet[u.ID] = struct{}{} } - out := make([]panel.User, 0, len(base)) + out := make([]model.UserSpec, 0, len(base)) for _, u := range base { if _, ok := removeSet[u.ID]; !ok { out = append(out, u) @@ -922,18 +874,19 @@ func (s *Service) trackAndEnforce(ctx context.Context) { // pushReportAsync sends the report in a background goroutine so the select // loop is never blocked by slow HTTP. Only one push runs at a time. func (s *Service) pushReportAsync() { + if !s.sink.SupportsReporting() { + return + } if !s.pushActive.CompareAndSwap(false, true) { nlog.Core().Debug("push already in progress, skipping") return } - if s.pushBackoff.shouldSkip() { nlog.Core().Debug("skipping report due to backoff") s.pushActive.Store(false) return } - // Snapshot data under the select goroutine (fast, mutex-only). traffic := s.tracker.FlushTraffic() aliveIPs := s.tracker.FlushAliveIPs() online := s.tracker.CurrentOnline() @@ -943,26 +896,13 @@ func (s *Service) pushReportAsync() { go func() { defer s.pushActive.Store(false) - - if err := s.panel.Report( - traffic, aliveIPs, online, - status.CPU, - [2]uint64{status.MemTotal, status.MemUsed}, - [2]uint64{status.SwapTotal, status.SwapUsed}, - [2]uint64{status.DiskTotal, status.DiskUsed}, - metrics, - ); err != nil { + if err := s.sink.Report(controlplane.ReportPayload{Traffic: traffic, Alive: aliveIPs, Online: online, CPU: status.CPU, Mem: [2]uint64{status.MemTotal, status.MemUsed}, Swap: [2]uint64{status.SwapTotal, status.SwapUsed}, Disk: [2]uint64{status.DiskTotal, status.DiskUsed}, Metrics: metrics}); err != nil { nlog.Core().Error("failed to push report", "error", err) - if len(traffic) > 0 { - s.tracker.RestoreTraffic(traffic) - } - if len(aliveIPs) > 0 { - s.tracker.RestoreAliveIPs(aliveIPs) - } + if len(traffic) > 0 { s.tracker.RestoreTraffic(traffic) } + if len(aliveIPs) > 0 { s.tracker.RestoreAliveIPs(aliveIPs) } s.pushBackoff.onFailure() return } - s.pushBackoff.onSuccess() nlog.ReportPushed(len(traffic), len(online)) }() @@ -970,6 +910,9 @@ func (s *Service) pushReportAsync() { // pushReportSync is used only during shutdown to ensure final data is sent. func (s *Service) pushReportSync() { + if !s.sink.SupportsReporting() { + return + } traffic := s.tracker.FlushTraffic() aliveIPs := s.tracker.FlushAliveIPs() online := s.tracker.CurrentOnline() @@ -977,14 +920,7 @@ func (s *Service) pushReportSync() { metrics := s.buildMetrics(status) metrics["kernel_status"] = s.kernel.IsRunning() - if err := s.panel.Report( - traffic, aliveIPs, online, - status.CPU, - [2]uint64{status.MemTotal, status.MemUsed}, - [2]uint64{status.SwapTotal, status.SwapUsed}, - [2]uint64{status.DiskTotal, status.DiskUsed}, - metrics, - ); err != nil { + if err := s.sink.Report(controlplane.ReportPayload{Traffic: traffic, Alive: aliveIPs, Online: online, CPU: status.CPU, Mem: [2]uint64{status.MemTotal, status.MemUsed}, Swap: [2]uint64{status.SwapTotal, status.SwapUsed}, Disk: [2]uint64{status.DiskTotal, status.DiskUsed}, Metrics: metrics}); err != nil { nlog.Core().Error("failed to push final report", "error", err) } } @@ -1038,7 +974,7 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} { } // API metrics. - api := s.panel.SnapshotMetrics() + api := s.source.Metrics() m["api"] = map[string]interface{}{ "success": api.Success, "failure": api.Failure, @@ -1065,7 +1001,7 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} { // computeConfigHash returns a deterministic hash of the node config. // It uses JSON marshaling to ensure all fields are captured, ensuring that // any configuration change correctly triggers a kernel reload. -func computeConfigHash(cfg *panel.NodeConfig) string { +func computeConfigHash(cfg *model.NodeSpec) string { if cfg == nil { return "" } @@ -1079,8 +1015,8 @@ func computeConfigHash(cfg *panel.NodeConfig) string { // computeUserHash returns a deterministic hash of the user list for change detection. // Uses direct byte encoding instead of binary.Write to avoid reflection overhead. -func computeUserHash(users []panel.User) string { - sorted := make([]panel.User, len(users)) +func computeUserHash(users []model.UserSpec) string { + sorted := make([]model.UserSpec, len(users)) copy(sorted, users) sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) @@ -1112,7 +1048,7 @@ func (s *Service) sendDeviceBatch() { nlog.Core().Debug("device snapshot unchanged, skipping") return } - s.wsClient.SendDeviceReport(devices) + s.sink.ReportDevices(s.wsClient, devices) nlog.Core().Debug("device snapshot sent", "users", len(devices)) } diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..e016adb --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,241 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/cedar2025/xboard-node/internal/cert" + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/limiter" + "github.com/cedar2025/xboard-node/internal/model" + "golang.org/x/time/rate" +) + +type fakeKernel struct { + running bool + + startErr error + updateErr error + addErr error + + startCalls int + updateCalls int + addCalls int + removeCalls int + + onUpdateUsers func([]model.UserSpec) + onAddUsers func([]model.UserSpec) + onRemoveUsers func([]model.UserSpec) + + speedLimitFunc func(string) *rate.Limiter + deviceLimitFunc func(string) (int, bool) +} + +func (f *fakeKernel) Name() string { return "fake" } +func (f *fakeKernel) Protocols() []string { return []string{"vless"} } +func (f *fakeKernel) Capabilities() kernel.Capabilities { return kernel.Capabilities{} } +func (f *fakeKernel) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { + _, _, _, _ = nodeConfig, users, certFile, keyFile + f.startCalls++ + if f.startErr != nil { + return f.startErr + } + f.running = true + return nil +} +func (f *fakeKernel) Stop() { f.running = false } +func (f *fakeKernel) IsRunning() bool { return f.running } +func (f *fakeKernel) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { + _, _, _, _ = nodeConfig, users, certFile, keyFile + return nil +} +func (f *fakeKernel) AddUsers(users []model.UserSpec) (int, error) { + f.addCalls++ + if f.onAddUsers != nil { + f.onAddUsers(users) + } + if f.addErr != nil { + return 0, f.addErr + } + return len(users), nil +} +func (f *fakeKernel) RemoveUsers(users []model.UserSpec) (int, error) { + f.removeCalls++ + if f.onRemoveUsers != nil { + f.onRemoveUsers(users) + } + return len(users), nil +} +func (f *fakeKernel) UpdateUsers(users []model.UserSpec) (int, int, error) { + f.updateCalls++ + if f.onUpdateUsers != nil { + f.onUpdateUsers(users) + } + if f.updateErr != nil { + return 0, 0, f.updateErr + } + return len(users), 0, nil +} +func (f *fakeKernel) GetUserTraffic(ctx context.Context) (map[int][2]int64, map[int]map[string]bool, int, error) { + _ = ctx + return nil, nil, 0, nil +} +func (f *fakeKernel) CloseConnection(ctx context.Context, connID string) error { + _, _ = ctx, connID + return nil +} +func (f *fakeKernel) CloseUserConnections(ctx context.Context, uuid string) error { + _, _ = ctx, uuid + return nil +} +func (f *fakeKernel) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) { f.speedLimitFunc = fn } +func (f *fakeKernel) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) { f.deviceLimitFunc = fn } +func (f *fakeKernel) UpdateGlobalDevices(users map[int][]string) { _ = users } +func (f *fakeKernel) ClearGlobalDevices() {} + +func newTestService(k *fakeKernel) *Service { + s := &Service{ + kernel: k, + limiter: limiter.New(), + speedTracker: limiter.NewSpeedTracker(limiter.New()), + cert: cert.NewManager(config.CertConfig{}), + } + // Use a shared limiter instance for device + speed bookkeeping. + s.limiter = limiter.New() + s.speedTracker = limiter.NewSpeedTracker(s.limiter) + k.SetSpeedLimitFunc(s.speedTracker.GetLimiter) + k.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID) + return s +} + +func TestApplyUserUpdatePreparesLimiterBeforeKernelUpdate(t *testing.T) { + k := &fakeKernel{running: true} + s := newTestService(k) + s.lastConfig = &model.NodeSpec{Protocol: "vless"} + oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} + s.updateUserState(oldUsers) + + newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}} + k.onUpdateUsers = func(users []model.UserSpec) { + if len(users) != 1 || users[0].UUID != "uuid-new" { + t.Fatalf("unexpected users passed to UpdateUsers: %#v", users) + } + if got := k.speedLimitFunc("uuid-new"); got == nil { + t.Fatal("expected new user's limiter to be visible before kernel UpdateUsers") + } + } + + s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers)) + + if got := k.updateCalls; got != 1 { + t.Fatalf("UpdateUsers call count = %d, want 1", got) + } + if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" { + t.Fatalf("lastUsers = %#v, want new users", s.lastUsers) + } + if s.speedTracker.GetLimiter("uuid-new") == nil { + t.Fatal("expected limiter for new user after successful update") + } +} + +func TestApplyUserUpdateRestoresStateWhenKernelAndRestartFail(t *testing.T) { + k := &fakeKernel{ + running: true, + updateErr: errors.New("update failed"), + startErr: errors.New("restart failed"), + } + s := newTestService(k) + s.lastConfig = &model.NodeSpec{Protocol: "vless"} + oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} + s.updateUserState(oldUsers) + oldHash := s.lastUserHash + + newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}} + s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers)) + + if got := k.startCalls; got != 1 { + t.Fatalf("Start call count = %d, want 1", got) + } + if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-old" { + t.Fatalf("lastUsers = %#v, want restored old users", s.lastUsers) + } + if s.lastUserHash != oldHash { + t.Fatalf("lastUserHash = %q, want %q", s.lastUserHash, oldHash) + } + if s.speedTracker.GetLimiter("uuid-old") == nil { + t.Fatal("expected old limiter to be restored after rollback") + } + if s.speedTracker.GetLimiter("uuid-new") != nil { + t.Fatal("expected new limiter to be removed after rollback") + } +} + +func TestApplyUserDeltaAddPreparesLimiterBeforeKernelUpdate(t *testing.T) { + k := &fakeKernel{running: true} + s := newTestService(k) + s.lastConfig = &model.NodeSpec{Protocol: "vless"} + oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} + s.updateUserState(oldUsers) + + delta := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}} + k.onAddUsers = func(users []model.UserSpec) { + if len(users) != 1 || users[0].UUID != "uuid-new" { + t.Fatalf("unexpected users passed to AddUsers: %#v", users) + } + if got := k.speedLimitFunc("uuid-new"); got == nil { + t.Fatal("expected delta user's limiter to be visible before kernel AddUsers") + } + } + + s.applyUserDelta(context.Background(), "add", delta) + + if got := k.addCalls; got != 1 { + t.Fatalf("AddUsers call count = %d, want 1", got) + } + if s.speedTracker.GetLimiter("uuid-new") == nil { + t.Fatal("expected limiter for delta-added user after successful update") + } +} + + +func TestApplyUserDeltaAddRemovesOldUUIDBeforeAdd(t *testing.T) { + k := &fakeKernel{running: true} + s := newTestService(k) + s.lastConfig = &model.NodeSpec{Protocol: "vless"} + oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} + s.updateUserState(oldUsers) + + delta := []model.UserSpec{{ID: 1, UUID: "uuid-new", SpeedLimit: 8}} + removalsBeforeAdd := 0 + k.onRemoveUsers = func(users []model.UserSpec) { + removalsBeforeAdd = k.addCalls + if len(users) != 1 || users[0].UUID != "uuid-old" { + t.Fatalf("unexpected users passed to RemoveUsers: %#v", users) + } + } + k.onAddUsers = func(users []model.UserSpec) { + if len(users) != 1 || users[0].UUID != "uuid-new" { + t.Fatalf("unexpected users passed to AddUsers: %#v", users) + } + if k.removeCalls != 1 || removalsBeforeAdd != 0 { + t.Fatalf("expected RemoveUsers before AddUsers, removeCalls=%d removalsBeforeAdd=%d", k.removeCalls, removalsBeforeAdd) + } + if got := k.speedLimitFunc("uuid-new"); got == nil { + t.Fatal("expected replacement user's limiter to be visible before kernel AddUsers") + } + } + + s.applyUserDelta(context.Background(), "add", delta) + + if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" { + t.Fatalf("lastUsers = %#v, want replacement user", s.lastUsers) + } + if s.speedTracker.GetLimiter("uuid-old") != nil { + t.Fatal("expected old UUID limiter to be removed after replacement") + } + if s.speedTracker.GetLimiter("uuid-new") == nil { + t.Fatal("expected new UUID limiter to be installed after replacement") + } +}