feat: add standalone controlplane support
This commit is contained in:
@@ -23,3 +23,5 @@ Thumbs.db
|
|||||||
vendor/
|
vendor/
|
||||||
config.json
|
config.json
|
||||||
benchmark/
|
benchmark/
|
||||||
|
third_party
|
||||||
|
bench-results
|
||||||
@@ -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.
|
> **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
|
## Install
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -24,7 +38,6 @@ git clone -b compose --depth 1 https://github.com/cedar2025/xboard-node.git
|
|||||||
cd xboard-node
|
cd xboard-node
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
**2. Edit local config**
|
**2. Edit local config**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,14 +51,6 @@ vim config/config.yml
|
|||||||
docker compose up -d
|
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
|
## Configuration
|
||||||
|
|
||||||
`config.yml`:
|
`config.yml`:
|
||||||
@@ -59,4 +64,3 @@ panel:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MPL-2.0.
|
MPL-2.0.
|
||||||
|
|
||||||
|
|||||||
+36
-40
@@ -2,90 +2,86 @@
|
|||||||
# Copy this file to config.yml and modify as needed
|
# Copy this file to config.yml and modify as needed
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
# MINIMAL CONFIGURATION (Recommended)
|
# PANEL MODE (Default)
|
||||||
# Most settings (Node ID, Kernel, TLS, Rules) can be managed via the Panel.
|
# Connect to an Xboard panel for config / users / reporting.
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# Panel connection settings
|
|
||||||
panel:
|
panel:
|
||||||
# Xboard panel URL (required)
|
# Xboard panel URL (required in panel mode)
|
||||||
url: "https://panel.example.com"
|
url: "https://panel.example.com"
|
||||||
# Server communication token (required, must match panel settings)
|
# Server communication token (required in panel mode)
|
||||||
token: "your-server-token"
|
token: "your-server-token"
|
||||||
# Node ID as configured in the panel
|
# Node ID as configured in the panel
|
||||||
node_id: 1
|
node_id: 1
|
||||||
|
|
||||||
# Kernel settings (optional defaults)
|
# Kernel settings (used by both panel mode and standalone mode)
|
||||||
kernel:
|
kernel:
|
||||||
# Kernel type: "singbox" (default) or "xray"
|
# Kernel type: "singbox" (default) or "xray"
|
||||||
type: "singbox"
|
type: "singbox"
|
||||||
# Directory to store generated configs (default: /etc/xboard-node)
|
# Directory to store generated configs (default: /etc/xboard-node)
|
||||||
config_dir: "/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"
|
log_level: "warn"
|
||||||
|
|
||||||
# TLS certificate settings (optional, can be managed by panel)
|
# TLS certificate settings
|
||||||
# cert_mode: "http" | "dns" | "self" | "file" | "none"
|
# 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:
|
||||||
cert_mode: "none"
|
cert_mode: "none"
|
||||||
# Domain for ACME or self-signed cert
|
|
||||||
# domain: "node.example.com"
|
# domain: "node.example.com"
|
||||||
# Email for ACME registration
|
|
||||||
# email: "admin@example.com"
|
# email: "admin@example.com"
|
||||||
|
# http_port: 80
|
||||||
# ── HTTP-01 mode ──
|
# dns_provider: "cloudflare"
|
||||||
# http_port: 80 # local port for ACME challenge (default: 80)
|
# dns_env:
|
||||||
|
|
||||||
# ── DNS-01 mode ──
|
|
||||||
# dns_provider: "cloudflare" # supported: cloudflare, alidns
|
|
||||||
# dns_env: # provider-specific credentials
|
|
||||||
# CF_API_TOKEN: "your-cloudflare-api-token"
|
# CF_API_TOKEN: "your-cloudflare-api-token"
|
||||||
|
|
||||||
# ── File mode ──
|
|
||||||
# cert_file: "/etc/ssl/certs/cert.pem"
|
# cert_file: "/etc/ssl/certs/cert.pem"
|
||||||
# key_file: "/etc/ssl/private/key.pem"
|
# key_file: "/etc/ssl/private/key.pem"
|
||||||
|
|
||||||
# Storage directory for auto certs (default: {config_dir}/certs)
|
|
||||||
# cert_dir: "/etc/xboard-node/certs"
|
# cert_dir: "/etc/xboard-node/certs"
|
||||||
|
|
||||||
# Logging settings
|
|
||||||
log:
|
log:
|
||||||
# Node log level: debug/info/warn/error (default: info)
|
|
||||||
level: "info"
|
level: "info"
|
||||||
# Log output: stdout/stderr or file path (default: stdout)
|
|
||||||
output: "stdout"
|
output: "stdout"
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# Standalone mode runs the node entirely from local config and never contacts
|
||||||
# ADVANCED / MULTI-NODE MODE
|
# the panel. When enabled, panel.* is ignored.
|
||||||
# Run multiple nodes with one configuration file.
|
#
|
||||||
# Each node gets its own isolated working directory.
|
# 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.
|
# When `nodes:` is present, the top-level `panel.node_id` is ignored.
|
||||||
#
|
#
|
||||||
# nodes:
|
# nodes:
|
||||||
# - node_id: 1
|
# - node_id: 1
|
||||||
#
|
|
||||||
# - node_id: 2
|
# - node_id: 2
|
||||||
# # Optional overrides per node
|
|
||||||
# kernel_type: "xray"
|
|
||||||
# cert:
|
# cert:
|
||||||
# auto_tls: true
|
# auto_tls: true
|
||||||
# domain: "node2.example.com"
|
# domain: "node2.example.com"
|
||||||
#
|
|
||||||
# - node_id: 3
|
# - node_id: 3
|
||||||
# kernel:
|
# kernel:
|
||||||
# config_dir: "/etc/xboard-node/custom-node-3"
|
# config_dir: "/etc/xboard-node/custom-node-3"
|
||||||
|
|
||||||
# Runtime memory tuning (optional)
|
# Runtime memory tuning (optional)
|
||||||
# runtime:
|
# runtime:
|
||||||
# # Soft memory limit (e.g. "30MiB", "512MiB")
|
|
||||||
# gomemlimit: ""
|
# gomemlimit: ""
|
||||||
# # GOGC target percentage (default: 100)
|
|
||||||
# gogc: 0
|
# gogc: 0
|
||||||
|
|
||||||
# Health check endpoint for Docker/K8s (optional, 0 = disabled)
|
# Health check endpoint for Docker/K8s (optional, 0 = disabled)
|
||||||
|
|||||||
@@ -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 => github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295
|
||||||
|
replace github.com/xtls/xray-core => github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965
|
||||||
// replace github.com/sagernet/sing-box => ./third_party/sing-box
|
|
||||||
|
|||||||
@@ -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/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 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
||||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
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 h1:bmtrfItcjIJ/jGfz17Ic1WBzjPP3aBdAGgagL8X5IPQ=
|
||||||
github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295/go.mod h1:DTyWqWrjh/DqOHynCUgMhW8fgrkctleficRKwQmh668=
|
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=
|
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/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 h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
|
||||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
|
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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||||
|
|||||||
+21
-13
@@ -21,6 +21,8 @@ type Config struct {
|
|||||||
Log LogConfig `yaml:"log"`
|
Log LogConfig `yaml:"log"`
|
||||||
Runtime RuntimeConfig `yaml:"runtime"`
|
Runtime RuntimeConfig `yaml:"runtime"`
|
||||||
WS WSConfig `yaml:"ws"`
|
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
|
// HealthPort enables a lightweight HTTP health-check endpoint on the
|
||||||
// given port (e.g. 65530). 0 = disabled (default).
|
// given port (e.g. 65530). 0 = disabled (default).
|
||||||
HealthPort int `yaml:"health_port"`
|
HealthPort int `yaml:"health_port"`
|
||||||
@@ -278,19 +280,25 @@ func (c *Config) setDefaults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
if c.Panel.URL == "" {
|
if c.IsStandalone() {
|
||||||
return fmt.Errorf("panel.url is required")
|
if err := c.validateStandalone(); err != nil {
|
||||||
}
|
return err
|
||||||
if c.Panel.Token == "" {
|
}
|
||||||
return fmt.Errorf("panel.token is required")
|
} else {
|
||||||
}
|
if c.Panel.URL == "" {
|
||||||
// In multi-node mode panel.node_id is optional; validate each NodeEntry instead.
|
return fmt.Errorf("panel.url is required")
|
||||||
if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 {
|
}
|
||||||
return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)")
|
if c.Panel.Token == "" {
|
||||||
}
|
return fmt.Errorf("panel.token is required")
|
||||||
for i, n := range c.Nodes {
|
}
|
||||||
if n.NodeID <= 0 {
|
// In multi-node mode panel.node_id is optional; validate each NodeEntry instead.
|
||||||
return fmt.Errorf("nodes[%d].node_id must be positive", i)
|
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 {
|
switch c.Kernel.Type {
|
||||||
|
|||||||
@@ -267,3 +267,59 @@ node:
|
|||||||
t.Errorf("pull_interval: got %d", cfg.Node.PullInterval)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{} }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@ package kernel
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"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.
|
// 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 _, r := range routes {
|
||||||
for _, m := range r.Match {
|
for _, m := range r.Match {
|
||||||
if strings.HasPrefix(m, "geoip:") {
|
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.
|
// 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 _, r := range routes {
|
||||||
for _, m := range r.Match {
|
for _, m := range r.Match {
|
||||||
if strings.HasPrefix(m, "geosite:") {
|
if strings.HasPrefix(m, "geosite:") {
|
||||||
|
|||||||
+22
-11
@@ -7,10 +7,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"golang.org/x/time/rate"
|
"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.).
|
// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.).
|
||||||
//
|
//
|
||||||
// The interface is split into lifecycle, user management, and observability
|
// The interface is split into lifecycle, user management, and observability
|
||||||
@@ -25,12 +34,14 @@ type Kernel interface {
|
|||||||
Name() string
|
Name() string
|
||||||
// Protocols returns the protocol names this kernel supports.
|
// Protocols returns the protocol names this kernel supports.
|
||||||
Protocols() []string
|
Protocols() []string
|
||||||
|
// Capabilities returns the explicit runtime semantics supported by the kernel.
|
||||||
|
Capabilities() Capabilities
|
||||||
|
|
||||||
// ─── Lifecycle ──────────────────────────────────────────────────────
|
// ─── Lifecycle ──────────────────────────────────────────────────────
|
||||||
// Start initialises the kernel with the given node config and initial
|
// Start initialises the kernel with the given node config and initial
|
||||||
// user set, binds listeners, and begins accepting connections.
|
// user set, binds listeners, and begins accepting connections.
|
||||||
// Calling Start on an already-running kernel stops the old instance first.
|
// 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 gracefully shuts down the kernel, draining active connections.
|
||||||
Stop()
|
Stop()
|
||||||
// IsRunning returns whether the kernel is currently accepting connections.
|
// 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.
|
// Reload re-generates the full config and hot-swaps listeners/routes.
|
||||||
// Use this when port, protocol, or TLS settings change.
|
// Use this when port, protocol, or TLS settings change.
|
||||||
// Existing connections MAY be briefly interrupted.
|
// 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) ───────────────────────────────
|
// ─── User management (non-disruptive) ───────────────────────────────
|
||||||
// AddUsers registers new users with the running kernel.
|
// AddUsers registers new users with the running kernel.
|
||||||
// Existing connections are unaffected. Returns the count actually added
|
// Existing connections are unaffected. Returns the count actually added
|
||||||
// (duplicates are silently skipped).
|
// (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.
|
// RemoveUsers deregisters users from the running kernel.
|
||||||
// Active connections of removed users are terminated. Returns the count
|
// Active connections of removed users are terminated. Returns the count
|
||||||
// actually removed (unknown IDs are silently skipped).
|
// 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.
|
// UpdateUsers replaces the entire user set atomically.
|
||||||
// This is equivalent to computing the diff and calling Add/Remove, but
|
// This is equivalent to computing the diff and calling Add/Remove, but
|
||||||
// may be more efficient for bulk changes. Returns (added, removed) counts.
|
// 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 ──────────────────────────────────────────────────
|
// ─── Observability ──────────────────────────────────────────────────
|
||||||
// GetUserTraffic returns per-user cumulative traffic counters and
|
// 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
|
// ComputeHash returns a hash of config + user identities that would
|
||||||
// require a kernel restart/reconstruction if changed.
|
// 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()
|
h := sha256.New()
|
||||||
configData, _ := json.Marshal(nc)
|
configData, _ := json.Marshal(nc)
|
||||||
h.Write(configData)
|
h.Write(configData)
|
||||||
sorted := make([]panel.User, len(users))
|
sorted := make([]model.UserSpec, len(users))
|
||||||
copy(sorted, users)
|
copy(sorted, users)
|
||||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
||||||
for _, u := range sorted {
|
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
|
// 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
|
// from oldUsers to newUsers. This is a pure helper used by callers; kernels
|
||||||
// may also use it internally.
|
// may also use it internally.
|
||||||
func UserDiff(oldUsers, newUsers []panel.User) (toAdd, toRemove []panel.User) {
|
func UserDiff(oldUsers, newUsers []model.UserSpec) (toAdd, toRemove []model.UserSpec) {
|
||||||
oldMap := make(map[int]panel.User, len(oldUsers))
|
oldMap := make(map[int]model.UserSpec, len(oldUsers))
|
||||||
for _, u := range oldUsers {
|
for _, u := range oldUsers {
|
||||||
oldMap[u.ID] = u
|
oldMap[u.ID] = u
|
||||||
}
|
}
|
||||||
newMap := make(map[int]panel.User, len(newUsers))
|
newMap := make(map[int]model.UserSpec, len(newUsers))
|
||||||
for _, u := range newUsers {
|
for _, u := range newUsers {
|
||||||
newMap[u.ID] = u
|
newMap[u.ID] = u
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// M is a shorthand for building JSON-like maps
|
// M is a shorthand for building JSON-like maps
|
||||||
type M = map[string]interface{}
|
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
|
var outbounds []M
|
||||||
tags := make(map[string]bool)
|
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)
|
// outboundConfigToSingbox converts a structured OutboundConfig (from the panel)
|
||||||
// into a sing-box outbound object. sing-box uses a flat layout where all
|
// 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".
|
// 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{
|
m := M{
|
||||||
"type": oc.Protocol,
|
"type": oc.Protocol,
|
||||||
"tag": oc.Tag,
|
"tag": oc.Tag,
|
||||||
@@ -144,7 +144,7 @@ func mergeRouteList(a, b []map[string]any) []map[string]any {
|
|||||||
return res
|
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
|
var rules []M
|
||||||
|
|
||||||
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
|
// 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{
|
base := M{
|
||||||
"tag": nc.Protocol + "-in",
|
"tag": nc.Protocol + "-in",
|
||||||
"listen": "::",
|
"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"
|
base["type"] = "mieru"
|
||||||
if nc.Transport != "" {
|
if nc.Transport != "" {
|
||||||
base["transport"] = nc.Transport
|
base["transport"] = nc.Transport
|
||||||
@@ -392,7 +392,7 @@ var ss2022Methods = map[string]ss2022Config{
|
|||||||
"2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32},
|
"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["type"] = "shadowsocks"
|
||||||
base["method"] = nc.Cipher
|
base["method"] = nc.Cipher
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
|||||||
return base
|
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"
|
base["type"] = "vmess"
|
||||||
|
|
||||||
userList := make([]M, 0, len(users))
|
userList := make([]M, 0, len(users))
|
||||||
@@ -459,7 +459,7 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
|||||||
return base
|
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" {
|
if nc.Decryption != "" && nc.Decryption != "none" {
|
||||||
nlog.Core().Warn("sing-box does not support VLESS encryption (decryption), use xray kernel for this feature")
|
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
|
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"
|
base["type"] = "trojan"
|
||||||
|
|
||||||
userList := make([]M, len(users))
|
userList := make([]M, len(users))
|
||||||
@@ -533,7 +533,7 @@ func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, key
|
|||||||
return base
|
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 {
|
if nc.Version == 2 {
|
||||||
base["type"] = "hysteria2"
|
base["type"] = "hysteria2"
|
||||||
|
|
||||||
@@ -584,7 +584,7 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k
|
|||||||
return base
|
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"
|
base["type"] = "tuic"
|
||||||
|
|
||||||
userList := make([]M, 0, len(users))
|
userList := make([]M, 0, len(users))
|
||||||
@@ -614,7 +614,7 @@ func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
|
|||||||
return base
|
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"
|
base["type"] = "anytls"
|
||||||
|
|
||||||
userList := make([]M, 0, len(users))
|
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
|
base["users"] = userList
|
||||||
|
|
||||||
if nc.PaddingScheme != "" {
|
if nc.PaddingScheme != "" {
|
||||||
base["padding_scheme"] = string(nc.PaddingScheme)
|
base["padding_scheme"] = nc.PaddingScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
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
|
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"
|
base["type"] = "naive"
|
||||||
|
|
||||||
userList := make([]M, 0, len(users))
|
userList := make([]M, 0, len(users))
|
||||||
@@ -658,7 +658,7 @@ func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSocks(base M, users []panel.User) M {
|
func buildSocks(base M, users []model.UserSpec) M {
|
||||||
base["type"] = "socks"
|
base["type"] = "socks"
|
||||||
|
|
||||||
userList := make([]M, 0, len(users))
|
userList := make([]M, 0, len(users))
|
||||||
@@ -672,7 +672,7 @@ func buildSocks(base M, users []panel.User) M {
|
|||||||
return base
|
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"
|
base["type"] = "http"
|
||||||
|
|
||||||
userList := make([]M, 0, len(users))
|
userList := make([]M, 0, len(users))
|
||||||
@@ -693,7 +693,7 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyTransport(base M, nc *panel.NodeConfig) {
|
func applyTransport(base M, nc *model.NodeSpec) {
|
||||||
if nc.Network == "" || nc.Network == "tcp" {
|
if nc.Network == "" || nc.Network == "tcp" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -723,6 +723,8 @@ func applyTransport(base M, nc *panel.NodeConfig) {
|
|||||||
case "grpc":
|
case "grpc":
|
||||||
if v, ok := nc.NetworkSettings["serviceName"]; ok {
|
if v, ok := nc.NetworkSettings["serviceName"]; ok {
|
||||||
transport["service_name"] = v
|
transport["service_name"] = v
|
||||||
|
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
|
||||||
|
transport["service_name"] = v
|
||||||
}
|
}
|
||||||
case "httpupgrade":
|
case "httpupgrade":
|
||||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
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
|
// 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).
|
// (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.
|
// 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 == "" {
|
if certFile == "" || keyFile == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -779,7 +781,7 @@ func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M {
|
|||||||
return tls
|
return tls
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildRealityConfig(nc *panel.NodeConfig) M {
|
func buildRealityConfig(nc *model.NodeSpec) M {
|
||||||
tls := M{"enabled": true}
|
tls := M{"enabled": true}
|
||||||
if nc.TLSSettings == nil {
|
if nc.TLSSettings == nil {
|
||||||
return tls
|
return tls
|
||||||
@@ -845,7 +847,7 @@ func buildRealityConfig(nc *panel.NodeConfig) M {
|
|||||||
return tls
|
return tls
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMultiplex(base M, nc *panel.NodeConfig) {
|
func applyMultiplex(base M, nc *model.NodeSpec) {
|
||||||
if nc.Multiplex == nil || !nc.Multiplex.Enabled {
|
if nc.Multiplex == nil || !nc.Multiplex.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -873,7 +875,7 @@ func applyMultiplex(base M, nc *panel.NodeConfig) {
|
|||||||
base["multiplex"] = mux
|
base["multiplex"] = mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyProxyProtocol(base M, nc *panel.NodeConfig) {
|
func applyProxyProtocol(base M, nc *model.NodeSpec) {
|
||||||
// if !nc.GetProxyProtocol() {
|
// if !nc.GetProxyProtocol() {
|
||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
|
|||||||
@@ -5,23 +5,35 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"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: 1, UUID: "aaaaaaaa-1111-2222-3333-444444444444", SpeedLimit: 0, DeviceLimit: 0},
|
||||||
{ID: 2, UUID: "bbbbbbbb-5555-6666-7777-888888888888", SpeedLimit: 3, DeviceLimit: 2},
|
{ID: 2, UUID: "bbbbbbbb-5555-6666-7777-888888888888", SpeedLimit: 3, DeviceLimit: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shadowsocks ---
|
// --- 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) {
|
func TestBuildInbound_Shadowsocks(t *testing.T) {
|
||||||
nc := &panel.NodeConfig{
|
nc := &panel.NodeConfig{
|
||||||
Protocol: "shadowsocks",
|
Protocol: "shadowsocks",
|
||||||
ServerPort: 111,
|
ServerPort: 111,
|
||||||
Cipher: "aes-128-gcm",
|
Cipher: "aes-128-gcm",
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
assertMapValue(t, inbound, "type", "shadowsocks")
|
assertMapValue(t, inbound, "type", "shadowsocks")
|
||||||
assertMapValue(t, inbound, "method", "aes-128-gcm")
|
assertMapValue(t, inbound, "method", "aes-128-gcm")
|
||||||
assertMapValue(t, inbound, "listen_port", 111)
|
assertMapValue(t, inbound, "listen_port", 111)
|
||||||
@@ -41,7 +53,7 @@ func TestBuildInbound_Shadowsocks2022(t *testing.T) {
|
|||||||
Cipher: "2022-blake3-aes-128-gcm",
|
Cipher: "2022-blake3-aes-128-gcm",
|
||||||
ServerKey: "base64serverkey==",
|
ServerKey: "base64serverkey==",
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
|
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
|
||||||
assertMapValue(t, inbound, "password", "base64serverkey==")
|
assertMapValue(t, inbound, "password", "base64serverkey==")
|
||||||
}
|
}
|
||||||
@@ -53,7 +65,7 @@ func TestBuildInbound_VMess(t *testing.T) {
|
|||||||
Protocol: "vmess",
|
Protocol: "vmess",
|
||||||
ServerPort: 443,
|
ServerPort: 443,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
assertMapValue(t, inbound, "type", "vmess")
|
assertMapValue(t, inbound, "type", "vmess")
|
||||||
assertMapValue(t, inbound, "tag", "vmess-in")
|
assertMapValue(t, inbound, "tag", "vmess-in")
|
||||||
|
|
||||||
@@ -69,7 +81,7 @@ func TestBuildInbound_VMess_WithTLS(t *testing.T) {
|
|||||||
TLS: 1,
|
TLS: 1,
|
||||||
ServerName: "example.com",
|
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)
|
tls := inbound["tls"].(M)
|
||||||
assertMapValue(t, tls, "enabled", true)
|
assertMapValue(t, tls, "enabled", true)
|
||||||
assertMapValue(t, tls, "server_name", "example.com")
|
assertMapValue(t, tls, "server_name", "example.com")
|
||||||
@@ -86,7 +98,7 @@ func TestBuildInbound_VMess_TLS_PanelOn_NoCertFiles(t *testing.T) {
|
|||||||
TLS: 1,
|
TLS: 1,
|
||||||
ServerName: "example.com",
|
ServerName: "example.com",
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
if _, exists := inbound["tls"]; exists {
|
if _, exists := inbound["tls"]; exists {
|
||||||
t.Fatal("expected no tls block when cert/key paths are empty (nginx offload)")
|
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,
|
ServerPort: 80,
|
||||||
TLS: 0,
|
TLS: 0,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
if _, exists := inbound["tls"]; exists {
|
if _, exists := inbound["tls"]; exists {
|
||||||
t.Error("should not have TLS when tls=0")
|
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)
|
transport := inbound["transport"].(M)
|
||||||
assertMapValue(t, transport, "type", "ws")
|
assertMapValue(t, transport, "type", "ws")
|
||||||
assertMapValue(t, transport, "path", "/ws")
|
assertMapValue(t, transport, "path", "/ws")
|
||||||
@@ -131,12 +143,27 @@ func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
|
|||||||
"serviceName": "mygrpc",
|
"serviceName": "mygrpc",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
transport := inbound["transport"].(M)
|
transport := inbound["transport"].(M)
|
||||||
assertMapValue(t, transport, "type", "grpc")
|
assertMapValue(t, transport, "type", "grpc")
|
||||||
assertMapValue(t, transport, "service_name", "mygrpc")
|
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) {
|
func TestBuildInbound_VMess_WithH2(t *testing.T) {
|
||||||
nc := &panel.NodeConfig{
|
nc := &panel.NodeConfig{
|
||||||
Protocol: "vmess",
|
Protocol: "vmess",
|
||||||
@@ -147,7 +174,7 @@ func TestBuildInbound_VMess_WithH2(t *testing.T) {
|
|||||||
"host": "example.com",
|
"host": "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
transport := inbound["transport"].(M)
|
transport := inbound["transport"].(M)
|
||||||
assertMapValue(t, transport, "type", "http")
|
assertMapValue(t, transport, "type", "http")
|
||||||
assertMapValue(t, transport, "path", "/h2path")
|
assertMapValue(t, transport, "path", "/h2path")
|
||||||
@@ -163,7 +190,7 @@ func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
|
|||||||
"host": "example.com",
|
"host": "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
transport := inbound["transport"].(M)
|
transport := inbound["transport"].(M)
|
||||||
assertMapValue(t, transport, "type", "httpupgrade")
|
assertMapValue(t, transport, "type", "httpupgrade")
|
||||||
assertMapValue(t, transport, "path", "/upgrade")
|
assertMapValue(t, transport, "path", "/upgrade")
|
||||||
@@ -177,7 +204,7 @@ func TestBuildInbound_VLESS(t *testing.T) {
|
|||||||
Protocol: "vless",
|
Protocol: "vless",
|
||||||
ServerPort: 443,
|
ServerPort: 443,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
assertMapValue(t, inbound, "type", "vless")
|
assertMapValue(t, inbound, "type", "vless")
|
||||||
|
|
||||||
users := inbound["users"].([]M)
|
users := inbound["users"].([]M)
|
||||||
@@ -190,7 +217,7 @@ func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
|
|||||||
ServerPort: 443,
|
ServerPort: 443,
|
||||||
Flow: "xtls-rprx-vision",
|
Flow: "xtls-rprx-vision",
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
users := inbound["users"].([]M)
|
users := inbound["users"].([]M)
|
||||||
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
|
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
|
||||||
}
|
}
|
||||||
@@ -207,7 +234,7 @@ func TestBuildInbound_VLESS_Reality(t *testing.T) {
|
|||||||
"server_name": "www.example.com",
|
"server_name": "www.example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
tls := inbound["tls"].(M)
|
tls := inbound["tls"].(M)
|
||||||
assertMapValue(t, tls, "enabled", true)
|
assertMapValue(t, tls, "enabled", true)
|
||||||
|
|
||||||
@@ -231,7 +258,7 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
|
|||||||
"dest": "example.com",
|
"dest": "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
reality := inbound["tls"].(M)["reality"].(M)
|
reality := inbound["tls"].(M)["reality"].(M)
|
||||||
ids := reality["short_id"].([]string)
|
ids := reality["short_id"].([]string)
|
||||||
if len(ids) != 2 {
|
if len(ids) != 2 {
|
||||||
@@ -246,7 +273,7 @@ func TestBuildInbound_Trojan(t *testing.T) {
|
|||||||
Protocol: "trojan",
|
Protocol: "trojan",
|
||||||
ServerPort: 443,
|
ServerPort: 443,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
assertMapValue(t, inbound, "type", "trojan")
|
assertMapValue(t, inbound, "type", "trojan")
|
||||||
|
|
||||||
users := inbound["users"].([]M)
|
users := inbound["users"].([]M)
|
||||||
@@ -259,7 +286,7 @@ func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
|
|||||||
ServerPort: 80,
|
ServerPort: 80,
|
||||||
TLS: 0,
|
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.
|
// Without certificate paths there is no TLS layer; sing-box trojan needs certs or Reality.
|
||||||
if _, exists := inbound["tls"]; exists {
|
if _, exists := inbound["tls"]; exists {
|
||||||
t.Fatal("expected no tls when cert paths are empty and tls=0")
|
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,
|
TLS: 1,
|
||||||
ServerName: "example.com",
|
ServerName: "example.com",
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||||
tls := inbound["tls"].(M)
|
tls := inbound["tls"].(M)
|
||||||
assertMapValue(t, tls, "enabled", true)
|
assertMapValue(t, tls, "enabled", true)
|
||||||
}
|
}
|
||||||
@@ -286,7 +313,7 @@ func TestBuildInbound_Hysteria2(t *testing.T) {
|
|||||||
ServerPort: 444,
|
ServerPort: 444,
|
||||||
Version: 2,
|
Version: 2,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||||
assertMapValue(t, inbound, "type", "hysteria2")
|
assertMapValue(t, inbound, "type", "hysteria2")
|
||||||
|
|
||||||
users := inbound["users"].([]M)
|
users := inbound["users"].([]M)
|
||||||
@@ -304,7 +331,7 @@ func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
|
|||||||
Obfs: "salamander",
|
Obfs: "salamander",
|
||||||
ObfsPassword: "secret",
|
ObfsPassword: "secret",
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||||
obfs := inbound["obfs"].(M)
|
obfs := inbound["obfs"].(M)
|
||||||
assertMapValue(t, obfs, "type", "salamander")
|
assertMapValue(t, obfs, "type", "salamander")
|
||||||
assertMapValue(t, obfs, "password", "secret")
|
assertMapValue(t, obfs, "password", "secret")
|
||||||
@@ -318,7 +345,7 @@ func TestBuildInbound_Hysteria1(t *testing.T) {
|
|||||||
UpMbps: 100,
|
UpMbps: 100,
|
||||||
DownMbps: 200,
|
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, "type", "hysteria")
|
||||||
assertMapValue(t, inbound, "up_mbps", 100)
|
assertMapValue(t, inbound, "up_mbps", 100)
|
||||||
assertMapValue(t, inbound, "down_mbps", 200)
|
assertMapValue(t, inbound, "down_mbps", 200)
|
||||||
@@ -335,7 +362,7 @@ func TestBuildInbound_TUIC(t *testing.T) {
|
|||||||
ServerPort: 555,
|
ServerPort: 555,
|
||||||
CongestionControl: "bbr",
|
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, "type", "tuic")
|
||||||
assertMapValue(t, inbound, "congestion_control", "bbr")
|
assertMapValue(t, inbound, "congestion_control", "bbr")
|
||||||
|
|
||||||
@@ -352,7 +379,7 @@ func TestBuildInbound_AnyTLS(t *testing.T) {
|
|||||||
ServerPort: 443,
|
ServerPort: 443,
|
||||||
PaddingScheme: "stop=8\n0=30-30",
|
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, "type", "anytls")
|
||||||
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
|
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
|
||||||
|
|
||||||
@@ -365,7 +392,7 @@ func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
|
|||||||
Protocol: "anytls",
|
Protocol: "anytls",
|
||||||
ServerPort: 443,
|
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 {
|
if _, exists := inbound["padding_scheme"]; exists {
|
||||||
t.Error("should not have padding_scheme when empty")
|
t.Error("should not have padding_scheme when empty")
|
||||||
}
|
}
|
||||||
@@ -379,7 +406,7 @@ func TestBuildInbound_Naive(t *testing.T) {
|
|||||||
ServerPort: 443,
|
ServerPort: 443,
|
||||||
TLS: 1,
|
TLS: 1,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||||
assertMapValue(t, inbound, "type", "naive")
|
assertMapValue(t, inbound, "type", "naive")
|
||||||
|
|
||||||
users := inbound["users"].([]M)
|
users := inbound["users"].([]M)
|
||||||
@@ -397,7 +424,7 @@ func TestBuildInbound_Socks(t *testing.T) {
|
|||||||
Protocol: "socks",
|
Protocol: "socks",
|
||||||
ServerPort: 1080,
|
ServerPort: 1080,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
assertMapValue(t, inbound, "type", "socks")
|
assertMapValue(t, inbound, "type", "socks")
|
||||||
|
|
||||||
users := inbound["users"].([]M)
|
users := inbound["users"].([]M)
|
||||||
@@ -416,7 +443,7 @@ func TestBuildInbound_HTTP(t *testing.T) {
|
|||||||
Protocol: "http",
|
Protocol: "http",
|
||||||
ServerPort: 8080,
|
ServerPort: 8080,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
assertMapValue(t, inbound, "type", "http")
|
assertMapValue(t, inbound, "type", "http")
|
||||||
|
|
||||||
users := inbound["users"].([]M)
|
users := inbound["users"].([]M)
|
||||||
@@ -429,7 +456,7 @@ func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
|
|||||||
ServerPort: 443,
|
ServerPort: 443,
|
||||||
TLS: 1,
|
TLS: 1,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||||
tls := inbound["tls"].(M)
|
tls := inbound["tls"].(M)
|
||||||
assertMapValue(t, tls, "enabled", true)
|
assertMapValue(t, tls, "enabled", true)
|
||||||
}
|
}
|
||||||
@@ -441,7 +468,7 @@ func TestBuildInbound_Unknown(t *testing.T) {
|
|||||||
Protocol: "unknown-proto",
|
Protocol: "unknown-proto",
|
||||||
ServerPort: 999,
|
ServerPort: 999,
|
||||||
}
|
}
|
||||||
inbound := buildInbound(nc, testUsers, "", "")
|
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||||
if inbound != nil {
|
if inbound != nil {
|
||||||
t.Errorf("unknown protocol should return nil, got %v", inbound)
|
t.Errorf("unknown protocol should return nil, got %v", inbound)
|
||||||
}
|
}
|
||||||
@@ -456,7 +483,7 @@ func TestBuildConfig(t *testing.T) {
|
|||||||
ServerPort: 111,
|
ServerPort: 111,
|
||||||
Cipher: "aes-128-gcm",
|
Cipher: "aes-128-gcm",
|
||||||
}
|
}
|
||||||
cfg := buildConfig(kcfg, nc, testUsers, "", "")
|
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
|
||||||
|
|
||||||
data, err := json.Marshal(cfg)
|
data, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
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)
|
outbounds := cfg["outbounds"].([]M)
|
||||||
|
|
||||||
// We have 2 overrides in input, so we should have exactly 2 outbounds total
|
// 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 {
|
for _, tc := range protocols {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
data, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal %s: %v", tc.name, err)
|
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: 2, Match: []string{"10.0.0.0/8"}, Action: "block"},
|
||||||
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
|
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
|
||||||
}
|
}
|
||||||
route := buildRoutes(rules, nil)
|
route := buildRoutes(testRouteRules(rules), nil)
|
||||||
allRules := route["rules"].([]M)
|
allRules := route["rules"].([]M)
|
||||||
|
|
||||||
if len(allRules) != 5 {
|
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: 1, Match: []string{"*.evil.com", "bad.org", "192.168.1.0/24"}, Action: "block"},
|
||||||
{ID: 2, Match: []string{"*.bypass.com"}, Action: "direct"},
|
{ID: 2, Match: []string{"*.bypass.com"}, Action: "direct"},
|
||||||
}
|
}
|
||||||
route := buildRoutes(rules, nil)
|
route := buildRoutes(testRouteRules(rules), nil)
|
||||||
allRules := route["rules"].([]M)
|
allRules := route["rules"].([]M)
|
||||||
|
|
||||||
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
|
// 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) {
|
func TestBuildTLSConfig_WithCert(t *testing.T) {
|
||||||
nc := &panel.NodeConfig{ServerName: "example.com"}
|
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, "enabled", true)
|
||||||
assertMapValue(t, tls, "server_name", "example.com")
|
assertMapValue(t, tls, "server_name", "example.com")
|
||||||
assertMapValue(t, tls, "certificate_path", "/cert.pem")
|
assertMapValue(t, tls, "certificate_path", "/cert.pem")
|
||||||
@@ -654,7 +681,7 @@ func TestBuildTLSConfig_WithCert(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildTLSConfig_NoCert(t *testing.T) {
|
func TestBuildTLSConfig_NoCert(t *testing.T) {
|
||||||
nc := &panel.NodeConfig{ServerName: "example.com"}
|
nc := &panel.NodeConfig{ServerName: "example.com"}
|
||||||
tls := buildTLSConfig(nc, "", "")
|
tls := buildTLSConfig(testNodeSpec(nc), "", "")
|
||||||
if tls != nil {
|
if tls != nil {
|
||||||
t.Fatalf("expected nil tls config when cert paths are empty, got %+v", tls)
|
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) {
|
func TestBuildTLSConfig_FallbackToHost(t *testing.T) {
|
||||||
nc := &panel.NodeConfig{Host: "fallback.com"}
|
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")
|
assertMapValue(t, tls, "server_name", "fallback.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +700,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
|
|||||||
"server_name": "override.com",
|
"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")
|
assertMapValue(t, tls, "server_name", "override.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,7 +709,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
|
|||||||
func TestApplyTransport_TCP(t *testing.T) {
|
func TestApplyTransport_TCP(t *testing.T) {
|
||||||
base := M{}
|
base := M{}
|
||||||
nc := &panel.NodeConfig{Network: "tcp"}
|
nc := &panel.NodeConfig{Network: "tcp"}
|
||||||
applyTransport(base, nc)
|
applyTransport(base, testNodeSpec(nc))
|
||||||
if _, exists := base["transport"]; exists {
|
if _, exists := base["transport"]; exists {
|
||||||
t.Error("tcp should not add transport")
|
t.Error("tcp should not add transport")
|
||||||
}
|
}
|
||||||
@@ -691,7 +718,7 @@ func TestApplyTransport_TCP(t *testing.T) {
|
|||||||
func TestApplyTransport_Empty(t *testing.T) {
|
func TestApplyTransport_Empty(t *testing.T) {
|
||||||
base := M{}
|
base := M{}
|
||||||
nc := &panel.NodeConfig{Network: ""}
|
nc := &panel.NodeConfig{Network: ""}
|
||||||
applyTransport(base, nc)
|
applyTransport(base, testNodeSpec(nc))
|
||||||
if _, exists := base["transport"]; exists {
|
if _, exists := base["transport"]; exists {
|
||||||
t.Error("empty network should not add transport")
|
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",
|
"early_data_header_name": "Sec-WebSocket-Protocol",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
applyTransport(base, nc)
|
applyTransport(base, testNodeSpec(nc))
|
||||||
transport := base["transport"].(M)
|
transport := base["transport"].(M)
|
||||||
assertMapValue(t, transport, "max_early_data", 2048)
|
assertMapValue(t, transport, "max_early_data", 2048)
|
||||||
assertMapValue(t, transport, "early_data_header_name", "Sec-WebSocket-Protocol")
|
assertMapValue(t, transport, "early_data_header_name", "Sec-WebSocket-Protocol")
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"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
|
// drainTimeout is how long stop() waits for in-flight connections to finish
|
||||||
@@ -40,8 +40,8 @@ type SingBox struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
||||||
users []panel.User
|
users []model.UserSpec
|
||||||
nodeConfig *panel.NodeConfig
|
nodeConfig *model.NodeSpec
|
||||||
certFile string
|
certFile string
|
||||||
keyFile string
|
keyFile string
|
||||||
|
|
||||||
@@ -71,6 +71,17 @@ var _ kernel.Kernel = (*SingBox)(nil)
|
|||||||
|
|
||||||
func (s *SingBox) Name() string { return "sing-box" }
|
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 {
|
func (s *SingBox) Protocols() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"vmess", "vless", "trojan", "shadowsocks",
|
"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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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.
|
// Reload hot-swaps the inbound users and routing rules without restarting the box.
|
||||||
// Routes, outbounds, and the connTracker stay alive so in-flight connections
|
// Routes, outbounds, and the connTracker stay alive so in-flight connections
|
||||||
// continue to be tracked correctly.
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 {
|
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
||||||
err = v.UpdateUsers(opts.Users)
|
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]:
|
case adapter.UpdatableInbound[auth.User]:
|
||||||
switch opts := inb.Options.(type) {
|
switch opts := inb.Options.(type) {
|
||||||
case *option.NaiveInboundOptions:
|
case *option.NaiveInboundOptions:
|
||||||
@@ -413,7 +428,7 @@ func (s *SingBox) ClearGlobalDevices() {
|
|||||||
// ─── User management (non-disruptive) ───────────────────────────────────────
|
// ─── User management (non-disruptive) ───────────────────────────────────────
|
||||||
|
|
||||||
// AddUsers hot-swaps users into running inbounds. Zero connection disruption.
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -425,7 +440,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
|
|||||||
for _, u := range s.users {
|
for _, u := range s.users {
|
||||||
existing[u.ID] = struct{}{}
|
existing[u.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
var toAdd []panel.User
|
var toAdd []model.UserSpec
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if _, dup := existing[u.ID]; !dup {
|
if _, dup := existing[u.ID]; !dup {
|
||||||
toAdd = append(toAdd, u)
|
toAdd = append(toAdd, u)
|
||||||
@@ -435,7 +450,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
|
|||||||
return 0, nil
|
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 {
|
if err := s.reloadInboundsLocked(merged); err != nil {
|
||||||
return 0, err
|
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
|
// RemoveUsers hot-swaps users out of running inbounds. Zero connection disruption
|
||||||
// for remaining users.
|
// for remaining users.
|
||||||
func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
|
func (s *SingBox) RemoveUsers(users []model.UserSpec) (int, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -457,7 +472,7 @@ func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
|
|||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
removeSet[u.ID] = struct{}{}
|
removeSet[u.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
var kept []panel.User
|
var kept []model.UserSpec
|
||||||
removed := 0
|
removed := 0
|
||||||
for _, u := range s.users {
|
for _, u := range s.users {
|
||||||
if _, rm := removeSet[u.ID]; rm {
|
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.
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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.
|
// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound.
|
||||||
// Must be called with s.mu held.
|
// 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)
|
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile)
|
||||||
data, err := json.Marshal(cfgMap)
|
data, err := json.Marshal(cfgMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -564,6 +579,10 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
|
|||||||
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
||||||
err = v.UpdateUsers(opts.Users)
|
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]:
|
case adapter.UpdatableInbound[auth.User]:
|
||||||
switch opts := inb.Options.(type) {
|
switch opts := inb.Options.(type) {
|
||||||
case *option.NaiveInboundOptions:
|
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
|
// 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
|
// connections to the correct user. All sing-box protocols use the user's UUID
|
||||||
// as the inbound name/username, so this covers every protocol.
|
// 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))
|
m := make(map[string]int, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
m[u.UUID] = u.ID
|
m[u.UUID] = u.ID
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,14 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// M is a shorthand for building JSON-like maps
|
// M is a shorthand for building JSON-like maps
|
||||||
type M = map[string]interface{}
|
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
|
var outbounds []M
|
||||||
tags := make(map[string]bool)
|
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)
|
// outboundConfigToXray converts a structured OutboundConfig (from the panel)
|
||||||
// into an Xray outbound object. Xray uses a nested layout where protocol-
|
// into an Xray outbound object. Xray uses a nested layout where protocol-
|
||||||
// specific fields go inside a "settings" key, and chain proxying uses "proxySettings".
|
// 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{
|
m := M{
|
||||||
"protocol": oc.Protocol,
|
"protocol": oc.Protocol,
|
||||||
"tag": oc.Tag,
|
"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 := "::"
|
listenAddr := "::"
|
||||||
if nc.ListenIP != "" {
|
if nc.ListenIP != "" {
|
||||||
listenAddr = nc.ListenIP
|
listenAddr = nc.ListenIP
|
||||||
@@ -238,7 +238,7 @@ func userEmail(userID int) string {
|
|||||||
return fmt.Sprintf("user@%d", userID)
|
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))
|
clients := make([]M, 0, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
clients = append(clients, M{
|
clients = append(clients, M{
|
||||||
@@ -253,7 +253,7 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
|||||||
return base
|
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))
|
clients := make([]M, 0, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
client := M{
|
client := M{
|
||||||
@@ -278,7 +278,7 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
|||||||
return base
|
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))
|
clients := make([]M, len(users))
|
||||||
for i := range users {
|
for i := range users {
|
||||||
u := &users[i]
|
u := &users[i]
|
||||||
@@ -314,7 +314,7 @@ var ss2022Methods = map[string]ss2022Config{
|
|||||||
"2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32},
|
"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]
|
ss2022, isSS2022 := ss2022Methods[nc.Cipher]
|
||||||
|
|
||||||
clients := make([]M, 0, len(users))
|
clients := make([]M, 0, len(users))
|
||||||
@@ -360,7 +360,7 @@ func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSocks(base M, users []panel.User) M {
|
func buildSocks(base M, users []model.UserSpec) M {
|
||||||
base["protocol"] = "socks"
|
base["protocol"] = "socks"
|
||||||
accounts := make([]M, 0, len(users))
|
accounts := make([]M, 0, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
@@ -378,7 +378,7 @@ func buildSocks(base M, users []panel.User) M {
|
|||||||
return base
|
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"
|
base["protocol"] = "http"
|
||||||
accounts := make([]M, 0, len(users))
|
accounts := make([]M, 0, len(users))
|
||||||
for _, u := range 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.
|
// 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 {
|
if nc.Version != 2 {
|
||||||
nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound")
|
nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound")
|
||||||
return nil
|
return nil
|
||||||
@@ -458,7 +458,7 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) {
|
func applyStreamSettings(base M, nc *model.NodeSpec, certFile, keyFile string) {
|
||||||
ss := M{}
|
ss := M{}
|
||||||
|
|
||||||
// Network / transport
|
// Network / transport
|
||||||
@@ -497,6 +497,8 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
|
|||||||
if nc.NetworkSettings != nil {
|
if nc.NetworkSettings != nil {
|
||||||
if v, ok := nc.NetworkSettings["serviceName"]; ok {
|
if v, ok := nc.NetworkSettings["serviceName"]; ok {
|
||||||
grpcSettings["serviceName"] = v
|
grpcSettings["serviceName"] = v
|
||||||
|
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
|
||||||
|
grpcSettings["serviceName"] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ss["grpcSettings"] = grpcSettings
|
ss["grpcSettings"] = grpcSettings
|
||||||
@@ -577,7 +579,7 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
|
|||||||
base["streamSettings"] = ss
|
base["streamSettings"] = ss
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildRealitySettings(nc *panel.NodeConfig) M {
|
func buildRealitySettings(nc *model.NodeSpec) M {
|
||||||
reality := M{"show": false}
|
reality := M{"show": false}
|
||||||
|
|
||||||
if nc.TLSSettings == nil {
|
if nc.TLSSettings == nil {
|
||||||
@@ -614,7 +616,7 @@ func buildRealitySettings(nc *panel.NodeConfig) M {
|
|||||||
return reality
|
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
|
var xrayRules []M
|
||||||
|
|
||||||
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
|
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/panel"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,11 +14,22 @@ var testKernelCfg = config.KernelConfig{
|
|||||||
LogLevel: "warn",
|
LogLevel: "warn",
|
||||||
}
|
}
|
||||||
|
|
||||||
var testUsers = []panel.User{
|
var testUsersPanel = []panel.User{
|
||||||
{ID: 1, UUID: "279d4f89-3a2c-488d-a67c-2d39a72acdde"},
|
{ID: 1, UUID: "279d4f89-3a2c-488d-a67c-2d39a72acdde"},
|
||||||
{ID: 5, UUID: "4d5965c8-a60c-452a-a943-af83ec0bb0db"},
|
{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) {
|
func TestBuildConfig_OutboundPriority(t *testing.T) {
|
||||||
kcfg := config.KernelConfig{
|
kcfg := config.KernelConfig{
|
||||||
LogLevel: "info",
|
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)
|
outbounds := cfg["outbounds"].([]M)
|
||||||
|
|
||||||
// Since we overrode both 'direct' and 'block', the result should contain
|
// 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 {
|
for _, tc := range protocols {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
data, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,7 +194,7 @@ func TestBuildConfig_VMess_Users(t *testing.T) {
|
|||||||
Protocol: "vmess",
|
Protocol: "vmess",
|
||||||
ServerPort: 10086,
|
ServerPort: 10086,
|
||||||
}
|
}
|
||||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||||
data, _ := json.Marshal(cfg)
|
data, _ := json.Marshal(cfg)
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
@@ -222,7 +234,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
|||||||
"server_name": "example.com",
|
"server_name": "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||||
data, _ := json.Marshal(cfg)
|
data, _ := json.Marshal(cfg)
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
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)
|
xrayRules := routing["rules"].([]M)
|
||||||
|
|
||||||
// 1 default + 2 domain rules + 1 IP rule = 4
|
// 1 default + 2 domain rules + 1 IP rule = 4
|
||||||
@@ -337,7 +349,7 @@ func TestBuildConfig_StatsEnabled(t *testing.T) {
|
|||||||
Protocol: "vmess",
|
Protocol: "vmess",
|
||||||
ServerPort: 10086,
|
ServerPort: 10086,
|
||||||
}
|
}
|
||||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||||
data, _ := json.Marshal(cfg)
|
data, _ := json.Marshal(cfg)
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
@@ -367,7 +379,7 @@ func TestBuildConfig_Shadowsocks_MultiUserTraditional(t *testing.T) {
|
|||||||
ServerPort: 8388,
|
ServerPort: 8388,
|
||||||
Cipher: "aes-128-gcm",
|
Cipher: "aes-128-gcm",
|
||||||
}
|
}
|
||||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||||
data, _ := json.Marshal(cfg)
|
data, _ := json.Marshal(cfg)
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
@@ -400,7 +412,7 @@ func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) {
|
|||||||
Cipher: "2022-blake3-aes-128-gcm",
|
Cipher: "2022-blake3-aes-128-gcm",
|
||||||
ServerKey: "server-key",
|
ServerKey: "server-key",
|
||||||
}
|
}
|
||||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||||
data, _ := json.Marshal(cfg)
|
data, _ := json.Marshal(cfg)
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
@@ -424,7 +436,7 @@ func TestBuildConfig_SocksStats(t *testing.T) {
|
|||||||
Protocol: "socks",
|
Protocol: "socks",
|
||||||
ServerPort: 1080,
|
ServerPort: 1080,
|
||||||
}
|
}
|
||||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||||
data, _ := json.Marshal(cfg)
|
data, _ := json.Marshal(cfg)
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/xtls/xray-core/common/session"
|
"github.com/xtls/xray-core/common/session"
|
||||||
"github.com/xtls/xray-core/features/routing"
|
"github.com/xtls/xray-core/features/routing"
|
||||||
"github.com/xtls/xray-core/transport"
|
"github.com/xtls/xray-core/transport"
|
||||||
"golang.org/x/time/rate"
|
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
)
|
)
|
||||||
@@ -59,13 +58,13 @@ func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{
|
|||||||
return ld, nil
|
return ld, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LimitDispatcher wraps xray's DefaultDispatcher to add per-user device
|
// LimitDispatcher wraps xray's DefaultDispatcher to enforce per-user
|
||||||
// limit enforcement, speed limiting, and per-user traffic counting.
|
// admission checks before a request is dispatched into xray-core.
|
||||||
//
|
//
|
||||||
// Architecture: traffic bytes are accumulated in per-user atomic counters
|
// It intentionally does NOT mutate transport.Link.Reader/Writer. Xray's
|
||||||
// (not per-connection maps). IP tracking uses sync.Map for unlimited users
|
// mux/XUDP close path requires the original concrete *pipe.Reader to remain
|
||||||
// (lock-free) and RWMutex-protected map for limited users (deterministic).
|
// intact, so the dispatcher is limited to gate-keeping and safe connection
|
||||||
// This means GetUserTraffic() is O(users), not O(connections).
|
// lifecycle bookkeeping.
|
||||||
type LimitDispatcher struct {
|
type LimitDispatcher struct {
|
||||||
inner interface{} // original DefaultDispatcher (Feature + Dispatcher)
|
inner interface{} // original DefaultDispatcher (Feature + Dispatcher)
|
||||||
innerDisp routing.Dispatcher // same object, typed as Dispatcher
|
innerDisp routing.Dispatcher // same object, typed as Dispatcher
|
||||||
@@ -75,43 +74,32 @@ type LimitDispatcher struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
limitedIPs map[string]map[string]int // email → sourceIP → refcount
|
limitedIPs map[string]map[string]int // email → sourceIP → refcount
|
||||||
deviceLimits map[string]int // email → max devices
|
deviceLimits map[string]int // email → max devices
|
||||||
speedLimits map[string]int // email → Mbps
|
|
||||||
emailToUID map[string]int // email → panel user ID
|
emailToUID map[string]int // email → panel user ID
|
||||||
|
|
||||||
// unlimitedIPs: users without device limit — sync.Map for lock-free access.
|
// 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
|
unlimitedIPs sync.Map // email → *ipCounter
|
||||||
|
|
||||||
speedBuckets sync.Map // email → *rate.Limiter
|
connCount atomic.Int64 // total active connections tracked by dispatcher
|
||||||
|
|
||||||
// Per-user traffic counters — lock-free atomics for the data path.
|
|
||||||
userTraffic sync.Map // email → *userTrafficCounter
|
|
||||||
|
|
||||||
connCount atomic.Int64 // total active connections (for metrics)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipCounter tracks IPs for unlimited users without any lock.
|
// ipCounter tracks IPs for unlimited users without any lock.
|
||||||
type ipCounter struct {
|
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.
|
// aliveIPs returns a snapshot of distinct IPs.
|
||||||
func (ic *ipCounter) aliveIPs() map[string]bool {
|
func (ic *ipCounter) aliveIPs() map[string]bool {
|
||||||
result := make(map[string]bool)
|
result := make(map[string]bool)
|
||||||
ic.ips.Range(func(key, _ interface{}) 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 true
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// userTrafficCounter holds per-user cumulative traffic counters.
|
|
||||||
type userTrafficCounter struct {
|
|
||||||
upload atomic.Int64
|
|
||||||
download atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── routing.Dispatcher ──────────────────────────────────────────────────────
|
// ─── routing.Dispatcher ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (*transport.Link, error) {
|
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 != "" {
|
if email != "" {
|
||||||
d.wrapLink(ctx, link, email, sourceIP, isTCP)
|
d.trackLink(link, email, sourceIP, isTCP)
|
||||||
}
|
}
|
||||||
return link, nil
|
return link, nil
|
||||||
}
|
}
|
||||||
@@ -141,7 +129,7 @@ func (d *LimitDispatcher) DispatchLink(ctx context.Context, dest net.Destination
|
|||||||
}
|
}
|
||||||
|
|
||||||
if email != "" {
|
if email != "" {
|
||||||
d.wrapLink(ctx, link, email, sourceIP, isTCP)
|
d.trackLink(link, email, sourceIP, isTCP)
|
||||||
}
|
}
|
||||||
return d.innerDisp.DispatchLink(ctx, dest, link)
|
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
|
return email, sourceIP, isTCP, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapLink instruments a transport.Link with per-user byte counting,
|
// trackLink records connection lifecycle without mutating xray-core owned
|
||||||
// rate limiting, and lifecycle tracking. Must only be called when email != "".
|
// transport primitives. This keeps mux/XUDP compatible while still allowing
|
||||||
func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, email, sourceIP string, isTCP bool) {
|
// the dispatcher to release device-limit state when the link closes.
|
||||||
// Get or create per-user traffic counter
|
func (d *LimitDispatcher) trackLink(link *transport.Link, email, sourceIP string, isTCP bool) {
|
||||||
v, _ := d.userTraffic.LoadOrStore(email, &userTrafficCounter{})
|
|
||||||
utc := v.(*userTrafficCounter)
|
|
||||||
|
|
||||||
d.connCount.Add(1)
|
d.connCount.Add(1)
|
||||||
|
|
||||||
onClose := func() {
|
onClose := func() {
|
||||||
@@ -181,19 +166,8 @@ func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, em
|
|||||||
d.connCount.Add(-1)
|
d.connCount.Add(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
limiter := d.getBucket(email)
|
link.Writer = &closeTrackingWriter{
|
||||||
|
|
||||||
link.Reader = &statsCloseReader{
|
|
||||||
Reader: link.Reader,
|
|
||||||
counter: &utc.upload,
|
|
||||||
limiter: limiter,
|
|
||||||
ctx: ctx,
|
|
||||||
}
|
|
||||||
link.Writer = &statsCloseWriter{
|
|
||||||
Writer: link.Writer,
|
Writer: link.Writer,
|
||||||
counter: &utc.download,
|
|
||||||
limiter: limiter,
|
|
||||||
ctx: ctx,
|
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,19 +192,12 @@ func (d *LimitDispatcher) Close() error {
|
|||||||
|
|
||||||
// ─── Limit management (called by Xray kernel) ──────────────────────────────
|
// ─── 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.mu.Lock()
|
||||||
d.emailToUID = emailToUID
|
d.emailToUID = emailToUID
|
||||||
d.deviceLimits = deviceLimits
|
d.deviceLimits = deviceLimits
|
||||||
d.speedLimits = speedLimits
|
|
||||||
d.mu.Unlock()
|
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() {
|
func (d *LimitDispatcher) ResetConns() {
|
||||||
@@ -244,21 +211,12 @@ func (d *LimitDispatcher) ResetConns() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
d.userTraffic.Range(func(key, _ interface{}) bool {
|
|
||||||
d.userTraffic.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
d.connCount.Store(0)
|
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.
|
// GetConnectionState returns dispatcher-tracked alive IPs and connection count.
|
||||||
// O(users), not O(connections).
|
// Traffic bytes are intentionally left to xray's built-in stats pipeline.
|
||||||
func (d *LimitDispatcher) GetUserTraffic() (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int) {
|
func (d *LimitDispatcher) GetConnectionState() (aliveIPs map[int]map[string]bool, connCount int) {
|
||||||
d.mu.RLock()
|
d.mu.RLock()
|
||||||
emailToUID := d.emailToUID
|
emailToUID := d.emailToUID
|
||||||
limitedIPs := d.limitedIPs
|
limitedIPs := d.limitedIPs
|
||||||
@@ -302,22 +260,6 @@ func (d *LimitDispatcher) GetUserTraffic() (traffic map[int][2]int64, aliveIPs m
|
|||||||
return true
|
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())
|
connCount = int(d.connCount.Load())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -337,7 +279,6 @@ func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) b
|
|||||||
if isTCP {
|
if isTCP {
|
||||||
v, _ := d.unlimitedIPs.LoadOrStore(email, &ipCounter{})
|
v, _ := d.unlimitedIPs.LoadOrStore(email, &ipCounter{})
|
||||||
ic := v.(*ipCounter)
|
ic := v.(*ipCounter)
|
||||||
ic.count.Add(1)
|
|
||||||
|
|
||||||
// Increment IP refcount atomically.
|
// Increment IP refcount atomically.
|
||||||
rv, _ := ic.ips.LoadOrStore(sourceIP, &atomic.Int64{})
|
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 {
|
type closeTrackingWriter struct {
|
||||||
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 {
|
|
||||||
buf.Writer
|
buf.Writer
|
||||||
counter *atomic.Int64
|
onClose func()
|
||||||
limiter *rate.Limiter
|
closed atomic.Bool
|
||||||
ctx context.Context
|
|
||||||
onClose func()
|
|
||||||
closed atomic.Bool
|
|
||||||
pendingTokens atomic.Int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *statsCloseWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
|
func (w *closeTrackingWriter) Close() 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 {
|
|
||||||
if w.closed.CompareAndSwap(false, true) {
|
if w.closed.CompareAndSwap(false, true) {
|
||||||
w.onClose()
|
w.onClose()
|
||||||
}
|
}
|
||||||
return common.Close(w.Writer)
|
return common.Close(w.Writer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *statsCloseWriter) Interrupt() {
|
func (w *closeTrackingWriter) Interrupt() {
|
||||||
if w.closed.CompareAndSwap(false, true) {
|
if w.closed.CompareAndSwap(false, true) {
|
||||||
w.onClose()
|
w.onClose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"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 {
|
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) {
|
func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
|
||||||
ld := newTestDispatcher()
|
ld := newTestDispatcher()
|
||||||
|
|
||||||
users := []panel.User{
|
users := []model.UserSpec{
|
||||||
{ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0},
|
{ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0},
|
||||||
{ID: 2, UUID: "uuid-2", DeviceLimit: 0, SpeedLimit: 10},
|
{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) {
|
func TestLimitDispatcher_GetConnectionState(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) {
|
|
||||||
ld := newTestDispatcher()
|
ld := newTestDispatcher()
|
||||||
|
|
||||||
email1 := userEmail(1)
|
email1 := userEmail(1)
|
||||||
email2 := userEmail(2)
|
email2 := userEmail(2)
|
||||||
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil)
|
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 := &ipCounter{}
|
||||||
ic1.ips.Store("1.1.1.1", &atomic.Int64{})
|
r1 := &atomic.Int64{}
|
||||||
ic1.ips.Store("2.2.2.2", &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)
|
ld.unlimitedIPs.Store(email1, ic1)
|
||||||
|
|
||||||
ic2 := &ipCounter{}
|
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.unlimitedIPs.Store(email2, ic2)
|
||||||
|
|
||||||
ld.connCount.Store(5)
|
ld.connCount.Store(5)
|
||||||
|
|
||||||
traffic, aliveIPs, connCount := ld.GetUserTraffic()
|
aliveIPs, connCount := ld.GetConnectionState()
|
||||||
|
|
||||||
if connCount != 5 {
|
if connCount != 5 {
|
||||||
t.Errorf("expected connCount=5, got %d", connCount)
|
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 {
|
if len(aliveIPs[1]) != 2 {
|
||||||
t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1]))
|
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) {
|
func TestLimitDispatcher_ResetConns(t *testing.T) {
|
||||||
ld := newTestDispatcher()
|
ld := newTestDispatcher()
|
||||||
|
|
||||||
// Add some state
|
|
||||||
ld.mu.Lock()
|
ld.mu.Lock()
|
||||||
ld.limitedIPs["user@1"] = map[string]int{"1.1.1.1": 1}
|
ld.limitedIPs["user@1"] = map[string]int{"1.1.1.1": 1}
|
||||||
ld.mu.Unlock()
|
ld.mu.Unlock()
|
||||||
ld.userTraffic.Store("user@1", &userTrafficCounter{})
|
|
||||||
ld.connCount.Store(3)
|
ld.connCount.Store(3)
|
||||||
|
|
||||||
ld.ResetConns()
|
ld.ResetConns()
|
||||||
@@ -189,16 +153,6 @@ func TestLimitDispatcher_ResetConns(t *testing.T) {
|
|||||||
t.Error("limitedIPs should be empty after reset")
|
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 {
|
if ld.connCount.Load() != 0 {
|
||||||
t.Error("connCount should be 0 after reset")
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
xrayCore "github.com/xtls/xray-core/core"
|
xrayCore "github.com/xtls/xray-core/core"
|
||||||
"github.com/xtls/xray-core/features/inbound"
|
"github.com/xtls/xray-core/features/inbound"
|
||||||
"github.com/xtls/xray-core/features/stats"
|
"github.com/xtls/xray-core/features/stats"
|
||||||
|
featurebandwidth "github.com/xtls/xray-core/features/bandwidth"
|
||||||
"github.com/xtls/xray-core/infra/conf/serial"
|
"github.com/xtls/xray-core/infra/conf/serial"
|
||||||
xrayProxy "github.com/xtls/xray-core/proxy"
|
xrayProxy "github.com/xtls/xray-core/proxy"
|
||||||
"github.com/xtls/xray-core/proxy/shadowsocks"
|
"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"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
|
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -59,14 +60,15 @@ type Xray struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
instance *xrayCore.Instance
|
instance *xrayCore.Instance
|
||||||
limitDispatcher *LimitDispatcher
|
limitDispatcher *LimitDispatcher
|
||||||
users []panel.User
|
users []model.UserSpec
|
||||||
nodeConfig *panel.NodeConfig
|
nodeConfig *model.NodeSpec
|
||||||
certFile string
|
certFile string
|
||||||
keyFile string
|
keyFile string
|
||||||
protocol string
|
protocol string
|
||||||
inboundTag string
|
inboundTag string
|
||||||
lastKernelHash string
|
lastKernelHash string
|
||||||
cumTraffic map[int][2]int64
|
cumTraffic map[int][2]int64
|
||||||
|
speedLimitFunc func(string) *rate.Limiter
|
||||||
|
|
||||||
// running is set after a successful Start and cleared before shutdown.
|
// running is set after a successful Start and cleared before shutdown.
|
||||||
// Atomic so IsRunning / GetConnections never block.
|
// 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) 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 {
|
func (x *Xray) Protocols() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"vmess", "vless", "trojan", "shadowsocks",
|
"vmess", "vless", "trojan", "shadowsocks",
|
||||||
@@ -102,7 +115,7 @@ func (x *Xray) Protocols() []string {
|
|||||||
// Phase 3 – StartNew: instance.Start (no lock, potentially slow)
|
// Phase 3 – StartNew: instance.Start (no lock, potentially slow)
|
||||||
// Phase 4 – Swap: store new, extract old (brief kernel lock)
|
// Phase 4 – Swap: store new, extract old (brief kernel lock)
|
||||||
// Phase 5 – RecycleOld: close old in background (non-blocking)
|
// 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) ─────────────────────────
|
// ── Phase 1: Build config (no shared state) ─────────────────────────
|
||||||
x.ensureGeoData(nodeConfig)
|
x.ensureGeoData(nodeConfig)
|
||||||
|
|
||||||
@@ -152,6 +165,7 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
|
|||||||
closeOld(old, oldLD)
|
closeOld(old, oldLD)
|
||||||
|
|
||||||
x.updateDispatcherLimits(users)
|
x.updateDispatcherLimits(users)
|
||||||
|
x.updateBandwidthLimits(users)
|
||||||
|
|
||||||
nlog.Core().Info("xray started",
|
nlog.Core().Info("xray started",
|
||||||
"users", len(users),
|
"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
|
// Since Xray does not support granular inbound reconstruction without an
|
||||||
// instance restart for most transport/TLS settings, it triggers a full restart
|
// instance restart for most transport/TLS settings, it triggers a full restart
|
||||||
// if any kernel-affecting fields (hash mismatch) have changed.
|
// 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.updateDispatcherLimits(users)
|
||||||
|
x.updateBandwidthLimits(users)
|
||||||
|
|
||||||
newHash := kernel.ComputeHash(nodeConfig, users)
|
newHash := kernel.ComputeHash(nodeConfig, users)
|
||||||
|
|
||||||
@@ -204,8 +219,10 @@ func (x *Xray) IsRunning() bool { return x.running.Load() }
|
|||||||
|
|
||||||
// ─── Observability ──────────────────────────────────────────────────────────
|
// ─── Observability ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GetUserTraffic returns per-user cumulative traffic and alive IPs.
|
// GetUserTraffic returns per-user cumulative traffic from xray's built-in
|
||||||
// Reads from LimitDispatcher's per-user atomic counters — O(users).
|
// 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) {
|
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() {
|
if !x.running.Load() {
|
||||||
return nil, nil, 0, nil
|
return nil, nil, 0, nil
|
||||||
@@ -213,18 +230,16 @@ func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliv
|
|||||||
|
|
||||||
x.mu.Lock()
|
x.mu.Lock()
|
||||||
ld := x.limitDispatcher
|
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()
|
traffic, err = x.aggregateStats()
|
||||||
x.mu.Unlock()
|
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 {
|
func (x *Xray) CloseConnection(_ context.Context, _ string) error {
|
||||||
@@ -238,7 +253,15 @@ func (x *Xray) CloseUserConnections(_ context.Context, _ string) error {
|
|||||||
return nil
|
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
|
// SetDeviceLimitFunc is a no-op for xray — device limits are already
|
||||||
// gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time.
|
// 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
|
// Delta "add" events also carry property updates (speed/device limits) for
|
||||||
// existing users, so this method always merges properties and refreshes the
|
// existing users, so this method always merges properties and refreshes the
|
||||||
// dispatcher limits — even when no brand-new users need to be added.
|
// 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()
|
x.mu.Lock()
|
||||||
if x.instance == nil {
|
if x.instance == nil {
|
||||||
x.mu.Unlock()
|
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.
|
// 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 {
|
for _, u := range x.users {
|
||||||
userMap[u.ID] = u
|
userMap[u.ID] = u
|
||||||
}
|
}
|
||||||
var toAdd []panel.User
|
var toAdd []model.UserSpec
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if _, exists := userMap[u.ID]; !exists {
|
if _, exists := userMap[u.ID]; !exists {
|
||||||
toAdd = append(toAdd, u)
|
toAdd = append(toAdd, u)
|
||||||
}
|
}
|
||||||
userMap[u.ID] = u // always overwrite properties
|
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 {
|
for _, u := range userMap {
|
||||||
merged = append(merged, u)
|
merged = append(merged, u)
|
||||||
}
|
}
|
||||||
@@ -289,6 +312,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
|||||||
x.users = merged
|
x.users = merged
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(merged)
|
x.updateDispatcherLimits(merged)
|
||||||
|
x.updateBandwidthLimits(merged)
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +352,7 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
|
|||||||
x.users = merged
|
x.users = merged
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(merged)
|
x.updateDispatcherLimits(merged)
|
||||||
|
x.updateBandwidthLimits(merged)
|
||||||
|
|
||||||
nlog.Core().Info("xray: users added via UserManager", "added", added, "total", len(merged))
|
nlog.Core().Info("xray: users added via UserManager", "added", added, "total", len(merged))
|
||||||
return added, nil
|
return added, nil
|
||||||
@@ -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.
|
// RemoveUsers removes users from the running kernel via xray's UserManager API.
|
||||||
// Truly hitless for supported protocols — remaining connections unaffected.
|
// 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()
|
x.mu.Lock()
|
||||||
if x.instance == nil {
|
if x.instance == nil {
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
@@ -345,7 +370,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
|||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
removeSet[u.ID] = struct{}{}
|
removeSet[u.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
var kept []panel.User
|
var kept []model.UserSpec
|
||||||
removed := 0
|
removed := 0
|
||||||
for _, u := range x.users {
|
for _, u := range x.users {
|
||||||
if _, rm := removeSet[u.ID]; rm {
|
if _, rm := removeSet[u.ID]; rm {
|
||||||
@@ -392,6 +417,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
|||||||
x.users = kept
|
x.users = kept
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(kept)
|
x.updateDispatcherLimits(kept)
|
||||||
|
x.updateBandwidthLimits(kept)
|
||||||
|
|
||||||
nlog.Core().Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
|
nlog.Core().Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
|
||||||
return actualRemoved, nil
|
return actualRemoved, nil
|
||||||
@@ -400,7 +426,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
|
|||||||
// UpdateUsers replaces the entire user set. If only speed/device limits
|
// UpdateUsers replaces the entire user set. If only speed/device limits
|
||||||
// changed, updates the dispatcher without restarting. Otherwise uses
|
// changed, updates the dispatcher without restarting. Otherwise uses
|
||||||
// UserManager for hitless add/remove where supported.
|
// 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()
|
x.mu.Lock()
|
||||||
if x.instance == nil {
|
if x.instance == nil {
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
@@ -414,6 +440,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
|||||||
x.users = users
|
x.users = users
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(users)
|
x.updateDispatcherLimits(users)
|
||||||
|
x.updateBandwidthLimits(users)
|
||||||
nlog.Core().Info("xray: limits updated, kernel unchanged")
|
nlog.Core().Info("xray: limits updated, kernel unchanged")
|
||||||
return 0, 0, nil
|
return 0, 0, nil
|
||||||
}
|
}
|
||||||
@@ -458,6 +485,7 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
|
|||||||
x.users = users
|
x.users = users
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
x.updateDispatcherLimits(users)
|
x.updateDispatcherLimits(users)
|
||||||
|
x.updateBandwidthLimits(users)
|
||||||
|
|
||||||
nlog.Core().Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
|
nlog.Core().Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
|
||||||
return
|
return
|
||||||
@@ -502,9 +530,9 @@ func (x *Xray) getUserManager() (xrayProxy.UserManager, error) {
|
|||||||
return um, nil
|
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.
|
// 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)
|
email := userEmail(u.ID)
|
||||||
mu := &protocol.MemoryUser{Email: email, Level: 0}
|
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.
|
// 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)
|
needIP, needSite := kernel.NeedsGeoIP(nc.Routes), kernel.NeedsGeoSite(nc.Routes)
|
||||||
if !needIP && !needSite {
|
if !needIP && !needSite {
|
||||||
return
|
return
|
||||||
@@ -611,7 +639,7 @@ func (x *Xray) ensureGeoData(nc *panel.NodeConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// marshalConfig builds the xray JSON config and returns the raw bytes.
|
// 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)
|
cfgMap := buildConfig(cfg, nc, users, certFile, keyFile)
|
||||||
data, err := json.MarshalIndent(cfgMap, "", " ")
|
data, err := json.MarshalIndent(cfgMap, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -712,9 +740,44 @@ func (x *Xray) aggregateStats() (map[int][2]int64, error) {
|
|||||||
return traffic, nil
|
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
|
// updateDispatcherLimits configures the LimitDispatcher with per-user
|
||||||
// device and speed limits.
|
// device-limit admission metadata only. Speed limits are enforced by the
|
||||||
func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
// patched xray-core bandwidth feature.
|
||||||
|
func (x *Xray) updateDispatcherLimits(users []model.UserSpec) {
|
||||||
x.mu.Lock()
|
x.mu.Lock()
|
||||||
ld := x.limitDispatcher
|
ld := x.limitDispatcher
|
||||||
x.mu.Unlock()
|
x.mu.Unlock()
|
||||||
@@ -724,7 +787,6 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
|||||||
|
|
||||||
emailToUID := make(map[string]int, len(users)*2)
|
emailToUID := make(map[string]int, len(users)*2)
|
||||||
deviceLimits := make(map[string]int)
|
deviceLimits := make(map[string]int)
|
||||||
speedLimits := make(map[string]int)
|
|
||||||
|
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
email := userEmail(u.ID)
|
email := userEmail(u.ID)
|
||||||
@@ -734,13 +796,9 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
|||||||
deviceLimits[email] = u.DeviceLimit
|
deviceLimits[email] = u.DeviceLimit
|
||||||
deviceLimits[u.UUID] = 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
|
// xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"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.
|
// Limiter enforces per-user device limits and detects removed users.
|
||||||
type Limiter struct {
|
type Limiter struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
users map[int]panel.User
|
users map[int]model.UserSpec
|
||||||
|
|
||||||
// uuidDeviceLimit provides O(1) UUID→device-limit lookup for the
|
// uuidDeviceLimit provides O(1) UUID→device-limit lookup for the
|
||||||
// kernel gate-keeping hot path (called per new connection).
|
// kernel gate-keeping hot path (called per new connection).
|
||||||
@@ -21,18 +21,18 @@ type Limiter struct {
|
|||||||
|
|
||||||
func New() *Limiter {
|
func New() *Limiter {
|
||||||
return &Limiter{
|
return &Limiter{
|
||||||
users: make(map[int]panel.User),
|
users: make(map[int]model.UserSpec),
|
||||||
uuidDeviceLimit: make(map[string]int),
|
uuidDeviceLimit: make(map[string]int),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUsers refreshes the user limit configuration and returns
|
// UpdateUsers refreshes the user limit configuration and returns
|
||||||
// the IDs of users that were present before but are now removed.
|
// 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()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
newUsers := make(map[int]panel.User, len(users))
|
newUsers := make(map[int]model.UserSpec, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
newUsers[u.ID] = u
|
newUsers[u.ID] = u
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
@@ -16,7 +16,7 @@ func TestNew(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateUsers(t *testing.T) {
|
func TestUpdateUsers(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
users := []panel.User{
|
users := []model.UserSpec{
|
||||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 2},
|
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 2},
|
||||||
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0},
|
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0},
|
||||||
}
|
}
|
||||||
@@ -29,14 +29,14 @@ func TestUpdateUsers(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateUsers_DetectsRemoved(t *testing.T) {
|
func TestUpdateUsers_DetectsRemoved(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
l.UpdateUsers([]panel.User{
|
l.UpdateUsers([]model.UserSpec{
|
||||||
{ID: 1, UUID: "u1"},
|
{ID: 1, UUID: "u1"},
|
||||||
{ID: 2, UUID: "u2"},
|
{ID: 2, UUID: "u2"},
|
||||||
{ID: 3, UUID: "u3"},
|
{ID: 3, UUID: "u3"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Remove user 2
|
// Remove user 2
|
||||||
removed := l.UpdateUsers([]panel.User{
|
removed := l.UpdateUsers([]model.UserSpec{
|
||||||
{ID: 1, UUID: "u1"},
|
{ID: 1, UUID: "u1"},
|
||||||
{ID: 3, UUID: "u3"},
|
{ID: 3, UUID: "u3"},
|
||||||
})
|
})
|
||||||
@@ -48,14 +48,14 @@ func TestUpdateUsers_DetectsRemoved(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) {
|
func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
l.UpdateUsers([]panel.User{
|
l.UpdateUsers([]model.UserSpec{
|
||||||
{ID: 1, UUID: "u1"},
|
{ID: 1, UUID: "u1"},
|
||||||
{ID: 2, UUID: "u2"},
|
{ID: 2, UUID: "u2"},
|
||||||
{ID: 3, UUID: "u3"},
|
{ID: 3, UUID: "u3"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Remove users 1 and 3
|
// Remove users 1 and 3
|
||||||
removed := l.UpdateUsers([]panel.User{
|
removed := l.UpdateUsers([]model.UserSpec{
|
||||||
{ID: 2, UUID: "u2"},
|
{ID: 2, UUID: "u2"},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,12 +67,12 @@ func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateUsers_NoRemovals(t *testing.T) {
|
func TestUpdateUsers_NoRemovals(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
l.UpdateUsers([]panel.User{
|
l.UpdateUsers([]model.UserSpec{
|
||||||
{ID: 1, UUID: "u1"},
|
{ID: 1, UUID: "u1"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add user, keep existing
|
// Add user, keep existing
|
||||||
removed := l.UpdateUsers([]panel.User{
|
removed := l.UpdateUsers([]model.UserSpec{
|
||||||
{ID: 1, UUID: "u1"},
|
{ID: 1, UUID: "u1"},
|
||||||
{ID: 2, UUID: "u2"},
|
{ID: 2, UUID: "u2"},
|
||||||
})
|
})
|
||||||
@@ -84,10 +84,10 @@ func TestUpdateUsers_NoRemovals(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateUsers_ReplacesAll(t *testing.T) {
|
func TestUpdateUsers_ReplacesAll(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
l.UpdateUsers([]panel.User{{ID: 1, DeviceLimit: 1}})
|
l.UpdateUsers([]model.UserSpec{{ID: 1, DeviceLimit: 1}})
|
||||||
|
|
||||||
// Replace with different users
|
// 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 {
|
if len(removed) != 1 || removed[0] != 1 {
|
||||||
t.Errorf("expected removed=[1], got %v", removed)
|
t.Errorf("expected removed=[1], got %v", removed)
|
||||||
@@ -96,7 +96,7 @@ func TestUpdateUsers_ReplacesAll(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetDeviceLimitByUUID(t *testing.T) {
|
func TestGetDeviceLimitByUUID(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
l.UpdateUsers([]panel.User{
|
l.UpdateUsers([]model.UserSpec{
|
||||||
{ID: 1, UUID: "u1", DeviceLimit: 3},
|
{ID: 1, UUID: "u1", DeviceLimit: 3},
|
||||||
{ID: 2, UUID: "u2", DeviceLimit: 0},
|
{ID: 2, UUID: "u2", DeviceLimit: 0},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"golang.org/x/time/rate"
|
"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.
|
// UpdateBuckets updates the UUID→userID mapping and syncs existing limiters.
|
||||||
func (t *SpeedTracker) UpdateBuckets() {
|
func (t *SpeedTracker) UpdateBuckets() {
|
||||||
currentUsers := make([]panel.User, 0, 32)
|
currentUsers := make([]model.UserSpec, 0, 32)
|
||||||
t.limiter.mu.RLock()
|
t.limiter.mu.RLock()
|
||||||
for _, u := range t.limiter.users {
|
for _, u := range t.limiter.users {
|
||||||
currentUsers = append(currentUsers, u)
|
currentUsers = append(currentUsers, u)
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSpeedTracker_UpdateBuckets(t *testing.T) {
|
func TestSpeedTracker_UpdateBuckets(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
st := NewSpeedTracker(l)
|
st := NewSpeedTracker(l)
|
||||||
|
|
||||||
users := []panel.User{
|
users := []model.UserSpec{
|
||||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, // 3 Mbps
|
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, // 3 Mbps
|
||||||
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0}, // unlimited
|
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0}, // unlimited
|
||||||
{ID: 3, UUID: "u3", SpeedLimit: 10, DeviceLimit: 2}, // 10 Mbps
|
{ID: 3, UUID: "u3", SpeedLimit: 10, DeviceLimit: 2}, // 10 Mbps
|
||||||
@@ -60,7 +60,7 @@ func TestSpeedTracker_UpdateBuckets_PreservesExisting(t *testing.T) {
|
|||||||
l := New()
|
l := New()
|
||||||
st := NewSpeedTracker(l)
|
st := NewSpeedTracker(l)
|
||||||
|
|
||||||
users := []panel.User{
|
users := []model.UserSpec{
|
||||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||||
}
|
}
|
||||||
l.UpdateUsers(users)
|
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
|
// 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},
|
{ID: 1, UUID: "u1", SpeedLimit: 5, DeviceLimit: 0},
|
||||||
}
|
}
|
||||||
l.UpdateUsers(users2)
|
l.UpdateUsers(users2)
|
||||||
@@ -97,7 +97,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
|
|||||||
l := New()
|
l := New()
|
||||||
st := NewSpeedTracker(l)
|
st := NewSpeedTracker(l)
|
||||||
|
|
||||||
users := []panel.User{
|
users := []model.UserSpec{
|
||||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||||
{ID: 2, UUID: "u2", SpeedLimit: 5, DeviceLimit: 0},
|
{ID: 2, UUID: "u2", SpeedLimit: 5, DeviceLimit: 0},
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove user 2
|
// Remove user 2
|
||||||
users2 := []panel.User{
|
users2 := []model.UserSpec{
|
||||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||||
}
|
}
|
||||||
l.UpdateUsers(users2)
|
l.UpdateUsers(users2)
|
||||||
@@ -128,7 +128,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
|
|||||||
func TestSpeedTracker_UpdateBuckets_LogCallbackMayCallLimitedUserCount(t *testing.T) {
|
func TestSpeedTracker_UpdateBuckets_LogCallbackMayCallLimitedUserCount(t *testing.T) {
|
||||||
l := New()
|
l := New()
|
||||||
st := NewSpeedTracker(l)
|
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.SetLogCallback(func(msg string) {
|
||||||
_ = st.LimitedUserCount()
|
_ = st.LimitedUserCount()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+175
-239
@@ -15,27 +15,29 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/cert"
|
"github.com/cedar2025/xboard-node/internal/cert"
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"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"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel/singbox"
|
"github.com/cedar2025/xboard-node/internal/kernel/singbox"
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel/xray"
|
"github.com/cedar2025/xboard-node/internal/kernel/xray"
|
||||||
"github.com/cedar2025/xboard-node/internal/limiter"
|
"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/monitor"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
|
||||||
"github.com/cedar2025/xboard-node/internal/tracker"
|
"github.com/cedar2025/xboard-node/internal/tracker"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
panel *panel.Client
|
source controlplane.Source
|
||||||
|
sink controlplane.Sink
|
||||||
kernel kernel.Kernel
|
kernel kernel.Kernel
|
||||||
tracker *tracker.Tracker
|
tracker *tracker.Tracker
|
||||||
limiter *limiter.Limiter
|
limiter *limiter.Limiter
|
||||||
speedTracker *limiter.SpeedTracker
|
speedTracker *limiter.SpeedTracker
|
||||||
cert *cert.Manager
|
cert *cert.Manager
|
||||||
|
|
||||||
lastConfig *panel.NodeConfig
|
lastConfig *model.NodeSpec
|
||||||
lastUsers []panel.User
|
lastUsers []model.UserSpec
|
||||||
|
|
||||||
// nodeLog is the logger with node context for this service instance.
|
// nodeLog is the logger with node context for this service instance.
|
||||||
nodeLog *nlog.NodeLog
|
nodeLog *nlog.NodeLog
|
||||||
@@ -43,8 +45,8 @@ type Service struct {
|
|||||||
// appliedState tracks the configuration and users that are currently
|
// appliedState tracks the configuration and users that are currently
|
||||||
// successfully running in the kernel.
|
// successfully running in the kernel.
|
||||||
appliedState struct {
|
appliedState struct {
|
||||||
Config *panel.NodeConfig
|
Config *model.NodeSpec
|
||||||
Users []panel.User
|
Users []model.UserSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
pushInterval int // seconds
|
pushInterval int // seconds
|
||||||
@@ -61,9 +63,9 @@ type Service struct {
|
|||||||
// pullResults delivers async pullViaAPI results back to the main goroutine.
|
// pullResults delivers async pullViaAPI results back to the main goroutine.
|
||||||
pullResults chan pullResult
|
pullResults chan pullResult
|
||||||
|
|
||||||
wsClient *panel.WSClient // WebSocket client (nil if WS not enabled)
|
wsClient controlplane.PushClient // Push client (nil if push is not enabled)
|
||||||
wsEvents chan panel.WSEvent // receives data events from WS client
|
wsEvents chan controlplane.Event // receives data events from push transport
|
||||||
wsStatusCh chan panel.WSStatusChange // receives WS connect/disconnect notifications
|
wsStatusCh chan controlplane.StatusChange // receives push connectivity notifications
|
||||||
wsCancel context.CancelFunc // cancels the WS client goroutine
|
wsCancel context.CancelFunc // cancels the WS client goroutine
|
||||||
wsDisconnectAt time.Time // when WS last disconnected (zero if connected)
|
wsDisconnectAt time.Time // when WS last disconnected (zero if connected)
|
||||||
|
|
||||||
@@ -73,8 +75,8 @@ type Service struct {
|
|||||||
|
|
||||||
// pullResult carries the outcome of an async pullViaAPI back to the main goroutine.
|
// pullResult carries the outcome of an async pullViaAPI back to the main goroutine.
|
||||||
type pullResult struct {
|
type pullResult struct {
|
||||||
config *panel.NodeConfig
|
config *model.NodeSpec
|
||||||
users []panel.User
|
users []model.UserSpec
|
||||||
configHash string
|
configHash string
|
||||||
userHash string
|
userHash string
|
||||||
certChanged bool
|
certChanged bool
|
||||||
@@ -113,8 +115,7 @@ func (b *apiBackoff) onFailure() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) *Service {
|
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
|
var k kernel.Kernel
|
||||||
switch cfg.Kernel.Type {
|
switch cfg.Kernel.Type {
|
||||||
@@ -130,16 +131,24 @@ func New(cfg *config.Config) *Service {
|
|||||||
l := limiter.New()
|
l := limiter.New()
|
||||||
st := limiter.NewSpeedTracker(l)
|
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{
|
return &Service{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
panel: panelClient,
|
source: cp,
|
||||||
|
sink: cp,
|
||||||
kernel: k,
|
kernel: k,
|
||||||
tracker: tracker.New(),
|
tracker: tracker.New(),
|
||||||
limiter: l,
|
limiter: l,
|
||||||
speedTracker: st,
|
speedTracker: st,
|
||||||
cert: certMgr,
|
cert: certMgr,
|
||||||
wsEvents: make(chan panel.WSEvent, 16),
|
wsEvents: make(chan controlplane.Event, 16),
|
||||||
wsStatusCh: make(chan panel.WSStatusChange, 4),
|
wsStatusCh: make(chan controlplane.StatusChange, 4),
|
||||||
pullResults: make(chan pullResult, 1),
|
pullResults: make(chan pullResult, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +166,7 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
defer s.kernel.Stop()
|
defer s.kernel.Stop()
|
||||||
|
|
||||||
|
|
||||||
// Set up tickers
|
// Set up tickers
|
||||||
trackTicker := time.NewTicker(time.Duration(s.cfg.Node.TrackInterval) * time.Second)
|
trackTicker := time.NewTicker(time.Duration(s.cfg.Node.TrackInterval) * time.Second)
|
||||||
pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * 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 {
|
func (s *Service) initialSetup(ctx context.Context) error {
|
||||||
// Register speed limit lookup with kernel unconditionally (before WS/V1 branch).
|
// Register speed limit lookup with kernel unconditionally (before push/poll branch).
|
||||||
// This ensures speedLimitFunc is set regardless of which code path applies config.
|
|
||||||
s.kernel.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
|
s.kernel.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
|
||||||
s.kernel.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID)
|
s.kernel.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID)
|
||||||
|
|
||||||
var hs *panel.HandshakeResponse
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Retry loop for initial handshake
|
bootstrap, err := s.source.Initial(ctx, s.wsMetrics, s.wsEvents, s.wsStatusCh)
|
||||||
for attempt := 1; ; attempt++ {
|
if err != nil {
|
||||||
hs, err = s.panel.Handshake()
|
return err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply settings from panel handshake (Handshake provides default intervals)
|
if s.cfg.Node.PushInterval == 0 && bootstrap.PushInterval > 0 {
|
||||||
if s.cfg.Node.PushInterval == 0 && hs.Settings.PushInterval > 0 {
|
s.pushInterval = bootstrap.PushInterval
|
||||||
s.pushInterval = hs.Settings.PushInterval
|
|
||||||
} else {
|
} else {
|
||||||
s.pushInterval = s.cfg.Node.PushInterval
|
s.pushInterval = s.cfg.Node.PushInterval
|
||||||
}
|
}
|
||||||
@@ -254,8 +247,8 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
|||||||
s.pushInterval = 60
|
s.pushInterval = 60
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cfg.Node.PullInterval == 0 && hs.Settings.PullInterval > 0 {
|
if s.cfg.Node.PullInterval == 0 && bootstrap.PullInterval > 0 {
|
||||||
s.pullInterval = hs.Settings.PullInterval
|
s.pullInterval = bootstrap.PullInterval
|
||||||
} else {
|
} else {
|
||||||
s.pullInterval = s.cfg.Node.PullInterval
|
s.pullInterval = s.cfg.Node.PullInterval
|
||||||
}
|
}
|
||||||
@@ -263,65 +256,41 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
|||||||
s.pullInterval = 60
|
s.pullInterval = 60
|
||||||
}
|
}
|
||||||
|
|
||||||
// If WebSocket is enabled, skip initial V1 APIs fetch and wait for push
|
if bootstrap.Push != nil {
|
||||||
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
|
s.wsClient = bootstrap.Push
|
||||||
s.wsClient = s.newWSClient(hs.WebSocket.WSURL)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if bootstrap.Config == 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 {
|
|
||||||
return fmt.Errorf("initial config is nil")
|
return fmt.Errorf("initial config is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := s.panel.GetUsers()
|
s.metricsMu.Lock()
|
||||||
if err != nil {
|
s.lastConfig = bootstrap.Config
|
||||||
return fmt.Errorf("initial user fetch: %w", err)
|
s.metricsMu.Unlock()
|
||||||
}
|
s.lastConfigHash = computeConfigHash(bootstrap.Config)
|
||||||
|
s.updateUserState(bootstrap.Users)
|
||||||
|
|
||||||
// Apply initial data
|
nlog.Core().Info("initial snapshot ready",
|
||||||
s.lastConfig = nodeConfig
|
"protocol", bootstrap.Config.Protocol,
|
||||||
s.lastUsers = users
|
"port", bootstrap.Config.ServerPort,
|
||||||
s.lastUserHash = computeUserHash(users)
|
"users", len(bootstrap.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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(users) == 0 {
|
if len(bootstrap.Users) == 0 {
|
||||||
nlog.Core().Warn("no users, kernel will not start until users are available")
|
nlog.Core().Warn("no users, kernel will not start until users are available")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update service-level config overrides from remote NodeConfig if present
|
s.applyRemoteOverrides(ctx, bootstrap.Config)
|
||||||
s.applyRemoteOverrides(ctx, nodeConfig)
|
if !s.startKernel(bootstrap.Config, bootstrap.Users) {
|
||||||
|
return fmt.Errorf("start kernel")
|
||||||
if err := s.kernel.Start(nodeConfig, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
|
||||||
return fmt.Errorf("start kernel: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// record applied state on success
|
|
||||||
s.appliedState.Config = nodeConfig
|
|
||||||
s.appliedState.Users = users
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyRemoteOverrides updates service-level settings (log level, cert config)
|
// applyRemoteOverrides updates service-level settings (log level, cert config)
|
||||||
// from the panel's NodeConfig. Returns true if cert paths changed (kernel restart needed).
|
// 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 {
|
if nc == nil {
|
||||||
return false
|
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)
|
// Certificate configuration from panel (panel-first: takes precedence over local config)
|
||||||
if nc.CertConfig != nil {
|
if nc.CertConfig != nil {
|
||||||
return s.applyPanelCert(ctx, nc.CertConfig)
|
return s.applyNodeCert(ctx, nc.CertConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fields (deprecated: prefer cert_config)
|
// 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
|
// applyPanelCert converts a panel CertConfig into the local config format and
|
||||||
// reconfigures the cert manager. Reports whether cert paths changed.
|
// reconfigures the cert manager. Reports whether cert paths changed.
|
||||||
func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool {
|
func (s *Service) applyNodeCert(ctx context.Context, newCfg *config.CertConfig) bool {
|
||||||
newCfg := config.CertConfig{
|
if newCfg == nil {
|
||||||
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)
|
|
||||||
return false
|
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 {
|
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 {
|
if s.nodeLog != nil {
|
||||||
s.nodeLog.Info(msg)
|
s.nodeLog.Info(msg)
|
||||||
} else {
|
} else {
|
||||||
@@ -384,7 +344,7 @@ func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool
|
|||||||
return changed
|
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) {
|
func (s *Service) startWSClient(ctx context.Context) {
|
||||||
if s.wsClient == nil {
|
if s.wsClient == nil {
|
||||||
return
|
return
|
||||||
@@ -394,46 +354,19 @@ func (s *Service) startWSClient(ctx context.Context) {
|
|||||||
go s.wsClient.Run(wsCtx)
|
go s.wsClient.Run(wsCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWSClient creates a WSClient with standard event/status callbacks.
|
func (s *Service) wsMetrics() map[string]interface{} {
|
||||||
func (s *Service) newWSClient(wsURL string) *panel.WSClient {
|
status := monitor.Collect()
|
||||||
wsCfg := panel.WSClientConfig{
|
m := s.buildMetrics(status)
|
||||||
StatusInterval: time.Duration(s.cfg.WS.StatusInterval) * time.Second,
|
m["kernel_status"] = s.kernel.IsRunning()
|
||||||
HandshakeTimeout: time.Duration(s.cfg.WS.HandshakeTimeout) * time.Second,
|
return m
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWSStatus reacts to WS connectivity changes.
|
// handleWSStatus reacts to WS connectivity changes.
|
||||||
|
|
||||||
//
|
//
|
||||||
// - On disconnect: record timestamp, immediately REST poll.
|
// - On disconnect: record timestamp, immediately REST poll.
|
||||||
// - On reconnect: clear disconnect timestamp, REST poll to catch missed events.
|
// - On reconnect: clear disconnect timestamp, REST poll to catch missed events.
|
||||||
func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChange) {
|
func (s *Service) handleWSStatus(ctx context.Context, status controlplane.StatusChange) {
|
||||||
if status.Connected {
|
if status.Connected {
|
||||||
s.metricsMu.Lock()
|
s.metricsMu.Lock()
|
||||||
s.wsDisconnectAt = time.Time{}
|
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 is now disabled, stop the WS client and switch to REST-only.
|
||||||
// If WS config changed (different URL/channel), restart with new config.
|
// If WS config changed (different URL/channel), restart with new config.
|
||||||
func (s *Service) wsDiscovery(ctx context.Context) {
|
func (s *Service) wsDiscovery(ctx context.Context) {
|
||||||
needsCheck := false
|
if !s.source.SupportsDiscovery() {
|
||||||
|
return
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
if !needsCheck {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hs, err := s.panel.Handshake()
|
pushClient, err := s.source.Discover(ctx, s.wsMetrics, s.wsEvents, s.wsStatusCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Debug("ws discovery: handshake failed", "error", err)
|
nlog.Core().Debug("push discovery failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.source.SupportsPolling() {
|
||||||
|
s.pullViaAPIAsync(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply any latest config/user changes via dedicated APIs
|
if pushClient != nil {
|
||||||
s.pullViaAPIAsync(ctx)
|
|
||||||
|
|
||||||
if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" {
|
|
||||||
if s.wsClient == nil {
|
if s.wsClient == nil {
|
||||||
nlog.Core().Info("ws discovery: panel has WS enabled, creating WS client")
|
nlog.Core().Info("push discovery: control plane enabled push, creating client")
|
||||||
wsc := s.newWSClient(hs.WebSocket.WSURL)
|
|
||||||
s.metricsMu.Lock()
|
s.metricsMu.Lock()
|
||||||
s.wsClient = wsc
|
s.wsClient = pushClient
|
||||||
s.wsDisconnectAt = time.Time{}
|
s.wsDisconnectAt = time.Time{}
|
||||||
s.metricsMu.Unlock()
|
s.metricsMu.Unlock()
|
||||||
s.startWSClient(ctx)
|
s.startWSClient(ctx)
|
||||||
}
|
}
|
||||||
} else if s.wsClient != nil {
|
} 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 {
|
if s.wsCancel != nil {
|
||||||
s.wsCancel()
|
s.wsCancel()
|
||||||
}
|
}
|
||||||
@@ -521,9 +455,9 @@ func (s *Service) wsDiscovery(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleWSEvent processes data events received via WebSocket
|
// 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 {
|
switch event.Type {
|
||||||
case panel.WSEventSyncConfig:
|
case controlplane.EventSyncConfig:
|
||||||
if event.Config == nil {
|
if event.Config == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -543,7 +477,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
|
|||||||
s.applyRemoteOverrides(ctx, event.Config)
|
s.applyRemoteOverrides(ctx, event.Config)
|
||||||
s.applyChanges(ctx, true, false)
|
s.applyChanges(ctx, true, false)
|
||||||
|
|
||||||
case panel.WSEventSyncUsers:
|
case controlplane.EventSyncUsers:
|
||||||
if event.Users == nil {
|
if event.Users == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -556,7 +490,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
|
|||||||
}
|
}
|
||||||
s.applyUserUpdate(ctx, event.Users, newHash)
|
s.applyUserUpdate(ctx, event.Users, newHash)
|
||||||
|
|
||||||
case panel.WSEventSyncUserDelta:
|
case controlplane.EventSyncUserDelta:
|
||||||
if len(event.DeltaUsers) == 0 {
|
if len(event.DeltaUsers) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -565,7 +499,7 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
|
|||||||
}
|
}
|
||||||
s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers)
|
s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers)
|
||||||
|
|
||||||
case panel.WSEventSyncDevices:
|
case controlplane.EventSyncDevices:
|
||||||
// Sync global device state
|
// Sync global device state
|
||||||
if event.DeviceUsers != nil {
|
if event.DeviceUsers != nil {
|
||||||
s.kernel.UpdateGlobalDevices(event.DeviceUsers)
|
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
|
// pullViaAPIAsync fetches config/users from the panel API in a background
|
||||||
// goroutine and sends the result to pullResults for the main goroutine to apply.
|
// goroutine and sends the result to pullResults for the main goroutine to apply.
|
||||||
func (s *Service) pullViaAPIAsync(ctx context.Context) {
|
func (s *Service) pullViaAPIAsync(ctx context.Context) {
|
||||||
|
if !s.source.SupportsPolling() {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !s.pullActive.CompareAndSwap(false, true) {
|
if !s.pullActive.CompareAndSwap(false, true) {
|
||||||
nlog.Core().Debug("pull already in progress, skipping")
|
nlog.Core().Debug("pull already in progress, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.pullBackoff.shouldSkip() {
|
if s.pullBackoff.shouldSkip() {
|
||||||
nlog.Core().Debug("skipping pull due to backoff")
|
nlog.Core().Debug("skipping pull due to backoff")
|
||||||
s.pullActive.Store(false)
|
s.pullActive.Store(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current hashes for comparison in the goroutine.
|
|
||||||
currentConfigHash := s.lastConfigHash
|
currentConfigHash := s.lastConfigHash
|
||||||
certChanged := s.cert.CertRenewed()
|
certChanged := s.cert.CertRenewed()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer s.pullActive.Store(false)
|
defer s.pullActive.Store(false)
|
||||||
|
snapshot, err := s.source.Poll(ctx)
|
||||||
config, err := s.panel.GetConfig()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Error("poll config failed", "error", err)
|
nlog.Core().Error("poll control plane failed", "error", err)
|
||||||
s.pullBackoff.onFailure()
|
s.pullBackoff.onFailure()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := s.panel.GetUsers()
|
|
||||||
if err != nil {
|
|
||||||
nlog.Core().Error("poll users failed", "error", err)
|
|
||||||
s.pullBackoff.onFailure()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.pullBackoff.onSuccess()
|
s.pullBackoff.onSuccess()
|
||||||
|
|
||||||
result := pullResult{
|
result := pullResult{certChanged: certChanged}
|
||||||
config: config,
|
if snapshot.Config != nil {
|
||||||
users: users,
|
result.config = snapshot.Config
|
||||||
certChanged: certChanged,
|
result.configHash = computeConfigHash(snapshot.Config)
|
||||||
|
if result.configHash == currentConfigHash && !certChanged {
|
||||||
|
result.config = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if config != nil {
|
if snapshot.Users != nil {
|
||||||
result.configHash = computeConfigHash(config)
|
result.users = snapshot.Users
|
||||||
}
|
result.userHash = computeUserHash(snapshot.Users)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -679,25 +601,48 @@ func (s *Service) applyPullResult(ctx context.Context, result pullResult) {
|
|||||||
|
|
||||||
// ─── User state helpers ─────────────────────────────────────────────────────
|
// ─── User state helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *Service) updateUserState(users []panel.User) {
|
func (s *Service) updateUserState(users []model.UserSpec) {
|
||||||
// Defensive nil check
|
|
||||||
if users == nil {
|
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.limiter.UpdateUsers(users)
|
||||||
s.speedTracker.UpdateBuckets()
|
s.speedTracker.UpdateBuckets()
|
||||||
|
|
||||||
s.metricsMu.Lock()
|
s.metricsMu.Lock()
|
||||||
s.lastUsers = users
|
s.lastUsers = append([]model.UserSpec(nil), users...)
|
||||||
s.metricsMu.Unlock()
|
s.metricsMu.Unlock()
|
||||||
|
|
||||||
s.lastUserHash = computeUserHash(users)
|
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
|
// startKernel starts (or restarts) the kernel with the given config/users and
|
||||||
// records the successfully applied state. Returns false on error.
|
// records the successfully applied state. Returns false on error.
|
||||||
func (s *Service) startKernel(nc *panel.NodeConfig, users []panel.User) bool {
|
func (s *Service) startKernel(nc *model.NodeSpec, users []model.UserSpec) bool {
|
||||||
if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
||||||
nlog.Core().Error("failed to start kernel", "error", err)
|
nlog.Core().Error("failed to start kernel", "error", err)
|
||||||
return false
|
return false
|
||||||
@@ -734,18 +679,23 @@ func (s *Service) ensureRunning() bool {
|
|||||||
|
|
||||||
// applyUserUpdate replaces the full user set and hot-swaps the kernel.
|
// applyUserUpdate replaces the full user set and hot-swaps the kernel.
|
||||||
// Called from WS sync.users and REST polling.
|
// Called from WS sync.users and REST polling.
|
||||||
func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHash string) {
|
func (s *Service) applyUserUpdate(ctx context.Context, users []model.UserSpec, newHash string) {
|
||||||
if !s.ensureRunning() {
|
if !s.ensureRunning() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevUsers, prevHash := s.prepareUserState(users)
|
||||||
added, removed, err := s.kernel.UpdateUsers(users)
|
added, removed, err := s.kernel.UpdateUsers(users)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Warn(fmt.Sprintf("UpdateUsers failed, restarting kernel: %v", err))
|
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
|
return
|
||||||
}
|
}
|
||||||
s.updateUserState(users)
|
if newHash != "" {
|
||||||
|
s.lastUserHash = newHash
|
||||||
|
}
|
||||||
if s.nodeLog != nil && (added > 0 || removed > 0) {
|
if s.nodeLog != nil && (added > 0 || removed > 0) {
|
||||||
s.nodeLog.Info(fmt.Sprintf("users updated: +%d -%d", added, removed))
|
s.nodeLog.Info(fmt.Sprintf("users updated: +%d -%d", added, removed))
|
||||||
}
|
}
|
||||||
@@ -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
|
// applyUserDelta applies an incremental user change (add or remove) directly
|
||||||
// via the kernel's atomic user API. Kernel updates run before updateUserState.
|
// 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 {
|
switch action {
|
||||||
case "add":
|
case "add":
|
||||||
// Defensive check for empty or nil deltaUsers
|
// 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 _, delta := range deltaUsers {
|
||||||
for _, old := range s.lastUsers {
|
for _, old := range s.lastUsers {
|
||||||
if old.ID == delta.ID && old.UUID != delta.UUID {
|
if old.ID == delta.ID && old.UUID != delta.UUID {
|
||||||
s.kernel.RemoveUsers([]panel.User{old})
|
s.kernel.RemoveUsers([]model.UserSpec{old})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevUsers, prevHash := s.prepareUserState(merged)
|
||||||
added, err := s.kernel.AddUsers(deltaUsers)
|
added, err := s.kernel.AddUsers(deltaUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Warn(fmt.Sprintf("AddUsers failed: %v, falling back to UpdateUsers", err))
|
nlog.Core().Warn(fmt.Sprintf("AddUsers failed: %v, falling back to UpdateUsers", err))
|
||||||
if _, _, err := s.kernel.UpdateUsers(merged); err != nil {
|
if _, _, err := s.kernel.UpdateUsers(merged); err != nil {
|
||||||
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
||||||
|
s.restoreUserState(prevUsers, prevHash)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.updateUserState(merged)
|
|
||||||
if s.nodeLog != nil && added > 0 {
|
if s.nodeLog != nil && added > 0 {
|
||||||
s.nodeLog.Info(fmt.Sprintf("users added: +%d", added))
|
s.nodeLog.Info(fmt.Sprintf("users added: +%d", added))
|
||||||
}
|
}
|
||||||
@@ -799,15 +750,16 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevUsers, prevHash := s.prepareUserState(filtered)
|
||||||
removed, err := s.kernel.RemoveUsers(deltaUsers)
|
removed, err := s.kernel.RemoveUsers(deltaUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nlog.Core().Warn(fmt.Sprintf("RemoveUsers failed: %v, falling back to UpdateUsers", err))
|
nlog.Core().Warn(fmt.Sprintf("RemoveUsers failed: %v, falling back to UpdateUsers", err))
|
||||||
if _, _, err := s.kernel.UpdateUsers(filtered); err != nil {
|
if _, _, err := s.kernel.UpdateUsers(filtered); err != nil {
|
||||||
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
nlog.Core().Error(fmt.Sprintf("UpdateUsers fallback failed: %v", err))
|
||||||
|
s.restoreUserState(prevUsers, prevHash)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.updateUserState(filtered)
|
|
||||||
if s.nodeLog != nil && removed > 0 {
|
if s.nodeLog != nil && removed > 0 {
|
||||||
s.nodeLog.Info(fmt.Sprintf("users removed: -%d", removed))
|
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
|
// mergeUsers overlays deltaUsers onto base (keyed by ID). New users are
|
||||||
// appended, existing users have their properties overwritten.
|
// 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
|
// Handle nil slices
|
||||||
if base == nil {
|
if base == nil {
|
||||||
base = []panel.User{}
|
base = []model.UserSpec{}
|
||||||
}
|
}
|
||||||
if delta == nil {
|
if delta == nil {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
m := make(map[int]panel.User, len(base))
|
m := make(map[int]model.UserSpec, len(base))
|
||||||
for _, u := range base {
|
for _, u := range base {
|
||||||
m[u.ID] = u
|
m[u.ID] = u
|
||||||
}
|
}
|
||||||
for _, u := range delta {
|
for _, u := range delta {
|
||||||
m[u.ID] = u
|
m[u.ID] = u
|
||||||
}
|
}
|
||||||
out := make([]panel.User, 0, len(m))
|
out := make([]model.UserSpec, 0, len(m))
|
||||||
for _, u := range m {
|
for _, u := range m {
|
||||||
out = append(out, u)
|
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.
|
// 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 {
|
if base == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -854,7 +806,7 @@ func subtractUsers(base, delta []panel.User) []panel.User {
|
|||||||
for _, u := range delta {
|
for _, u := range delta {
|
||||||
removeSet[u.ID] = struct{}{}
|
removeSet[u.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
out := make([]panel.User, 0, len(base))
|
out := make([]model.UserSpec, 0, len(base))
|
||||||
for _, u := range base {
|
for _, u := range base {
|
||||||
if _, ok := removeSet[u.ID]; !ok {
|
if _, ok := removeSet[u.ID]; !ok {
|
||||||
out = append(out, u)
|
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
|
// 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.
|
// loop is never blocked by slow HTTP. Only one push runs at a time.
|
||||||
func (s *Service) pushReportAsync() {
|
func (s *Service) pushReportAsync() {
|
||||||
|
if !s.sink.SupportsReporting() {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !s.pushActive.CompareAndSwap(false, true) {
|
if !s.pushActive.CompareAndSwap(false, true) {
|
||||||
nlog.Core().Debug("push already in progress, skipping")
|
nlog.Core().Debug("push already in progress, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.pushBackoff.shouldSkip() {
|
if s.pushBackoff.shouldSkip() {
|
||||||
nlog.Core().Debug("skipping report due to backoff")
|
nlog.Core().Debug("skipping report due to backoff")
|
||||||
s.pushActive.Store(false)
|
s.pushActive.Store(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot data under the select goroutine (fast, mutex-only).
|
|
||||||
traffic := s.tracker.FlushTraffic()
|
traffic := s.tracker.FlushTraffic()
|
||||||
aliveIPs := s.tracker.FlushAliveIPs()
|
aliveIPs := s.tracker.FlushAliveIPs()
|
||||||
online := s.tracker.CurrentOnline()
|
online := s.tracker.CurrentOnline()
|
||||||
@@ -943,26 +896,13 @@ func (s *Service) pushReportAsync() {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer s.pushActive.Store(false)
|
defer s.pushActive.Store(false)
|
||||||
|
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 {
|
||||||
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 {
|
|
||||||
nlog.Core().Error("failed to push report", "error", err)
|
nlog.Core().Error("failed to push report", "error", err)
|
||||||
if len(traffic) > 0 {
|
if len(traffic) > 0 { s.tracker.RestoreTraffic(traffic) }
|
||||||
s.tracker.RestoreTraffic(traffic)
|
if len(aliveIPs) > 0 { s.tracker.RestoreAliveIPs(aliveIPs) }
|
||||||
}
|
|
||||||
if len(aliveIPs) > 0 {
|
|
||||||
s.tracker.RestoreAliveIPs(aliveIPs)
|
|
||||||
}
|
|
||||||
s.pushBackoff.onFailure()
|
s.pushBackoff.onFailure()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.pushBackoff.onSuccess()
|
s.pushBackoff.onSuccess()
|
||||||
nlog.ReportPushed(len(traffic), len(online))
|
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.
|
// pushReportSync is used only during shutdown to ensure final data is sent.
|
||||||
func (s *Service) pushReportSync() {
|
func (s *Service) pushReportSync() {
|
||||||
|
if !s.sink.SupportsReporting() {
|
||||||
|
return
|
||||||
|
}
|
||||||
traffic := s.tracker.FlushTraffic()
|
traffic := s.tracker.FlushTraffic()
|
||||||
aliveIPs := s.tracker.FlushAliveIPs()
|
aliveIPs := s.tracker.FlushAliveIPs()
|
||||||
online := s.tracker.CurrentOnline()
|
online := s.tracker.CurrentOnline()
|
||||||
@@ -977,14 +920,7 @@ func (s *Service) pushReportSync() {
|
|||||||
metrics := s.buildMetrics(status)
|
metrics := s.buildMetrics(status)
|
||||||
metrics["kernel_status"] = s.kernel.IsRunning()
|
metrics["kernel_status"] = s.kernel.IsRunning()
|
||||||
|
|
||||||
if err := s.panel.Report(
|
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 {
|
||||||
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 {
|
|
||||||
nlog.Core().Error("failed to push final report", "error", err)
|
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 metrics.
|
||||||
api := s.panel.SnapshotMetrics()
|
api := s.source.Metrics()
|
||||||
m["api"] = map[string]interface{}{
|
m["api"] = map[string]interface{}{
|
||||||
"success": api.Success,
|
"success": api.Success,
|
||||||
"failure": api.Failure,
|
"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.
|
// computeConfigHash returns a deterministic hash of the node config.
|
||||||
// It uses JSON marshaling to ensure all fields are captured, ensuring that
|
// It uses JSON marshaling to ensure all fields are captured, ensuring that
|
||||||
// any configuration change correctly triggers a kernel reload.
|
// any configuration change correctly triggers a kernel reload.
|
||||||
func computeConfigHash(cfg *panel.NodeConfig) string {
|
func computeConfigHash(cfg *model.NodeSpec) string {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -1079,8 +1015,8 @@ func computeConfigHash(cfg *panel.NodeConfig) string {
|
|||||||
|
|
||||||
// computeUserHash returns a deterministic hash of the user list for change detection.
|
// computeUserHash returns a deterministic hash of the user list for change detection.
|
||||||
// Uses direct byte encoding instead of binary.Write to avoid reflection overhead.
|
// Uses direct byte encoding instead of binary.Write to avoid reflection overhead.
|
||||||
func computeUserHash(users []panel.User) string {
|
func computeUserHash(users []model.UserSpec) string {
|
||||||
sorted := make([]panel.User, len(users))
|
sorted := make([]model.UserSpec, len(users))
|
||||||
copy(sorted, users)
|
copy(sorted, users)
|
||||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
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")
|
nlog.Core().Debug("device snapshot unchanged, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wsClient.SendDeviceReport(devices)
|
s.sink.ReportDevices(s.wsClient, devices)
|
||||||
nlog.Core().Debug("device snapshot sent", "users", len(devices))
|
nlog.Core().Debug("device snapshot sent", "users", len(devices))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user