27 Commits

Author SHA1 Message Date
xboard 0a29338e1f fix(cert): reload PEM on warm restart and rebuild ACME on config change 2026-04-20 04:07:55 +08:00
xboard 68ca2d2716 feat(cert): add registry-based DNS provider system with 21 libdns providers
Refs #37
2026-04-20 03:28:11 +08:00
xboard 07c37023ab refactor: xbctl self-contained, remove install.sh dependency for status/upgrade/uninstall/bind 2026-04-18 03:46:12 +08:00
xboard 71c53e1071 feat: add xhttp transport, network monitoring and kernel auto-switch 2026-04-18 02:06:23 +08:00
Xboard 65d97f3da1 Update README 2026-04-17 06:07:35 +08:00
xboard 42948ba37c fix: xbctl not found after install
- Symlink /usr/local/bin/xbctl → /usr/bin/xbctl for distros where /usr/local/bin is not in PATH
- Show CLI path in install success message
- Remove symlink on uninstall
2026-04-17 06:00:14 +08:00
xboard 680795cdfc refactor: install.sh and xbctl 2026-04-17 05:31:53 +08:00
xboard 17823891a2 fix: use POST for machine/nodes endpoint to prevent token URL leakage 2026-04-17 04:01:38 +08:00
xboard c5bcbc3299 feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl 2026-04-17 00:10:37 +08:00
xboard 598b6b531b feat: add standalone controlplane support 2026-04-11 05:39:59 +08:00
xboard e9672fbdbe chore: update dockerfile 2026-03-30 17:01:28 +08:00
xboard f7ce8f22d4 feat(xray): add hysteria v2 inbound and VLESS encryption support 2026-03-30 16:55:02 +08:00
xboard 19aa20be87 chore: upgrade xray-core v1.260206.0 → v1.260327.0 2026-03-30 03:06:08 +08:00
xboard 920b0b1e09 fix: handle UUID update in add event 2026-03-27 01:21:57 +08:00
xboard 95f582c74f fix: correct sing-box traffic directio 2026-03-26 13:52:36 +08:00
xboard 4b10271ce0 fix: add nil checks for TLS config in sing-box inbound builders 2026-03-26 11:35:15 +08:00
xboard 691ca93c82 fix: update existing limiter rates & adapt flush test for dedup 2026-03-25 18:31:37 +08:00
xboard b51cecfe17 feat: multi-node device limit, optimize rate limiter 2026-03-25 17:38:40 +08:00
xboard 7e3ad7cb23 feat: nlog, config hot-reload, and concurrency fixes 2026-03-24 23:12:55 +08:00
xboard 191e58b9ee docs: update readme.md 2026-03-24 00:01:13 +08:00
xboard 3f1234a05c refactor: rework limiter, traffic accounting, and logging (nlog) 2026-03-23 23:01:13 +08:00
xboard 4676caac96 fix: fix Shadowsocks support for Xray kernel 2026-03-23 15:36:01 +08:00
xboard e8e2abbc37 perf: optimize throughput and scheduler efficency 2026-03-23 14:46:43 +08:00
xboard 4f1f41aa5d fix: fix Trojan compatibility in xray kernel 2026-03-23 00:14:16 +08:00
xboard bfc24c348a fix: fix Trojan compatibility 2026-03-22 23:58:36 +08:00
xboard aaad2babfd fix: fix Shadowsocks 2022 compatibility 2026-03-22 22:14:34 +08:00
Xboard ea832c8907 Update README to remove script installation section
Removed script installation instructions from README.
2026-03-19 02:40:42 +08:00
98 changed files with 14301 additions and 3250 deletions
+9 -1
View File
@@ -56,12 +56,18 @@ jobs:
make build-linux-arm64
fi
- name: Upload Artifact
- name: Upload xboard-node artifact
uses: actions/upload-artifact@v4
with:
name: xboard-node-${{ matrix.goos }}-${{ matrix.goarch }}
path: xboard-node-${{ matrix.goos }}-${{ matrix.goarch }}
- name: Upload xbctl artifact
uses: actions/upload-artifact@v4
with:
name: xbctl-${{ matrix.goos }}-${{ matrix.goarch }}
path: xbctl-${{ matrix.goos }}-${{ matrix.goarch }}
build-docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
@@ -118,3 +124,5 @@ jobs:
files: |
dist/xboard-node-linux-amd64
dist/xboard-node-linux-arm64
dist/xbctl-linux-amd64
dist/xbctl-linux-arm64
+10
View File
@@ -22,3 +22,13 @@ Thumbs.db
# Go
vendor/
config.json
benchmark/
third_party
bench-results
internal/kernel/singbox/config_bench_test.go
internal/kernel/xray/config_bench_test.go
internal/service/service_bench_test.go
internal/tracker/tracker_bench_test.go
cmd/xboard-node-bench-compare
cmd/xboard-node-bench
.env
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git
+6 -2
View File
@@ -2,19 +2,22 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
.PHONY: build clean test docker install
.PHONY: build clean test docker install build-linux build-linux-arm64 build-all
# Build for current platform
build:
go build -ldflags "$(LDFLAGS)" -tags "with_quic with_utls with_wireguard with_clash_api" -o xboard-node ./cmd/xboard-node
go build -ldflags "$(LDFLAGS)" -o xbctl ./cmd/xbctl
# Build for Linux amd64
build-linux:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -tags "with_quic with_utls with_wireguard with_acme with_clash_api" -o xboard-node-linux-amd64 ./cmd/xboard-node
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o xbctl-linux-amd64 ./cmd/xbctl
# Build for Linux arm64
build-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -tags "with_quic with_utls with_wireguard with_acme with_clash_api" -o xboard-node-linux-arm64 ./cmd/xboard-node
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o xbctl-linux-arm64 ./cmd/xbctl
# Build all platforms
build-all: build-linux build-linux-arm64
@@ -25,7 +28,7 @@ test:
# Clean build artifacts
clean:
rm -f xboard-node xboard-node-linux-*
rm -f xboard-node xbctl xboard-node-linux-* xbctl-linux-*
# Build Docker image
docker:
@@ -34,6 +37,7 @@ docker:
# Install to system (single node, legacy compat)
install: build
sudo cp xboard-node /usr/local/bin/
sudo cp xbctl /usr/local/bin/
sudo mkdir -p /etc/xboard-node
@if [ ! -f /etc/xboard-node/config.yml ]; then \
sudo cp config.yml.example /etc/xboard-node/config.yml; \
+49 -24
View File
@@ -1,45 +1,70 @@
# xboard-node
Dedicated node backend for [Xboard](https://github.com/cedar2025/Xboard). Fully compatible with Xboard API.
Node backend for [Xboard](https://github.com/cedar2025/Xboard). Supports `sing-box` / `xray-core` dual kernels.
> **Disclaimer**: This project is for educational and learning purposes only.
## Features
- Protocols: V2Ray family, Trojan, Shadowsocks, Hysteria2, TUIC, AnyTLS
- Sync: WebSocket push + REST polling dual channel
- User controls: speed limit, device limit, alive-IP tracking, hot update
- Deploy modes: node mode, machine mode, standalone mode
- Multi-instance: single process binding multiple panels / nodes
## Install
### Script
```bash
bash <(curl -Ls https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh) \
-a https://panel.com -t TOKEN -n 1
```
### Docker
```bash
docker run -d --restart=always --network=host \
-e apiHost=https://panel.com \
-e apiKey=TOKEN \
-e nodeID=1 \
-e apiHost=https://panel.com -e apiKey=TOKEN -e nodeID=1 \
ghcr.io/cedar2025/xboard-node:latest
```
## Features
### Docker Compose
- **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.
```bash
git clone -b compose --depth 1 https://github.com/cedar2025/xboard-node.git
cd xboard-node
vim config/config.yml # set panel.url / token / node_id
docker compose up -d
```
### Installer (Linux systemd)
```bash
# Node mode
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
sudo bash -s -- --mode node --panel https://panel.example.com --token TOKEN --node-id 1
# Machine mode
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
sudo bash -s -- --mode machine --panel https://panel.example.com --token TOKEN --machine-id 1
## xbctl
Run `xbctl` after installation for help. Common commands:
```bash
xbctl list # list all instances
xbctl status # running status
xbctl bind add-node --panel URL --token TOKEN --node-id 1
xbctl bind add-machine --panel URL --token TOKEN --machine-id 1
xbctl bind remove-node --panel URL --node-id 1
xbctl service restart
```
## Configuration
`config.yml`:
```yaml
panel:
url: "https://panel.com"
token: "token"
node_id: 1
```
Legacy single-panel config is fully compatible. Appending bindings auto-migrates to `instances` format. See `config.yml.example`.
## Extensions
- Custom routes: [docs-custom-routes.md](docs-custom-routes.md)
- Custom outbounds: [docs-custom-outbounds.md](docs-custom-outbounds.md)
- DNS providers (ACME DNS-01): [docs-dns-providers.md](docs-dns-providers.md)
## License
MPL-2.0.
+1561
View File
File diff suppressed because it is too large Load Diff
+176 -71
View File
@@ -4,7 +4,6 @@ import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
@@ -16,6 +15,8 @@ import (
"time"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/machine"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/cedar2025/xboard-node/internal/service"
)
@@ -34,51 +35,36 @@ func main() {
os.Exit(0)
}
cfg, err := config.Load(*configPath)
rootCfg, err := config.LoadRoot(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
}
config.InitLogger(cfg.Log)
instances, err := rootCfg.NormalizeInstances()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to normalize config: %v\n", err)
os.Exit(1)
}
config.InitLogger(instances[0].Log)
// Apply runtime memory tuning before anything else allocates.
applyRuntimeConfig(cfg.Runtime)
applyRuntimeConfig(instances[0].Runtime)
nodes := cfg.ExpandNodes()
slog.Info("xboard-node starting",
"version", version,
"build_time", buildTime,
"nodes", len(nodes),
)
runWithReload(rootCfg, *configPath)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
slog.Info("received signal, shutting down gracefully", "signal", sig)
cancel()
// Force-exit on second signal or after timeout.
select {
case sig = <-sigCh:
slog.Warn("received second signal, forcing exit", "signal", sig)
os.Exit(1)
case <-time.After(15 * time.Second):
slog.Error("shutdown timed out after 15s, forcing exit")
os.Exit(2)
// runWithReload restarts all node services when the config file changes.
func runWithReload(initialRoot *config.RootConfig, configPath string) {
var healthSrv *http.Server
var healthPort int
startHealth := func(port int) {
if port <= 0 {
return
}
}()
// Optional health-check endpoint for container orchestrators.
if cfg.HealthPort > 0 {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.HealthPort))
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
slog.Error("failed to start health check listener", "port", cfg.HealthPort, "error", err)
nlog.Core().Error("failed to start health check listener", "port", port, "error", err)
os.Exit(1)
}
mux := http.NewServeMux()
@@ -87,46 +73,165 @@ func main() {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
healthSrv = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
healthPort = port
go func() {
slog.Info("health check listening", "addr", ln.Addr())
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
slog.Warn("health check server stopped", "error", err)
}
}()
go func() {
<-ctx.Done()
srv.Close()
}()
}
// Run one service per node. If any node fails, cancel all others.
errCh := make(chan error, len(nodes))
var wg sync.WaitGroup
for _, nodeCfg := range nodes {
nodeCfg := nodeCfg // capture
wg.Add(1)
go func() {
defer wg.Done()
svc := service.New(nodeCfg)
if err := svc.Run(ctx); err != nil {
slog.Error("node service exited with error",
"node_id", nodeCfg.Panel.NodeID, "error", err)
errCh <- err
cancel() // bring down all other nodes
} else {
slog.Info("node service stopped", "node_id", nodeCfg.Panel.NodeID)
nlog.Core().Debug(fmt.Sprintf("health check listening on :%d", port))
if err := healthSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
nlog.Core().Warn("health check server stopped", "error", err)
}
}()
}
wg.Wait()
close(errCh)
if err := <-errCh; err != nil {
initialInstances, err := initialRoot.NormalizeInstances()
if err != nil {
nlog.Core().Error("failed to normalize initial config", "error", err)
os.Exit(1)
}
slog.Info("xboard-node stopped")
startHealth(initialInstances[0].HealthPort)
defer func() {
if healthSrv != nil {
healthSrv.Close()
}
}()
for root := initialRoot; ; {
ctx, cancel := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
reloadCh := make(chan *config.RootConfig, 1)
watcher, err := config.WatchConfigRoot(ctx, configPath, func(newCfg *config.RootConfig) {
select {
case reloadCh <- newCfg:
default:
}
})
if err != nil {
nlog.Core().Warn("config watcher unavailable, hot-reload disabled", "error", err)
}
go func() {
select {
case sig := <-sigCh:
nlog.Core().Info(fmt.Sprintf("received %v, shutting down...", sig))
cancel()
select {
case sig = <-sigCh:
nlog.Core().Warn("received second signal, forcing exit", "signal", sig)
os.Exit(1)
case <-time.After(15 * time.Second):
nlog.Core().Error("shutdown timed out after 15s, forcing exit")
os.Exit(2)
}
case <-ctx.Done():
}
}()
instances, err := root.NormalizeInstances()
if err != nil {
nlog.Core().Error("failed to normalize config", "error", err)
os.Exit(1)
}
if err := config.ValidateStartupLayout(instances); err != nil {
nlog.Core().Error("startup layout validation failed", "error", err)
os.Exit(1)
}
if instances[0].HealthPort != healthPort {
if healthSrv != nil {
healthSrv.Close()
healthSrv = nil
}
startHealth(instances[0].HealthPort)
}
errCh := make(chan error, len(instances))
doneCh := make(chan struct{})
var wg sync.WaitGroup
for _, instanceCfg := range instances {
instanceCfg := instanceCfg
wg.Add(1)
go func() {
defer wg.Done()
if instanceCfg.IsMachineMode() {
nlog.Core().Info("starting machine instance", "instance", instanceCfg.InstanceID, "machine_id", instanceCfg.Machine.MachineID, "panel_url", instanceCfg.Panel.URL)
orch := machine.New(instanceCfg)
if err := orch.Run(ctx); err != nil {
nlog.Core().Error("machine instance exited with error", "instance", instanceCfg.InstanceID, "error", err)
errCh <- err
cancel()
}
return
}
nodes := instanceCfg.ExpandNodes()
nlog.Core().Info("starting node instance", "instance", instanceCfg.InstanceID, "nodes", len(nodes), "panel_url", instanceCfg.Panel.URL)
var instanceWG sync.WaitGroup
for idx, nodeCfg := range nodes {
nodeCfg := nodeCfg
instanceWG.Add(1)
go func(idx int) {
defer instanceWG.Done()
if idx > 0 {
delay := time.Duration(idx) * 250 * time.Millisecond
if delay > 2*time.Second {
delay = 2 * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return
}
}
svc := service.New(nodeCfg)
if err := svc.Run(ctx); err != nil {
nlog.Core().Error("node service exited with error", "instance", nodeCfg.InstanceID, "node_id", nodeCfg.Panel.NodeID, "error", err)
errCh <- err
cancel()
}
}(idx)
}
instanceWG.Wait()
}()
}
go func() { wg.Wait(); close(doneCh) }()
var newRoot *config.RootConfig
select {
case newRoot = <-reloadCh:
nlog.Core().Info("config changed, restarting all services...")
cancel()
<-doneCh
case <-doneCh:
}
signal.Stop(sigCh)
if watcher != nil {
watcher.Stop()
}
if newRoot == nil {
close(errCh)
if err := <-errCh; err != nil {
os.Exit(1)
}
nlog.Core().Info("stopped")
return
}
newInstances, err := newRoot.NormalizeInstances()
if err != nil {
nlog.Core().Error("failed to normalize reloaded config", "error", err)
os.Exit(1)
}
config.InitLogger(newInstances[0].Log)
applyRuntimeConfig(newInstances[0].Runtime)
root = newRoot
nlog.Core().Info("reload complete, services restarting with new config")
}
}
// applyRuntimeConfig wires up Go runtime memory limits from the config file.
@@ -138,17 +243,17 @@ func applyRuntimeConfig(rt config.RuntimeConfig) {
// GOGC
if rt.GoGCPercent > 0 {
prev := debug.SetGCPercent(rt.GoGCPercent)
slog.Info("runtime: GOGC set", "gogc", rt.GoGCPercent, "prev", prev)
nlog.Core().Info("runtime: GOGC set", "gogc", rt.GoGCPercent, "prev", prev)
}
// GOMEMLIMIT — parse human-readable size string (e.g. "30MiB")
if rt.GoMemLimit != "" {
limit, err := parseMemLimit(rt.GoMemLimit)
if err != nil {
slog.Warn("runtime: invalid gomemlimit, ignoring", "value", rt.GoMemLimit, "error", err)
nlog.Core().Warn("runtime: invalid gomemlimit, ignoring", "value", rt.GoMemLimit, "error", err)
} else {
prev := debug.SetMemoryLimit(limit)
slog.Info("runtime: GOMEMLIMIT set",
nlog.Core().Info("runtime: GOMEMLIMIT set",
"limit", rt.GoMemLimit,
"bytes", limit,
"prev_bytes", prev,
+23 -89
View File
@@ -1,92 +1,26 @@
# Xboard Node Configuration
# Copy this file to config.yml and modify as needed
# Top-level settings are inherited by all instances. Instance overrides take precedence.
# ══════════════════════════════════════════════════════════════════════════════
# MINIMAL CONFIGURATION (Recommended)
# Most settings (Node ID, Kernel, TLS, Rules) can be managed via the Panel.
# ══════════════════════════════════════════════════════════════════════════════
# ── Single node (simplest) ──
# panel:
# url: "https://panel.example.com"
# token: "your-api-key"
# node_id: 1
# Panel connection settings
panel:
# Xboard panel URL (required)
url: "https://panel.example.com"
# Server communication token (required, must match panel settings)
token: "your-server-token"
# Node ID as configured in the panel
node_id: 1
# Kernel settings (optional defaults)
kernel:
# Kernel type: "singbox" (default) or "xray"
type: "singbox"
# Directory to store generated configs (default: /etc/xboard-node)
config_dir: "/etc/xboard-node"
# Kernel log level: debug/info/warn/error (default: warn, can be overridden by panel)
log_level: "warn"
# TLS certificate settings (optional, can be managed by panel)
# cert_mode: "http" | "dns" | "self" | "file" | "none"
# http - ACME HTTP-01 challenge (requires port 80 open to internet)
# dns - ACME DNS-01 challenge (supports wildcard certs, requires dns_provider)
# self - self-signed certificate (valid 10 years, no external dependency)
# file - manually specify cert_file / key_file
# none - no TLS (default)
cert:
cert_mode: "none"
# Domain for ACME or self-signed cert
# domain: "node.example.com"
# Email for ACME registration
# email: "admin@example.com"
# ── HTTP-01 mode ──
# http_port: 80 # local port for ACME challenge (default: 80)
# ── DNS-01 mode ──
# dns_provider: "cloudflare" # supported: cloudflare, alidns
# dns_env: # provider-specific credentials
# CF_API_TOKEN: "your-cloudflare-api-token"
# ── File mode ──
# cert_file: "/etc/ssl/certs/cert.pem"
# key_file: "/etc/ssl/private/key.pem"
# Storage directory for auto certs (default: {config_dir}/certs)
# cert_dir: "/etc/xboard-node/certs"
# Logging settings
log:
# Node log level: debug/info/warn/error (default: info)
level: "info"
# Log output: stdout/stderr or file path (default: stdout)
output: "stdout"
# ══════════════════════════════════════════════════════════════════════════════
# ADVANCED / MULTI-NODE MODE
# Run multiple nodes with one configuration file.
# Each node gets its own isolated working directory.
# ══════════════════════════════════════════════════════════════════════════════
# When `nodes:` is present, the top-level `panel.node_id` is ignored.
#
# nodes:
# - node_id: 1
#
# - node_id: 2
# # Optional overrides per node
# kernel_type: "xray"
# cert:
# auto_tls: true
# domain: "node2.example.com"
#
# - node_id: 3
# kernel:
# config_dir: "/etc/xboard-node/custom-node-3"
# Runtime memory tuning (optional)
# runtime:
# # Soft memory limit (e.g. "30MiB", "512MiB")
# gomemlimit: ""
# # GOGC target percentage (default: 100)
# gogc: 0
# Health check endpoint for Docker/K8s (optional, 0 = disabled)
# health_port: 65530
# ── Multiple instances ──
# kernel:
# type: "singbox"
# log:
# level: "info"
# instances:
# - panel:
# url: "https://panel.example.com"
# token: "api-key-1"
# node_id: 1
# - panel:
# url: "https://panel.example.com"
# machine:
# machine_id: 2
# token: "machine-token-2"
# log:
# level: "debug" # override for this instance only
+55
View File
@@ -0,0 +1,55 @@
# Custom Outbounds
## Quick Example
```json
{
"custom_outbounds": [
{
"tag": "warp",
"protocol": "wireguard",
"settings": {
"peers": [{"address": "162.159.195.1"}],
"private_key": "aMzA..."
}
},
{
"tag": "us-proxy",
"protocol": "socks",
"settings": {"servers": [{"address": "1.2.3.4", "port": 1080}]},
"proxy_tag": "warp"
}
]
}
```
## Supported Protocols
| Protocol | Xray | Sing-box |
|----------|------|----------|
| vmess | ✅ | ✅ |
| vless | ✅ | ✅ |
| trojan | ✅ | ✅ |
| shadowsocks | ✅ | ✅ |
| socks | ✅ | ✅ |
| http | ✅ | ✅ |
| wireguard | ✅ | ✅ |
| tuic | ❌ | ✅ |
| hysteria2 | ❌ | ✅ |
| anytls | ❌ | ✅ |
| naive | ❌ | ✅ |
| mieru | ❌ | ✅ |
## Core Fields
| Field | Required | Description |
|-------|----------|-------------|
| `tag` | ✅ | Outbound tag, referenced in routes |
| `protocol` | ✅ | Protocol type |
| `settings` | ✅ | Protocol-specific configuration |
| `proxy_tag` | ❌ | Chain to another outbound (references its tag) |
## Best Practices
- **Prefer** `custom_outbounds`: panel-managed, cross-kernel compatible
- **Use** `kernel.custom_outbound` only: when native fields are needed
+64
View File
@@ -0,0 +1,64 @@
# Custom Routes
## Quick Example
```json
{
"custom_route_rules": [
{
"name": "direct-example",
"match": {"domain_suffixes": ["example.com"]},
"action": {"type": "direct"}
},
{
"name": "block-ads",
"match": {"domains": ["ads.example.com"]},
"action": {"type": "block"}
},
{
"name": "route-warp",
"match": {"ip_cidrs": ["1.1.1.0/24"], "ports": ["80", "443"]},
"action": {"type": "route", "target": "warp-out"}
}
]
}
```
## Match Conditions
| Condition | Description | Example |
|-----------|-------------|---------|
| `domains` | Exact domain match | `["api.example.com"]` |
| `domain_suffixes` | Suffix match | `["example.com"]` |
| `ip_cidrs` | IP CIDR ranges | `["10.0.0.0/8"]` |
| `ports` | Port (single or range) | `["443", "8000-9000"]` |
| `networks` | Protocol | `["tcp"]` or `["udp"]` |
| `source_cidrs` | Source IP CIDR | `["192.168.1.0/24"]` |
| `source_ports` | Source port | `["1024-65535"]` |
## Action Types
| Action | Description |
|--------|-------------|
| `{"type": "direct"}` | Direct connection, bypass proxy |
| `{"type": "block"}` | Block connection |
| `{"type": "route", "target": "tag"}` | Route to specified outbound (by tag) |
## Application Order
1. Structured `custom_route_rules` (highest priority)
2. Raw `custom_routes`
3. Built-in blocklist rules
4. Panel routes
## Kernel Compatibility
| Feature | Xray | Sing-box |
|---------|------|----------|
| All match conditions | ✅ | ✅ |
| direct / block / route | ✅ | ✅ |
## Best Practices
- **Prefer** `custom_route_rules`: cross-kernel compatible, panel-managed
- **Use** `custom_routes` only: when native features are needed (e.g., load balancing)
+35
View File
@@ -0,0 +1,35 @@
# DNS Providers
Used by `cert_config.cert_mode: "dns"` for ACME DNS-01.
```yaml
cert_config:
cert_mode: "dns"
dns_provider: "cloudflare"
dns_env:
CLOUDFLARE_DNS_API_TOKEN: "xxx"
```
| provider | aliases | env |
|---|---|---|
| cloudflare | cf | `CLOUDFLARE_DNS_API_TOKEN` |
| alidns | aliyun | `ALICLOUD_ACCESS_KEY_ID`, `ALICLOUD_ACCESS_KEY_SECRET` |
| tencentcloud | tencent | `TENCENTCLOUD_SECRET_ID`, `TENCENTCLOUD_SECRET_KEY` |
| route53 | aws | AWS credential chain (env / IAM role / profile) |
| godaddy | | `GODADDY_API_KEY`, `GODADDY_API_SECRET` |
| namecheap | | `NAMECHEAP_API_KEY`, `NAMECHEAP_API_USER` |
| namesilo | | `NAMESILO_API_TOKEN` |
| digitalocean | do | `DO_AUTH_TOKEN` |
| linode | | `LINODE_TOKEN` |
| hetzner | | `HETZNER_API_TOKEN` |
| gandi | | `GANDI_BEARER_TOKEN` |
| porkbun | | `PORKBUN_API_KEY`, `PORKBUN_API_SECRET_KEY` |
| netlify | | `NETLIFY_PERSONAL_ACCESS_TOKEN` |
| azure | | `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP_NAME` |
| googleclouddns | gcp, gcloud | `GCE_PROJECT` |
| huaweicloud | huawei | `HUAWEICLOUD_ACCESS_KEY_ID`, `HUAWEICLOUD_SECRET_ACCESS_KEY` |
| bunny | | `BUNNY_ACCESS_KEY` |
| duckdns | | `DUCKDNS_TOKEN` |
| ovh | | `OVH_APPLICATION_KEY`, `OVH_APPLICATION_SECRET`, `OVH_CONSUMER_KEY` |
| vultr | | `VULTR_API_TOKEN` |
| desec | | `DESEC_TOKEN` |
+102 -16
View File
@@ -1,31 +1,80 @@
module github.com/cedar2025/xboard-node
go 1.25.7
go 1.26
require (
github.com/caddyserver/certmagic v0.25.2
github.com/fsnotify/fsnotify v1.9.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gorilla/websocket v1.5.3
github.com/libdns/alidns v1.0.6
github.com/libdns/azure v0.5.0
github.com/libdns/bunny v1.6.1
github.com/libdns/cloudflare v0.2.2
github.com/libdns/desec v1.1.1
github.com/libdns/digitalocean v0.0.0-20250606071607-dfa7af5c2e31
github.com/libdns/duckdns v0.3.0
github.com/libdns/gandi v1.1.0
github.com/libdns/godaddy v1.1.0
github.com/libdns/googleclouddns v1.2.0
github.com/libdns/hetzner v1.0.0
github.com/libdns/huaweicloud v1.0.1
github.com/libdns/linode v0.5.0
github.com/libdns/namecheap v1.0.0
github.com/libdns/namesilo v1.0.0
github.com/libdns/netlify v1.2.0
github.com/libdns/ovh v1.1.0
github.com/libdns/porkbun v1.1.0
github.com/libdns/route53 v1.6.0
github.com/libdns/tencentcloud v1.4.3
github.com/libdns/vultr/v2 v2.0.4
github.com/sagernet/sing v0.8.2
github.com/sagernet/sing-box v1.13.2
github.com/shirou/gopsutil/v4 v4.26.2
github.com/xtls/xray-core v1.260206.0
github.com/xtls/xray-core v1.260327.0
golang.org/x/net v0.52.0
golang.org/x/term v0.41.0
golang.org/x/time v0.12.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go/auth v0.16.5 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
github.com/anytls/sing-anytls v0.0.11 // indirect
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 // indirect
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect
github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/caddyserver/zerossl v0.1.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cilium/ebpf v0.21.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
@@ -33,52 +82,82 @@ require (
github.com/database64128/netx-go v0.1.1 // indirect
github.com/database64128/tfo-go/v2 v2.3.2 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/digitalocean/godo v1.148.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/enfein/mieru/v3 v3.29.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gaissmai/bart v0.18.0 // indirect
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-chi/render v1.0.3 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/analysis v0.19.16 // indirect
github.com/go-openapi/errors v0.19.9 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.5 // indirect
github.com/go-openapi/loads v0.20.0 // indirect
github.com/go-openapi/runtime v0.19.24 // indirect
github.com/go-openapi/spec v0.20.0 // indirect
github.com/go-openapi/strfmt v0.19.11 // indirect
github.com/go-openapi/swag v0.19.12 // indirect
github.com/go-openapi/validate v0.20.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gofrs/uuid/v5 v5.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/juju/ratelimit v1.0.2 // indirect
github.com/keybase/go-keychain v0.0.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/libdns/acmedns v0.5.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/linode/linodego v1.56.0 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mdlayher/netlink v1.9.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/metacubex/utls v1.8.4 // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/mapstructure v1.4.0 // indirect
github.com/netlify/open-api/v2 v2.40.0 // indirect
github.com/openai/openai-go/v3 v3.26.0 // indirect
github.com/ovh/go-ovh v1.7.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pires/go-proxyproto v0.9.2 // indirect
github.com/pires/go-proxyproto v0.11.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect
github.com/sagernet/cors v1.2.1 // indirect
@@ -145,31 +224,38 @@ require (
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/vultr/govultr/v3 v3.20.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535 // indirect
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.mongodb.org/mongo-driver v1.4.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/api v0.249.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
@@ -177,4 +263,4 @@ require (
replace github.com/sagernet/sing-box => github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295
// replace github.com/sagernet/sing-box => ./third_party/sing-box
replace github.com/xtls/xray-core => github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965
+769 -32
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+716 -599
View File
File diff suppressed because it is too large Load Diff
+267 -179
View File
@@ -5,24 +5,28 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/caddyserver/certmagic"
"github.com/libdns/alidns"
"github.com/libdns/cloudflare"
"github.com/cedar2025/xboard-node/internal/cert/dnsproviders"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/nlog"
)
// Manager handles TLS certificate lifecycle.
@@ -30,80 +34,191 @@ import (
// Supported modes (CertConfig.CertMode):
// - "http" — ACME HTTP-01 challenge via certmagic (needs port 80).
// - "dns" — ACME DNS-01 challenge via certmagic + libdns provider.
// - "self" — Self-signed certificate. Automatically generated and stored in CertDir.
// Recommended for nodes behind a CDN or reverse proxy.
// - "self" — Self-signed certificate generated in memory.
// - "file" — User-provided certificate and key file paths (CertFile/KeyFile).
// - "content" — Certificate and key PEM content pushed from the panel.
// Written to disk in CertDir for the kernel to consume.
// - "none" — No TLS. The node will handle plain connections.
//
// Priority Logic:
// All modes normalise to in-memory PEM. Kernels never see file paths —
// they always receive PEM bytes via TLSCert().
//
// If CertMode is empty, the manager infers the mode in this order:
// 1. "http" (if auto_tls is true)
// 2. "content" (if both CertContent and KeyContent are provided)
// 3. "file" (if both CertFile and KeyFile paths are provided)
// 4. "none" (default)
// Priority Logic (when CertMode is empty):
//
// 1. "http" (if auto_tls is true)
// 2. "content" (if both CertContent and KeyContent are provided)
// 3. "file" (if both CertFile and KeyFile paths are provided)
// 4. "none" (default)
type Manager struct {
cfg config.CertConfig
certFile string
keyFile string
cfg config.CertConfig
magic *certmagic.Config
renewed atomic.Bool
acmeStarted bool // prevents double-init of certmagic background goroutines
// Atomic so the ACME renewal goroutine can swap without racing readers.
mat atomic.Pointer[certMaterial]
magic *certmagic.Config
renewed atomic.Bool
acmeStarted bool
acmeFingerprint string
acmeCancel context.CancelFunc
}
// certMaterial is an immutable snapshot of PEM-encoded cert + key.
type certMaterial struct {
certPEM []byte
keyPEM []byte
}
// NewManager creates a certificate manager.
func NewManager(cfg config.CertConfig) *Manager {
m := &Manager{cfg: cfg}
m.setCertPaths()
return m
return &Manager{cfg: cfg}
}
// setCertPaths derives certFile / keyFile from the current m.cfg.
func (m *Manager) setCertPaths() {
switch m.resolveMode() {
case "file":
m.certFile = m.cfg.CertFile
m.keyFile = m.cfg.KeyFile
case "http", "dns", "self", "content":
dir := m.cfg.CertDir
if dir == "" {
dir = "/etc/xboard-node/certs"
}
domain := m.cfg.Domain
if domain == "" {
domain = "localhost"
}
m.certFile = filepath.Join(dir, domain+".crt")
m.keyFile = filepath.Join(dir, domain+".key")
default:
m.certFile = ""
m.keyFile = ""
// storePEM atomically swaps the in-memory cert material and persists to disk
// so that restarts can reload without re-generating or re-requesting.
func (m *Manager) storePEM(cert, key []byte) {
m.mat.Store(&certMaterial{certPEM: cert, keyPEM: key})
m.persistPEM(cert, key)
}
// persistPEM writes cert/key to cert_dir for restart recovery.
// Errors are logged but not returned — persistence is best-effort.
func (m *Manager) persistPEM(cert, key []byte) {
dir := m.cfg.CertDir
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
nlog.Core().Warn("cert: failed to create cert_dir", "dir", dir, "error", err)
return
}
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
if err := atomicWriteFile(certPath, cert, 0o644); err != nil {
nlog.Core().Warn("cert: failed to persist cert", "path", certPath, "error", err)
}
if err := atomicWriteFile(keyPath, key, 0o600); err != nil {
nlog.Core().Warn("cert: failed to persist key", "path", keyPath, "error", err)
}
}
// atomicWriteFile writes data to a temp file and renames, preventing partial reads.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return err
}
return os.Rename(tmp, path)
}
// loadPersistedPEM loads previously persisted cert material from cert_dir.
// Returns true if valid material was loaded.
func (m *Manager) loadPersistedPEM() bool {
dir := m.cfg.CertDir
if dir == "" {
return false
}
certPEM, err := os.ReadFile(filepath.Join(dir, "cert.pem"))
if err != nil {
return false
}
keyPEM, err := os.ReadFile(filepath.Join(dir, "key.pem"))
if err != nil {
return false
}
if err := validateKeyPair(certPEM, keyPEM); err != nil {
nlog.Core().Warn("cert: persisted cert invalid, will regenerate", "error", err)
return false
}
// Store in memory only (don't re-persist what we just read).
m.mat.Store(&certMaterial{certPEM: certPEM, keyPEM: keyPEM})
nlog.Core().Info("cert: loaded persisted certificate from disk", "dir", dir)
return true
}
// Reconfigure applies a new cert configuration at runtime (e.g. from panel push).
// It is safe to call after Start(). For ACME modes that are already running this
// is a no-op — a process restart is required to change ACME settings.
// Returns true when the on-disk cert paths changed (caller should restart kernel).
// Returns true when the PEM material changed (caller should restart kernel).
func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) {
// Preserve the storage directory so panel-pushed configs don't change
// where certs live on disk (only the operator controls this path).
if newCfg.CertDir == "" {
newCfg.CertDir = m.cfg.CertDir
}
oldCertFile, oldKeyFile := m.certFile, m.keyFile
// If ACME is running and the new config materially differs (or switches
// away from ACME), tear down the old certmagic instance first.
if m.acmeStarted {
newFp := acmeFingerprint(newCfg)
newMode := resolveModeFor(newCfg)
if newMode != "http" && newMode != "dns" || newFp != m.acmeFingerprint {
m.tearDownACME()
}
}
oldTLS := m.TLSCert()
m.cfg = newCfg
m.setCertPaths()
if err := m.Start(ctx); err != nil {
return false, fmt.Errorf("cert reconfigure: %w", err)
}
return m.certFile != oldCertFile || m.keyFile != oldKeyFile, nil
newTLS := m.TLSCert()
return !pemEqual(oldTLS, newTLS), nil
}
// tearDownACME cancels the running certmagic background goroutines and clears
// related state so a fresh ACME init can run.
func (m *Manager) tearDownACME() {
if m.acmeCancel != nil {
m.acmeCancel()
m.acmeCancel = nil
}
m.magic = nil
m.acmeStarted = false
m.acmeFingerprint = ""
m.mat.Store(nil)
}
// acmeFingerprint produces a stable string capturing every config field that
// requires re-running ACME when changed.
func acmeFingerprint(cfg config.CertConfig) string {
keys := make([]string, 0, len(cfg.DNSEnv))
for k := range cfg.DNSEnv {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(strings.ToLower(strings.TrimSpace(cfg.CertMode)))
b.WriteByte('|')
b.WriteString(strings.TrimSpace(cfg.Domain))
b.WriteByte('|')
b.WriteString(strings.TrimSpace(cfg.Email))
b.WriteByte('|')
b.WriteString(strings.TrimSpace(cfg.DNSProvider))
b.WriteByte('|')
for _, k := range keys {
b.WriteString(k)
b.WriteByte('=')
b.WriteString(cfg.DNSEnv[k])
b.WriteByte(';')
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
// resolveModeFor mirrors (*Manager).resolveMode() for an arbitrary cfg, used
// by Reconfigure to decide tear-down without mutating manager state.
func resolveModeFor(cfg config.CertConfig) string {
mode := strings.ToLower(strings.TrimSpace(cfg.CertMode))
if mode != "" {
return mode
}
if cfg.AutoTLS {
return "http"
}
if cfg.CertContent != "" && cfg.KeyContent != "" {
return "content"
}
if cfg.CertFile != "" && cfg.KeyFile != "" {
return "file"
}
return "none"
}
// resolveMode returns the effective cert mode, handling backward compat for auto_tls.
@@ -127,15 +242,29 @@ func (m *Manager) resolveMode() string {
return "none"
}
func (m *Manager) CertFile() string { return m.certFile }
func (m *Manager) KeyFile() string { return m.keyFile }
func (m *Manager) HasCert() bool { return m.certFile != "" && m.keyFile != "" }
func (m *Manager) HasCert() bool {
mat := m.mat.Load()
return mat != nil && len(mat.certPEM) > 0 && len(mat.keyPEM) > 0
}
func (m *Manager) CertRenewed() bool { return m.renewed.Swap(false) }
// TLSCert returns the current PEM material as a kernel.TLSCert.
func (m *Manager) TLSCert() kernel.TLSCert {
mat := m.mat.Load()
if mat == nil {
return kernel.TLSCert{}
}
return kernel.TLSCert{CertPEM: mat.certPEM, KeyPEM: mat.keyPEM}
}
// pemEqual reports whether two TLSCert values carry identical PEM content.
func pemEqual(a, b kernel.TLSCert) bool {
return string(a.CertPEM) == string(b.CertPEM) && string(a.KeyPEM) == string(b.KeyPEM)
}
// Start initializes the cert manager based on the resolved mode.
func (m *Manager) Start(ctx context.Context) error {
mode := m.resolveMode()
slog.Info("cert manager starting", "mode", mode, "domain", m.cfg.Domain)
switch mode {
case "none", "":
@@ -166,29 +295,30 @@ func (m *Manager) Stop() {}
// ─── Mode: file ────────────────────────────────────────────────────────────
func (m *Manager) startFile() error {
if _, err := os.Stat(m.cfg.CertFile); err != nil {
certPEM, err := os.ReadFile(m.cfg.CertFile)
if err != nil {
return fmt.Errorf("cert file: %w", err)
}
if _, err := os.Stat(m.cfg.KeyFile); err != nil {
keyPEM, err := os.ReadFile(m.cfg.KeyFile)
if err != nil {
return fmt.Errorf("key file: %w", err)
}
slog.Info("using manual TLS certificates", "cert", m.certFile, "key", m.keyFile)
if err := validateKeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("invalid certificate pair: %w", err)
}
m.storePEM(certPEM, keyPEM)
nlog.Core().Debug("TLS certificate loaded from files", "cert", m.cfg.CertFile, "key", m.cfg.KeyFile)
return nil
}
// ─── Mode: self ────────────────────────────────────────────────────────────
func (m *Manager) startSelfSigned() error {
// If files already exist and are valid, skip regeneration.
if fileExists(m.certFile) && fileExists(m.keyFile) {
slog.Info("self-signed cert already exists, reusing", "cert", m.certFile)
// Reuse persisted self-signed cert if available and valid.
if m.loadPersistedPEM() {
return nil
}
if err := os.MkdirAll(filepath.Dir(m.certFile), 0o755); err != nil {
return fmt.Errorf("create cert dir: %w", err)
}
domain := m.cfg.Domain
if domain == "" {
domain = "localhost"
@@ -232,46 +362,37 @@ func (m *Manager) startSelfSigned() error {
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil {
return fmt.Errorf("write cert: %w", err)
}
if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil {
return fmt.Errorf("write key: %w", err)
}
slog.Info("self-signed certificate generated",
"domain", domain, "cert", m.certFile, "valid_years", 10)
m.storePEM(certPEM, keyPEM)
nlog.Core().Info("self-signed certificate generated (in-memory)", "domain", domain, "valid_years", 10)
return nil
}
// ─── Mode: content ────────────────────────────────────────────────────────
// startContent writes panel-supplied PEM strings to disk so the kernel can
// reference them as ordinary cert/key files.
// startContent stores panel-supplied PEM strings in memory for inline use.
func (m *Manager) startContent() error {
if m.cfg.CertContent == "" || m.cfg.KeyContent == "" {
// No fresh content from panel — try previously persisted material.
if m.loadPersistedPEM() {
return nil
}
return fmt.Errorf("cert_mode 'content' requires both cert_content and key_content")
}
if err := os.MkdirAll(filepath.Dir(m.certFile), 0o755); err != nil {
return fmt.Errorf("create cert dir: %w", err)
certPEM := []byte(m.cfg.CertContent)
keyPEM := []byte(m.cfg.KeyContent)
if err := validateKeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("invalid certificate content: %w", err)
}
if err := atomicWriteFile(m.certFile, []byte(m.cfg.CertContent), 0o644); err != nil {
return fmt.Errorf("write cert content: %w", err)
}
if err := atomicWriteFile(m.keyFile, []byte(m.cfg.KeyContent), 0o600); err != nil {
return fmt.Errorf("write key content: %w", err)
}
slog.Info("TLS certificate written from panel content", "cert", m.certFile)
m.storePEM(certPEM, keyPEM)
nlog.Core().Info("TLS certificate loaded from panel content (in-memory)")
return nil
}
// ─── Mode: http / dns (ACME) ──────────────────────────────────────────────
func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solver) error {
// Guard: certmagic's background goroutines must only be started once.
// A process restart is required to change ACME settings at runtime.
// Idempotent for unchanged config: Reconfigure already tore down stale ACME state.
if m.acmeStarted {
slog.Info("cert: ACME already active, skipping re-init (restart to change ACME settings)")
return nil
}
@@ -295,9 +416,23 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
magic = certmagic.New(cache, certmagic.Config{
Storage: storage,
OnEvent: func(evtCtx context.Context, event string, data map[string]any) error {
if event == "cert_obtained" {
m.syncCertFiles(evtCtx, storage, data)
// Only react to renewals; initial load happens explicitly after ObtainCertSync.
if event != "cert_obtained" {
return nil
}
if renewed, _ := data["renewal"].(bool); !renewed {
return nil
}
issuerKey, _ := data["issuer"].(string)
if issuerKey == "" {
return nil
}
if err := m.loadPEMFromStorage(evtCtx, storage, issuerKey, m.cfg.Domain); err != nil {
nlog.Core().Error("failed to reload cert after renewal", "error", err)
return nil
}
m.renewed.Store(true)
nlog.Core().Info("TLS certificate reloaded after renewal", "domain", m.cfg.Domain)
return nil
},
})
@@ -327,18 +462,38 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
}
m.magic = magic
if err := magic.ObtainCertSync(ctx, m.cfg.Domain); err != nil {
// Derived ctx so Reconfigure can cancel ACME background goroutines.
acmeCtx, acmeCancel := context.WithCancel(ctx)
if err := magic.ObtainCertSync(acmeCtx, m.cfg.Domain); err != nil {
acmeCancel()
return fmt.Errorf("obtain certificate: %w", err)
}
if err := magic.ManageAsync(ctx, []string{m.cfg.Domain}); err != nil {
issuerKey := magic.Issuers[0].IssuerKey()
if err := m.loadPEMFromStorage(acmeCtx, storage, issuerKey, m.cfg.Domain); err != nil {
acmeCancel()
return fmt.Errorf("load cert from storage: %w", err)
}
if err := magic.ManageAsync(acmeCtx, []string{m.cfg.Domain}); err != nil {
acmeCancel()
return fmt.Errorf("start cert manager: %w", err)
}
m.acmeCancel = acmeCancel
m.acmeFingerprint = acmeFingerprint(m.cfg)
m.acmeStarted = true
return nil
}
func validateKeyPair(certPEM, keyPEM []byte) error {
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
return err
}
return nil
}
// ─── DNS Provider Factory ──────────────────────────────────────────────────
// buildDNSSolver builds a certmagic DNS01Solver from the configured provider.
@@ -351,111 +506,44 @@ func (m *Manager) buildDNSSolver() (*certmagic.DNS01Solver, error) {
}
func (m *Manager) newDNSProvider() (certmagic.DNSProvider, error) {
name := strings.ToLower(strings.TrimSpace(m.cfg.DNSProvider))
name := strings.TrimSpace(m.cfg.DNSProvider)
if name == "" {
return nil, fmt.Errorf("dns_provider is required for cert_mode=dns")
}
env := m.cfg.DNSEnv
if env == nil {
env = map[string]string{}
}
switch name {
case "cloudflare", "cf":
token := firstOf(env, "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN")
if token == "" {
return nil, fmt.Errorf("cloudflare requires CF_API_TOKEN in dns_env")
}
return &cloudflare.Provider{APIToken: token}, nil
case "alidns", "aliyun":
keyID := firstOf(env, "ALICLOUD_ACCESS_KEY_ID", "ALI_ACCESS_KEY_ID")
keySecret := firstOf(env, "ALICLOUD_ACCESS_KEY_SECRET", "ALI_ACCESS_KEY_SECRET")
if keyID == "" || keySecret == "" {
return nil, fmt.Errorf("alidns requires ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET in dns_env")
}
return &alidns.Provider{
CredentialInfo: alidns.CredentialInfo{
AccessKeyID: keyID,
AccessKeySecret: keySecret,
},
}, nil
default:
return nil, fmt.Errorf("unsupported dns_provider: %q (supported: cloudflare, alidns)", name)
p, ok := dnsproviders.Get(name)
if !ok {
return nil, fmt.Errorf("unsupported dns_provider: %q (supported: %s)",
name, strings.Join(dnsproviders.CanonicalNames(), ", "))
}
return p.Build(env)
}
// ─── Helpers ───────────────────────────────────────────────────────────────
// syncCertFiles copies the cert and key from certmagic's storage to stable paths.
func (m *Manager) syncCertFiles(ctx context.Context, storage certmagic.Storage, data map[string]any) {
certKey, _ := data["certificate_path"].(string)
keyKey, _ := data["private_key_path"].(string)
if certKey == "" || keyKey == "" {
slog.Warn("cert_obtained event missing storage paths", "data", data)
return
// loadPEMFromStorage reads cert + key for domain from certmagic storage into
// the in-memory cache. Single entry point for both initial load and renewal refresh.
func (m *Manager) loadPEMFromStorage(ctx context.Context, storage certmagic.Storage, issuerKey, domain string) error {
if issuerKey == "" || domain == "" {
return fmt.Errorf("loadPEMFromStorage: issuerKey and domain are required")
}
certKey := certmagic.StorageKeys.SiteCert(issuerKey, domain)
keyKey := certmagic.StorageKeys.SitePrivateKey(issuerKey, domain)
certPEM, err := storage.Load(ctx, certKey)
if err != nil {
slog.Error("failed to load cert from storage", "error", err)
return
return fmt.Errorf("load cert %q: %w", certKey, err)
}
keyPEM, err := storage.Load(ctx, keyKey)
if err != nil {
slog.Error("failed to load key from storage", "error", err)
return
return fmt.Errorf("load key %q: %w", keyKey, err)
}
if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil {
slog.Error("failed to write cert file", "path", m.certFile, "error", err)
return
if err := validateKeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("invalid cert/key pair on disk: %w", err)
}
if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil {
slog.Error("failed to write key file", "path", m.keyFile, "error", err)
return
}
renewal, _ := data["renewal"].(bool)
slog.Info("TLS certificate synced", "domain", m.cfg.Domain, "renewal", renewal,
"cert", m.certFile)
m.renewed.Store(true)
}
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, path)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// firstOf returns the first non-empty value for any of the given keys in the map.
func firstOf(m map[string]string, keys ...string) string {
for _, k := range keys {
if v := m[k]; v != "" {
return v
}
}
return ""
m.storePEM(certPEM, keyPEM)
return nil
}
+236
View File
@@ -0,0 +1,236 @@
package cert
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"path/filepath"
"testing"
"time"
"github.com/caddyserver/certmagic"
"github.com/cedar2025/xboard-node/internal/config"
)
// generateSelfSignedPair returns a fresh self-signed certificate and matching
// private key in PEM form, suitable for storing as a libdns/certmagic site
// resource in tests.
func generateSelfSignedPair(t *testing.T, domain string) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: domain},
DNSNames: []string{domain},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return
}
// TestLoadPEMFromStorage_RestartScenario simulates the warm-restart bug fix:
// certmagic.ObtainCertSync returns nil without firing cert_obtained when a
// valid cert already exists in storage. The manager must still populate its
// in-memory PEM cache by reading storage explicitly.
func TestLoadPEMFromStorage_RestartScenario(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
certPEM, keyPEM := generateSelfSignedPair(t, domain)
ctx := context.Background()
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certPEM); err != nil {
t.Fatalf("seed cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyPEM); err != nil {
t.Fatalf("seed key: %v", err)
}
m := NewManager(config.CertConfig{})
if m.HasCert() {
t.Fatal("manager should start with no cert in memory")
}
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("loadPEMFromStorage: %v", err)
}
if !m.HasCert() {
t.Fatal("expected HasCert() == true after explicit load")
}
got := m.TLSCert()
if string(got.CertPEM) != string(certPEM) {
t.Errorf("certPEM mismatch")
}
if string(got.KeyPEM) != string(keyPEM) {
t.Errorf("keyPEM mismatch")
}
}
// TestLoadPEMFromStorage_RenewalRefresh confirms that calling load a second
// time with newer bytes on disk swaps the in-memory cache atomically — this
// is the renewal hot-reload path triggered from the cert_obtained event.
func TestLoadPEMFromStorage_RenewalRefresh(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
ctx := context.Background()
certV1, keyV1 := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV1); err != nil {
t.Fatalf("seed v1 cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV1); err != nil {
t.Fatalf("seed v1 key: %v", err)
}
m := NewManager(config.CertConfig{})
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("initial load: %v", err)
}
certV2, keyV2 := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV2); err != nil {
t.Fatalf("seed v2 cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV2); err != nil {
t.Fatalf("seed v2 key: %v", err)
}
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("refresh load: %v", err)
}
got := m.TLSCert()
if string(got.CertPEM) != string(certV2) {
t.Error("expected v2 cert after refresh")
}
if string(got.KeyPEM) != string(keyV2) {
t.Error("expected v2 key after refresh")
}
}
// TestLoadPEMFromStorage_MissingFiles ensures we surface a clear error when
// storage doesn't have the expected keys (defensive — should not happen in
// practice because ObtainCertSync guarantees presence on success).
func TestLoadPEMFromStorage_MissingFiles(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
m := NewManager(config.CertConfig{})
err := m.loadPEMFromStorage(context.Background(), storage, "test-issuer", "example.test")
if err == nil {
t.Fatal("expected error when storage is empty")
}
}
// TestLoadPEMFromStorage_RejectsMismatchedPair guards against silently loading
// a cert and key that do not pair (e.g. partial write or external tampering).
func TestLoadPEMFromStorage_RejectsMismatchedPair(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
ctx := context.Background()
certA, _ := generateSelfSignedPair(t, domain)
_, keyB := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certA); err != nil {
t.Fatalf("seed cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyB); err != nil {
t.Fatalf("seed key: %v", err)
}
m := NewManager(config.CertConfig{})
err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain)
if err == nil {
t.Fatal("expected error for mismatched cert/key pair")
}
if m.HasCert() {
t.Error("manager should not store invalid pair")
}
_ = filepath.Join(dir, "stub")
}
// TestACMEFingerprint covers the config-equality logic that decides whether
// Reconfigure must tear down a running certmagic instance.
func TestACMEFingerprint(t *testing.T) {
base := config.CertConfig{
CertMode: "dns",
Domain: "a.example.com",
Email: "ops@example.com",
DNSProvider: "cloudflare",
DNSEnv: map[string]string{"CLOUDFLARE_API_TOKEN": "tok"},
}
cases := []struct {
name string
mutate func(*config.CertConfig)
differs bool
}{
{"same", func(*config.CertConfig) {}, false},
{"domain", func(c *config.CertConfig) { c.Domain = "b.example.com" }, true},
{"mode", func(c *config.CertConfig) { c.CertMode = "http" }, true},
{"email", func(c *config.CertConfig) { c.Email = "other@example.com" }, true},
{"provider", func(c *config.CertConfig) { c.DNSProvider = "alidns" }, true},
{"env_value", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"CLOUDFLARE_API_TOKEN": "other"} }, true},
{"env_key_only", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"OTHER": "tok"} }, true},
}
baseFp := acmeFingerprint(base)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := base
if tc.mutate != nil {
// deep-copy DNSEnv before mutation to avoid sharing
cfg.DNSEnv = map[string]string{}
for k, v := range base.DNSEnv {
cfg.DNSEnv[k] = v
}
tc.mutate(&cfg)
}
got := acmeFingerprint(cfg)
if (got != baseFp) != tc.differs {
t.Errorf("differs=%v but got==base=%v", tc.differs, got == baseFp)
}
})
}
}
// TestACMEFingerprintEnvOrderStable ensures map iteration order does not
// affect the fingerprint (must be sorted internally).
func TestACMEFingerprintEnvOrderStable(t *testing.T) {
a := config.CertConfig{
CertMode: "dns", Domain: "x", DNSProvider: "p",
DNSEnv: map[string]string{"A": "1", "B": "2", "C": "3"},
}
b := config.CertConfig{
CertMode: "dns", Domain: "x", DNSProvider: "p",
DNSEnv: map[string]string{"C": "3", "A": "1", "B": "2"},
}
if acmeFingerprint(a) != acmeFingerprint(b) {
t.Fatal("fingerprint not order-independent")
}
}
+26
View File
@@ -0,0 +1,26 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/alidns"
)
func init() {
Register(&Provider{
Names: []string{"alidns", "aliyun"},
EnvVars: []string{"ALICLOUD_ACCESS_KEY_ID", "ALICLOUD_ACCESS_KEY_SECRET"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
keyID := firstOf(env, "ALICLOUD_ACCESS_KEY_ID", "ALI_ACCESS_KEY_ID", "ALIYUN_ACCESS_KEY_ID")
keySecret := firstOf(env, "ALICLOUD_ACCESS_KEY_SECRET", "ALI_ACCESS_KEY_SECRET", "ALIYUN_ACCESS_KEY_SECRET")
if keyID == "" || keySecret == "" {
return nil, missingEnvErr("alidns", "ALICLOUD_ACCESS_KEY_ID", "ALICLOUD_ACCESS_KEY_SECRET")
}
return &alidns.Provider{
CredentialInfo: alidns.CredentialInfo{
AccessKeyID: keyID,
AccessKeySecret: keySecret,
},
}, nil
},
})
}
+33
View File
@@ -0,0 +1,33 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/azure"
)
func init() {
Register(&Provider{
Names: []string{"azure"},
EnvVars: []string{
"AZURE_SUBSCRIPTION_ID", "AZURE_RESOURCE_GROUP_NAME",
"AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET",
},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
sub := firstOf(env, "AZURE_SUBSCRIPTION_ID")
rg := firstOf(env, "AZURE_RESOURCE_GROUP_NAME", "AZURE_RESOURCE_GROUP")
if sub == "" || rg == "" {
return nil, missingEnvErr("azure", "AZURE_SUBSCRIPTION_ID", "AZURE_RESOURCE_GROUP_NAME")
}
// TenantId/ClientId/ClientSecret are optional — when omitted, libdns
// falls back to the default Azure credential chain (managed identity,
// CLI login, etc.).
return &azure.Provider{
SubscriptionId: sub,
ResourceGroupName: rg,
TenantId: firstOf(env, "AZURE_TENANT_ID"),
ClientId: firstOf(env, "AZURE_CLIENT_ID"),
ClientSecret: firstOf(env, "AZURE_CLIENT_SECRET"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/bunny"
)
func init() {
Register(&Provider{
Names: []string{"bunny"},
EnvVars: []string{"BUNNY_ACCESS_KEY"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
key := firstOf(env, "BUNNY_ACCESS_KEY", "BUNNY_API_KEY")
if key == "" {
return nil, missingEnvErr("bunny", "BUNNY_ACCESS_KEY")
}
return &bunny.Provider{AccessKey: key}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/cloudflare"
)
func init() {
Register(&Provider{
Names: []string{"cloudflare", "cf"},
EnvVars: []string{"CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN")
if token == "" {
return nil, missingEnvErr("cloudflare", "CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN")
}
return &cloudflare.Provider{APIToken: token}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/desec"
)
func init() {
Register(&Provider{
Names: []string{"desec"},
EnvVars: []string{"DESEC_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "DESEC_TOKEN", "DESEC_API_TOKEN")
if token == "" {
return nil, missingEnvErr("desec", "DESEC_TOKEN")
}
return &desec.Provider{Token: token}, nil
},
})
}
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/digitalocean"
)
func init() {
Register(&Provider{
Names: []string{"digitalocean", "do"},
EnvVars: []string{"DO_AUTH_TOKEN", "DIGITALOCEAN_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "DO_AUTH_TOKEN", "DIGITALOCEAN_TOKEN", "DIGITALOCEAN_API_TOKEN")
if token == "" {
return nil, missingEnvErr("digitalocean", "DO_AUTH_TOKEN")
}
return &digitalocean.Provider{APIToken: token}, nil
},
})
}
+23
View File
@@ -0,0 +1,23 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/duckdns"
)
func init() {
Register(&Provider{
Names: []string{"duckdns"},
EnvVars: []string{"DUCKDNS_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "DUCKDNS_TOKEN", "DUCKDNS_API_TOKEN")
if token == "" {
return nil, missingEnvErr("duckdns", "DUCKDNS_TOKEN")
}
return &duckdns.Provider{
APIToken: token,
OverrideDomain: firstOf(env, "DUCKDNS_OVERRIDE_DOMAIN"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/gandi"
)
func init() {
Register(&Provider{
Names: []string{"gandi"},
EnvVars: []string{"GANDI_BEARER_TOKEN", "GANDI_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "GANDI_BEARER_TOKEN", "GANDI_API_TOKEN", "GANDI_PERSONAL_ACCESS_TOKEN")
if token == "" {
return nil, missingEnvErr("gandi", "GANDI_BEARER_TOKEN")
}
return &gandi.Provider{BearerToken: token}, nil
},
})
}
+30
View File
@@ -0,0 +1,30 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/godaddy"
)
func init() {
Register(&Provider{
Names: []string{"godaddy"},
EnvVars: []string{"GODADDY_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
// GoDaddy auth header format: "sso-key KEY:SECRET"
// Pass the full "KEY:SECRET" pair via GODADDY_API_TOKEN, OR set
// GODADDY_API_KEY + GODADDY_API_SECRET and we'll join them.
token := firstOf(env, "GODADDY_API_TOKEN")
if token == "" {
key := firstOf(env, "GODADDY_API_KEY")
secret := firstOf(env, "GODADDY_API_SECRET")
if key != "" && secret != "" {
token = key + ":" + secret
}
}
if token == "" {
return nil, missingEnvErr("godaddy", "GODADDY_API_TOKEN", "GODADDY_API_KEY+GODADDY_API_SECRET")
}
return &godaddy.Provider{APIToken: token}, nil
},
})
}
@@ -0,0 +1,25 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/googleclouddns"
)
func init() {
Register(&Provider{
Names: []string{"googleclouddns", "gcp", "gcloud"},
EnvVars: []string{"GCE_PROJECT", "GCE_SERVICE_ACCOUNT_FILE"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
project := firstOf(env, "GCE_PROJECT", "GOOGLE_PROJECT_ID", "GCP_PROJECT")
if project == "" {
return nil, missingEnvErr("googleclouddns", "GCE_PROJECT")
}
// ServiceAccountJSON may be a file path OR a raw JSON blob; when
// empty, the default Google credential chain is used.
return &googleclouddns.Provider{
Project: project,
ServiceAccountJSON: firstOf(env, "GCE_SERVICE_ACCOUNT_FILE", "GOOGLE_APPLICATION_CREDENTIALS"),
}, nil
},
})
}
+22
View File
@@ -0,0 +1,22 @@
package dnsproviders
import (
"fmt"
"strings"
)
// firstOf returns the value of the first non-empty env key from candidates.
// Lookup is case-sensitive (env keys are conventionally upper-case).
func firstOf(env map[string]string, keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(env[k]); v != "" {
return v
}
}
return ""
}
// missingEnvErr formats a "missing required env" error consistently.
func missingEnvErr(provider string, keys ...string) error {
return fmt.Errorf("dns provider %q requires one of %s in dns_env", provider, strings.Join(keys, ", "))
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/hetzner"
)
func init() {
Register(&Provider{
Names: []string{"hetzner"},
EnvVars: []string{"HETZNER_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "HETZNER_API_TOKEN", "HETZNER_DNS_API_TOKEN")
if token == "" {
return nil, missingEnvErr("hetzner", "HETZNER_API_TOKEN")
}
return &hetzner.Provider{AuthAPIToken: token}, nil
},
})
}
+25
View File
@@ -0,0 +1,25 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/huaweicloud"
)
func init() {
Register(&Provider{
Names: []string{"huaweicloud", "huawei"},
EnvVars: []string{"HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEICLOUD_SECRET_ACCESS_KEY", "HUAWEICLOUD_REGION_ID"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
id := firstOf(env, "HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEI_ACCESS_KEY_ID")
secret := firstOf(env, "HUAWEICLOUD_SECRET_ACCESS_KEY", "HUAWEI_SECRET_ACCESS_KEY")
if id == "" || secret == "" {
return nil, missingEnvErr("huaweicloud", "HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEICLOUD_SECRET_ACCESS_KEY")
}
return &huaweicloud.Provider{
AccessKeyId: id,
SecretAccessKey: secret,
RegionId: firstOf(env, "HUAWEICLOUD_REGION_ID", "HUAWEI_REGION"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/linode"
)
func init() {
Register(&Provider{
Names: []string{"linode"},
EnvVars: []string{"LINODE_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "LINODE_TOKEN", "LINODE_API_TOKEN")
if token == "" {
return nil, missingEnvErr("linode", "LINODE_TOKEN")
}
return &linode.Provider{APIToken: token}, nil
},
})
}
+26
View File
@@ -0,0 +1,26 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/namecheap"
)
func init() {
Register(&Provider{
Names: []string{"namecheap"},
EnvVars: []string{"NAMECHEAP_API_KEY", "NAMECHEAP_API_USER", "NAMECHEAP_CLIENT_IP"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
apiKey := firstOf(env, "NAMECHEAP_API_KEY")
user := firstOf(env, "NAMECHEAP_API_USER", "NAMECHEAP_USER")
if apiKey == "" || user == "" {
return nil, missingEnvErr("namecheap", "NAMECHEAP_API_KEY", "NAMECHEAP_API_USER")
}
return &namecheap.Provider{
APIKey: apiKey,
User: user,
APIEndpoint: firstOf(env, "NAMECHEAP_API_ENDPOINT"),
ClientIP: firstOf(env, "NAMECHEAP_CLIENT_IP"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/namesilo"
)
func init() {
Register(&Provider{
Names: []string{"namesilo"},
EnvVars: []string{"NAMESILO_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "NAMESILO_API_TOKEN")
if token == "" {
return nil, missingEnvErr("namesilo", "NAMESILO_API_TOKEN")
}
return &namesilo.Provider{APIToken: token}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/netlify"
)
func init() {
Register(&Provider{
Names: []string{"netlify"},
EnvVars: []string{"NETLIFY_PERSONAL_ACCESS_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "NETLIFY_PERSONAL_ACCESS_TOKEN", "NETLIFY_AUTH_TOKEN", "NETLIFY_API_TOKEN")
if token == "" {
return nil, missingEnvErr("netlify", "NETLIFY_PERSONAL_ACCESS_TOKEN")
}
return &netlify.Provider{PersonalAccessToken: token}, nil
},
})
}
+29
View File
@@ -0,0 +1,29 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/ovh"
)
func init() {
Register(&Provider{
Names: []string{"ovh"},
EnvVars: []string{
"OVH_ENDPOINT", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY",
},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
appKey := firstOf(env, "OVH_APPLICATION_KEY")
appSecret := firstOf(env, "OVH_APPLICATION_SECRET")
consumerKey := firstOf(env, "OVH_CONSUMER_KEY")
if appKey == "" || appSecret == "" || consumerKey == "" {
return nil, missingEnvErr("ovh", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY")
}
return &ovh.Provider{
Endpoint: firstOf(env, "OVH_ENDPOINT"),
ApplicationKey: appKey,
ApplicationSecret: appSecret,
ConsumerKey: consumerKey,
}, nil
},
})
}
+21
View File
@@ -0,0 +1,21 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/porkbun"
)
func init() {
Register(&Provider{
Names: []string{"porkbun"},
EnvVars: []string{"PORKBUN_API_KEY", "PORKBUN_API_SECRET_KEY"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
key := firstOf(env, "PORKBUN_API_KEY")
secret := firstOf(env, "PORKBUN_API_SECRET_KEY", "PORKBUN_SECRET_API_KEY")
if key == "" || secret == "" {
return nil, missingEnvErr("porkbun", "PORKBUN_API_KEY", "PORKBUN_API_SECRET_KEY")
}
return &porkbun.Provider{APIKey: key, APISecretKey: secret}, nil
},
})
}
+93
View File
@@ -0,0 +1,93 @@
// Package dnsproviders is a registry of DNS providers usable for ACME DNS-01
// challenges. Each provider is implemented as a separate file that registers
// itself in init(); to add a new provider, drop a file in this package — no
// changes are needed elsewhere.
//
// All providers wrap the libdns ecosystem (https://github.com/libdns) so they
// plug directly into certmagic's DNSManager.
package dnsproviders
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/caddyserver/certmagic"
)
// Builder constructs a certmagic.DNSProvider from a flat env-style map.
// The map mirrors the cert_config.dns_env block from config.yml.
type Builder func(env map[string]string) (certmagic.DNSProvider, error)
// Provider describes a single DNS provider entry in the registry.
type Provider struct {
// Names is the list of accepted dns_provider values; the first is canonical,
// the rest are aliases (case-insensitive match).
Names []string
// EnvVars lists the environment variable names this provider may consume.
// Used for documentation and error messages only.
EnvVars []string
// Build constructs the underlying libdns provider.
Build Builder
}
var (
mu sync.RWMutex
registry = map[string]*Provider{}
ordered []*Provider
)
// Register adds a provider to the global registry. It panics on duplicate
// names so that registration errors surface at process start.
func Register(p *Provider) {
if p == nil || len(p.Names) == 0 || p.Build == nil {
panic("dnsproviders: invalid provider registration")
}
mu.Lock()
defer mu.Unlock()
for _, n := range p.Names {
key := strings.ToLower(strings.TrimSpace(n))
if key == "" {
panic("dnsproviders: empty provider name")
}
if _, exists := registry[key]; exists {
panic(fmt.Sprintf("dnsproviders: duplicate name %q", key))
}
registry[key] = p
}
ordered = append(ordered, p)
}
// Get returns the provider registered under the given (case-insensitive) name
// or alias.
func Get(name string) (*Provider, bool) {
mu.RLock()
defer mu.RUnlock()
p, ok := registry[strings.ToLower(strings.TrimSpace(name))]
return p, ok
}
// CanonicalNames returns the canonical (first-listed) name of every registered
// provider, sorted alphabetically. Useful for error messages.
func CanonicalNames() []string {
mu.RLock()
defer mu.RUnlock()
names := make([]string, 0, len(ordered))
for _, p := range ordered {
names = append(names, p.Names[0])
}
sort.Strings(names)
return names
}
// List returns all registered providers (canonical entries only), sorted by
// canonical name.
func List() []*Provider {
mu.RLock()
defer mu.RUnlock()
out := make([]*Provider, len(ordered))
copy(out, ordered)
sort.Slice(out, func(i, j int) bool { return out[i].Names[0] < out[j].Names[0] })
return out
}
@@ -0,0 +1,90 @@
package dnsproviders
import (
"strings"
"testing"
)
// TestRegistryHasMainstreamProviders verifies that all expected mainstream
// providers are registered with their canonical names (and key aliases).
func TestRegistryHasMainstreamProviders(t *testing.T) {
cases := map[string][]string{
"cloudflare": {"cf"},
"alidns": {"aliyun"},
"tencentcloud": {"tencent"},
"route53": {"aws"},
"godaddy": nil,
"namecheap": nil,
"namesilo": nil,
"digitalocean": {"do"},
"linode": nil,
"hetzner": nil,
"gandi": nil,
"porkbun": nil,
"netlify": nil,
"azure": nil,
"googleclouddns": {"gcp", "gcloud"},
"huaweicloud": {"huawei"},
"bunny": nil,
"duckdns": nil,
"ovh": nil,
"vultr": nil,
"desec": nil,
}
for canonical, aliases := range cases {
if _, ok := Get(canonical); !ok {
t.Errorf("provider %q not registered", canonical)
}
// case-insensitive lookup must work
if _, ok := Get(strings.ToUpper(canonical)); !ok {
t.Errorf("provider %q not found case-insensitively", canonical)
}
for _, a := range aliases {
if _, ok := Get(a); !ok {
t.Errorf("alias %q for %q not registered", a, canonical)
}
}
}
}
func TestRegistryRejectsUnknown(t *testing.T) {
if _, ok := Get("definitely-not-a-real-provider"); ok {
t.Fatal("Get returned ok for unknown provider")
}
}
// TestProvidersValidateMissingEnv ensures every provider's Build returns a
// helpful error when no credentials are supplied (rather than panicking or
// silently building an unusable client).
//
// route53/azure/googleclouddns are excluded because they intentionally fall
// back to an ambient credential chain (IAM role, managed identity, ADC) and
// produce a usable provider even with empty config.
func TestProvidersValidateMissingEnv(t *testing.T) {
excluded := map[string]bool{
"route53": true,
"azure": true, // partially excluded — actually still requires sub+rg
"googleclouddns": true, // requires GCE_PROJECT
}
for _, p := range List() {
canonical := p.Names[0]
if excluded[canonical] {
continue
}
t.Run(canonical, func(t *testing.T) {
_, err := p.Build(map[string]string{})
if err == nil {
t.Errorf("provider %q built successfully with empty env; expected missing-credential error", canonical)
}
})
}
}
func TestCanonicalNamesSorted(t *testing.T) {
names := CanonicalNames()
for i := 1; i < len(names); i++ {
if names[i-1] > names[i] {
t.Fatalf("CanonicalNames not sorted: %v", names)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/route53"
)
func init() {
Register(&Provider{
Names: []string{"route53", "aws"},
EnvVars: []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "AWS_SESSION_TOKEN", "AWS_HOSTED_ZONE_ID", "AWS_PROFILE"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
// route53 supports the full AWS credential chain (env, IAM role, profile,
// shared credentials file). We deliberately do not require any field —
// users running on EC2/EKS may rely entirely on the instance role.
return &route53.Provider{
AccessKeyId: firstOf(env, "AWS_ACCESS_KEY_ID"),
SecretAccessKey: firstOf(env, "AWS_SECRET_ACCESS_KEY"),
SessionToken: firstOf(env, "AWS_SESSION_TOKEN"),
Region: firstOf(env, "AWS_REGION", "AWS_DEFAULT_REGION"),
Profile: firstOf(env, "AWS_PROFILE"),
HostedZoneID: firstOf(env, "AWS_HOSTED_ZONE_ID"),
}, nil
},
})
}
@@ -0,0 +1,25 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/tencentcloud"
)
func init() {
Register(&Provider{
Names: []string{"tencentcloud", "tencent"},
EnvVars: []string{"TENCENTCLOUD_SECRET_ID", "TENCENTCLOUD_SECRET_KEY", "TENCENTCLOUD_REGION"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
id := firstOf(env, "TENCENTCLOUD_SECRET_ID", "TENCENT_SECRET_ID")
key := firstOf(env, "TENCENTCLOUD_SECRET_KEY", "TENCENT_SECRET_KEY")
if id == "" || key == "" {
return nil, missingEnvErr("tencentcloud", "TENCENTCLOUD_SECRET_ID", "TENCENTCLOUD_SECRET_KEY")
}
return &tencentcloud.Provider{
SecretId: id,
SecretKey: key,
Region: firstOf(env, "TENCENTCLOUD_REGION"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/vultr/v2"
)
func init() {
Register(&Provider{
Names: []string{"vultr"},
EnvVars: []string{"VULTR_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "VULTR_API_TOKEN", "VULTR_API_KEY")
if token == "" {
return nil, missingEnvErr("vultr", "VULTR_API_TOKEN")
}
return &vultr.Provider{APIToken: token}, nil
},
})
}
+567 -31
View File
@@ -1,23 +1,34 @@
package config
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/cedar2025/xboard-node/internal/nlog"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)
type Config struct {
InstanceID string `yaml:"-"`
Panel PanelConfig `yaml:"panel"`
Node NodeConfig `yaml:"node"`
Kernel KernelConfig `yaml:"kernel"`
Cert CertConfig `yaml:"cert"`
Log LogConfig `yaml:"log"`
Runtime RuntimeConfig `yaml:"runtime"`
WS WSConfig `yaml:"ws"`
// Standalone enables a local-only node that never contacts the panel.
Standalone *StandaloneConfig `yaml:"standalone,omitempty"`
// HealthPort enables a lightweight HTTP health-check endpoint on the
// given port (e.g. 65530). 0 = disabled (default).
HealthPort int `yaml:"health_port"`
@@ -25,6 +36,20 @@ type Config struct {
// one service instance is started per entry. All entries share the same
// panel URL/token, kernel type, log settings and runtime tuning.
Nodes []NodeEntry `yaml:"nodes,omitempty"`
// Machine enables machine mode: a single process manages all nodes
// bound to this machine on the panel. Nodes are discovered dynamically
// via GET /api/v2/server/machine/nodes. When set, Panel.NodeID, Nodes
// and Panel.Token are ignored; the machine token is used instead.
Machine *MachineConfig `yaml:"machine,omitempty"`
}
// MachineConfig identifies this process as a panel-managed machine that
// dynamically discovers and runs all nodes bound to it.
type MachineConfig struct {
MachineID int `yaml:"machine_id"`
Token string `yaml:"token"`
TokenEnv string `yaml:"token_env,omitempty"`
}
// NodeEntry describes a single node in multi-node mode.
@@ -68,15 +93,28 @@ type RuntimeConfig struct {
}
type PanelConfig struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
NodeID int `yaml:"node_id"`
NodeType string `yaml:"node_type"`
URL string `yaml:"url"`
Token string `yaml:"token"`
TokenEnv string `yaml:"token_env,omitempty"`
NodeID int `yaml:"node_id"`
NodeType string `yaml:"node_type"`
MachineID int `yaml:"-"`
}
type NodeConfig struct {
PushInterval int `yaml:"push_interval"`
PullInterval int `yaml:"pull_interval"`
PushInterval int `yaml:"push_interval"`
PullInterval int `yaml:"pull_interval"`
TrackInterval int `yaml:"track_interval"` // sec, default 10
DeviceReportInterval int `yaml:"device_report_interval"` // sec, default 30
}
// WSConfig holds WebSocket client tuning options.
type WSConfig struct {
StatusInterval int `yaml:"status_interval"` // node.status interval (sec), default 10
HandshakeTimeout int `yaml:"handshake_timeout"` // WS handshake timeout (sec), default 15
BackoffInitial int `yaml:"backoff_initial"` // initial reconnect delay (sec), default 1
BackoffMax int `yaml:"backoff_max"` // max reconnect delay (sec), default 60
DiscoveryInterval int `yaml:"discovery_interval"` // WS discovery interval (sec), default 300
}
type KernelConfig struct {
@@ -145,6 +183,174 @@ type LogConfig struct {
Output string `yaml:"output"`
}
type RootConfig struct {
Instances []Config `yaml:"instances,omitempty"`
Config `yaml:",inline"`
}
func LoadRoot(path string) (*RootConfig, error) {
rc := &RootConfig{}
data, err := os.ReadFile(path)
if err == nil {
if err := yaml.Unmarshal(data, rc); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if len(rc.Instances) == 0 {
legacy := &Config{}
if err := yaml.Unmarshal(data, legacy); err != nil {
return nil, fmt.Errorf("parse legacy config: %w", err)
}
rc.Config = *legacy
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("read config file: %w", err)
}
// In multi-instance mode, top-level settings (log, kernel, node, ws, etc.)
// serve as defaults that each instance inherits. Instance-level settings
// take precedence over top-level ones.
if len(rc.Instances) > 0 {
for i := range rc.Instances {
rc.Instances[i].inheritFrom(&rc.Config)
}
}
// Derive default base directory from the config file's location so that
// data (certs, sing-box cache, …) lives next to the config file when
// config_dir is not explicitly set. This makes non-root / dev deployments
// work without any extra configuration.
baseDir := configBaseDir(path)
rc.applyEnvOverrides()
rc.resolveEnvRefs()
// Compute stable instance IDs early — they're content-based (derived from
// panel URL + machine/node ID), so reordering instances in the YAML doesn't
// cause data-directory mix-ups. Must run before setDefaultsFrom which uses
// InstanceID to derive per-instance config_dir.
if err := rc.assignInstanceIDs(); err != nil {
return nil, err
}
rc.setDefaultsFrom(baseDir)
if err := rc.validateRoot(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return rc, nil
}
func (rc *RootConfig) applyEnvOverrides() {
if len(rc.Instances) == 0 {
rc.Config.applyEnvOverrides()
return
}
for i := range rc.Instances {
rc.Instances[i].applyEnvOverrides()
}
}
func (rc *RootConfig) resolveEnvRefs() {
if len(rc.Instances) == 0 {
rc.Config.resolveEnvRefs()
return
}
for i := range rc.Instances {
rc.Instances[i].resolveEnvRefs()
}
}
func (rc *RootConfig) setDefaultsFrom(baseDir string) {
if len(rc.Instances) == 0 {
rc.Config.setDefaultsFrom(baseDir)
return
}
for i := range rc.Instances {
instBase := baseDir
// When multiple instances share the same base dir, derive a unique
// sub-directory per instance to avoid config_dir collisions.
// InstanceID is always populated by assignInstanceIDs() before this runs.
if len(rc.Instances) > 1 && rc.Instances[i].Kernel.ConfigDir == "" {
instBase = filepath.Join(baseDir, rc.Instances[i].InstanceID)
}
rc.Instances[i].setDefaultsFrom(instBase)
}
}
// assignInstanceIDs computes a stable, content-based InstanceID for each
// instance that doesn't already have one. The ID is derived from the panel URL
// and machine/node ID, so reordering instances in the YAML file doesn't cause
// data directories to swap.
func (rc *RootConfig) assignInstanceIDs() error {
for i := range rc.Instances {
if rc.Instances[i].InstanceID != "" {
continue
}
id, err := rc.Instances[i].AutoInstanceID()
if err != nil {
return fmt.Errorf("instances[%d]: %w", i, err)
}
rc.Instances[i].InstanceID = id
}
return nil
}
// configBaseDir resolves the canonical base directory from the config file path.
// This directory is used as the default for config_dir when not explicitly set,
// so that data (certs, sing-box cache, …) lives next to the config file.
func configBaseDir(configPath string) string {
abs, err := filepath.Abs(configPath)
if err != nil {
return "/etc/xboard-node"
}
return filepath.Dir(abs)
}
func (rc *RootConfig) validateRoot() error {
if len(rc.Instances) == 0 {
return rc.Config.validate()
}
seen := map[string]struct{}{}
for i := range rc.Instances {
// InstanceID is already computed by assignInstanceIDs().
id := rc.Instances[i].InstanceID
if err := rc.Instances[i].validate(); err != nil {
return fmt.Errorf("instances[%d]: %w", i, err)
}
if _, ok := seen[id]; ok {
return fmt.Errorf("instances[%d]: duplicate instance id %q", i, id)
}
seen[id] = struct{}{}
}
return nil
}
func (rc *RootConfig) NormalizeInstances() ([]*Config, error) {
if len(rc.Instances) == 0 {
id, err := rc.Config.AutoInstanceID()
if err != nil {
return nil, err
}
rc.Config.InstanceID = id
return []*Config{&rc.Config}, nil
}
out := make([]*Config, 0, len(rc.Instances))
for i := range rc.Instances {
cfg := rc.Instances[i]
if cfg.InstanceID == "" {
id, err := cfg.AutoInstanceID()
if err != nil {
return nil, err
}
cfg.InstanceID = id
}
out = append(out, &cfg)
}
return out, nil
}
// Load reads configuration from a YAML file, then applies environment variable
// overrides. If the config file does not exist, a config is built entirely from
// environment variables (useful for Docker deployment with -e flags).
@@ -160,8 +366,11 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("read config file: %w", err)
}
baseDir := configBaseDir(path)
cfg.applyEnvOverrides()
cfg.setDefaults()
cfg.resolveEnvRefs()
cfg.setDefaultsFrom(baseDir)
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
@@ -211,14 +420,145 @@ func (c *Config) applyEnvOverrides() {
if v := envFirst("logLevel", "LOG_LEVEL"); v != "" {
c.Log.Level = v
}
if v := envFirst("MACHINE_ID"); v != "" {
if id, err := strconv.Atoi(v); err == nil {
if c.Machine == nil {
c.Machine = &MachineConfig{}
}
c.Machine.MachineID = id
}
}
if v := envFirst("MACHINE_TOKEN"); v != "" {
if c.Machine == nil {
c.Machine = &MachineConfig{}
}
c.Machine.Token = v
}
}
func (c *Config) setDefaults() {
func (c *Config) resolveEnvRefs() {
if c.Panel.Token == "" && c.Panel.TokenEnv != "" {
c.Panel.Token = os.Getenv(c.Panel.TokenEnv)
}
if c.Machine != nil && c.Machine.Token == "" && c.Machine.TokenEnv != "" {
c.Machine.Token = os.Getenv(c.Machine.TokenEnv)
}
}
// inheritFrom copies non-zero fields from the parent config into c when c's
// corresponding field is zero-valued. This lets top-level settings serve as
// defaults for each instance in multi-instance configs.
// Fields that must be unique per instance (config_dir, cert_dir, instance_id)
// are intentionally excluded.
func (c *Config) inheritFrom(parent *Config) {
// Log
if c.Log.Level == "" {
c.Log.Level = parent.Log.Level
}
if c.Log.Output == "" {
c.Log.Output = parent.Log.Output
}
// Node intervals
if c.Node.PushInterval == 0 {
c.Node.PushInterval = parent.Node.PushInterval
}
if c.Node.PullInterval == 0 {
c.Node.PullInterval = parent.Node.PullInterval
}
if c.Node.TrackInterval == 0 {
c.Node.TrackInterval = parent.Node.TrackInterval
}
if c.Node.DeviceReportInterval == 0 {
c.Node.DeviceReportInterval = parent.Node.DeviceReportInterval
}
// WS
if c.WS.StatusInterval == 0 {
c.WS.StatusInterval = parent.WS.StatusInterval
}
if c.WS.HandshakeTimeout == 0 {
c.WS.HandshakeTimeout = parent.WS.HandshakeTimeout
}
if c.WS.BackoffInitial == 0 {
c.WS.BackoffInitial = parent.WS.BackoffInitial
}
if c.WS.BackoffMax == 0 {
c.WS.BackoffMax = parent.WS.BackoffMax
}
if c.WS.DiscoveryInterval == 0 {
c.WS.DiscoveryInterval = parent.WS.DiscoveryInterval
}
// Runtime
if c.Runtime.GoGCPercent == 0 {
c.Runtime.GoGCPercent = parent.Runtime.GoGCPercent
}
if c.Runtime.GoMemLimit == "" {
c.Runtime.GoMemLimit = parent.Runtime.GoMemLimit
}
// Kernel (NOT config_dir — each instance needs unique dir)
if c.Kernel.Type == "" {
c.Kernel.Type = parent.Kernel.Type
}
if c.Kernel.LogLevel == "" {
c.Kernel.LogLevel = parent.Kernel.LogLevel
}
if c.Kernel.GeoDataDir == "" {
c.Kernel.GeoDataDir = parent.Kernel.GeoDataDir
}
if c.Kernel.CustomConfig == "" {
c.Kernel.CustomConfig = parent.Kernel.CustomConfig
}
if len(c.Kernel.CustomOutbound) == 0 {
c.Kernel.CustomOutbound = parent.Kernel.CustomOutbound
}
if len(c.Kernel.CustomRoute) == 0 {
c.Kernel.CustomRoute = parent.Kernel.CustomRoute
}
// Cert (NOT cert_dir — derived from config_dir later)
if c.Cert.CertMode == "" {
c.Cert.CertMode = parent.Cert.CertMode
}
if c.Cert.Domain == "" {
c.Cert.Domain = parent.Cert.Domain
}
if c.Cert.Email == "" {
c.Cert.Email = parent.Cert.Email
}
if c.Cert.CertFile == "" {
c.Cert.CertFile = parent.Cert.CertFile
}
if c.Cert.KeyFile == "" {
c.Cert.KeyFile = parent.Cert.KeyFile
}
if c.Cert.DNSProvider == "" {
c.Cert.DNSProvider = parent.Cert.DNSProvider
}
if len(c.Cert.DNSEnv) == 0 {
c.Cert.DNSEnv = parent.Cert.DNSEnv
}
if c.Cert.HTTPPort == 0 {
c.Cert.HTTPPort = parent.Cert.HTTPPort
}
if c.Cert.CertContent == "" {
c.Cert.CertContent = parent.Cert.CertContent
}
if c.Cert.KeyContent == "" {
c.Cert.KeyContent = parent.Cert.KeyContent
}
// Only inherit AutoTLS when the child has no explicit cert configuration.
// A child with cert_mode, cert_file, or cert_content explicitly chooses
// its own cert strategy — inheriting auto_tls would override that choice.
childHasCertConfig := c.Cert.CertMode != "" || c.Cert.CertFile != "" || c.Cert.CertContent != ""
if !childHasCertConfig && !c.Cert.AutoTLS && parent.Cert.AutoTLS {
c.Cert.AutoTLS = parent.Cert.AutoTLS
}
}
func (c *Config) setDefaultsFrom(baseDir string) {
if c.Kernel.Type == "" {
c.Kernel.Type = "singbox"
}
if c.Kernel.ConfigDir == "" {
c.Kernel.ConfigDir = "/etc/xboard-node"
c.Kernel.ConfigDir = baseDir
}
if c.Kernel.GeoDataDir == "" {
c.Kernel.GeoDataDir = c.Kernel.ConfigDir
@@ -238,22 +578,145 @@ func (c *Config) setDefaults() {
if c.Cert.HTTPPort == 0 {
c.Cert.HTTPPort = 80
}
// WS defaults
if c.WS.StatusInterval == 0 {
c.WS.StatusInterval = 10
}
if c.WS.HandshakeTimeout == 0 {
c.WS.HandshakeTimeout = 15
}
if c.WS.BackoffInitial == 0 {
c.WS.BackoffInitial = 1
}
if c.WS.BackoffMax == 0 {
c.WS.BackoffMax = 60
}
if c.WS.DiscoveryInterval == 0 {
c.WS.DiscoveryInterval = 300
}
// Node defaults
if c.Node.TrackInterval == 0 {
c.Node.TrackInterval = 10
}
if c.Node.DeviceReportInterval == 0 {
c.Node.DeviceReportInterval = 30
}
}
func (c *Config) IsMachineMode() bool {
return c.Machine != nil && c.Machine.MachineID > 0
}
func (c *Config) AutoInstanceID() (string, error) {
mode := "node"
target := "0"
baseURL := strings.TrimSpace(c.Panel.URL)
if c.IsStandalone() {
mode = "standalone"
target = "local"
baseURL = "standalone"
} else if c.IsMachineMode() {
mode = "machine"
target = strconv.Itoa(c.Machine.MachineID)
} else if len(c.Nodes) > 0 {
// Multi-node mode: include sorted node IDs in the hash to avoid collisions.
ids := make([]string, len(c.Nodes))
for i, n := range c.Nodes {
ids[i] = strconv.Itoa(n.NodeID)
}
sort.Strings(ids)
target = strings.Join(ids, ",")
} else {
target = strconv.Itoa(c.Panel.NodeID)
}
normalized, slug, err := normalizeBaseURL(baseURL)
if err != nil {
return "", err
}
key := normalized + "|" + mode + "|" + target
sum := sha1.Sum([]byte(key))
short := hex.EncodeToString(sum[:])[:6]
return fmt.Sprintf("%s-%s-%s-%s", slug, mode, target, short), nil
}
func normalizeBaseURL(raw string) (string, string, error) {
if raw == "standalone" {
return raw, raw, nil
}
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", "", fmt.Errorf("invalid panel.url %q: %w", raw, err)
}
if u.Scheme == "" || u.Host == "" {
return "", "", fmt.Errorf("invalid panel.url %q", raw)
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.RawQuery = ""
u.Fragment = ""
pathPart := strings.TrimSuffix(u.EscapedPath(), "/")
normalized := u.Scheme + "://" + u.Host
if pathPart != "" && pathPart != "/" {
normalized += pathPart
}
slugBase := u.Host
if pathPart != "" && pathPart != "/" {
slugBase += "-" + strings.Trim(pathPart, "/")
}
slugBase = strings.ToLower(slugBase)
var b strings.Builder
lastDash := false
for _, r := range slugBase {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
slug := strings.Trim(b.String(), "-")
if slug == "" {
slug = "panel"
}
if len(slug) > 48 {
slug = strings.Trim(slug[:48], "-")
}
return normalized, slug, nil
}
func (c *Config) validate() error {
if c.Panel.URL == "" {
return fmt.Errorf("panel.url is required")
}
if c.Panel.Token == "" {
return fmt.Errorf("panel.token is required")
}
// In multi-node mode panel.node_id is optional; validate each NodeEntry instead.
if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 {
return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)")
}
for i, n := range c.Nodes {
if n.NodeID <= 0 {
return fmt.Errorf("nodes[%d].node_id must be positive", i)
if c.IsStandalone() {
if err := c.validateStandalone(); err != nil {
return err
}
} else if c.IsMachineMode() {
if c.Panel.URL == "" {
return fmt.Errorf("panel.url is required")
}
if c.Machine.Token == "" {
return fmt.Errorf("machine.token is required")
}
if len(c.Nodes) > 0 {
return fmt.Errorf("machine mode and nodes: are mutually exclusive")
}
} else {
if c.Panel.URL == "" {
return fmt.Errorf("panel.url is required")
}
if c.Panel.Token == "" {
return fmt.Errorf("panel.token is required")
}
// In multi-node mode panel.node_id is optional; validate each NodeEntry instead.
if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 {
return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)")
}
for i, n := range c.Nodes {
if n.NodeID <= 0 {
return fmt.Errorf("nodes[%d].node_id must be positive", i)
}
}
}
switch c.Kernel.Type {
@@ -330,41 +793,114 @@ func (c *Config) ExpandNodes() []*Config {
return result
}
func (c *Config) ExpandMachineNode(nodeID int, nodeType string) *Config {
nodeCfg := *c
nodeCfg.Nodes = nil
nodeCfg.Panel.NodeID = nodeID
nodeCfg.Panel.NodeType = nodeType
nodeCfg.Panel.Token = c.Machine.Token
nodeCfg.Panel.MachineID = c.Machine.MachineID
nodeCfg.Kernel.ConfigDir = fmt.Sprintf("%s/node-%d", c.Kernel.ConfigDir, nodeID)
if nodeCfg.Kernel.GeoDataDir == c.Kernel.ConfigDir {
nodeCfg.Kernel.GeoDataDir = c.Kernel.GeoDataDir
}
nodeCfg.Cert.CertDir = filepath.Join(nodeCfg.Kernel.ConfigDir, "certs")
return &nodeCfg
}
func InitLogger(cfg LogConfig) {
var level slog.Level
var minLevel slog.Level
switch cfg.Level {
case "debug":
level = slog.LevelDebug
minLevel = slog.LevelDebug
case "warn":
level = slog.LevelWarn
minLevel = slog.LevelWarn
case "error":
level = slog.LevelError
minLevel = slog.LevelError
default:
level = slog.LevelInfo
minLevel = slog.LevelInfo
}
var w io.Writer
useColor := false
switch cfg.Output {
case "stdout", "":
w = os.Stdout
useColor = term.IsTerminal(int(os.Stdout.Fd()))
case "stderr":
w = os.Stderr
useColor = term.IsTerminal(int(os.Stderr.Fd()))
default:
dir := filepath.Dir(cfg.Output)
if err := os.MkdirAll(dir, 0o755); err != nil {
slog.Error("failed to create log dir, falling back to stdout", "error", err)
fmt.Fprintf(os.Stderr, "failed to create log dir, falling back to stdout: %v\n", err)
w = os.Stdout
useColor = term.IsTerminal(int(os.Stdout.Fd()))
} else {
f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
slog.Error("failed to open log file, falling back to stdout", "error", err)
fmt.Fprintf(os.Stderr, "failed to open log file, falling back to stdout: %v\n", err)
w = os.Stdout
useColor = term.IsTerminal(int(os.Stdout.Fd()))
} else {
w = f
useColor = false
}
}
}
handler := slog.NewTextHandler(w, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(handler))
nlog.Init(w, minLevel, useColor)
// Application logging goes through nlog; silence slog.Default for stray library use.
slog.SetDefault(slog.New(slog.DiscardHandler))
}
// ValidateStartupLayout checks that multiple instances do not conflict on
// health ports, kernel config directories, or node bindings.
func ValidateStartupLayout(instances []*Config) error {
healthPorts := make(map[int]string)
configDirs := make(map[string]string)
nodeBindings := make(map[string]string)
for _, instance := range instances {
if instance == nil {
continue
}
owner := instance.InstanceID
if instance.HealthPort > 0 {
if other, ok := healthPorts[instance.HealthPort]; ok {
return fmt.Errorf("health_port %d is used by both %s and %s", instance.HealthPort, other, owner)
}
healthPorts[instance.HealthPort] = owner
}
if dir := strings.TrimSpace(instance.Kernel.ConfigDir); dir != "" {
if other, ok := configDirs[dir]; ok && other != owner {
return fmt.Errorf("kernel config_dir %q is shared by %s and %s", dir, other, owner)
}
configDirs[dir] = owner
}
// Machine mode discovers nodes dynamically — skip static binding check,
// but still validate custom_config if set at the instance level.
if instance.IsMachineMode() {
if path := strings.TrimSpace(instance.Kernel.CustomConfig); path != "" {
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("kernel.custom_config %q for %s: %w", path, owner, err)
}
}
continue
}
for _, node := range instance.ExpandNodes() {
binding := fmt.Sprintf("%s/%d", strings.TrimSpace(node.Panel.URL), node.Panel.NodeID)
if other, ok := nodeBindings[binding]; ok {
return fmt.Errorf("node binding %s is declared by both %s and %s", binding, other, owner)
}
nodeBindings[binding] = owner
if path := strings.TrimSpace(node.Kernel.CustomConfig); path != "" {
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("kernel.custom_config %q for %s: %w", path, owner, err)
}
}
}
}
return nil
}
+279 -3
View File
@@ -1,6 +1,7 @@
package config
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -67,8 +68,10 @@ panel:
if cfg.Kernel.Type != "singbox" {
t.Errorf("default kernel.type: got %q, want singbox", cfg.Kernel.Type)
}
if cfg.Kernel.ConfigDir != "/etc/xboard-node" {
t.Errorf("default config_dir: got %q", cfg.Kernel.ConfigDir)
// config_dir should default to the directory containing the config file.
expectedDir := filepath.Dir(path)
if cfg.Kernel.ConfigDir != expectedDir {
t.Errorf("default config_dir: got %q, want %q", cfg.Kernel.ConfigDir, expectedDir)
}
if cfg.Kernel.LogLevel != "warn" {
t.Errorf("default kernel log_level: got %q, want warn", cfg.Kernel.LogLevel)
@@ -82,7 +85,7 @@ panel:
if cfg.Cert.HTTPPort != 80 {
t.Errorf("default http_port: got %d, want 80", cfg.Cert.HTTPPort)
}
expectedCertDir := filepath.Join("/etc/xboard-node", "certs")
expectedCertDir := filepath.Join(expectedDir, "certs")
if cfg.Cert.CertDir != expectedCertDir {
t.Errorf("default cert_dir: got %q, want %q", cfg.Cert.CertDir, expectedCertDir)
}
@@ -267,3 +270,276 @@ node:
t.Errorf("pull_interval: got %d", cfg.Node.PullInterval)
}
}
func TestLoad_StandaloneConfig(t *testing.T) {
path := writeTemp(t, `
standalone:
enabled: true
node:
protocol: "vless"
server_port: 8443
network: "ws"
tls: 1
network_settings:
path: "/ws"
users:
- id: 1
uuid: "11111111-1111-1111-1111-111111111111"
kernel:
type: xray
cert:
cert_mode: none
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.IsStandalone() {
t.Fatal("expected standalone mode to be enabled")
}
if cfg.Standalone.Node.Protocol != "vless" {
t.Errorf("standalone.node.protocol: got %q", cfg.Standalone.Node.Protocol)
}
if cfg.Standalone.Node.ServerPort != 8443 {
t.Errorf("standalone.node.server_port: got %d", cfg.Standalone.Node.ServerPort)
}
if len(cfg.Standalone.Users) != 1 {
t.Fatalf("standalone.users: got %d", len(cfg.Standalone.Users))
}
if cfg.Standalone.Users[0].UUID != "11111111-1111-1111-1111-111111111111" {
t.Errorf("standalone.users[0].uuid: got %q", cfg.Standalone.Users[0].UUID)
}
}
func TestLoad_StandaloneRequiresUsers(t *testing.T) {
path := writeTemp(t, `
standalone:
enabled: true
node:
protocol: "trojan"
server_port: 443
kernel:
type: singbox
`)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for standalone mode without users")
}
}
func TestLoadRoot_LegacyConfigNormalizesToSingleInstance(t *testing.T) {
path := writeTemp(t, `
panel:
url: "https://panel.example.com"
token: "tok"
node_id: 1
`)
root, err := LoadRoot(path)
if err != nil {
t.Fatalf("LoadRoot: %v", err)
}
instances, err := root.NormalizeInstances()
if err != nil {
t.Fatalf("NormalizeInstances: %v", err)
}
if len(instances) != 1 {
t.Fatalf("instances: got %d, want 1", len(instances))
}
if instances[0].Panel.URL != "https://panel.example.com" {
t.Errorf("instance url: got %q", instances[0].Panel.URL)
}
if instances[0].InstanceID == "" {
t.Fatal("expected auto instance id")
}
}
func TestLoadRoot_InstancesConfig(t *testing.T) {
path := writeTemp(t, `
instances:
- panel:
url: "https://panel-a.example.com"
token_env: "PANEL_A_TOKEN"
node_id: 1
kernel:
type: singbox
- panel:
url: "https://panel-b.example.com"
machine:
machine_id: 2
token_env: "PANEL_B_MACHINE_TOKEN"
kernel:
type: singbox
`)
t.Setenv("PANEL_A_TOKEN", "token-a")
t.Setenv("PANEL_B_MACHINE_TOKEN", "token-b")
root, err := LoadRoot(path)
if err != nil {
t.Fatalf("LoadRoot: %v", err)
}
instances, err := root.NormalizeInstances()
if err != nil {
t.Fatalf("NormalizeInstances: %v", err)
}
if len(instances) != 2 {
t.Fatalf("instances: got %d, want 2", len(instances))
}
if instances[0].Panel.Token != "token-a" {
t.Errorf("panel a token: got %q", instances[0].Panel.Token)
}
if instances[1].Machine == nil || instances[1].Machine.Token != "token-b" {
t.Fatalf("machine token: got %+v", instances[1].Machine)
}
if instances[0].InstanceID == instances[1].InstanceID {
t.Fatal("expected unique instance ids")
}
}
func TestConfig_AutoInstanceIDStable(t *testing.T) {
cfg := &Config{Panel: PanelConfig{URL: "https://Panel.Example.com/", NodeID: 1, Token: "tok"}}
cfg.setDefaultsFrom("/etc/xboard-node")
id1, err := cfg.AutoInstanceID()
if err != nil {
t.Fatalf("AutoInstanceID: %v", err)
}
id2, err := cfg.AutoInstanceID()
if err != nil {
t.Fatalf("AutoInstanceID second: %v", err)
}
if id1 != id2 {
t.Fatalf("ids differ: %q vs %q", id1, id2)
}
}
func TestLoadRoot_InheritanceFromTopLevel(t *testing.T) {
path := writeTemp(t, `
log:
level: "debug"
output: "stderr"
kernel:
type: xray
log_level: "error"
node:
push_interval: 42
pull_interval: 99
instances:
- panel:
url: "https://panel.example.com"
token: "tok-a"
node_id: 1
- panel:
url: "https://panel.example.com"
token: "tok-b"
node_id: 2
log:
level: "warn"
`)
root, err := LoadRoot(path)
if err != nil {
t.Fatalf("LoadRoot: %v", err)
}
if len(root.Instances) != 2 {
t.Fatalf("instances: got %d, want 2", len(root.Instances))
}
// Instance 0: inherits everything from top-level.
inst0 := root.Instances[0]
if inst0.Log.Level != "debug" {
t.Errorf("inst0 log.level: got %q, want %q", inst0.Log.Level, "debug")
}
if inst0.Log.Output != "stderr" {
t.Errorf("inst0 log.output: got %q, want %q", inst0.Log.Output, "stderr")
}
if inst0.Kernel.Type != "xray" {
t.Errorf("inst0 kernel.type: got %q, want %q", inst0.Kernel.Type, "xray")
}
if inst0.Kernel.LogLevel != "error" {
t.Errorf("inst0 kernel.log_level: got %q, want %q", inst0.Kernel.LogLevel, "error")
}
if inst0.Node.PushInterval != 42 {
t.Errorf("inst0 push_interval: got %d, want 42", inst0.Node.PushInterval)
}
if inst0.Node.PullInterval != 99 {
t.Errorf("inst0 pull_interval: got %d, want 99", inst0.Node.PullInterval)
}
// Instance 1: overrides log.level, inherits the rest.
inst1 := root.Instances[1]
if inst1.Log.Level != "warn" {
t.Errorf("inst1 log.level: got %q, want %q", inst1.Log.Level, "warn")
}
if inst1.Log.Output != "stderr" {
t.Errorf("inst1 log.output: got %q, want %q", inst1.Log.Output, "stderr")
}
if inst1.Kernel.Type != "xray" {
t.Errorf("inst1 kernel.type: got %q, want %q", inst1.Kernel.Type, "xray")
}
}
func TestLoadRoot_InstanceOrderDoesNotAffectConfigDir(t *testing.T) {
yamlTmpl := func(first, second int) string {
return fmt.Sprintf(`
instances:
- panel:
url: "https://panel.example.com"
token: "tok-a"
node_id: %d
- panel:
url: "https://panel.example.com"
token: "tok-b"
node_id: %d
`, first, second)
}
path1 := writeTemp(t, yamlTmpl(1, 2))
path2 := writeTemp(t, yamlTmpl(2, 1))
root1, err := LoadRoot(path1)
if err != nil {
t.Fatalf("LoadRoot order1: %v", err)
}
root2, err := LoadRoot(path2)
if err != nil {
t.Fatalf("LoadRoot order2: %v", err)
}
// Collect the config_dir suffix (relative to base) by InstanceID.
suffix := func(instances []Config) map[string]string {
m := map[string]string{}
for _, inst := range instances {
// The config_dir ends with .../{instanceID}, extract last component.
m[inst.InstanceID] = filepath.Base(inst.Kernel.ConfigDir)
}
return m
}
dirs1 := suffix(root1.Instances)
dirs2 := suffix(root2.Instances)
for id, s1 := range dirs1 {
s2, ok := dirs2[id]
if !ok {
t.Fatalf("instance %q missing in reversed order", id)
}
if s1 != s2 {
t.Errorf("instance %q config_dir suffix differs: %q vs %q", id, s1, s2)
}
}
}
func TestInheritFrom_AutoTLSNotForcedWhenChildHasCertMode(t *testing.T) {
parent := &Config{Cert: CertConfig{AutoTLS: true, Domain: "example.com"}}
child := &Config{Cert: CertConfig{CertMode: "file", CertFile: "/etc/cert.pem"}}
child.inheritFrom(parent)
if child.Cert.AutoTLS {
t.Error("auto_tls should NOT be inherited when child has explicit cert_mode")
}
if child.Cert.Domain != "example.com" {
t.Errorf("domain: got %q, want %q", child.Cert.Domain, "example.com")
}
}
func TestInheritFrom_AutoTLSInheritedWhenChildHasNoCertConfig(t *testing.T) {
parent := &Config{Cert: CertConfig{AutoTLS: true, Domain: "example.com"}}
child := &Config{}
child.inheritFrom(parent)
if !child.Cert.AutoTLS {
t.Error("auto_tls should be inherited when child has no cert config")
}
}
+152
View File
@@ -0,0 +1,152 @@
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"`
CustomRouteRules []StandaloneCustomRouteRule `yaml:"custom_route_rules,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 StandaloneCustomRouteRule struct {
Name string `yaml:"name,omitempty"`
Disabled bool `yaml:"disabled,omitempty"`
Match StandaloneRouteMatch `yaml:"match,omitempty"`
Action StandaloneRouteAction `yaml:"action,omitempty"`
}
type StandaloneRouteMatch struct {
Domains []string `yaml:"domains,omitempty"`
DomainSuffixes []string `yaml:"domain_suffixes,omitempty"`
IPCIDRs []string `yaml:"ip_cidrs,omitempty"`
Ports []string `yaml:"ports,omitempty"`
Networks []string `yaml:"networks,omitempty"`
SourceCIDRs []string `yaml:"source_cidrs,omitempty"`
SourcePorts []string `yaml:"source_ports,omitempty"`
}
type StandaloneRouteAction struct {
Type string `yaml:"type,omitempty"`
Target string `yaml:"target,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
}
+160
View File
@@ -0,0 +1,160 @@
package config
import (
"context"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/fsnotify/fsnotify"
)
// Watcher reloads the config file on change (debounced) and calls onChange.
type Watcher struct {
path string
debounce time.Duration
onChange func(*Config)
onChangeRoot func(*RootConfig)
watcher *fsnotify.Watcher
stopOnce sync.Once
stopCh chan struct{}
stopped atomic.Bool
}
// WatchConfig watches path; invalid reloads are logged and ignored.
// Stop via Stop() or cancel ctx.
func WatchConfig(ctx context.Context, path string, onChange func(*Config)) (*Watcher, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, err
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
// Watch parent dir so rename/atomic saves are visible.
dir := filepath.Dir(absPath)
if err := fsw.Add(dir); err != nil {
fsw.Close()
return nil, err
}
w := &Watcher{
path: absPath,
debounce: 1 * time.Second,
onChange: onChange,
watcher: fsw,
stopCh: make(chan struct{}),
}
go w.loop(ctx)
nlog.Core().Info("config watcher started", "path", absPath)
return w, nil
}
// WatchConfigRoot watches path and reloads the root config model.
func WatchConfigRoot(ctx context.Context, path string, onChange func(*RootConfig)) (*Watcher, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, err
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
dir := filepath.Dir(absPath)
if err := fsw.Add(dir); err != nil {
fsw.Close()
return nil, err
}
w := &Watcher{
path: absPath,
debounce: 1 * time.Second,
onChangeRoot: onChange,
watcher: fsw,
stopCh: make(chan struct{}),
}
go w.loop(ctx)
nlog.Core().Info("config watcher started", "path", absPath)
return w, nil
}
func (w *Watcher) loop(ctx context.Context) {
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
w.watcher.Close()
}()
for {
select {
case <-ctx.Done():
return
case <-w.stopCh:
return
case ev, ok := <-w.watcher.Events:
if !ok {
return
}
if filepath.Clean(ev.Name) != w.path {
continue
}
if !ev.Has(fsnotify.Write) && !ev.Has(fsnotify.Create) {
continue
}
if timer == nil {
timer = time.AfterFunc(w.debounce, w.reload)
} else {
timer.Reset(w.debounce)
}
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
nlog.Core().Warn("config watcher error", "error", err)
}
}
}
func (w *Watcher) reload() {
if w.stopped.Load() {
return
}
if w.onChangeRoot != nil {
root, err := LoadRoot(w.path)
if err != nil {
nlog.Core().Error("config reload failed, keeping current config", "error", err)
return
}
nlog.Core().Info("config reloaded successfully")
w.onChangeRoot(root)
return
}
cfg, err := Load(w.path)
if err != nil {
nlog.Core().Error("config reload failed, keeping current config", "error", err)
return
}
nlog.Core().Info("config reloaded successfully")
w.onChange(cfg)
}
func (w *Watcher) Stop() {
w.stopped.Store(true)
w.stopOnce.Do(func() { close(w.stopCh) })
}
+61
View File
@@ -0,0 +1,61 @@
package controlplane
import (
"context"
"fmt"
"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:
}
nodeSpec, err := model.NodeSpecFromStandaloneValidated(l.cfg)
if err != nil {
return Bootstrap{}, fmt.Errorf("load standalone node config: %w", err)
}
return Bootstrap{
PushInterval: l.cfg.Node.PushInterval,
PullInterval: l.cfg.Node.PullInterval,
Config: nodeSpec,
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{} }
+32
View File
@@ -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)
}
}
+145
View File
@@ -0,0 +1,145 @@
package controlplane
import (
"context"
"fmt"
"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"
)
// MachinePanelControlPlane implements ControlPlane for a single node running
// under the machine orchestrator. REST calls go through a per-node panel.Client
// with machine-level auth; WS events are delivered via an externally-managed
// PushClient supplied by the machine WS mux.
type MachinePanelControlPlane struct {
client *panel.Client
kcfg config.KernelConfig
push PushClient // virtual push client from WS mux (may be nil)
registerFn func(statuses chan<- StatusChange) *NodeMailbox
}
// NewMachinePanelControlPlane creates a per-node control plane for machine mode.
// push may be nil if WS is unavailable.
// registerFn is called during Initial() to wire machine mailbox access and status
// updates into the service loop. It may be nil when WS is not used.
func NewMachinePanelControlPlane(
client *panel.Client,
kcfg config.KernelConfig,
push PushClient,
registerFn func(statuses chan<- StatusChange) *NodeMailbox,
) *MachinePanelControlPlane {
return &MachinePanelControlPlane{
client: client,
kcfg: kcfg,
push: push,
registerFn: registerFn,
}
}
func (p *MachinePanelControlPlane) SupportsPolling() bool { return true }
func (p *MachinePanelControlPlane) SupportsDiscovery() bool { return false }
func (p *MachinePanelControlPlane) SupportsReporting() bool { return true }
func (p *MachinePanelControlPlane) SupportsDeviceReports() bool { return p.push != nil }
func (p *MachinePanelControlPlane) Initial(
ctx context.Context,
metricsFn func() map[string]interface{},
events chan<- Event,
statuses chan<- StatusChange,
) (Bootstrap, error) {
// In machine mode the orchestrator handles the shared WS handshake, but each
// node service still needs one initial REST snapshot so newly discovered nodes
// can start immediately instead of waiting for a subsequent WS full-sync.
hs, err := p.client.Handshake()
if err != nil {
nlog.Core().Debug("machine node handshake skipped, using REST", "error", err)
}
bootstrap := Bootstrap{}
if hs != nil {
bootstrap.PushInterval = hs.Settings.PushInterval
bootstrap.PullInterval = hs.Settings.PullInterval
}
configSnapshot, err := p.client.GetConfig()
if err != nil {
return Bootstrap{}, fmt.Errorf("machine initial config: %w", err)
}
if configSnapshot == nil {
return Bootstrap{}, fmt.Errorf("machine initial config is nil")
}
users, err := p.client.GetUsers()
if err != nil {
return Bootstrap{}, fmt.Errorf("machine initial users: %w", err)
}
bootstrap.Config, err = model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
if err != nil {
return Bootstrap{}, fmt.Errorf("machine initial config normalize: %w", err)
}
bootstrap.Users = model.UserSpecsFromPanel(users)
// Register mailbox access after the initial snapshot is prepared, so the
// Service can start the kernel immediately and only then drain coalesced WS
// state from the machine mailbox.
if p.push != nil {
if p.registerFn != nil {
bootstrap.Mailbox = p.registerFn(statuses)
}
bootstrap.Push = p.push
}
return bootstrap, nil
}
func (p *MachinePanelControlPlane) Poll(ctx context.Context) (Snapshot, error) {
configSnapshot, err := p.client.GetConfig()
if err != nil {
return Snapshot{}, fmt.Errorf("machine poll config: %w", err)
}
users, err := p.client.GetUsers()
if err != nil {
return Snapshot{}, fmt.Errorf("machine poll users: %w", err)
}
select {
case <-ctx.Done():
return Snapshot{}, ctx.Err()
default:
}
nodeSpec, err := model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
if err != nil {
return Snapshot{}, fmt.Errorf("machine poll normalize: %w", err)
}
return Snapshot{Config: nodeSpec, Users: model.UserSpecsFromPanel(users)}, nil
}
func (p *MachinePanelControlPlane) Discover(
ctx context.Context,
metricsFn func() map[string]interface{},
events chan<- Event,
statuses chan<- StatusChange,
) (PushClient, error) {
return nil, nil
}
func (p *MachinePanelControlPlane) 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 *MachinePanelControlPlane) ReportDevices(push PushClient, devices map[int][]string) {
if push != nil {
push.SendDeviceReport(devices)
}
}
func (p *MachinePanelControlPlane) Metrics() APIMetrics {
api := p.client.SnapshotMetrics()
return APIMetrics{Success: api.Success, Failure: api.Failure}
}
+261
View File
@@ -0,0 +1,261 @@
package controlplane
import (
"sync"
"github.com/cedar2025/xboard-node/internal/model"
)
// MailboxState is the coalesced snapshot drained from a NodeMailbox.
type MailboxState struct {
Config *model.NodeSpec
Users []model.UserSpec
DeviceUsers map[int][]string
HasConfig bool
HasUsers bool
HasDevices bool
NeedsReconcile bool
}
// NodeMailbox buffers WS events for a machine-mode node service that is not
// yet ready to process them. Events are coalesced (latest-wins) and drained
// as a single snapshot once the service marks the mailbox as ready.
type NodeMailbox struct {
mu sync.Mutex
ready bool
config *model.NodeSpec
users []model.UserSpec
deviceUsers map[int][]string
dirtyConfig bool
dirtyUsers bool
dirtyDevices bool
needsReconcile bool
hasFullUserState bool
notifyCh chan struct{}
}
func NewNodeMailbox() *NodeMailbox {
return &NodeMailbox{notifyCh: make(chan struct{}, 1)}
}
func (m *NodeMailbox) MarkReady() {
m.mu.Lock()
m.ready = true
m.mu.Unlock()
m.notify()
}
// SeedBaseline initializes the mailbox with the REST-derived baseline so that
// subsequent delta events can be applied incrementally instead of triggering
// a full REST reconciliation.
func (m *NodeMailbox) SeedBaseline(users []model.UserSpec, config *model.NodeSpec) {
m.mu.Lock()
defer m.mu.Unlock()
if users != nil && !m.hasFullUserState {
m.users = cloneUsers(users)
m.hasFullUserState = true
}
if config != nil && m.config == nil {
m.config = cloneNodeSpec(config)
}
}
func (m *NodeMailbox) Apply(event Event) {
m.mu.Lock()
changed := false
switch event.Type {
case EventSyncConfig:
if event.Config != nil {
m.config = cloneNodeSpec(event.Config)
m.dirtyConfig = true
changed = true
}
case EventSyncUsers:
if event.Users != nil {
m.users = cloneUsers(event.Users)
m.dirtyUsers = true
m.hasFullUserState = true
changed = true
}
case EventSyncDevices:
if event.DeviceUsers != nil {
m.deviceUsers = cloneDeviceUsers(event.DeviceUsers)
m.dirtyDevices = true
changed = true
}
case EventSyncUserDelta:
if len(event.DeltaUsers) > 0 {
if !m.hasFullUserState {
m.needsReconcile = true
changed = true
} else if !applyUserDelta(&m.users, event.DeltaAction, event.DeltaUsers) {
m.needsReconcile = true
changed = true
} else {
m.dirtyUsers = true
changed = true
}
}
}
m.mu.Unlock()
if changed {
m.notify()
}
}
func (m *NodeMailbox) notify() {
select {
case m.notifyCh <- struct{}{}:
default:
}
}
func (m *NodeMailbox) NotifyCh() <-chan struct{} { return m.notifyCh }
func (m *NodeMailbox) DrainIfReady() MailboxState {
m.mu.Lock()
defer m.mu.Unlock()
if !m.ready {
return MailboxState{}
}
state := MailboxState{NeedsReconcile: m.needsReconcile}
if m.dirtyConfig && m.config != nil {
state.Config = cloneNodeSpec(m.config)
state.HasConfig = true
m.dirtyConfig = false
}
if m.dirtyUsers {
state.Users = cloneUsers(m.users)
state.HasUsers = true
m.dirtyUsers = false
}
if m.dirtyDevices && m.deviceUsers != nil {
state.DeviceUsers = cloneDeviceUsers(m.deviceUsers)
state.HasDevices = true
m.dirtyDevices = false
}
m.needsReconcile = false
return state
}
func cloneNodeSpec(spec *model.NodeSpec) *model.NodeSpec {
if spec == nil {
return nil
}
clone := *spec
if spec.NetworkSettings != nil {
clone.NetworkSettings = make(map[string]interface{}, len(spec.NetworkSettings))
for k, v := range spec.NetworkSettings {
clone.NetworkSettings[k] = v
}
}
if spec.TLSSettings != nil {
clone.TLSSettings = make(map[string]interface{}, len(spec.TLSSettings))
for k, v := range spec.TLSSettings {
clone.TLSSettings[k] = v
}
}
if spec.Routes != nil {
clone.Routes = append([]model.RouteRule(nil), spec.Routes...)
}
if spec.CustomOutbounds != nil {
clone.CustomOutbounds = append([]model.OutboundConfig(nil), spec.CustomOutbounds...)
}
if spec.CustomRoutes != nil {
clone.CustomRoutes = make([]map[string]any, len(spec.CustomRoutes))
for i, route := range spec.CustomRoutes {
if route == nil {
continue
}
copied := make(map[string]any, len(route))
for k, v := range route {
copied[k] = v
}
clone.CustomRoutes[i] = copied
}
}
if spec.CustomRouteRules != nil {
clone.CustomRouteRules = append([]model.CustomRouteRule(nil), spec.CustomRouteRules...)
}
if spec.CertConfig != nil {
certCopy := *spec.CertConfig
if spec.CertConfig.DNSEnv != nil {
certCopy.DNSEnv = make(map[string]string, len(spec.CertConfig.DNSEnv))
for k, v := range spec.CertConfig.DNSEnv {
certCopy.DNSEnv[k] = v
}
}
clone.CertConfig = &certCopy
}
if spec.Multiplex != nil {
muxCopy := *spec.Multiplex
if spec.Multiplex.Brutal != nil {
brutalCopy := *spec.Multiplex.Brutal
muxCopy.Brutal = &brutalCopy
}
clone.Multiplex = &muxCopy
}
return &clone
}
func cloneUsers(users []model.UserSpec) []model.UserSpec {
if users == nil {
return nil
}
cloned := make([]model.UserSpec, len(users))
copy(cloned, users)
return cloned
}
func cloneDeviceUsers(devices map[int][]string) map[int][]string {
if devices == nil {
return nil
}
cloned := make(map[int][]string, len(devices))
for uid, ips := range devices {
dup := make([]string, len(ips))
copy(dup, ips)
cloned[uid] = dup
}
return cloned
}
func applyUserDelta(users *[]model.UserSpec, action string, delta []model.UserSpec) bool {
current := cloneUsers(*users)
index := make(map[int]int, len(current))
for i, user := range current {
index[user.ID] = i
}
switch action {
case "add":
for _, user := range delta {
if idx, ok := index[user.ID]; ok {
current[idx] = user
continue
}
index[user.ID] = len(current)
current = append(current, user)
}
case "remove":
removeIDs := make(map[int]struct{}, len(delta))
for _, user := range delta {
removeIDs[user.ID] = struct{}{}
}
filtered := current[:0]
for _, user := range current {
if _, ok := removeIDs[user.ID]; ok {
continue
}
filtered = append(filtered, user)
}
current = append([]model.UserSpec(nil), filtered...)
default:
return false
}
*users = current
return true
}
+164
View File
@@ -0,0 +1,164 @@
package controlplane
import (
"testing"
"github.com/cedar2025/xboard-node/internal/model"
)
func TestNodeMailboxLatestStateAndNotifyCollapse(t *testing.T) {
mb := NewNodeMailbox()
cfg1 := &model.NodeSpec{Protocol: "vmess", ServerPort: 10010}
cfg2 := &model.NodeSpec{Protocol: "vless", ServerPort: 10020}
mb.Apply(Event{Type: EventSyncConfig, Config: cfg1})
mb.Apply(Event{Type: EventSyncConfig, Config: cfg2})
select {
case <-mb.NotifyCh():
default:
t.Fatal("expected collapsed notify signal")
}
mb.MarkReady()
state := mb.DrainIfReady()
if !state.HasConfig || state.Config == nil {
t.Fatal("expected config state")
}
if state.Config.Protocol != "vless" || state.Config.ServerPort != 10020 {
t.Fatalf("unexpected config: %+v", state.Config)
}
}
func TestNodeMailboxFullUsersOverwriteAndDeltaBeforeBaseline(t *testing.T) {
mb := NewNodeMailbox()
mb.Apply(Event{
Type: EventSyncUserDelta,
DeltaAction: "add",
DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}},
})
mb.MarkReady()
state := mb.DrainIfReady()
if !state.NeedsReconcile {
t.Fatal("expected reconcile when delta arrives before baseline")
}
mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 1, UUID: "u1"}}})
mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 2, UUID: "u2"}}})
state = mb.DrainIfReady()
if !state.HasUsers {
t.Fatal("expected users snapshot")
}
if len(state.Users) != 1 || state.Users[0].ID != 2 {
t.Fatalf("unexpected users: %+v", state.Users)
}
}
func TestNodeMailboxDevicesLatestWins(t *testing.T) {
mb := NewNodeMailbox()
mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"1.1.1.1"}}})
mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"2.2.2.2"}}})
mb.MarkReady()
state := mb.DrainIfReady()
if !state.HasDevices {
t.Fatal("expected devices state")
}
if got := state.DeviceUsers[1][0]; got != "2.2.2.2" {
t.Fatalf("unexpected device state: %v", state.DeviceUsers)
}
}
func TestNodeMailboxReconcileNotifies(t *testing.T) {
mb := NewNodeMailbox()
mb.MarkReady()
// Drain the MarkReady notification
select {
case <-mb.NotifyCh():
default:
}
// Delta without baseline should trigger reconcile AND notify
mb.Apply(Event{
Type: EventSyncUserDelta,
DeltaAction: "add",
DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}},
})
select {
case <-mb.NotifyCh():
default:
t.Fatal("expected notify on reconcile path")
}
state := mb.DrainIfReady()
if !state.NeedsReconcile {
t.Fatal("expected NeedsReconcile")
}
}
func TestNodeMailboxSeedBaselineEnablesDelta(t *testing.T) {
mb := NewNodeMailbox()
mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}, {ID: 2, UUID: "u2"}}, nil)
mb.MarkReady()
// Drain MarkReady notify
select {
case <-mb.NotifyCh():
default:
}
// Delta should now apply incrementally, not trigger reconcile
mb.Apply(Event{
Type: EventSyncUserDelta,
DeltaAction: "add",
DeltaUsers: []model.UserSpec{{ID: 3, UUID: "u3"}},
})
select {
case <-mb.NotifyCh():
default:
t.Fatal("expected notify after delta")
}
state := mb.DrainIfReady()
if state.NeedsReconcile {
t.Fatal("should not need reconcile after seeded baseline")
}
if !state.HasUsers {
t.Fatal("expected users")
}
if len(state.Users) != 3 {
t.Fatalf("expected 3 users, got %d: %+v", len(state.Users), state.Users)
}
}
func TestNodeMailboxDeltaFailedApplyNotifies(t *testing.T) {
mb := NewNodeMailbox()
mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}}, nil)
mb.MarkReady()
// Drain MarkReady notify
select {
case <-mb.NotifyCh():
default:
}
// Invalid action should trigger reconcile and still notify
mb.Apply(Event{
Type: EventSyncUserDelta,
DeltaAction: "invalid_action",
DeltaUsers: []model.UserSpec{{ID: 2, UUID: "u2"}},
})
select {
case <-mb.NotifyCh():
default:
t.Fatal("expected notify on failed delta apply")
}
state := mb.DrainIfReady()
if !state.NeedsReconcile {
t.Fatal("expected NeedsReconcile for invalid delta action")
}
}
+184
View File
@@ -0,0 +1,184 @@
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
kcfg config.KernelConfig
client *panel.Client
}
type panelPushClient struct {
inner *panel.WSClient
}
func NewPanelControlPlane(panelCfg config.PanelConfig, wsCfg config.WSConfig, kcfg config.KernelConfig) *PanelControlPlane {
return &PanelControlPlane{
cfg: panelCfg,
wsCfg: wsCfg,
kcfg: kcfg,
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, err = model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
if err != nil {
return Bootstrap{}, fmt.Errorf("initial config normalize: %w", err)
}
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:
}
nodeSpec, err := model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
if err != nil {
return Snapshot{}, fmt.Errorf("poll config normalize: %w", err)
}
return Snapshot{Config: nodeSpec, 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, err := TranslateWSEvent(event, p.kcfg)
if err != nil {
nlog.Core().Warn("invalid ws event config, dropping event", "type", event.Type, "error", err)
return
}
select {
case events <- translated:
default:
nlog.Core().Warn("ws event channel full, dropping event", "type", translated.Type)
select {
case statuses <- StatusChange{Connected: true, NeedsResync: true}:
default:
}
}
},
func(status panel.WSStatusChange) {
select {
case statuses <- StatusChange{Connected: status.Connected}:
default:
}
},
metricsFn,
)
return &panelPushClient{inner: inner}
}
func TranslateWSEvent(event panel.WSEvent, kcfg config.KernelConfig) (Event, error) {
translated := Event{Type: EventType(event.Type), DeltaAction: event.DeltaAction, DeviceUsers: event.DeviceUsers}
if event.Config != nil {
var err error
translated.Config, err = model.NodeSpecFromPanelValidated(event.Config, kcfg)
if err != nil {
return Event{}, fmt.Errorf("translate node config: %w", err)
}
}
if event.Users != nil {
translated.Users = model.UserSpecsFromPanel(event.Users)
}
if event.DeltaUsers != nil {
translated.DeltaUsers = model.UserSpecsFromPanel(event.DeltaUsers)
}
return translated, nil
}
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)
}
+95
View File
@@ -0,0 +1,95 @@
package controlplane
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/cedar2025/xboard-node/internal/config"
panelapi "github.com/cedar2025/xboard-node/internal/panel"
)
func TestPanelControlPlaneInitialRejectsInvalidCustomOutbounds(t *testing.T) {
server := newPanelTestServer(`{"protocol":"shadowsocks","server_port":8388,"custom_outbounds":[{"tag":"proxy","protocol":"socks","proxy_tag":"missing","settings":{"server":"2.2.2.2","server_port":1080}}]}`)
defer server.Close()
cp := NewPanelControlPlane(config.PanelConfig{URL: server.URL, Token: "token", NodeID: 1}, config.WSConfig{}, config.KernelConfig{Type: "singbox"})
_, err := cp.Initial(context.Background(), nil, nil, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `initial config normalize: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPanelControlPlanePollRejectsInvalidCustomOutbounds(t *testing.T) {
server := newPanelTestServer(`{"protocol":"shadowsocks","server_port":8388,"custom_outbounds":[{"tag":"proxy","protocol":"socks","proxy_tag":"missing","settings":{"server":"2.2.2.2","server_port":1080}}]}`)
defer server.Close()
cp := NewPanelControlPlane(config.PanelConfig{URL: server.URL, Token: "token", NodeID: 1}, config.WSConfig{}, config.KernelConfig{Type: "singbox"})
_, err := cp.Poll(context.Background())
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `poll config normalize: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTranslateWSEventRejectsInvalidCustomOutbounds(t *testing.T) {
_, err := TranslateWSEvent(panelapi.WSEvent{
Type: panelapi.WSEventSyncConfig,
Config: &panelapi.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panelapi.OutboundConfig{
{Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
},
},
}, config.KernelConfig{Type: "singbox"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `translate node config: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
t.Fatalf("unexpected error: %v", err)
}
}
func newPanelTestServer(configBody string) *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/server/handshake", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"websocket":{"enabled":false},"settings":{"push_interval":60,"pull_interval":60}}`))
})
mux.HandleFunc("/api/v1/server/UniProxy/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(configBody))
})
mux.HandleFunc("/api/v1/server/UniProxy/user", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"users":[{"id":1,"uuid":"11111111-1111-1111-1111-111111111111"}]}`))
})
return httptest.NewServer(mux)
}
func TestTranslateWSEventRejectsUnsupportedProtocolForKernel(t *testing.T) {
_, err := TranslateWSEvent(panelapi.WSEvent{
Type: panelapi.WSEventSyncConfig,
Config: &panelapi.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panelapi.OutboundConfig{
{Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443}},
},
},
}, config.KernelConfig{Type: "xray"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `translate node config: validate custom outbounds: custom_outbounds[0].protocol "hysteria2" is not supported by kernel "xray"`) {
t.Fatalf("unexpected error: %v", err)
}
}
+87
View File
@@ -0,0 +1,87 @@
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
NeedsResync bool
}
type APIMetrics struct {
Success uint64
Failure uint64
}
type Bootstrap struct {
PushInterval int
PullInterval int
Push PushClient
Config *model.NodeSpec
Users []model.UserSpec
Mailbox *NodeMailbox
}
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
}
+39
View File
@@ -0,0 +1,39 @@
package kernel
import (
"strings"
"github.com/cedar2025/xboard-node/internal/model"
)
func NeedsGeoIPRules(rules []model.CustomRouteRule) bool {
for _, r := range rules {
for _, v := range r.Match.IPCIDRs {
if strings.HasPrefix(v, "geoip:") {
return true
}
}
for _, v := range r.Match.SourceCIDRs {
if strings.HasPrefix(v, "geoip:") {
return true
}
}
}
return false
}
func NeedsGeoSiteRules(rules []model.CustomRouteRule) bool {
for _, r := range rules {
for _, v := range r.Match.Domains {
if strings.HasPrefix(v, "geosite:") {
return true
}
}
for _, v := range r.Match.DomainSuffixes {
if strings.HasPrefix(v, "geosite:") {
return true
}
}
}
return false
}
+3 -3
View File
@@ -3,10 +3,10 @@ package kernel
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"github.com/cedar2025/xboard-node/internal/nlog"
"gopkg.in/yaml.v3"
)
@@ -20,7 +20,7 @@ func LoadCustomConfig(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
slog.Warn("custom config file not found, skipping", "path", path)
nlog.Core().Warn("custom config file not found, skipping", "path", path)
return nil, nil
}
return nil, fmt.Errorf("read custom config %s: %w", path, err)
@@ -43,7 +43,7 @@ func LoadCustomConfig(path string) (map[string]any, error) {
}
}
slog.Info("loaded custom config", "path", path, "keys", mapKeys(result))
nlog.Core().Info("loaded custom config", "path", path, "keys", mapKeys(result))
return result, nil
}
+3 -3
View File
@@ -3,11 +3,11 @@ package kernel
import (
"strings"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
)
// NeedsGeoIP returns true when any panel route rule contains a "geoip:" match entry.
func NeedsGeoIP(routes []panel.RouteRule) bool {
func NeedsGeoIP(routes []model.RouteRule) bool {
for _, r := range routes {
for _, m := range r.Match {
if strings.HasPrefix(m, "geoip:") {
@@ -19,7 +19,7 @@ func NeedsGeoIP(routes []panel.RouteRule) bool {
}
// NeedsGeoSite returns true when any panel route rule contains a "geosite:" match entry.
func NeedsGeoSite(routes []panel.RouteRule) bool {
func NeedsGeoSite(routes []model.RouteRule) bool {
for _, r := range routes {
for _, m := range r.Match {
if strings.HasPrefix(m, "geosite:") {
+5 -4
View File
@@ -3,11 +3,12 @@ package geodata
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/cedar2025/xboard-node/internal/nlog"
)
const (
@@ -49,7 +50,7 @@ func Ensure(dir string, needGeoIP, needGeoSite bool, kernelType string) error {
var firstErr error
for _, f := range files {
if err := ensureFile(dir, f.name, f.url); err != nil {
slog.Warn("geo database auto-download failed", "file", f.name, "error", err)
nlog.Core().Warn("geo database auto-download failed", "file", f.name, "error", err)
if firstErr == nil {
firstErr = err
}
@@ -63,12 +64,12 @@ func ensureFile(dir, name, url string) error {
if _, err := os.Stat(dst); err == nil {
return nil
}
slog.Info("geo database missing, downloading automatically", "file", name, "url", url)
nlog.Core().Info("geo database missing, downloading automatically", "file", name, "url", url)
if err := atomicDownload(dst, url); err != nil {
return err
}
if fi, err := os.Stat(dst); err == nil {
slog.Info("geo database ready", "file", name, "size_kb", fi.Size()/1024)
nlog.Core().Info("geo database ready", "file", name, "size_kb", fi.Size()/1024)
}
return nil
}
+52 -22
View File
@@ -7,10 +7,32 @@ import (
"fmt"
"sort"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
"golang.org/x/time/rate"
)
type Capabilities struct {
PerUserSpeedLimit bool
DeviceLimit bool
BuiltInTrafficStats bool
AliveIPTracking bool
ForceCloseConnection bool
ForceCloseUser bool
}
// TLSCert holds PEM-encoded TLS certificate material.
// All cert modes (self-signed, file, ACME, panel-content) normalise to PEM
// so kernels never need to deal with file paths.
type TLSCert struct {
CertPEM []byte
KeyPEM []byte
}
// HasCert reports whether a valid cert+key pair is available.
func (t TLSCert) HasCert() bool {
return len(t.CertPEM) > 0 && len(t.KeyPEM) > 0
}
// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.).
//
// The interface is split into lifecycle, user management, and observability
@@ -25,12 +47,14 @@ type Kernel interface {
Name() string
// Protocols returns the protocol names this kernel supports.
Protocols() []string
// Capabilities returns the explicit runtime semantics supported by the kernel.
Capabilities() Capabilities
// ─── Lifecycle ──────────────────────────────────────────────────────
// Start initialises the kernel with the given node config and initial
// user set, binds listeners, and begins accepting connections.
// Calling Start on an already-running kernel stops the old instance first.
Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error
Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls TLSCert) error
// Stop gracefully shuts down the kernel, draining active connections.
Stop()
// IsRunning returns whether the kernel is currently accepting connections.
@@ -38,39 +62,54 @@ type Kernel interface {
// Reload re-generates the full config and hot-swaps listeners/routes.
// Use this when port, protocol, or TLS settings change.
// Existing connections MAY be briefly interrupted.
Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error
Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls TLSCert) error
// ─── User management (non-disruptive) ───────────────────────────────
// AddUsers registers new users with the running kernel.
// Existing connections are unaffected. Returns the count actually added
// (duplicates are silently skipped).
AddUsers(users []panel.User) (added int, err error)
AddUsers(users []model.UserSpec) (added int, err error)
// RemoveUsers deregisters users from the running kernel.
// Active connections of removed users are terminated. Returns the count
// actually removed (unknown IDs are silently skipped).
RemoveUsers(users []panel.User) (removed int, err error)
RemoveUsers(users []model.UserSpec) (removed int, err error)
// UpdateUsers replaces the entire user set atomically.
// This is equivalent to computing the diff and calling Add/Remove, but
// may be more efficient for bulk changes. Returns (added, removed) counts.
UpdateUsers(users []panel.User) (added, removed int, err error)
UpdateUsers(users []model.UserSpec) (added, removed int, err error)
// ─── Observability ──────────────────────────────────────────────────
// GetConnections returns a snapshot of all active proxy connections.
GetConnections(ctx context.Context) ([]Connection, error)
// GetUserTraffic returns per-user cumulative traffic counters and
// per-user alive IP sets. The kernel maintains these internally using
// per-user atomic counters — no per-connection iteration needed.
// aliveIPs maps userID → set of source IPs currently connected.
// traffic maps userID → [upload, download] cumulative bytes.
// connCount is the total number of active connections (for metrics).
GetUserTraffic(ctx context.Context) (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int, err error)
// CloseConnection terminates a specific connection by ID.
CloseConnection(ctx context.Context, connID string) error
// CloseUserConnections terminates all connections for the given user UUID.
CloseUserConnections(ctx context.Context, uuid string) error
// SetSpeedLimitFunc configures per-user bandwidth throttling.
// The function resolves a user UUID to a *rate.Limiter (nil = unlimited).
SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter)
// SetDeviceLimitFunc configures per-user device limit gate-keeping.
// The function resolves a user UUID to (limit, hasLimit).
// Kernels that already gate-keep internally (e.g. xray) may no-op.
SetDeviceLimitFunc(fn func(uuid string) (int, bool))
// UpdateGlobalDevices updates the global device state from panel (for multi-node).
UpdateGlobalDevices(users map[int][]string)
// ClearGlobalDevices clears the global device state (on WS disconnect).
ClearGlobalDevices()
}
// ComputeHash returns a hash of config + user identities that would
// require a kernel restart/reconstruction if changed.
func ComputeHash(nc *panel.NodeConfig, users []panel.User) string {
func ComputeHash(nc *model.NodeSpec, users []model.UserSpec) string {
h := sha256.New()
configData, _ := json.Marshal(nc)
h.Write(configData)
sorted := make([]panel.User, len(users))
sorted := make([]model.UserSpec, len(users))
copy(sorted, users)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
for _, u := range sorted {
@@ -79,24 +118,15 @@ func ComputeHash(nc *panel.NodeConfig, users []panel.User) string {
return fmt.Sprintf("%x", h.Sum(nil))
}
// Connection represents an active proxy connection reported by the kernel.
type Connection struct {
ID string
UserID int // extracted user ID (0 if unknown)
Upload int64 // cumulative upload bytes
Download int64 // cumulative download bytes
SourceIP string // client source IP (cleaned, no port/brackets)
}
// UserDiff computes which users to add and which to remove when transitioning
// from oldUsers to newUsers. This is a pure helper used by callers; kernels
// may also use it internally.
func UserDiff(oldUsers, newUsers []panel.User) (toAdd, toRemove []panel.User) {
oldMap := make(map[int]panel.User, len(oldUsers))
func UserDiff(oldUsers, newUsers []model.UserSpec) (toAdd, toRemove []model.UserSpec) {
oldMap := make(map[int]model.UserSpec, len(oldUsers))
for _, u := range oldUsers {
oldMap[u.ID] = u
}
newMap := make(map[int]panel.User, len(newUsers))
newMap := make(map[int]model.UserSpec, len(newUsers))
for _, u := range newUsers {
newMap[u.ID] = u
}
+370 -150
View File
@@ -1,8 +1,8 @@
package singbox
import (
"encoding/base64"
"fmt"
"log/slog"
"net"
"path/filepath"
"strconv"
@@ -10,14 +10,15 @@ import (
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/go-viper/mapstructure/v2"
)
// M is a shorthand for building JSON-like maps
type M = map[string]interface{}
func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
var outbounds []M
tags := make(map[string]bool)
@@ -52,18 +53,18 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
"outbounds": outbounds,
}
inbound := buildInbound(nc, users, certFile, keyFile)
inbound := buildInbound(nc, users, tc)
if inbound != nil {
cfg["inbounds"] = []M{inbound}
}
// Merge panel routes and static config routes
cfg["route"] = buildRoutes(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
cfg["route"] = buildRoutes(nc.Routes, nc.CustomRouteRules, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
// Automatically enable rule_set caching (cache_file) when panel routes
// reference geoip:/geosite: entries so that the downloaded .srs rule_set
// files survive across process restarts.
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) {
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) || kernel.NeedsGeoIPRules(nc.CustomRouteRules) || kernel.NeedsGeoSiteRules(nc.CustomRouteRules) {
cfg["experimental"] = M{
"cache_file": M{
"enabled": true,
@@ -79,7 +80,7 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
// outboundConfigToSingbox converts a structured OutboundConfig (from the panel)
// into a sing-box outbound object. sing-box uses a flat layout where all
// protocol-specific fields sit at the top level alongside "type" and "tag".
func outboundConfigToSingbox(oc panel.OutboundConfig) M {
func outboundConfigToSingbox(oc model.OutboundConfig) M {
m := M{
"type": oc.Protocol,
"tag": oc.Tag,
@@ -143,100 +144,49 @@ func mergeRouteList(a, b []map[string]any) []map[string]any {
return res
}
func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M {
func buildRoutes(panelRoutes []model.RouteRule, customRules []model.CustomRouteRule, custom []map[string]any) M {
var rules []M
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
// Structured custom routes now take the highest priority for panel-managed overrides.
for _, rule := range customRules {
if rule.Disabled {
continue
}
rules = append(rules, compileCustomRouteRule(rule)...)
}
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
for _, cr := range custom {
rules = append(rules, M(cr))
}
// Standard blocks for private/loopback
rules = append(rules, M{
"outbound": "block",
"ip_cidr": []string{
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"fc00::/7",
"fe80::/10",
"::1/128",
// Standard blocks for private IPv4 and IPv6 ranges to prevent SSRF.
rules = append(rules,
M{
"outbound": "block",
"ip_cidr": []string{
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
},
},
}, M{
"outbound": "block",
"domain": []string{
"geoip:private",
M{
"outbound": "block",
"ip_cidr": []string{
"fc00::/7",
"fe80::/10",
"::1/128",
},
},
})
)
// Panel-defined routes (usually specific blocks/proxies)
for _, pr := range panelRoutes {
if len(pr.Match) == 0 {
continue
}
var domains, cidrs []string
for _, m := range pr.Match {
m = strings.TrimSpace(m)
if m == "" {
continue
}
// Strip leading wildcard for domain matching
if strings.HasPrefix(m, "*.") {
m = strings.TrimPrefix(m, "*.")
}
// Check if it's a CIDR block (contains /)
if strings.Contains(m, "/") {
cidrs = append(cidrs, m)
} else {
// Otherwise treat as domain
domains = append(domains, m)
}
}
// Determine outbound tag based on action
outbound := "block"
if pr.Action == "direct" {
outbound = "direct"
} else if pr.Action == "dns" {
// Special case for sing-box DNS routing:
// If action is "dns" and action_value is provided, we use it as the server name.
// Otherwise it defaults to "dns-out".
server := "dns-out"
if pr.ActionValue != "" {
server = pr.ActionValue
}
outbound = server
} else if pr.Action == "proxy" {
// If panel provides a specific outbound tag in action_value, use it.
// This allows routing to WARP_JP etc. via normal routes.
if pr.ActionValue != "" {
outbound = pr.ActionValue
}
}
// Create separate rule for domains
if len(domains) > 0 {
rule := M{
"domain_suffix": domains,
"outbound": outbound,
}
rules = append(rules, rule)
}
// Create separate rule for CIDRs
if len(cidrs) > 0 {
rule := M{
"ip_cidr": cidrs,
"outbound": outbound,
}
rules = append(rules, rule)
}
rules = append(rules, compilePanelRouteRule(pr)...)
}
return M{
@@ -245,10 +195,166 @@ func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M {
}
}
func compilePanelRouteRule(pr model.RouteRule) []M {
if len(pr.Match) == 0 {
return nil
}
var domains, cidrs []string
for _, item := range pr.Match {
item = strings.TrimSpace(item)
if item == "" {
continue
}
item = strings.TrimPrefix(item, "*.")
if strings.Contains(item, "/") {
cidrs = append(cidrs, item)
continue
}
domains = append(domains, item)
}
outbound := "block"
switch pr.Action {
case "direct":
outbound = "direct"
case "dns":
if pr.ActionValue != "" {
outbound = pr.ActionValue
} else {
outbound = "dns-out"
}
case "proxy":
if pr.ActionValue != "" {
outbound = pr.ActionValue
}
}
var compiled []M
if len(domains) > 0 {
compiled = append(compiled, M{
"domain_suffix": copyStrings(domains),
"outbound": outbound,
})
}
if len(cidrs) > 0 {
compiled = append(compiled, M{
"ip_cidr": copyStrings(cidrs),
"outbound": outbound,
})
}
return compiled
}
func compileCustomRouteRule(rule model.CustomRouteRule) []M {
if rule.Disabled {
return nil
}
outbound := singboxOutboundForAction(rule.Action)
var compiled []M
if len(rule.Match.Domains) > 0 {
compiled = append(compiled, M{
"domain": copyStrings(rule.Match.Domains),
"outbound": outbound,
})
}
if len(rule.Match.DomainSuffixes) > 0 {
compiled = append(compiled, M{
"domain_suffix": copyStrings(rule.Match.DomainSuffixes),
"outbound": outbound,
})
}
if len(rule.Match.IPCIDRs) > 0 {
compiled = append(compiled, M{
"ip_cidr": copyStrings(rule.Match.IPCIDRs),
"outbound": outbound,
})
}
if len(rule.Match.Ports) > 0 {
ports, portRanges := splitPorts(rule.Match.Ports)
entry := M{"outbound": outbound}
if len(ports) > 0 {
entry["port"] = ports
}
if len(portRanges) > 0 {
entry["port_range"] = portRanges
}
compiled = append(compiled, entry)
}
if len(rule.Match.Networks) > 0 {
compiled = append(compiled, M{
"network": copyStrings(rule.Match.Networks),
"outbound": outbound,
})
}
if len(rule.Match.SourceCIDRs) > 0 {
compiled = append(compiled, M{
"source_ip_cidr": copyStrings(rule.Match.SourceCIDRs),
"outbound": outbound,
})
}
if len(rule.Match.SourcePorts) > 0 {
ports, portRanges := splitPorts(rule.Match.SourcePorts)
entry := M{"outbound": outbound}
if len(ports) > 0 {
entry["source_port"] = ports
}
if len(portRanges) > 0 {
entry["source_port_range"] = portRanges
}
compiled = append(compiled, entry)
}
return compiled
}
func singboxOutboundForAction(action model.RouteAction) string {
switch action.Type {
case "direct":
return "direct"
case "route":
if action.Target != "" {
return action.Target
}
return "block"
default:
return "block"
}
}
func splitPorts(values []string) ([]int, []string) {
var ports []int
var ranges []string
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.ContainsAny(value, ":-") {
ranges = append(ranges, strings.ReplaceAll(value, "-", ":"))
continue
}
if port, err := strconv.Atoi(value); err == nil {
ports = append(ports, port)
}
}
return ports, ranges
}
func copyStrings(src []string) []string {
if len(src) == 0 {
return nil
}
out := make([]string, len(src))
copy(out, src)
return out
}
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
if err != nil {
slog.Error("failed to load custom sing-box config", "error", err)
nlog.Core().Error("failed to load custom sing-box config", "error", err)
return
}
if custom == nil {
@@ -323,7 +429,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, tc kernel.TLSCert) M {
base := M{
"tag": nc.Protocol + "-in",
"listen": "::",
@@ -334,23 +440,23 @@ func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile st
case "shadowsocks":
return buildShadowsocks(base, nc, users)
case "vmess":
return buildVMess(base, nc, users, certFile, keyFile)
return buildVMess(base, nc, users, tc)
case "vless":
return buildVLESS(base, nc, users, certFile, keyFile)
return buildVLESS(base, nc, users, tc)
case "trojan":
return buildTrojan(base, nc, users, certFile, keyFile)
return buildTrojan(base, nc, users, tc)
case "hysteria":
return buildHysteria(base, nc, users, certFile, keyFile)
return buildHysteria(base, nc, users, tc)
case "tuic":
return buildTUIC(base, nc, users, certFile, keyFile)
return buildTUIC(base, nc, users, tc)
case "anytls":
return buildAnyTLS(base, nc, users, certFile, keyFile)
return buildAnyTLS(base, nc, users, tc)
case "naive":
return buildNaive(base, nc, users, certFile, keyFile)
return buildNaive(base, nc, users, tc)
case "socks":
return buildSocks(base, users)
case "http":
return buildHTTP(base, nc, users, certFile, keyFile)
return buildHTTP(base, nc, users, tc)
case "mieru":
return buildMieru(base, nc, users)
default:
@@ -358,7 +464,7 @@ func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile st
}
}
func buildMieru(base M, nc *panel.NodeConfig, users []panel.User) M {
func buildMieru(base M, nc *model.NodeSpec, users []model.UserSpec) M {
base["type"] = "mieru"
if nc.Transport != "" {
base["transport"] = nc.Transport
@@ -378,31 +484,59 @@ func buildMieru(base M, nc *panel.NodeConfig, users []panel.User) M {
return base
}
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
type ss2022Config struct {
method string
size int
}
var ss2022Methods = map[string]ss2022Config{
"2022-blake3-aes-128-gcm": {"2022-blake3-aes-128-gcm", 16},
"2022-blake3-aes-256-gcm": {"2022-blake3-aes-256-gcm", 32},
"2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32},
}
func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M {
base["type"] = "shadowsocks"
base["method"] = nc.Cipher
if strings.HasPrefix(nc.Cipher, "2022-blake3-") {
ss2022, isSS2022 := ss2022Methods[nc.Cipher]
if isSS2022 {
base["password"] = nc.ServerKey
}
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
userList := make([]M, len(users))
var rawBuf []byte
if isSS2022 {
rawBuf = make([]byte, ss2022.size)
}
for i := range users {
u := &users[i]
user := M{
"name": u.UUID,
"password": u.UUID,
})
}
if isSS2022 {
// Reuse buffer and clear it to maintain SS2022 key integrity
for j := range rawBuf {
rawBuf[j] = 0
}
copy(rawBuf, u.UUID)
user["password"] = base64.StdEncoding.EncodeToString(rawBuf)
}
userList[i] = user
}
base["users"] = userList
if nc.Plugin != "" {
slog.Warn("sing-box shadowsocks inbound does not support plugin, ignoring", "plugin", nc.Plugin)
nlog.Core().Warn("sing-box shadowsocks inbound does not support plugin, ignoring", "plugin", nc.Plugin)
}
return base
}
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "vmess"
userList := make([]M, 0, len(users))
@@ -416,16 +550,23 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
base["users"] = userList
applyTransport(base, nc)
applyProxyProtocol(base, nc)
applyMultiplex(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
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, tc kernel.TLSCert) M {
if nc.Decryption != "" && nc.Decryption != "none" {
nlog.Core().Warn("sing-box does not support VLESS encryption (decryption), use xray kernel for this feature")
}
base["type"] = "vless"
userList := make([]M, 0, len(users))
@@ -442,10 +583,13 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
base["users"] = userList
applyTransport(base, nc)
applyProxyProtocol(base, nc)
applyMultiplex(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
@@ -453,30 +597,46 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
return base
}
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "trojan"
userList := make([]M, 0, len(users))
for _, u := range users {
userList = append(userList, M{
userList := make([]M, len(users))
for i := range users {
u := &users[i]
userList[i] = M{
"name": u.UUID,
"password": u.UUID,
})
}
}
base["users"] = userList
applyTransport(base, nc)
applyProxyProtocol(base, nc)
applyMultiplex(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
} else if nc.TLS == 2 {
base["tls"] = buildRealityConfig(nc)
}
// If still no TLS/Reality but cert paths exist, enable TLS (panel may omit tls=1).
if _, ok := base["tls"]; !ok {
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
if base["tls"] == nil {
nlog.Core().Warn("trojan inbound has no TLS (no certificate paths and no Reality); sing-box may refuse to start until cert_mode provides cert/key or Reality is configured")
}
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, tc kernel.TLSCert) M {
if nc.Version == 2 {
base["type"] = "hysteria2"
@@ -514,7 +674,11 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k
}
}
tls := buildTLSConfig(nc, certFile, keyFile)
tls := buildTLSConfig(nc, tc)
if tls == nil {
nlog.Core().Warn("hysteria requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
return base
}
// Hysteria/Hysteria2 uses QUIC and requires ALPN; default to h3 if not set.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
@@ -523,7 +687,7 @@ func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, k
return base
}
func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "tuic"
userList := make([]M, 0, len(users))
@@ -540,7 +704,11 @@ func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
base["congestion_control"] = nc.CongestionControl
}
tls := buildTLSConfig(nc, certFile, keyFile)
tls := buildTLSConfig(nc, tc)
if tls == nil {
nlog.Core().Warn("tuic requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
return base
}
// TUIC requires ALPN for QUIC negotiation; default to h3 if not set by panel.
if _, ok := tls["alpn"]; !ok {
tls["alpn"] = []string{"h3"}
@@ -549,7 +717,7 @@ func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
return base
}
func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "anytls"
userList := make([]M, 0, len(users))
@@ -562,14 +730,18 @@ func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, key
base["users"] = userList
if nc.PaddingScheme != "" {
base["padding_scheme"] = string(nc.PaddingScheme)
base["padding_scheme"] = nc.PaddingScheme
}
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
} else {
nlog.Core().Warn("anytls requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
}
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, tc kernel.TLSCert) M {
base["type"] = "naive"
userList := make([]M, 0, len(users))
@@ -582,12 +754,14 @@ func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
base["users"] = userList
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
return base
}
func buildSocks(base M, users []panel.User) M {
func buildSocks(base M, users []model.UserSpec) M {
base["type"] = "socks"
userList := make([]M, 0, len(users))
@@ -601,7 +775,7 @@ func buildSocks(base M, users []panel.User) M {
return base
}
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["type"] = "http"
userList := make([]M, 0, len(users))
@@ -613,13 +787,16 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
}
base["users"] = userList
applyProxyProtocol(base, nc)
if nc.TLS == 1 {
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
if tls := buildTLSConfig(nc, tc); tls != nil {
base["tls"] = tls
}
}
return base
}
func applyTransport(base M, nc *panel.NodeConfig) {
func applyTransport(base M, nc *model.NodeSpec) {
if nc.Network == "" || nc.Network == "tcp" {
return
}
@@ -647,7 +824,9 @@ func applyTransport(base M, nc *panel.NodeConfig) {
transport["early_data_header_name"] = v
}
case "grpc":
if v, ok := nc.NetworkSettings["service_name"]; ok {
if v, ok := nc.NetworkSettings["serviceName"]; ok {
transport["service_name"] = v
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
transport["service_name"] = v
}
case "httpupgrade":
@@ -671,40 +850,43 @@ func applyTransport(base M, nc *panel.NodeConfig) {
base["transport"] = transport
}
func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M {
tls := M{"enabled": true}
// buildTLSConfig returns sing-box inbound TLS options when certificate material
// is available. Returns nil if no TLS material is provided.
func buildTLSConfig(nc *model.NodeSpec, tc kernel.TLSCert) M {
if !tc.HasCert() {
return nil
}
t := M{"enabled": true}
serverName := nc.ServerName
if serverName == "" && nc.Host != "" {
serverName = nc.Host
}
if serverName != "" {
tls["server_name"] = serverName
t["server_name"] = serverName
}
if nc.TLSSettings != nil {
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
tls["server_name"] = sn
t["server_name"] = sn
}
if alpn, ok := nc.TLSSettings["alpn"]; ok {
tls["alpn"] = alpn
t["alpn"] = alpn
}
// ECH server-side: only key/key_path needed for inbound
if ech := extractECHInbound(nc.TLSSettings); ech != nil {
t["ech"] = ech
}
}
if certFile != "" && keyFile != "" {
tls["certificate_path"] = certFile
tls["key_path"] = keyFile
} else {
// If no real certificates are provided, but TLS is requested,
// use a self-signed certificate as fallback to prevent sing-box
// from failing with "missing certificate".
tls["certificate_path"] = "self-signed"
}
t["certificate"] = []string{string(tc.CertPEM)}
t["key"] = []string{string(tc.KeyPEM)}
return tls
return t
}
func buildRealityConfig(nc *panel.NodeConfig) M {
func buildRealityConfig(nc *model.NodeSpec) M {
tls := M{"enabled": true}
if nc.TLSSettings == nil {
return tls
@@ -770,7 +952,7 @@ func buildRealityConfig(nc *panel.NodeConfig) M {
return tls
}
func applyMultiplex(base M, nc *panel.NodeConfig) {
func applyMultiplex(base M, nc *model.NodeSpec) {
if nc.Multiplex == nil || !nc.Multiplex.Enabled {
return
}
@@ -797,3 +979,41 @@ func applyMultiplex(base M, nc *panel.NodeConfig) {
base["multiplex"] = mux
}
func applyProxyProtocol(base M, nc *model.NodeSpec) {
// if !nc.GetProxyProtocol() {
// return
// }
// base["proxy_protocol"] = true
}
// extractECHInbound extracts ECH config for sing-box server (inbound).
// sing-box InboundECHOptions needs: enabled, key (PEM array), key_path.
func extractECHInbound(tlsSettings map[string]interface{}) M {
echRaw, ok := tlsSettings["ech"]
if !ok {
return nil
}
echMap, ok := echRaw.(map[string]interface{})
if !ok {
return nil
}
enabled, _ := echMap["enabled"].(bool)
if !enabled {
return nil
}
ech := M{"enabled": true}
hasSource := false
if key, _ := echMap["key"].(string); key != "" {
ech["key"] = []string{key} // sing-box expects PEM as-is
hasSource = true
}
if keyPath, _ := echMap["key_path"].(string); keyPath != "" {
ech["key_path"] = keyPath
hasSource = true
}
if !hasSource {
nlog.Core().Warn("ECH enabled but no key or key_path provided")
}
return ech // let sing-box report a clear error if key is missing
}
+216 -55
View File
@@ -2,26 +2,40 @@ package singbox
import (
"encoding/json"
"reflect"
"testing"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/panel"
)
var testUsers = []panel.User{
var testUsersPanel = []panel.User{
{ID: 1, UUID: "aaaaaaaa-1111-2222-3333-444444444444", SpeedLimit: 0, DeviceLimit: 0},
{ID: 2, UUID: "bbbbbbbb-5555-6666-7777-888888888888", SpeedLimit: 3, DeviceLimit: 2},
}
// --- Shadowsocks ---
var testUsers = model.UserSpecsFromPanel(testUsersPanel)
func testNodeSpec(nc *panel.NodeConfig) *model.NodeSpec { return model.NodeSpecFromPanel(nc) }
func testRouteRules(r []panel.RouteRule) []model.RouteRule {
if r == nil {
return nil
}
return model.NodeSpecFromPanel(&panel.NodeConfig{Routes: r}).Routes
}
func TestBuildInbound_Shadowsocks(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 111,
Cipher: "aes-128-gcm",
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "shadowsocks")
assertMapValue(t, inbound, "method", "aes-128-gcm")
assertMapValue(t, inbound, "listen_port", 111)
@@ -41,7 +55,7 @@ func TestBuildInbound_Shadowsocks2022(t *testing.T) {
Cipher: "2022-blake3-aes-128-gcm",
ServerKey: "base64serverkey==",
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
assertMapValue(t, inbound, "password", "base64serverkey==")
}
@@ -53,7 +67,7 @@ func TestBuildInbound_VMess(t *testing.T) {
Protocol: "vmess",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "vmess")
assertMapValue(t, inbound, "tag", "vmess-in")
@@ -69,12 +83,27 @@ func TestBuildInbound_VMess_WithTLS(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(nc, testUsers, "/path/cert.pem", "/path/key.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "example.com")
assertMapValue(t, tls, "certificate_path", "/path/cert.pem")
assertMapValue(t, tls, "key_path", "/path/key.pem")
assertMapValue(t, tls, "certificate", []string{"CERT"})
assertMapValue(t, tls, "key", []string{"KEY"})
}
// Panel tls=1 with cert_mode none (no paths): inbound must be plain — TLS terminated at nginx/CDN.
func TestBuildInbound_VMess_TLS_PanelOn_NoCertFiles(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 8888,
Network: "ws",
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
if _, exists := inbound["tls"]; exists {
t.Fatal("expected no tls block when cert/key paths are empty (nginx offload)")
}
}
func TestBuildInbound_VMess_NoTLS(t *testing.T) {
@@ -83,7 +112,7 @@ func TestBuildInbound_VMess_NoTLS(t *testing.T) {
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
if _, exists := inbound["tls"]; exists {
t.Error("should not have TLS when tls=0")
}
@@ -101,7 +130,7 @@ func TestBuildInbound_VMess_WithWebSocket(t *testing.T) {
},
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "ws")
assertMapValue(t, transport, "path", "/ws")
@@ -113,15 +142,30 @@ func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
ServerPort: 443,
Network: "grpc",
NetworkSettings: map[string]interface{}{
"service_name": "mygrpc",
"serviceName": "mygrpc",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "grpc")
assertMapValue(t, transport, "service_name", "mygrpc")
}
func TestBuildInbound_VMess_WithGRPCSnakeCaseServiceName(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
ServerPort: 443,
Network: "grpc",
NetworkSettings: map[string]interface{}{
"service_name": "mygrpc-snake",
},
}
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "grpc")
assertMapValue(t, transport, "service_name", "mygrpc-snake")
}
func TestBuildInbound_VMess_WithH2(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "vmess",
@@ -132,7 +176,7 @@ func TestBuildInbound_VMess_WithH2(t *testing.T) {
"host": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "http")
assertMapValue(t, transport, "path", "/h2path")
@@ -148,7 +192,7 @@ func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
"host": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
transport := inbound["transport"].(M)
assertMapValue(t, transport, "type", "httpupgrade")
assertMapValue(t, transport, "path", "/upgrade")
@@ -162,7 +206,7 @@ func TestBuildInbound_VLESS(t *testing.T) {
Protocol: "vless",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "vless")
users := inbound["users"].([]M)
@@ -175,7 +219,7 @@ func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
ServerPort: 443,
Flow: "xtls-rprx-vision",
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
users := inbound["users"].([]M)
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
}
@@ -192,7 +236,7 @@ func TestBuildInbound_VLESS_Reality(t *testing.T) {
"server_name": "www.example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
@@ -216,7 +260,7 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
"dest": "example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
reality := inbound["tls"].(M)["reality"].(M)
ids := reality["short_id"].([]string)
if len(ids) != 2 {
@@ -226,16 +270,29 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
// --- Trojan ---
func TestBuildInbound_Trojan(t *testing.T) {
func TestBuildInbound_Trojan_Reality(t *testing.T) {
nc := &panel.NodeConfig{
Protocol: "trojan",
ServerPort: 443,
TLS: 2,
TLSSettings: map[string]interface{}{
"private_key": "test-private-key",
"dest": "gateway.example.com:8443",
"server_name": "cdn.example.com",
},
}
inbound := buildInbound(nc, testUsers, "", "")
assertMapValue(t, inbound, "type", "trojan")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "cdn.example.com")
users := inbound["users"].([]M)
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
reality := tls["reality"].(M)
assertMapValue(t, reality, "enabled", true)
assertMapValue(t, reality, "private_key", "test-private-key")
handshake := reality["handshake"].(M)
assertMapValue(t, handshake, "server", "gateway.example.com")
assertMapValue(t, handshake, "server_port", 8443)
}
func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
@@ -244,9 +301,10 @@ func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
ServerPort: 80,
TLS: 0,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
// Without certificate paths there is no TLS layer; sing-box trojan needs certs or Reality.
if _, exists := inbound["tls"]; exists {
t.Error("trojan with tls=0 should not have TLS config")
t.Fatal("expected no tls when cert paths are empty and tls=0")
}
}
@@ -257,7 +315,7 @@ func TestBuildInbound_Trojan_WithTLS(t *testing.T) {
TLS: 1,
ServerName: "example.com",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
@@ -270,7 +328,7 @@ func TestBuildInbound_Hysteria2(t *testing.T) {
ServerPort: 444,
Version: 2,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "hysteria2")
users := inbound["users"].([]M)
@@ -288,7 +346,7 @@ func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
Obfs: "salamander",
ObfsPassword: "secret",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
obfs := inbound["obfs"].(M)
assertMapValue(t, obfs, "type", "salamander")
assertMapValue(t, obfs, "password", "secret")
@@ -302,7 +360,7 @@ func TestBuildInbound_Hysteria1(t *testing.T) {
UpMbps: 100,
DownMbps: 200,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "hysteria")
assertMapValue(t, inbound, "up_mbps", 100)
assertMapValue(t, inbound, "down_mbps", 200)
@@ -319,7 +377,7 @@ func TestBuildInbound_TUIC(t *testing.T) {
ServerPort: 555,
CongestionControl: "bbr",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "tuic")
assertMapValue(t, inbound, "congestion_control", "bbr")
@@ -336,7 +394,7 @@ func TestBuildInbound_AnyTLS(t *testing.T) {
ServerPort: 443,
PaddingScheme: "stop=8\n0=30-30",
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "anytls")
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
@@ -349,7 +407,7 @@ func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
Protocol: "anytls",
ServerPort: 443,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
if _, exists := inbound["padding_scheme"]; exists {
t.Error("should not have padding_scheme when empty")
}
@@ -363,7 +421,7 @@ func TestBuildInbound_Naive(t *testing.T) {
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, inbound, "type", "naive")
users := inbound["users"].([]M)
@@ -381,7 +439,7 @@ func TestBuildInbound_Socks(t *testing.T) {
Protocol: "socks",
ServerPort: 1080,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "socks")
users := inbound["users"].([]M)
@@ -400,7 +458,7 @@ func TestBuildInbound_HTTP(t *testing.T) {
Protocol: "http",
ServerPort: 8080,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
assertMapValue(t, inbound, "type", "http")
users := inbound["users"].([]M)
@@ -413,7 +471,7 @@ func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
ServerPort: 443,
TLS: 1,
}
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
tls := inbound["tls"].(M)
assertMapValue(t, tls, "enabled", true)
}
@@ -425,7 +483,7 @@ func TestBuildInbound_Unknown(t *testing.T) {
Protocol: "unknown-proto",
ServerPort: 999,
}
inbound := buildInbound(nc, testUsers, "", "")
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
if inbound != nil {
t.Errorf("unknown protocol should return nil, got %v", inbound)
}
@@ -440,7 +498,7 @@ func TestBuildConfig(t *testing.T) {
ServerPort: 111,
Cipher: "aes-128-gcm",
}
cfg := buildConfig(kcfg, nc, testUsers, "", "")
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
data, err := json.Marshal(cfg)
if err != nil {
@@ -484,7 +542,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) {
},
}
cfg := buildConfig(kcfg, nc, testUsers, "", "")
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
outbounds := cfg["outbounds"].([]M)
// We have 2 overrides in input, so we should have exactly 2 outbounds total
@@ -537,7 +595,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
for _, tc := range protocols {
t.Run(tc.name, func(t *testing.T) {
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, tc.nc, testUsers, "/c.pem", "/k.pem")
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, testNodeSpec(tc.nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal %s: %v", tc.name, err)
@@ -553,7 +611,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
// --- Routes ---
func TestBuildRoutes_Default(t *testing.T) {
route := buildRoutes(nil, nil)
route := buildRoutes(nil, nil, nil)
assertMapValue(t, route, "final", "direct")
rules := route["rules"].([]M)
@@ -570,7 +628,7 @@ func TestBuildRoutes_WithCustomRules(t *testing.T) {
{ID: 2, Match: []string{"10.0.0.0/8"}, Action: "block"},
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
}
route := buildRoutes(rules, nil)
route := buildRoutes(testRouteRules(rules), nil, nil)
allRules := route["rules"].([]M)
if len(allRules) != 5 {
@@ -595,7 +653,7 @@ func TestBuildRoutes_MultiMatch(t *testing.T) {
{ID: 1, Match: []string{"*.evil.com", "bad.org", "192.168.1.0/24"}, Action: "block"},
{ID: 2, Match: []string{"*.bypass.com"}, Action: "direct"},
}
route := buildRoutes(rules, nil)
route := buildRoutes(testRouteRules(rules), nil, nil)
allRules := route["rules"].([]M)
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
@@ -625,28 +683,88 @@ func TestBuildRoutes_MultiMatch(t *testing.T) {
assertMapValue(t, allRules[4], "outbound", "direct")
}
func TestBuildRoutes_WithCustomRouteRules(t *testing.T) {
customRules := []model.CustomRouteRule{
{
Name: "direct-mixed",
Match: model.RouteMatch{
Domains: []string{"full.example.com"},
DomainSuffixes: []string{"example.org"},
IPCIDRs: []string{"1.1.1.0/24"},
Ports: []string{"53", "1000-1002"},
Networks: []string{"tcp"},
SourceCIDRs: []string{"10.10.0.0/16"},
SourcePorts: []string{"2000-2001"},
},
Action: model.RouteAction{Type: "direct"},
},
}
route := buildRoutes(nil, customRules, nil)
allRules := route["rules"].([]M)
if len(allRules) != 9 {
t.Fatalf("rules count: got %d, want 9", len(allRules))
}
if allRules[0]["domain"].([]string)[0] != "full.example.com" {
t.Fatalf("unexpected exact domain rule: %v", allRules[0])
}
if allRules[1]["domain_suffix"].([]string)[0] != "example.org" {
t.Fatalf("unexpected domain suffix rule: %v", allRules[1])
}
if allRules[2]["ip_cidr"].([]string)[0] != "1.1.1.0/24" {
t.Fatalf("unexpected ip cidr rule: %v", allRules[2])
}
if allRules[3]["port"].([]int)[0] != 53 {
t.Fatalf("unexpected port rule: %v", allRules[3])
}
if allRules[4]["network"].([]string)[0] != "tcp" {
t.Fatalf("unexpected network rule: %v", allRules[4])
}
if allRules[5]["source_ip_cidr"].([]string)[0] != "10.10.0.0/16" {
t.Fatalf("unexpected source cidr rule: %v", allRules[5])
}
if allRules[6]["source_port_range"].([]string)[0] != "2000:2001" {
t.Fatalf("unexpected source port rule: %v", allRules[6])
}
}
func TestBuildRoutes_StructuredCustomRulesRemainFirst(t *testing.T) {
raw := []map[string]any{{"domain": []string{"raw.example.com"}, "outbound": "raw-tag"}}
custom := []model.CustomRouteRule{{
Match: model.RouteMatch{DomainSuffixes: []string{"structured.example"}},
Action: model.RouteAction{Type: "direct"},
}}
route := buildRoutes(nil, custom, raw)
allRules := route["rules"].([]M)
if allRules[0]["outbound"] != "direct" {
t.Fatalf("expected structured route first, got %v", allRules[0]["outbound"])
}
if allRules[1]["outbound"] != "raw-tag" {
t.Fatalf("expected raw custom route second, got %v", allRules[1]["outbound"])
}
}
// --- TLS Config ---
func TestBuildTLSConfig_WithCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(nc, "/cert.pem", "/key.pem")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, tls, "enabled", true)
assertMapValue(t, tls, "server_name", "example.com")
assertMapValue(t, tls, "certificate_path", "/cert.pem")
assertMapValue(t, tls, "key_path", "/key.pem")
assertMapValue(t, tls, "certificate", []string{"CERT"})
assertMapValue(t, tls, "key", []string{"KEY"})
}
func TestBuildTLSConfig_NoCert(t *testing.T) {
nc := &panel.NodeConfig{ServerName: "example.com"}
tls := buildTLSConfig(nc, "", "")
assertMapValue(t, tls, "enabled", true)
// Now we fallback to self-signed certificates when both cert and key are empty
assertMapValue(t, tls, "certificate_path", "self-signed")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{})
if tls != nil {
t.Fatalf("expected nil tls config when cert paths are empty, got %+v", tls)
}
}
func TestBuildTLSConfig_FallbackToHost(t *testing.T) {
nc := &panel.NodeConfig{Host: "fallback.com"}
tls := buildTLSConfig(nc, "", "")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, tls, "server_name", "fallback.com")
}
@@ -657,7 +775,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
"server_name": "override.com",
},
}
tls := buildTLSConfig(nc, "", "")
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
assertMapValue(t, tls, "server_name", "override.com")
}
@@ -666,7 +784,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
func TestApplyTransport_TCP(t *testing.T) {
base := M{}
nc := &panel.NodeConfig{Network: "tcp"}
applyTransport(base, nc)
applyTransport(base, testNodeSpec(nc))
if _, exists := base["transport"]; exists {
t.Error("tcp should not add transport")
}
@@ -675,7 +793,7 @@ func TestApplyTransport_TCP(t *testing.T) {
func TestApplyTransport_Empty(t *testing.T) {
base := M{}
nc := &panel.NodeConfig{Network: ""}
applyTransport(base, nc)
applyTransport(base, testNodeSpec(nc))
if _, exists := base["transport"]; exists {
t.Error("empty network should not add transport")
}
@@ -691,7 +809,7 @@ func TestApplyTransport_WS_MaxEarlyData(t *testing.T) {
"early_data_header_name": "Sec-WebSocket-Protocol",
},
}
applyTransport(base, nc)
applyTransport(base, testNodeSpec(nc))
transport := base["transport"].(M)
assertMapValue(t, transport, "max_early_data", 2048)
assertMapValue(t, transport, "early_data_header_name", "Sec-WebSocket-Protocol")
@@ -721,8 +839,51 @@ func assertMapValue(t *testing.T, m M, key string, expected interface{}) {
t.Errorf("%s: got %v (type %T), want %d", key, val, val, e)
}
default:
if val != expected {
if !reflect.DeepEqual(val, expected) {
t.Errorf("%s: got %v, want %v", key, val, expected)
}
}
}
func TestExtractECHInbound(t *testing.T) {
tests := []struct {
name string
tls map[string]interface{}
expectNil bool
expectKey []string
}{
{"no ech", map[string]interface{}{}, true, nil},
{"disabled", map[string]interface{}{"ech": map[string]interface{}{"enabled": false}}, true, nil},
{"enabled with key", map[string]interface{}{"ech": map[string]interface{}{
"enabled": true,
"key": "-----BEGIN ECH KEYS-----\nABCD\n-----END ECH KEYS-----\n",
}}, false, []string{"-----BEGIN ECH KEYS-----\nABCD\n-----END ECH KEYS-----\n"}},
{"enabled with key_path", map[string]interface{}{"ech": map[string]interface{}{
"enabled": true,
"key_path": "/etc/ech/keys.pem",
}}, false, nil},
{"enabled no key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true}}, false, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractECHInbound(tt.tls)
if tt.expectNil {
if result != nil {
t.Errorf("expected nil, got %v", result)
}
return
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result["enabled"] != true {
t.Errorf("expected enabled=true, got %v", result["enabled"])
}
if tt.expectKey != nil {
if !reflect.DeepEqual(result["key"], tt.expectKey) {
t.Errorf("key: got %v, want %v", result["key"], tt.expectKey)
}
}
})
}
}
File diff suppressed because it is too large Load Diff
+146 -48
View File
@@ -1,10 +1,10 @@
package singbox
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
@@ -20,7 +20,8 @@ import (
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/nlog"
)
// drainTimeout is how long stop() waits for in-flight connections to finish
@@ -35,15 +36,14 @@ const drainTimeout = 5 * time.Second
type SingBox struct {
cfg config.KernelConfig
mu sync.Mutex
mu sync.RWMutex
box *box.Box
ctx context.Context
cancel context.CancelFunc
users []panel.User
nodeConfig *panel.NodeConfig
certFile string
keyFile string
users []model.UserSpec
nodeConfig *model.NodeSpec
tls kernel.TLSCert
// connTracker is our lightweight in-process byte/IP tracker.
// Created fresh on every Start (full restart).
@@ -54,6 +54,10 @@ type SingBox struct {
// Set once by SetSpeedLimitFunc and forwarded to every new ConnTracker.
speedLimitFunc func(string) *rate.Limiter
// deviceLimitFunc resolves a user UUID to (limit, hasLimit) for gate-keeping.
// Set once by SetDeviceLimitFunc and forwarded to every new ConnTracker.
deviceLimitFunc func(string) (int, bool)
// trackerRegistered prevents duplicate AppendTracker calls on the same
// Router instance during Reload. Reset to false on full restart.
trackerRegistered bool
@@ -67,24 +71,35 @@ var _ kernel.Kernel = (*SingBox)(nil)
func (s *SingBox) Name() string { return "sing-box" }
func (s *SingBox) Protocols() []string {
return []string{
"vmess", "vless", "trojan", "shadowsocks",
"hysteria2", "tuic", "naive", "socks", "http", "anytls", "mieru",
func (s *SingBox) Capabilities() kernel.Capabilities {
return kernel.Capabilities{
PerUserSpeedLimit: true,
DeviceLimit: true,
BuiltInTrafficStats: false,
AliveIPTracking: true,
ForceCloseConnection: false,
ForceCloseUser: true,
}
}
func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
func (s *SingBox) Protocols() []string {
return []string{
"vmess", "vless", "trojan", "shadowsocks",
"hysteria", "hysteria2", "tuic", "naive", "socks", "http", "anytls", "mieru",
}
}
func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
s.mu.Lock()
defer s.mu.Unlock()
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
cfgMap := buildConfig(s.cfg, nodeConfig, users, tls)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
slog.Debug("sing-box config generated", "len", len(data))
nlog.Core().Debug("sing-box config generated", "len", len(data))
ctx, cancel := context.WithCancel(context.Background())
ctx = include.Context(ctx)
@@ -95,7 +110,11 @@ func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFi
return fmt.Errorf("parse sing-box options: %w", err)
}
s.stop()
// Save old state before creating the new instance.
oldBox := s.box
oldCancel := s.cancel
oldCtx := s.ctx
oldTracker := s.connTracker
instance, err := box.New(box.Options{
Context: ctx,
@@ -112,32 +131,68 @@ func (s *SingBox) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFi
return fmt.Errorf("start sing-box: %w", err)
}
// New instance started successfully — swap state.
s.box = instance
s.ctx = ctx
s.cancel = cancel
s.users = users
s.nodeConfig = nodeConfig
s.certFile = certFile
s.keyFile = keyFile
s.tls = tls
// Fresh tracker on full restart (all connections were terminated by s.stop).
// Fresh tracker on full restart.
s.connTracker = NewConnTracker(0)
s.connTracker.SetUserMap(buildUserMap(users))
if s.speedLimitFunc != nil {
s.connTracker.SetSpeedLimitFunc(s.speedLimitFunc)
}
if s.deviceLimitFunc != nil {
s.connTracker.SetDeviceLimitFunc(s.deviceLimitFunc)
}
s.trackerRegistered = false
s.registerTracker(ctx)
slog.Info("sing-box started", "users", len(users))
// Recycle old instance in background — drain then close.
if oldBox != nil {
go recycleOldBox(oldBox, oldCancel, oldCtx, oldTracker)
}
nlog.Core().Debug("sing-box started", "users", len(users))
return nil
}
// recycleOldBox gracefully shuts down a previous sing-box instance in the
// background. It closes listen sockets first, waits for connections to drain,
// then hard-closes. This avoids blocking the new instance's startup.
func recycleOldBox(oldBox *box.Box, oldCancel context.CancelFunc, oldCtx context.Context, oldTracker *ConnTracker) {
// Step 1: close listen sockets so no new connections arrive on old ports.
if im := service.FromContext[adapter.InboundManager](oldCtx); im != nil {
_ = im.Close()
}
// Step 2: drain in-flight connections (best-effort).
if oldTracker != nil {
deadline := time.Now().Add(drainTimeout)
for time.Now().Before(deadline) {
if oldTracker.ActiveCount() == 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
}
// Step 3: hard-close everything.
oldBox.Close()
if oldCancel != nil {
oldCancel()
}
nlog.Core().Debug("sing-box: old instance recycled")
}
// Reload hot-swaps the inbound users and routing rules without restarting the box.
// Routes, outbounds, and the connTracker stay alive so in-flight connections
// continue to be tracked correctly.
func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -145,7 +200,7 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
return fmt.Errorf("not running")
}
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
cfgMap := buildConfig(s.cfg, nodeConfig, users, tls)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
@@ -168,15 +223,16 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
// Update routing rules
if err := router.UpdateRules(opts.Route.Rules, opts.Route.RuleSet); err != nil {
slog.Warn("routing reload failed", "error", err)
nlog.Core().Debug("routing reload failed", "error", err)
} else {
slog.Info("sing-box routing reloaded")
nlog.Core().Debug("sing-box routing reloaded")
}
nopFactory := singLog.NewNOPFactory()
// Configuration hash check for inbound reconstruction
configChanged := s.nodeConfig == nil || kernel.ComputeHash(nodeConfig, users) != kernel.ComputeHash(s.nodeConfig, s.users)
tlsChanged := !bytes.Equal(s.tls.CertPEM, tls.CertPEM) || !bytes.Equal(s.tls.KeyPEM, tls.KeyPEM)
configChanged := tlsChanged || s.nodeConfig == nil || kernel.ComputeHash(nodeConfig, users) != kernel.ComputeHash(s.nodeConfig, s.users)
for _, inb := range opts.Inbounds {
tag := inb.Tag
@@ -212,6 +268,10 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.MieruUser]:
if opts, ok := inb.Options.(*option.MieruInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[auth.User]:
switch opts := inb.Options.(type) {
case *option.NaiveInboundOptions:
@@ -225,7 +285,7 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
if err == nil {
continue
}
slog.Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
nlog.Core().Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
}
}
@@ -247,11 +307,10 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
s.connTracker.SetUserMap(buildUserMap(users))
}
slog.Info("sing-box reloaded (users and routes hot-swapped)", "users", len(users))
nlog.Core().Debug("sing-box reloaded", "users", len(users))
s.users = users
s.nodeConfig = nodeConfig
s.certFile = certFile
s.keyFile = keyFile
s.tls = tls
return nil
}
@@ -327,9 +386,6 @@ func (s *SingBox) connTrackerSafe() *ConnTracker {
}
// SetSpeedLimitFunc configures per-user bandwidth throttling.
// The function is forwarded to the ConnTracker, which embeds the rate limiter
// in each tracked connection wrapper. This merges byte counting and rate limiting
// into a single CountFunc callback, allowing splice/zero-copy paths.
func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -339,10 +395,39 @@ func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
}
}
// SetDeviceLimitFunc configures per-user device limit gate-keeping.
// Connections exceeding the limit are rejected at connect time.
func (s *SingBox) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) {
s.mu.Lock()
defer s.mu.Unlock()
s.deviceLimitFunc = fn
if s.connTracker != nil {
s.connTracker.SetDeviceLimitFunc(fn)
}
}
// UpdateGlobalDevices updates the global device state from panel (for multi-node).
func (s *SingBox) UpdateGlobalDevices(users map[int][]string) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.connTracker != nil {
s.connTracker.UpdateGlobalDevices(users)
}
}
// ClearGlobalDevices clears the global device state (on WS disconnect).
func (s *SingBox) ClearGlobalDevices() {
s.mu.RLock()
defer s.mu.RUnlock()
if s.connTracker != nil {
s.connTracker.ClearGlobalDevices()
}
}
// ─── User management (non-disruptive) ───────────────────────────────────────
// AddUsers hot-swaps users into running inbounds. Zero connection disruption.
func (s *SingBox) AddUsers(users []panel.User) (int, error) {
func (s *SingBox) AddUsers(users []model.UserSpec) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -354,7 +439,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
for _, u := range s.users {
existing[u.ID] = struct{}{}
}
var toAdd []panel.User
var toAdd []model.UserSpec
for _, u := range users {
if _, dup := existing[u.ID]; !dup {
toAdd = append(toAdd, u)
@@ -364,7 +449,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
return 0, nil
}
merged := append(append([]panel.User{}, s.users...), toAdd...)
merged := append(append([]model.UserSpec{}, s.users...), toAdd...)
if err := s.reloadInboundsLocked(merged); err != nil {
return 0, err
}
@@ -374,7 +459,7 @@ func (s *SingBox) AddUsers(users []panel.User) (int, error) {
// RemoveUsers hot-swaps users out of running inbounds. Zero connection disruption
// for remaining users.
func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
func (s *SingBox) RemoveUsers(users []model.UserSpec) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -386,7 +471,7 @@ func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
for _, u := range users {
removeSet[u.ID] = struct{}{}
}
var kept []panel.User
var kept []model.UserSpec
removed := 0
for _, u := range s.users {
if _, rm := removeSet[u.ID]; rm {
@@ -407,7 +492,7 @@ func (s *SingBox) RemoveUsers(users []panel.User) (int, error) {
}
// UpdateUsers replaces the entire user set atomically via hot-swap.
func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error) {
func (s *SingBox) UpdateUsers(users []model.UserSpec) (added, removed int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -436,8 +521,8 @@ func (s *SingBox) UpdateUsers(users []panel.User) (added, removed int, err error
// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound.
// Must be called with s.mu held.
func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile)
func (s *SingBox) reloadInboundsLocked(users []model.UserSpec) error {
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.tls)
data, err := json.Marshal(cfgMap)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
@@ -493,6 +578,10 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[option.MieruUser]:
if opts, ok := inb.Options.(*option.MieruInboundOptions); ok {
err = v.UpdateUsers(opts.Users)
}
case adapter.UpdatableInbound[auth.User]:
switch opts := inb.Options.(type) {
case *option.NaiveInboundOptions:
@@ -506,7 +595,7 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
if err == nil {
continue
}
slog.Warn("incremental update failed, recreating inbound", "tag", tag, "error", err)
nlog.Core().Warn("incremental update failed, recreating inbound", "tag", tag, "error", err)
}
_ = im.Remove(tag)
@@ -520,19 +609,18 @@ func (s *SingBox) reloadInboundsLocked(users []panel.User) error {
s.connTracker.SetUserMap(buildUserMap(users))
}
slog.Info("sing-box users hot-swapped", "users", len(users))
nlog.Core().Debug("sing-box users hot-swapped", "users", len(users))
return nil
}
// ─── Observability ──────────────────────────────────────────────────────────
func (s *SingBox) GetConnections(_ context.Context) ([]kernel.Connection, error) {
func (s *SingBox) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int, err error) {
ct := s.connTrackerSafe()
if ct == nil {
return nil, nil
return nil, nil, 0, nil
}
conns := ct.Snapshot()
slog.Debug("GetConnections snapshot", "count", len(conns))
return conns, nil
traffic, aliveIPs, connCount = ct.GetUserTraffic()
return traffic, aliveIPs, connCount, nil
}
// CloseConnection force-closes a specific connection by its ID.
@@ -542,15 +630,25 @@ func (s *SingBox) CloseConnection(_ context.Context, connID string) error {
return fmt.Errorf("not running")
}
if !ct.CloseByID(connID) {
slog.Debug("CloseConnection: connection not found (already closed?)", "id", connID)
nlog.Core().Debug("CloseConnection: connection not found (already closed?)", "id", connID)
}
return nil
}
// CloseUserConnections terminates all connections for a user UUID.
func (s *SingBox) CloseUserConnections(_ context.Context, uuid string) error {
ct := s.connTrackerSafe()
if ct == nil {
return nil
}
ct.CloseByUUID(uuid)
return nil
}
// buildUserMap creates a UUID→userID mapping used by ConnTracker to attribute
// connections to the correct user. All sing-box protocols use the user's UUID
// as the inbound name/username, so this covers every protocol.
func buildUserMap(users []panel.User) map[string]int {
func buildUserMap(users []model.UserSpec) map[string]int {
m := make(map[string]int, len(users))
for _, u := range users {
m[u.UUID] = u.ID
+215
View File
@@ -0,0 +1,215 @@
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)
}
protocols := s.Protocols()
found := false
for _, protocol := range protocols {
if protocol == "hysteria" {
found = true
break
}
}
if !found {
t.Fatalf("Protocols() missing hysteria: %v", protocols)
}
}
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)
}
}
+403 -111
View File
@@ -1,19 +1,22 @@
package xray
import (
"encoding/base64"
"encoding/pem"
"fmt"
"log/slog"
"os"
"strings"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/nlog"
)
// M is a shorthand for building JSON-like maps
type M = map[string]interface{}
func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
var outbounds []M
tags := make(map[string]bool)
@@ -44,8 +47,8 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
cfg := M{
"log": M{
"loglevel": xrayLogLevel(kcfg.LogLevel),
"error": "stdout",
"access": "stdout",
"error": "",
"access": "",
},
"stats": M{},
"policy": M{
@@ -65,17 +68,17 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
"outbounds": outbounds,
}
inbound := buildInbound(nc, users, certFile, keyFile)
inbound := buildInbound(nc, users, tc)
if inbound != nil {
cfg["inbounds"] = []M{inbound}
} else {
slog.Warn("xray: unsupported protocol, no inbound configured — node will not accept connections",
nlog.Core().Warn("xray: unsupported protocol, no inbound configured — node will not accept connections",
"protocol", nc.Protocol,
"supported", "vmess, vless, trojan, shadowsocks, socks, http")
"supported", "vmess, vless, trojan, shadowsocks, hysteria, socks, http")
}
// Merge panel routes and static config routes
cfg["routing"] = buildRouting(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
cfg["routing"] = buildRouting(nc.Routes, nc.CustomRouteRules, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
mergeCustomXray(cfg, kcfg)
return cfg
@@ -84,7 +87,7 @@ func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.U
// outboundConfigToXray converts a structured OutboundConfig (from the panel)
// into an Xray outbound object. Xray uses a nested layout where protocol-
// specific fields go inside a "settings" key, and chain proxying uses "proxySettings".
func outboundConfigToXray(oc panel.OutboundConfig) M {
func outboundConfigToXray(oc model.OutboundConfig) M {
m := M{
"protocol": oc.Protocol,
"tag": oc.Tag,
@@ -115,7 +118,7 @@ func mergeRouteList(a, b []map[string]any) []map[string]any {
func mergeCustomXray(cfg M, kcfg config.KernelConfig) {
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
if err != nil {
slog.Error("failed to load custom xray config", "error", err)
nlog.Core().Error("failed to load custom xray config", "error", err)
return
}
if custom == nil {
@@ -194,7 +197,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, tc kernel.TLSCert) M {
listenAddr := "::"
if nc.ListenIP != "" {
listenAddr = nc.ListenIP
@@ -213,17 +216,19 @@ func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile st
switch nc.Protocol {
case "vmess":
return buildVMess(base, nc, users, certFile, keyFile)
return buildVMess(base, nc, users, tc)
case "vless":
return buildVLESS(base, nc, users, certFile, keyFile)
return buildVLESS(base, nc, users, tc)
case "trojan":
return buildTrojan(base, nc, users, certFile, keyFile)
return buildTrojan(base, nc, users, tc)
case "shadowsocks":
return buildShadowsocks(base, nc, users)
case "socks":
return buildSocks(base, users)
case "http":
return buildHTTP(base, nc, users, certFile, keyFile)
return buildHTTP(base, nc, users, tc)
case "hysteria":
return buildHysteria(base, nc, users, tc)
default:
return nil
}
@@ -235,7 +240,7 @@ func userEmail(userID int) string {
return fmt.Sprintf("user@%d", userID)
}
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
clients := make([]M, 0, len(users))
for _, u := range users {
clients = append(clients, M{
@@ -246,11 +251,11 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
}
base["settings"] = M{"clients": clients}
applyStreamSettings(base, nc, certFile, keyFile)
applyStreamSettings(base, nc, tc)
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, tc kernel.TLSCert) M {
clients := make([]M, 0, len(users))
for _, u := range users {
client := M{
@@ -262,61 +267,102 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
}
clients = append(clients, client)
}
decryption := "none"
if nc.Decryption != "" {
decryption = nc.Decryption
}
base["settings"] = M{
"clients": clients,
"decryption": "none",
"decryption": decryption,
}
applyStreamSettings(base, nc, certFile, keyFile)
applyStreamSettings(base, nc, tc)
return base
}
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
clients := make([]M, 0, len(users))
for _, u := range users {
clients = append(clients, M{
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
clients := make([]M, len(users))
for i := range users {
u := &users[i]
clients[i] = M{
"password": u.UUID,
"email": userEmail(u.ID),
})
}
}
base["settings"] = M{"clients": clients}
applyStreamSettings(base, nc, certFile, keyFile)
applyStreamSettings(base, nc, tc)
// Trojan requires TLS or Reality to be enabled.
// If the panel didn't explicitly set TLS=1 or TLS=2, but we have certs,
// we should enable a default TLS config to ensure the inbound can start.
ss, _ := base["streamSettings"].(M)
if security, ok := ss["security"].(string); !ok || (security != "tls" && security != "reality") {
nc.TLS = 1 // Force internal state to trigger TLS build in applyStreamSettings
applyStreamSettings(base, nc, tc)
}
return base
}
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
// Single-user mode for traditional ciphers
if !strings.HasPrefix(nc.Cipher, "2022-blake3-") {
if len(users) > 0 {
base["settings"] = M{
"method": nc.Cipher,
"password": users[0].UUID,
"email": userEmail(users[0].ID),
"network": "tcp,udp",
}
}
return base
}
type ss2022Config struct {
method string
size int
}
var ss2022Methods = map[string]ss2022Config{
"2022-blake3-aes-128-gcm": {"2022-blake3-aes-128-gcm", 16},
"2022-blake3-aes-256-gcm": {"2022-blake3-aes-256-gcm", 32},
"2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32},
}
func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M {
ss2022, isSS2022 := ss2022Methods[nc.Cipher]
// Multi-user mode for 2022-blake3 ciphers
clients := make([]M, 0, len(users))
for _, u := range users {
clients = append(clients, M{
"password": u.UUID,
"email": userEmail(u.ID),
})
}
base["settings"] = M{
"method": nc.Cipher,
"password": nc.ServerKey,
"clients": clients,
"network": "tcp,udp",
if isSS2022 {
// SS2022: server key at top level, per-user key must be Base64 of fixed-length raw bytes.
// Only blake3-aes-* supports multi-user in Xray; chacha20 variant is single-user only.
rawBuf := make([]byte, ss2022.size)
for i := range users {
u := &users[i]
for j := range rawBuf {
rawBuf[j] = 0
}
copy(rawBuf, u.UUID)
clients = append(clients, M{
"password": base64.StdEncoding.EncodeToString(rawBuf),
"email": userEmail(u.ID),
})
}
base["settings"] = M{
"method": nc.Cipher,
"password": nc.ServerKey,
"clients": clients,
"network": "tcp,udp",
}
} else {
// Traditional ciphers: multi-user via clients array.
// Each entry must carry its own "method" field for Xray to parse correctly.
for i := range users {
u := &users[i]
clients = append(clients, M{
"method": nc.Cipher,
"password": u.UUID,
"email": userEmail(u.ID),
})
}
base["settings"] = M{
"method": nc.Cipher,
"clients": clients,
"network": "tcp,udp",
}
}
return base
}
func buildSocks(base M, users []panel.User) M {
func buildSocks(base M, users []model.UserSpec) M {
base["protocol"] = "socks"
accounts := make([]M, 0, len(users))
for _, u := range users {
@@ -334,7 +380,7 @@ func buildSocks(base M, users []panel.User) M {
return base
}
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
base["protocol"] = "http"
accounts := make([]M, 0, len(users))
for _, u := range users {
@@ -349,12 +395,71 @@ func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFi
}
if nc.TLS == 1 {
applyStreamSettings(base, nc, certFile, keyFile)
applyStreamSettings(base, nc, tc)
}
return base
}
func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) {
// buildHysteria creates a Hysteria v2 inbound for xray-core.
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
if nc.Version != 2 {
nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound")
return nil
}
clients := make([]M, 0, len(users))
for _, u := range users {
clients = append(clients, M{
"auth": u.UUID,
"email": userEmail(u.ID),
})
}
base["settings"] = M{"clients": clients}
ss := M{
"network": "hysteria",
"hysteriaSettings": M{
"version": nc.Version,
"udpIdleTimeout": 60,
},
}
if nc.UpMbps > 0 || nc.DownMbps > 0 {
quicParams := M{}
if nc.UpMbps > 0 {
quicParams["brutalUp"] = fmt.Sprintf("%d mbps", nc.UpMbps)
}
if nc.DownMbps > 0 {
quicParams["brutalDown"] = fmt.Sprintf("%d mbps", nc.DownMbps)
}
ss["finalMask"] = M{
"quicParams": quicParams,
}
}
if tc.HasCert() {
ss["security"] = "tls"
tlsCert := M{
"certificate": []string{string(tc.CertPEM)},
"key": []string{string(tc.KeyPEM)},
}
ss["tlsSettings"] = M{
"certificates": []M{tlsCert},
"alpn": []string{"h3"},
}
} else {
nlog.Core().Warn("hysteria requires TLS certificate files; configure cert_mode (self, file, http, dns, or content)")
}
if nc.GetProxyProtocol() {
ss["sockopt"] = M{"acceptProxyProtocol": true}
}
base["streamSettings"] = ss
return base
}
func applyStreamSettings(base M, nc *model.NodeSpec, tc kernel.TLSCert) {
ss := M{}
// Network / transport
@@ -391,7 +496,9 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
case "grpc":
grpcSettings := M{}
if nc.NetworkSettings != nil {
if v, ok := nc.NetworkSettings["service_name"]; ok {
if v, ok := nc.NetworkSettings["serviceName"]; ok {
grpcSettings["serviceName"] = v
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
grpcSettings["serviceName"] = v
}
}
@@ -422,6 +529,31 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
}
ss["httpSettings"] = h2Settings
case "xhttp", "splithttp":
ss["network"] = "xhttp"
xhttpSettings := M{}
if nc.NetworkSettings != nil {
if v, ok := nc.NetworkSettings["path"]; ok {
xhttpSettings["path"] = v
}
if v, ok := nc.NetworkSettings["host"]; ok {
xhttpSettings["host"] = v
}
if v, ok := nc.NetworkSettings["mode"]; ok {
xhttpSettings["mode"] = v
}
if v, ok := nc.NetworkSettings["extra"]; ok {
// PHP sends empty arrays [] instead of {} for empty objects;
// xray rejects [] for fields that expect objects (e.g. sockopt, tlsSettings).
// Recursively strip empty arrays from the extra map before passing to xray.
if m, ok := v.(map[string]interface{}); ok && len(m) > 0 {
sanitizeEmptyArrays(m)
xhttpSettings["extra"] = m
}
}
}
ss["xhttpSettings"] = xhttpSettings
case "tcp":
// default, no extra settings
}
@@ -440,14 +572,17 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
tlsSettings["serverName"] = sn
}
}
if certFile != "" && keyFile != "" {
tlsSettings["certificates"] = []M{
{
"certificateFile": certFile,
"keyFile": keyFile,
},
// ECH server-side: extract key from tls_settings.ech
if echKeys := extractECHServerKeys(nc.TLSSettings); echKeys != "" {
tlsSettings["echServerKeys"] = echKeys
}
}
if tc.HasCert() {
tlsCert := M{
"certificate": []string{string(tc.CertPEM)},
"key": []string{string(tc.KeyPEM)},
}
tlsSettings["certificates"] = []M{tlsCert}
} else {
// Fallback placeholder for auto-TLS environments.
// Xray allows empty certificates array in more cases than sing-box,
@@ -461,7 +596,7 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
}
// Proxy Protocol
if nc.AcceptProxyProtocol {
if nc.GetProxyProtocol() {
sockopt, ok := base["streamSettings"].(M)["sockopt"].(M)
if !ok {
sockopt = M{}
@@ -473,7 +608,7 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
base["streamSettings"] = ss
}
func buildRealitySettings(nc *panel.NodeConfig) M {
func buildRealitySettings(nc *model.NodeSpec) M {
reality := M{"show": false}
if nc.TLSSettings == nil {
@@ -510,10 +645,18 @@ func buildRealitySettings(nc *panel.NodeConfig) M {
return reality
}
func buildRouting(rules []panel.RouteRule, customRules []map[string]any) M {
func buildRouting(rules []model.RouteRule, customRouteRules []model.CustomRouteRule, customRules []map[string]any) M {
var xrayRules []M
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
// Structured custom routes now take the highest priority for panel-managed overrides.
for _, rule := range customRouteRules {
if rule.Disabled {
continue
}
xrayRules = append(xrayRules, compileCustomRouteRule(rule)...)
}
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
for _, cr := range customRules {
xrayRules = append(xrayRules, M(cr))
}
@@ -537,49 +680,7 @@ func buildRouting(rules []panel.RouteRule, customRules []map[string]any) M {
})
for _, rule := range rules {
if len(rule.Match) == 0 {
continue
}
var ips, domains []string
for _, m := range rule.Match {
m = strings.TrimSpace(m)
if m == "" {
continue
}
if strings.HasPrefix(m, "geoip:") {
ips = append(ips, m) // "geoip:cn" → ip field, passed as-is
} else if strings.HasPrefix(m, "geosite:") {
domains = append(domains, m) // "geosite:google" → domain field, passed as-is
} else if strings.Contains(m, "/") {
ips = append(ips, m)
} else {
m = strings.TrimPrefix(m, "*.")
domains = append(domains, "domain:"+m)
}
}
outbound := "block"
if rule.Action == "direct" {
outbound = "direct"
} else if rule.Action == "proxy" && rule.ActionValue != "" {
outbound = rule.ActionValue
}
if len(domains) > 0 {
xrayRules = append(xrayRules, M{
"type": "field",
"domain": domains,
"outboundTag": outbound,
})
}
if len(ips) > 0 {
xrayRules = append(xrayRules, M{
"type": "field",
"ip": ips,
"outboundTag": outbound,
})
}
xrayRules = append(xrayRules, compilePanelRouteRule(rule)...)
}
return M{
@@ -587,3 +688,194 @@ func buildRouting(rules []panel.RouteRule, customRules []map[string]any) M {
"rules": xrayRules,
}
}
func compilePanelRouteRule(rule model.RouteRule) []M {
if len(rule.Match) == 0 {
return nil
}
match := model.RouteMatch{}
for _, item := range rule.Match {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if strings.HasPrefix(item, "geoip:") || strings.Contains(item, "/") {
match.IPCIDRs = append(match.IPCIDRs, item)
continue
}
if strings.HasPrefix(item, "geosite:") {
match.Domains = append(match.Domains, item)
continue
}
match.DomainSuffixes = append(match.DomainSuffixes, strings.TrimPrefix(item, "*."))
}
action := model.RouteAction{Type: "block"}
switch rule.Action {
case "direct":
action.Type = "direct"
case "proxy":
action.Type = "route"
action.Target = rule.ActionValue
}
return compileCustomRouteRule(model.CustomRouteRule{Match: match, Action: action})
}
func compileCustomRouteRule(rule model.CustomRouteRule) []M {
outbound := xrayOutboundForAction(rule.Action)
var compiled []M
if len(rule.Match.Domains) > 0 || len(rule.Match.DomainSuffixes) > 0 {
domains := make([]string, 0, len(rule.Match.Domains)+len(rule.Match.DomainSuffixes))
for _, value := range rule.Match.Domains {
value = strings.TrimSpace(value)
if value == "" {
continue
}
domains = append(domains, value)
}
for _, value := range rule.Match.DomainSuffixes {
value = strings.TrimSpace(value)
if value == "" {
continue
}
domains = append(domains, "domain:"+value)
}
if len(domains) > 0 {
compiled = append(compiled, M{
"type": "field",
"domain": domains,
"outboundTag": outbound,
})
}
}
if len(rule.Match.IPCIDRs) > 0 {
compiled = append(compiled, M{
"type": "field",
"ip": copyStrings(rule.Match.IPCIDRs),
"outboundTag": outbound,
})
}
if len(rule.Match.Ports) > 0 {
compiled = append(compiled, M{
"type": "field",
"port": strings.Join(rule.Match.Ports, ","),
"outboundTag": outbound,
})
}
if len(rule.Match.Networks) > 0 {
compiled = append(compiled, M{
"type": "field",
"network": strings.Join(rule.Match.Networks, ","),
"outboundTag": outbound,
})
}
if len(rule.Match.SourceCIDRs) > 0 {
compiled = append(compiled, M{
"type": "field",
"source": copyStrings(rule.Match.SourceCIDRs),
"outboundTag": outbound,
})
}
if len(rule.Match.SourcePorts) > 0 {
compiled = append(compiled, M{
"type": "field",
"sourcePort": strings.Join(rule.Match.SourcePorts, ","),
"outboundTag": outbound,
})
}
return compiled
}
func xrayOutboundForAction(action model.RouteAction) string {
switch action.Type {
case "direct":
return "direct"
case "route":
if action.Target != "" {
return action.Target
}
return "block"
default:
return "block"
}
}
func copyStrings(src []string) []string {
if len(src) == 0 {
return nil
}
out := make([]string, len(src))
copy(out, src)
return out
}
// sanitizeEmptyArrays recursively removes empty slices from a map.
// PHP encodes empty objects as [] instead of {}; xray-core rejects []
// for fields that expect struct types (e.g. sockopt, tlsSettings).
func sanitizeEmptyArrays(m map[string]interface{}) {
for k, v := range m {
switch val := v.(type) {
case []interface{}:
if len(val) == 0 {
delete(m, k)
}
case map[string]interface{}:
sanitizeEmptyArrays(val)
}
}
}
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
// and converts it from PEM to base64 for xray-core's echServerKeys field.
func extractECHServerKeys(tlsSettings map[string]interface{}) string {
echRaw, ok := tlsSettings["ech"]
if !ok {
return ""
}
echMap, ok := echRaw.(map[string]interface{})
if !ok {
return ""
}
enabled, _ := echMap["enabled"].(bool)
if !enabled {
return ""
}
// Try inline key first, then key_path
var pemData []byte
if key, _ := echMap["key"].(string); key != "" {
pemData = []byte(key)
} else if keyPath, _ := echMap["key_path"].(string); keyPath != "" {
data, err := os.ReadFile(keyPath)
if err != nil {
nlog.Core().Warn("ECH enabled but key_path read failed", "error", err)
return ""
}
pemData = data
} else {
nlog.Core().Warn("ECH enabled but no key or key_path provided")
return ""
}
return echPEMToBase64(pemData)
}
// echPEMToBase64 parses an "ECH KEYS" PEM block and returns base64 of the raw bytes.
// If the input is not valid PEM or has the wrong type, it returns empty string.
func echPEMToBase64(data []byte) string {
trimmed := strings.TrimSpace(string(data))
if !strings.Contains(trimmed, "-----") {
// Assume already raw base64
return trimmed
}
block, _ := pem.Decode(data)
if block == nil {
nlog.Core().Warn("ECH key: invalid PEM data")
return ""
}
if block.Type != "ECH KEYS" {
nlog.Core().Warn("ECH key: unexpected PEM type", "expected", "ECH KEYS", "got", block.Type)
return ""
}
return base64.StdEncoding.EncodeToString(block.Bytes)
}
+151 -16
View File
@@ -1,10 +1,14 @@
package xray
import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"testing"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/panel"
)
@@ -13,11 +17,22 @@ var testKernelCfg = config.KernelConfig{
LogLevel: "warn",
}
var testUsers = []panel.User{
var testUsersPanel = []panel.User{
{ID: 1, UUID: "279d4f89-3a2c-488d-a67c-2d39a72acdde"},
{ID: 5, UUID: "4d5965c8-a60c-452a-a943-af83ec0bb0db"},
}
var testUsers = model.UserSpecsFromPanel(testUsersPanel)
func testNodeSpec(nc *panel.NodeConfig) *model.NodeSpec { return model.NodeSpecFromPanel(nc) }
func testRouteRules(r []panel.RouteRule) []model.RouteRule {
if r == nil {
return nil
}
return model.NodeSpecFromPanel(&panel.NodeConfig{Routes: r}).Routes
}
func TestBuildConfig_OutboundPriority(t *testing.T) {
kcfg := config.KernelConfig{
LogLevel: "info",
@@ -34,7 +49,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) {
},
}
cfg := buildConfig(kcfg, nc, testUsers, "", "")
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
outbounds := cfg["outbounds"].([]M)
// Since we overrode both 'direct' and 'block', the result should contain
@@ -149,7 +164,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
for _, tc := range protocols {
t.Run(tc.name, func(t *testing.T) {
cfg := buildConfig(testKernelCfg, &tc.nc, testUsers, "/cert.pem", "/key.pem")
cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
data, err := json.Marshal(cfg)
if err != nil {
@@ -182,7 +197,7 @@ func TestBuildConfig_VMess_Users(t *testing.T) {
Protocol: "vmess",
ServerPort: 10086,
}
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
data, _ := json.Marshal(cfg)
var parsed map[string]interface{}
@@ -222,7 +237,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
"server_name": "example.com",
},
}
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
data, _ := json.Marshal(cfg)
var parsed map[string]interface{}
@@ -245,7 +260,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
}
func TestBuildRouting_Default(t *testing.T) {
routing := buildRouting(nil, nil)
routing := buildRouting(nil, nil, nil)
rules := routing["rules"].([]M)
if len(rules) != 1 {
@@ -275,7 +290,7 @@ func TestBuildRouting_WithRules(t *testing.T) {
},
}
routing := buildRouting(rules, nil)
routing := buildRouting(testRouteRules(rules), nil, nil)
xrayRules := routing["rules"].([]M)
// 1 default + 2 domain rules + 1 IP rule = 4
@@ -310,6 +325,63 @@ func TestBuildRouting_WithRules(t *testing.T) {
}
}
func TestBuildRouting_WithCustomRouteRules(t *testing.T) {
customRules := []model.CustomRouteRule{
{
Name: "proxy-web",
Match: model.RouteMatch{
Domains: []string{"full.example.com"},
DomainSuffixes: []string{"example.org"},
Ports: []string{"80", "443-445"},
Networks: []string{"tcp", "udp"},
SourceCIDRs: []string{"192.168.1.0/24"},
SourcePorts: []string{"1000-1002"},
},
Action: model.RouteAction{Type: "route", Target: "warp-jp"},
},
}
routing := buildRouting(nil, customRules, nil)
xrayRules := routing["rules"].([]M)
if len(xrayRules) != 6 {
t.Fatalf("expected 6 rules, got %d", len(xrayRules))
}
if xrayRules[0]["outboundTag"] != "warp-jp" {
t.Fatalf("expected first custom outbound warp-jp, got %v", xrayRules[0]["outboundTag"])
}
if got := xrayRules[0]["domain"].([]string); len(got) != 2 || got[0] != "full.example.com" || got[1] != "domain:example.org" {
t.Fatalf("unexpected custom domains: %v", got)
}
if got := xrayRules[1]["port"]; got != "80,443-445" {
t.Fatalf("unexpected port matcher: %v", got)
}
if got := xrayRules[2]["network"]; got != "tcp,udp" {
t.Fatalf("unexpected network matcher: %v", got)
}
if got := xrayRules[3]["source"].([]string); len(got) != 1 || got[0] != "192.168.1.0/24" {
t.Fatalf("unexpected source cidr matcher: %v", got)
}
if got := xrayRules[4]["sourcePort"]; got != "1000-1002" {
t.Fatalf("unexpected source port matcher: %v", got)
}
}
func TestBuildRouting_StructuredCustomRulesRemainFirst(t *testing.T) {
raw := []map[string]any{{"type": "field", "domain": []string{"keyword:raw"}, "outboundTag": "raw-tag"}}
custom := []model.CustomRouteRule{{
Match: model.RouteMatch{DomainSuffixes: []string{"structured.example"}},
Action: model.RouteAction{Type: "direct"},
}}
routing := buildRouting(nil, custom, raw)
xrayRules := routing["rules"].([]M)
if xrayRules[0]["outboundTag"] != "direct" {
t.Fatalf("expected structured rule first, got %v", xrayRules[0]["outboundTag"])
}
if xrayRules[1]["outboundTag"] != "raw-tag" {
t.Fatalf("expected raw custom rule second, got %v", xrayRules[1]["outboundTag"])
}
}
func TestBuildConfig_LogLevel(t *testing.T) {
tests := []struct {
input string
@@ -337,7 +409,7 @@ func TestBuildConfig_StatsEnabled(t *testing.T) {
Protocol: "vmess",
ServerPort: 10086,
}
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
data, _ := json.Marshal(cfg)
var parsed map[string]interface{}
@@ -361,13 +433,13 @@ func TestBuildConfig_StatsEnabled(t *testing.T) {
}
}
func TestBuildConfig_Shadowsocks_SingleUser(t *testing.T) {
func TestBuildConfig_Shadowsocks_MultiUserTraditional(t *testing.T) {
nc := panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
Cipher: "aes-128-gcm",
}
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
data, _ := json.Marshal(cfg)
var parsed map[string]interface{}
@@ -377,12 +449,19 @@ func TestBuildConfig_Shadowsocks_SingleUser(t *testing.T) {
ib := inbounds[0].(map[string]interface{})
settings := ib["settings"].(map[string]interface{})
// Single-user mode: password directly, no clients array
if settings["method"] != "aes-128-gcm" {
t.Errorf("expected method aes-128-gcm, got %v", settings["method"])
}
if settings["password"] != testUsers[0].UUID {
t.Errorf("expected password %s, got %v", testUsers[0].UUID, settings["password"])
clients := settings["clients"].([]interface{})
if len(clients) != len(testUsers) {
t.Fatalf("expected %d clients, got %d", len(testUsers), len(clients))
}
c0 := clients[0].(map[string]interface{})
if c0["method"] != "aes-128-gcm" {
t.Errorf("expected per-user method aes-128-gcm, got %v", c0["method"])
}
if c0["password"] != testUsers[0].UUID {
t.Errorf("expected password %s, got %v", testUsers[0].UUID, c0["password"])
}
}
@@ -393,7 +472,7 @@ func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) {
Cipher: "2022-blake3-aes-128-gcm",
ServerKey: "server-key",
}
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
data, _ := json.Marshal(cfg)
var parsed map[string]interface{}
@@ -417,7 +496,7 @@ func TestBuildConfig_SocksStats(t *testing.T) {
Protocol: "socks",
ServerPort: 1080,
}
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
data, _ := json.Marshal(cfg)
var parsed map[string]interface{}
@@ -427,7 +506,7 @@ func TestBuildConfig_SocksStats(t *testing.T) {
ib := inbounds[0].(map[string]interface{})
settings := ib["settings"].(map[string]interface{})
accounts := settings["accounts"].([]interface{})
if len(accounts) == 0 {
t.Fatal("no accounts in socks config")
}
@@ -437,3 +516,59 @@ func TestBuildConfig_SocksStats(t *testing.T) {
t.Errorf("expected email user@1 for socks account, got %v", a1["email"])
}
}
func TestEchPEMToBase64(t *testing.T) {
// Valid ECH KEYS PEM
rawBytes := []byte{0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x03, 0xCA, 0xFE, 0x00}
pemBlock := &pem.Block{Type: "ECH KEYS", Bytes: rawBytes}
pemStr := string(pem.EncodeToMemory(pemBlock))
result := echPEMToBase64([]byte(pemStr))
expected := base64.StdEncoding.EncodeToString(rawBytes)
if result != expected {
t.Errorf("echPEMToBase64 got %q, want %q", result, expected)
}
// Wrong PEM type should return empty
wrongBlock := &pem.Block{Type: "ECH CONFIGS", Bytes: rawBytes}
wrongPEM := string(pem.EncodeToMemory(wrongBlock))
if got := echPEMToBase64([]byte(wrongPEM)); got != "" {
t.Errorf("echPEMToBase64 should reject ECH CONFIGS, got %q", got)
}
// Invalid PEM should return empty
if got := echPEMToBase64([]byte("-----BEGIN GARBAGE-----\nwhat\n-----END GARBAGE-----")); got != "" {
t.Errorf("echPEMToBase64 should reject invalid PEM, got %q", got)
}
// Raw base64 passthrough
raw := "AAQDQKDIAAMD"
if got := echPEMToBase64([]byte(raw)); got != raw {
t.Errorf("echPEMToBase64 raw passthrough: got %q, want %q", got, raw)
}
}
func TestExtractECHServerKeys(t *testing.T) {
rawBytes := []byte{0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x03, 0xCA, 0xFE, 0x00}
pemBlock := &pem.Block{Type: "ECH KEYS", Bytes: rawBytes}
pemStr := string(pem.EncodeToMemory(pemBlock))
tests := []struct {
name string
tls map[string]interface{}
expect string
}{
{"no ech", map[string]interface{}{}, ""},
{"disabled", map[string]interface{}{"ech": map[string]interface{}{"enabled": false, "key": pemStr}}, ""},
{"enabled with key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true, "key": pemStr}}, base64.StdEncoding.EncodeToString(rawBytes)},
{"enabled no key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractECHServerKeys(tt.tls)
if got != tt.expect {
t.Errorf("got %q, want %q", got, tt.expect)
}
})
}
}
+168 -189
View File
@@ -3,10 +3,8 @@ package xray
import (
"context"
"errors"
"log/slog"
"reflect"
"sort"
"strconv"
"sync"
"sync/atomic"
_ "unsafe"
@@ -18,9 +16,8 @@ import (
"github.com/xtls/xray-core/common/session"
"github.com/xtls/xray-core/features/routing"
"github.com/xtls/xray-core/transport"
"golang.org/x/time/rate"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/nlog"
)
// Access xray's internal config creator registry so we can replace the
@@ -52,43 +49,55 @@ func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{
return orig, nil
}
ld := &LimitDispatcher{
inner: orig,
innerDisp: inner,
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
inner: orig,
innerDisp: inner,
limitedIPs: make(map[string]map[string]int),
}
globalLimitDispatcher.Store(ld)
slog.Debug("xray: limit dispatcher installed")
nlog.Core().Debug("xray: limit dispatcher installed")
return ld, nil
}
// LimitDispatcher wraps xray's DefaultDispatcher to add per-user device
// limit enforcement, speed limiting, and per-connection source IP tracking.
// It hooks into Dispatch/DispatchLink which are called AFTER the protocol
// handler has identified the user, so it works for ALL protocols.
// LimitDispatcher wraps xray's DefaultDispatcher to enforce per-user
// admission checks before a request is dispatched into xray-core.
//
// It intentionally does NOT mutate transport.Link.Reader/Writer. Xray's
// mux/XUDP close path requires the original concrete *pipe.Reader to remain
// intact, so the dispatcher is limited to gate-keeping and safe connection
// lifecycle bookkeeping.
type LimitDispatcher struct {
inner interface{} // original DefaultDispatcher (Feature + Dispatcher)
innerDisp routing.Dispatcher // same object, typed as Dispatcher
// limitedUsers: users with device limit > 0, protected by mu.
// Needs deterministic IP ordering for kick decisions.
mu sync.RWMutex
conns map[string]*dispatchedConn // connID → conn
userIPs map[string]map[string]int // email → sourceIP → count
deviceLimits map[string]int // email → max devices
speedLimits map[string]int // email → Mbps
speedBuckets sync.Map // email → *rate.Limiter
emailToUID map[string]int // email → panel user ID
limitedIPs map[string]map[string]int // email → sourceIP → refcount
deviceLimits map[string]int // email → max devices
emailToUID map[string]int // email → panel user ID
connIDSeq atomic.Uint64
// unlimitedIPs: users without device limit — sync.Map for lock-free access.
// Each entry is *ipCounter{ips sync.Map}.
unlimitedIPs sync.Map // email → *ipCounter
connCount atomic.Int64 // total active connections tracked by dispatcher
}
type dispatchedConn struct {
id string
email string
sourceIP string
userID int
upload atomic.Int64
download atomic.Int64
closed atomic.Bool
// ipCounter tracks IPs for unlimited users without any lock.
type ipCounter struct {
ips sync.Map // sourceIP → *atomic.Int64 (refcount)
}
// aliveIPs returns a snapshot of distinct IPs.
func (ic *ipCounter) aliveIPs() map[string]bool {
result := make(map[string]bool)
ic.ips.Range(func(key, _ interface{}) bool {
if rv, ok := ic.ips.Load(key); ok && rv.(*atomic.Int64).Load() > 0 {
result[key.(string)] = true
}
return true
})
return result
}
// ─── routing.Dispatcher ──────────────────────────────────────────────────────
@@ -108,7 +117,7 @@ func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (*
}
if email != "" {
d.wrapLink(ctx, link, email, sourceIP, isTCP)
d.trackLink(link, email, sourceIP, isTCP)
}
return link, nil
}
@@ -120,7 +129,7 @@ func (d *LimitDispatcher) DispatchLink(ctx context.Context, dest net.Destination
}
if email != "" {
d.wrapLink(ctx, link, email, sourceIP, isTCP)
d.trackLink(link, email, sourceIP, isTCP)
}
return d.innerDisp.DispatchLink(ctx, dest, link)
}
@@ -138,51 +147,27 @@ func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destina
isTCP = dest.Network == net.Network_TCP
if d.checkDeviceLimit(email, sourceIP, isTCP) {
slog.Info("xray: device limit exceeded", "email", email, "ip", sourceIP)
nlog.Core().Debug("xray: device limit exceeded", "email", email, "ip", sourceIP)
return "", "", false, errors.New("device limit exceeded for " + email)
}
return email, sourceIP, isTCP, nil
}
// wrapLink instruments a transport.Link with per-connection byte counting,
// rate limiting, and lifecycle tracking. Must only be called when email != "".
func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, email, sourceIP string, isTCP bool) {
connID := "xd-" + strconv.FormatUint(d.connIDSeq.Add(1), 36)
dc := &dispatchedConn{
id: connID,
email: email,
sourceIP: sourceIP,
userID: d.getUID(email),
}
d.mu.Lock()
d.conns[connID] = dc
d.mu.Unlock()
// trackLink records connection lifecycle without mutating xray-core owned
// transport primitives. This keeps mux/XUDP compatible while still allowing
// the dispatcher to release device-limit state when the link closes.
func (d *LimitDispatcher) trackLink(link *transport.Link, email, sourceIP string, isTCP bool) {
d.connCount.Add(1)
onClose := func() {
if dc.closed.CompareAndSwap(false, true) {
if isTCP {
d.delConn(email, sourceIP)
}
d.mu.Lock()
delete(d.conns, connID)
d.mu.Unlock()
if isTCP {
d.delConn(email, sourceIP)
}
d.connCount.Add(-1)
}
limiter := d.getBucket(email)
link.Reader = &statsCloseReader{
Reader: link.Reader,
counter: &dc.upload,
limiter: limiter,
ctx: ctx,
}
link.Writer = &statsCloseWriter{
link.Writer = &closeTrackingWriter{
Writer: link.Writer,
counter: &dc.download,
limiter: limiter,
ctx: ctx,
onClose: onClose,
}
}
@@ -207,86 +192,137 @@ func (d *LimitDispatcher) Close() error {
// ─── Limit management (called by Xray kernel) ──────────────────────────────
func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, speedLimits map[string]int) {
func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, _ map[string]int) {
d.mu.Lock()
d.emailToUID = emailToUID
d.deviceLimits = deviceLimits
d.speedLimits = speedLimits
d.mu.Unlock()
d.speedBuckets.Range(func(key, _ interface{}) bool {
if _, ok := speedLimits[key.(string)]; !ok {
d.speedBuckets.Delete(key)
}
return true
})
}
func (d *LimitDispatcher) ResetConns() {
d.mu.Lock()
d.conns = make(map[string]*dispatchedConn)
d.userIPs = make(map[string]map[string]int)
d.limitedIPs = make(map[string]map[string]int)
d.mu.Unlock()
d.speedBuckets.Range(func(key, _ interface{}) bool {
d.speedBuckets.Delete(key)
// Clear unlimited IPs
d.unlimitedIPs.Range(func(key, _ interface{}) bool {
d.unlimitedIPs.Delete(key)
return true
})
d.connCount.Store(0)
}
func (d *LimitDispatcher) Snapshot() []kernel.Connection {
// GetConnectionState returns dispatcher-tracked alive IPs and connection count.
// Traffic bytes are intentionally left to xray's built-in stats pipeline.
func (d *LimitDispatcher) GetConnectionState() (aliveIPs map[int]map[string]bool, connCount int) {
d.mu.RLock()
defer d.mu.RUnlock()
result := make([]kernel.Connection, 0, len(d.conns))
for _, c := range d.conns {
result = append(result, kernel.Connection{
ID: c.id,
UserID: c.userID,
SourceIP: c.sourceIP,
Upload: c.upload.Load(),
Download: c.download.Load(),
})
}
return result
}
func (d *LimitDispatcher) CloseConn(id string) bool {
d.mu.RLock()
c, ok := d.conns[id]
emailToUID := d.emailToUID
limitedIPs := d.limitedIPs
d.mu.RUnlock()
if !ok {
return false
aliveIPs = make(map[int]map[string]bool)
// Collect IPs from limited users (under RLock snapshot).
for email, ipsMap := range limitedIPs {
uid := emailToUID[email]
if uid == 0 {
continue
}
ipSet := make(map[string]bool, len(ipsMap))
for ip := range ipsMap {
ipSet[ip] = true
}
if len(ipSet) > 0 {
aliveIPs[uid] = ipSet
}
}
c.closed.Store(true)
d.delConn(c.email, c.sourceIP)
d.mu.Lock()
delete(d.conns, id)
d.mu.Unlock()
return true
// Collect IPs from unlimited users (lock-free).
d.unlimitedIPs.Range(func(key, value interface{}) bool {
email := key.(string)
uid := emailToUID[email]
if uid == 0 {
return true
}
ic := value.(*ipCounter)
if ips := ic.aliveIPs(); len(ips) > 0 {
// Merge with limited IPs if any
if existing, ok := aliveIPs[uid]; ok {
for ip := range ips {
existing[ip] = true
}
} else {
aliveIPs[uid] = ips
}
}
return true
})
connCount = int(d.connCount.Load())
return
}
// ─── Internal helpers ───────────────────────────────────────────────────────
// checkDeviceLimit enforces per-user device limits.
// Fast path: unlimited users use lock-free sync.Map.
// Slow path: limited users use RWMutex with deterministic IP ordering.
func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) bool {
d.mu.Lock()
defer d.mu.Unlock()
d.mu.RLock()
limit, hasLimit := d.deviceLimits[email]
d.mu.RUnlock()
// Fast path: no device limit — use lock-free sync.Map.
if !hasLimit || limit <= 0 {
if isTCP {
if d.userIPs[email] == nil {
d.userIPs[email] = make(map[string]int)
}
d.userIPs[email][sourceIP]++
v, _ := d.unlimitedIPs.LoadOrStore(email, &ipCounter{})
ic := v.(*ipCounter)
// Increment IP refcount atomically.
rv, _ := ic.ips.LoadOrStore(sourceIP, &atomic.Int64{})
rv.(*atomic.Int64).Add(1)
}
return false
}
ips := d.userIPs[email]
// Slow path: user has device limit — need deterministic ordering.
d.mu.RLock()
ips := d.limitedIPs[email]
if ips != nil && ips[sourceIP] > 0 {
d.mu.RUnlock()
if isTCP {
d.mu.Lock()
d.limitedIPs[email][sourceIP]++
d.mu.Unlock()
}
return false
}
if ips != nil && len(ips) < limit {
d.mu.RUnlock()
if isTCP {
d.mu.Lock()
if d.limitedIPs[email] == nil {
d.limitedIPs[email] = make(map[string]int)
}
d.limitedIPs[email][sourceIP]++
d.mu.Unlock()
}
return false
}
d.mu.RUnlock()
// Over limit — need write lock for deterministic check.
d.mu.Lock()
defer d.mu.Unlock()
// Re-check under write lock.
ips = d.limitedIPs[email]
if ips == nil {
ips = make(map[string]int)
d.userIPs[email] = ips
d.limitedIPs[email] = ips
}
if ips[sourceIP] > 0 {
@@ -303,7 +339,7 @@ func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) b
return false
}
// Over limit — deterministic: allow lowest IPs lexicographically
// Over limit — deterministic: allow lowest IPs lexicographically.
ipList := make([]string, 0, len(ips)+1)
for ip := range ips {
ipList = append(ipList, ip)
@@ -322,105 +358,48 @@ func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) b
return true
}
// delConn decrements the IP refcount when a connection closes.
func (d *LimitDispatcher) delConn(email, sourceIP string) {
// Check if this is an unlimited user first (lock-free).
if v, ok := d.unlimitedIPs.Load(email); ok {
ic := v.(*ipCounter)
if rv, ok := ic.ips.Load(sourceIP); ok {
counter := rv.(*atomic.Int64)
if counter.Add(-1) <= 0 {
ic.ips.Delete(sourceIP)
}
}
return
}
// Limited user — use write lock.
d.mu.Lock()
defer d.mu.Unlock()
if ips, ok := d.userIPs[email]; ok {
if ips, ok := d.limitedIPs[email]; ok {
ips[sourceIP]--
if ips[sourceIP] <= 0 {
delete(ips, sourceIP)
}
if len(ips) == 0 {
delete(d.userIPs, email)
delete(d.limitedIPs, email)
}
}
}
func (d *LimitDispatcher) getUID(email string) int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.emailToUID[email]
}
func (d *LimitDispatcher) getBucket(email string) *rate.Limiter {
d.mu.RLock()
mbps, ok := d.speedLimits[email]
d.mu.RUnlock()
if !ok || mbps <= 0 {
return nil
}
bytesPerSec := int(mbps) * 1_000_000 / 8
burst := bytesPerSec
if burst < 64*1024 {
burst = 64 * 1024
}
v, loaded := d.speedBuckets.LoadOrStore(email, rate.NewLimiter(rate.Limit(bytesPerSec), burst))
lim := v.(*rate.Limiter)
if loaded {
// Update existing limiter in case speed limit changed.
lim.SetLimit(rate.Limit(bytesPerSec))
lim.SetBurst(burst)
}
return lim
}
// ─── I/O wrappers ───────────────────────────────────────────────────────────
type statsCloseReader struct {
buf.Reader
counter *atomic.Int64
limiter *rate.Limiter
ctx context.Context
}
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 {
waitN := int(n)
if burst := r.limiter.Burst(); waitN > burst {
waitN = burst
}
_ = r.limiter.WaitN(r.ctx, waitN)
}
}
return mb, err
}
func (r *statsCloseReader) Close() error { return common.Close(r.Reader) }
func (r *statsCloseReader) Interrupt() { common.Interrupt(r.Reader) }
type statsCloseWriter struct {
type closeTrackingWriter struct {
buf.Writer
counter *atomic.Int64
limiter *rate.Limiter
ctx context.Context
onClose func()
closed atomic.Bool
}
func (w *statsCloseWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
n := int64(mb.Len())
if w.limiter != nil {
waitN := int(n)
if burst := w.limiter.Burst(); waitN > burst {
waitN = burst
}
_ = w.limiter.WaitN(w.ctx, waitN)
}
w.counter.Add(n)
return w.Writer.WriteMultiBuffer(mb)
}
func (w *statsCloseWriter) Close() error {
func (w *closeTrackingWriter) Close() error {
if w.closed.CompareAndSwap(false, true) {
w.onClose()
}
return common.Close(w.Writer)
}
func (w *statsCloseWriter) Interrupt() {
func (w *closeTrackingWriter) Interrupt() {
if w.closed.CompareAndSwap(false, true) {
w.onClose()
}
+131 -100
View File
@@ -1,18 +1,28 @@
package xray
import (
"sync/atomic"
"testing"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/xtls/xray-core/common/buf"
"github.com/xtls/xray-core/transport"
)
func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
func newTestDispatcher() *LimitDispatcher {
return &LimitDispatcher{
limitedIPs: make(map[string]map[string]int),
}
}
users := []panel.User{
type nopReader struct{}
func (nopReader) ReadMultiBuffer() (buf.MultiBuffer, error) { return nil, nil }
func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
ld := newTestDispatcher()
users := []model.UserSpec{
{ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0},
{ID: 2, UUID: "uuid-2", DeviceLimit: 0, SpeedLimit: 10},
}
@@ -65,10 +75,7 @@ func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
}
func TestLimitDispatcher_DelConn(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
ld := newTestDispatcher()
email := userEmail(1)
deviceLimits := map[string]int{email: 2}
@@ -92,113 +99,137 @@ func TestLimitDispatcher_DelConn(t *testing.T) {
}
}
func TestLimitDispatcher_SpeedBucket(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
func TestLimitDispatcher_GetConnectionState(t *testing.T) {
ld := newTestDispatcher()
email1 := userEmail(1)
email2 := userEmail(2)
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil)
speedLimits := map[string]int{email1: 10} // 10 Mbps
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, speedLimits)
ic1 := &ipCounter{}
r1 := &atomic.Int64{}
r1.Store(1)
ic1.ips.Store("1.1.1.1", r1)
r2 := &atomic.Int64{}
r2.Store(1)
ic1.ips.Store("2.2.2.2", r2)
ld.unlimitedIPs.Store(email1, ic1)
// 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")
}
ic2 := &ipCounter{}
r3 := &atomic.Int64{}
r3.Store(1)
ic2.ips.Store("3.3.3.3", r3)
ld.unlimitedIPs.Store(email2, ic2)
// Same user should get the same limiter (cached)
limiter2 := ld.getBucket(email1)
if limiter != limiter2 {
t.Error("same user should get cached limiter")
}
ld.connCount.Store(5)
// 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")
}
}
aliveIPs, connCount := ld.GetConnectionState()
func TestLimitDispatcher_Snapshot(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
if connCount != 5 {
t.Errorf("expected connCount=5, got %d", connCount)
}
// Add some connections
ld.conns["conn-1"] = &dispatchedConn{
id: "conn-1", email: "user@1", sourceIP: "1.1.1.1", userID: 1,
if len(aliveIPs[1]) != 2 {
t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1]))
}
ld.conns["conn-2"] = &dispatchedConn{
id: "conn-2", email: "user@2", sourceIP: "2.2.2.2", userID: 2,
}
ld.conns["conn-1"].upload.Store(1000)
ld.conns["conn-1"].download.Store(2000)
snapshot := ld.Snapshot()
if len(snapshot) != 2 {
t.Fatalf("expected 2 connections, got %d", len(snapshot))
}
found := false
for _, c := range snapshot {
if c.ID == "conn-1" {
found = true
if c.UserID != 1 || c.SourceIP != "1.1.1.1" || c.Upload != 1000 || c.Download != 2000 {
t.Errorf("unexpected connection data: %+v", c)
}
}
}
if !found {
t.Error("conn-1 not found in snapshot")
}
}
func TestLimitDispatcher_CloseConn(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
email := userEmail(1)
ld.userIPs[email] = map[string]int{"1.1.1.1": 1}
ld.conns["conn-1"] = &dispatchedConn{
id: "conn-1", email: email, sourceIP: "1.1.1.1", userID: 1,
}
ok := ld.CloseConn("conn-1")
if !ok {
t.Error("CloseConn should return true for existing connection")
}
if len(ld.conns) != 0 {
t.Error("connection should be removed after close")
}
ok = ld.CloseConn("nonexistent")
if ok {
t.Error("CloseConn should return false for nonexistent connection")
if len(aliveIPs[2]) != 1 {
t.Errorf("user 2 IPs: got %d, want 1", len(aliveIPs[2]))
}
}
func TestLimitDispatcher_ResetConns(t *testing.T) {
ld := &LimitDispatcher{
conns: make(map[string]*dispatchedConn),
userIPs: make(map[string]map[string]int),
}
ld.conns["c1"] = &dispatchedConn{id: "c1"}
ld.userIPs["user@1"] = map[string]int{"1.1.1.1": 1}
ld := newTestDispatcher()
ld.mu.Lock()
ld.limitedIPs["user@1"] = map[string]int{"1.1.1.1": 1}
ld.mu.Unlock()
ld.connCount.Store(3)
ld.ResetConns()
if len(ld.conns) != 0 {
t.Error("conns should be empty after reset")
ld.mu.RLock()
ipCount := len(ld.limitedIPs)
ld.mu.RUnlock()
if ipCount != 0 {
t.Error("limitedIPs should be empty after reset")
}
if len(ld.userIPs) != 0 {
t.Error("userIPs should be empty after reset")
if ld.connCount.Load() != 0 {
t.Error("connCount should be 0 after reset")
}
}
func TestLimitDispatcher_UnlimitedUserFastPath(t *testing.T) {
ld := newTestDispatcher()
email := userEmail(1)
// No device limit set for this user
ld.UpdateLimits(map[string]int{email: 1}, nil, nil)
// Should use fast path (sync.Map), no lock needed
for i := 0; i < 100; i++ {
ip := "10.0.0." + string(rune('0'+i%10))
if ld.checkDeviceLimit(email, ip, true) {
t.Errorf("unlimited user should always be allowed (ip=%s)", ip)
}
}
// Verify IPs are tracked in unlimitedIPs
v, ok := ld.unlimitedIPs.Load(email)
if !ok {
t.Error("unlimited user should have entry in unlimitedIPs")
}
ic := v.(*ipCounter)
ips := ic.aliveIPs()
if len(ips) == 0 {
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")
}
}
+181 -121
View File
@@ -6,7 +6,6 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"sync"
@@ -18,6 +17,7 @@ import (
xrayCore "github.com/xtls/xray-core/core"
"github.com/xtls/xray-core/features/inbound"
"github.com/xtls/xray-core/features/stats"
featurebandwidth "github.com/xtls/xray-core/features/bandwidth"
"github.com/xtls/xray-core/infra/conf/serial"
xrayProxy "github.com/xtls/xray-core/proxy"
"github.com/xtls/xray-core/proxy/shadowsocks"
@@ -32,7 +32,8 @@ import (
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/cedar2025/xboard-node/internal/model"
)
const (
@@ -59,14 +60,14 @@ type Xray struct {
mu sync.Mutex
instance *xrayCore.Instance
limitDispatcher *LimitDispatcher
users []panel.User
nodeConfig *panel.NodeConfig
certFile string
keyFile string
users []model.UserSpec
nodeConfig *model.NodeSpec
tls kernel.TLSCert
protocol string
inboundTag string
lastKernelHash string
cumTraffic map[int][2]int64
speedLimitFunc func(string) *rate.Limiter
// running is set after a successful Start and cleared before shutdown.
// Atomic so IsRunning / GetConnections never block.
@@ -82,29 +83,42 @@ func New(cfg config.KernelConfig) *Xray {
func (x *Xray) Name() string { return "xray" }
func (x *Xray) Capabilities() kernel.Capabilities {
return kernel.Capabilities{
PerUserSpeedLimit: true,
DeviceLimit: true,
BuiltInTrafficStats: true,
AliveIPTracking: true,
ForceCloseConnection: false,
ForceCloseUser: false,
}
}
func (x *Xray) Protocols() []string {
return []string{
"vmess", "vless", "trojan", "shadowsocks",
"socks", "http", "dokodemo-door",
"hysteria",
}
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
// Start builds a new xray-core instance and atomically replaces the old
// one. The method is organised in four non-overlapping phases so that the
// one. The method is organised in five non-overlapping phases so that the
// kernel mutex is never held during slow operations (Start / Close).
// Crucially, the old instance stays alive until the new one is confirmed
// running — if StartNew fails, the old instance is untouched.
//
// Phase 1 – Build: generate protobuf config (no lock, pure computation)
// Phase 2 – Create: xrayCore.New + capture LD (brief global lock)
// Phase 3 – Swap: stop old instance (brief kernel lock)
// Phase 4 – Start: instance.Start (no lock, potentially slow)
// Phase 5 – Commit: store new state (brief kernel lock)
func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
// Phase 1 – Build: generate protobuf config (no lock, pure computation)
// Phase 2 – Create: xrayCore.New + capture LD (brief global lock)
// Phase 3 – StartNew: instance.Start (no lock, potentially slow)
// Phase 4 – Swap: store new, extract old (brief kernel lock)
// Phase 5 – RecycleOld: close old in background (non-blocking)
func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
// ── Phase 1: Build config (no shared state) ─────────────────────────
x.ensureGeoData(nodeConfig)
data, err := marshalConfig(x.cfg, nodeConfig, users, certFile, keyFile)
data, err := marshalConfig(x.cfg, nodeConfig, users, tls)
if err != nil {
return err
}
@@ -123,31 +137,21 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
return fmt.Errorf("create xray: %w", err)
}
// ── Phase 3: Stop old (brief kernel lock, no drain) ─────────────────
x.mu.Lock()
x.running.Store(false)
old := x.instance
x.instance = nil
oldLD := x.limitDispatcher
x.limitDispatcher = nil
x.mu.Unlock()
closeOld(old, oldLD)
// ── Phase 4: Start new (no lock, potentially slow) ──────────────────
// ── Phase 3: Start new (no lock, potentially slow) ──────────────────
if err := startWithTimeout(inst, startTimeout); err != nil {
inst.Close()
return err
}
// ── Phase 5: Commit (brief kernel lock) ─────────────────────────────
// ── Phase 4: Swap old → new (brief kernel lock) ─────────────────────
x.mu.Lock()
old := x.instance
oldLD := x.limitDispatcher
x.instance = inst
x.limitDispatcher = ld
x.users = users
x.nodeConfig = nodeConfig
x.certFile = certFile
x.keyFile = keyFile
x.tls = tls
x.protocol = nodeConfig.Protocol
x.inboundTag = nodeConfig.Protocol + "-in"
x.cumTraffic = make(map[int][2]int64)
@@ -155,9 +159,13 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
x.running.Store(true)
x.mu.Unlock()
x.updateDispatcherLimits(users)
// ── Phase 5: Recycle old (background, non-blocking) ─────────────────
closeOld(old, oldLD)
slog.Info("xray started",
x.updateDispatcherLimits(users)
x.updateBandwidthLimits(users)
nlog.Core().Info("xray started",
"users", len(users),
"protocol", nodeConfig.Protocol,
)
@@ -168,23 +176,24 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
// Since Xray does not support granular inbound reconstruction without an
// instance restart for most transport/TLS settings, it triggers a full restart
// if any kernel-affecting fields (hash mismatch) have changed.
func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
x.updateDispatcherLimits(users)
x.updateBandwidthLimits(users)
newHash := kernel.ComputeHash(nodeConfig, users)
x.mu.Lock()
same := x.lastKernelHash == newHash
same := x.lastKernelHash == newHash && tlsEqual(x.tls, tls)
if same {
x.users = users
x.mu.Unlock()
slog.Debug("xray: limits updated, kernel configuration unchanged")
nlog.Core().Debug("xray: limits updated, kernel configuration unchanged")
return nil
}
x.mu.Unlock()
slog.Info("xray: configuration changed, performing full restart")
return x.Start(nodeConfig, users, certFile, keyFile)
nlog.Core().Info("xray: configuration changed, performing full restart")
return x.Start(nodeConfig, users, tls)
}
// Stop gracefully shuts down the kernel, draining active connections first.
@@ -206,42 +215,65 @@ func (x *Xray) Stop() {
func (x *Xray) IsRunning() bool { return x.running.Load() }
// ─── Connection queries ─────────────────────────────────────────────────────
// GetConnections returns a snapshot of active proxy connections.
// The lock is safe to take here because mu is never held during slow I/O.
func (x *Xray) GetConnections(ctx context.Context) ([]kernel.Connection, error) {
if !x.running.Load() {
return nil, nil
}
x.mu.Lock()
defer x.mu.Unlock()
if x.instance == nil {
return nil, nil
}
if x.limitDispatcher != nil {
if conns := x.limitDispatcher.Snapshot(); len(conns) > 0 {
return conns, nil
}
}
return x.aggregateStats()
func tlsEqual(a, b kernel.TLSCert) bool {
return bytes.Equal(a.CertPEM, b.CertPEM) && bytes.Equal(a.KeyPEM, b.KeyPEM)
}
func (x *Xray) CloseConnection(_ context.Context, connID string) error {
// ─── Observability ──────────────────────────────────────────────────────────
// GetUserTraffic returns per-user cumulative traffic from xray's built-in
// stats pipeline and connection state from the admission dispatcher when
// available. The dispatcher intentionally no longer wraps transport links,
// so traffic accounting must come from xray-core itself.
func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int, err error) {
if !x.running.Load() {
return nil, nil, 0, nil
}
x.mu.Lock()
ld := x.limitDispatcher
traffic, err = x.aggregateStats()
x.mu.Unlock()
if ld != nil {
ld.CloseConn(connID)
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 {
// No-op: xray doesn't support force-closing individual connections.
// User removal goes through RemoveUsers which removes the inbound user.
return nil
}
func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {}
func (x *Xray) CloseUserConnections(_ context.Context, _ string) error {
// No-op: handled by RemoveUsers at the xray core level.
return nil
}
// SetSpeedLimitFunc wires xray's patched bandwidth feature to the shared
// per-user limiter callback used by the service layer. Unlike the old no-op
// behavior, xray now consumes this callback to support dynamic speed updates.
func (x *Xray) SetSpeedLimitFunc(fn func(string) *rate.Limiter) {
x.mu.Lock()
x.speedLimitFunc = fn
x.mu.Unlock()
x.updateBandwidthLimits(nil)
}
// SetDeviceLimitFunc is a no-op for xray — device limits are already
// gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time.
func (x *Xray) SetDeviceLimitFunc(_ func(string) (int, bool)) {}
// UpdateGlobalDevices is a no-op for xray — xray handles device limits differently.
func (x *Xray) UpdateGlobalDevices(_ map[int][]string) {}
// ClearGlobalDevices is a no-op for xray.
func (x *Xray) ClearGlobalDevices() {}
// ─── User management (non-disruptive where possible) ────────────────────────
@@ -253,7 +285,7 @@ func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {}
// Delta "add" events also carry property updates (speed/device limits) for
// existing users, so this method always merges properties and refreshes the
// dispatcher limits — even when no brand-new users need to be added.
func (x *Xray) AddUsers(users []panel.User) (int, error) {
func (x *Xray) AddUsers(users []model.UserSpec) (int, error) {
x.mu.Lock()
if x.instance == nil {
x.mu.Unlock()
@@ -261,18 +293,18 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
}
// Merge: overwrite existing users' properties, collect truly new ones.
userMap := make(map[int]panel.User, len(x.users))
userMap := make(map[int]model.UserSpec, len(x.users))
for _, u := range x.users {
userMap[u.ID] = u
}
var toAdd []panel.User
var toAdd []model.UserSpec
for _, u := range users {
if _, exists := userMap[u.ID]; !exists {
toAdd = append(toAdd, u)
}
userMap[u.ID] = u // always overwrite properties
}
merged := make([]panel.User, 0, len(userMap))
merged := make([]model.UserSpec, 0, len(userMap))
for _, u := range userMap {
merged = append(merged, u)
}
@@ -282,16 +314,17 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
x.users = merged
x.mu.Unlock()
x.updateDispatcherLimits(merged)
x.updateBandwidthLimits(merged)
return 0, nil
}
um, err := x.getUserManager()
if err != nil {
// Protocol doesn't support UserManager → full restart
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
nc, t := x.nodeConfig, x.tls
x.mu.Unlock()
slog.Debug("xray: AddUsers fallback to restart", "reason", err)
if err := x.Start(nc, merged, cf, kf); err != nil {
nlog.Core().Debug("xray: AddUsers fallback to restart", "reason", err)
if err := x.Start(nc, merged, t); err != nil {
return 0, err
}
return len(toAdd), nil
@@ -306,11 +339,11 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
for _, u := range toAdd {
mu, err := toMemoryUser(proto, nc, u)
if err != nil {
slog.Warn("xray: skip user, cannot build account", "user", u.ID, "error", err)
nlog.Core().Warn("xray: skip user, cannot build account", "user", u.ID, "error", err)
continue
}
if err := um.AddUser(ctx, mu); err != nil {
slog.Warn("xray: AddUser failed", "user", u.ID, "error", err)
nlog.Core().Warn("xray: AddUser failed", "user", u.ID, "error", err)
continue
}
added++
@@ -321,14 +354,15 @@ func (x *Xray) AddUsers(users []panel.User) (int, error) {
x.users = merged
x.mu.Unlock()
x.updateDispatcherLimits(merged)
x.updateBandwidthLimits(merged)
slog.Info("xray: users added via UserManager", "added", added, "total", len(merged))
nlog.Core().Info("xray: users added via UserManager", "added", added, "total", len(merged))
return added, nil
}
// RemoveUsers removes users from the running kernel via xray's UserManager API.
// Truly hitless for supported protocols — remaining connections unaffected.
func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
func (x *Xray) RemoveUsers(users []model.UserSpec) (int, error) {
x.mu.Lock()
if x.instance == nil {
x.mu.Unlock()
@@ -338,7 +372,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
for _, u := range users {
removeSet[u.ID] = struct{}{}
}
var kept []panel.User
var kept []model.UserSpec
removed := 0
for _, u := range x.users {
if _, rm := removeSet[u.ID]; rm {
@@ -360,10 +394,10 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
um, err := x.getUserManager()
if err != nil {
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
nc, t := x.nodeConfig, x.tls
x.mu.Unlock()
slog.Debug("xray: RemoveUsers fallback to restart", "reason", err)
if err := x.Start(nc, kept, cf, kf); err != nil {
nlog.Core().Debug("xray: RemoveUsers fallback to restart", "reason", err)
if err := x.Start(nc, kept, t); err != nil {
return 0, err
}
return removed, nil
@@ -375,7 +409,7 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
for _, u := range users {
email := userEmail(u.ID)
if err := um.RemoveUser(ctx, email); err != nil {
slog.Debug("xray: RemoveUser skipped", "user", u.ID, "error", err)
nlog.Core().Debug("xray: RemoveUser skipped", "user", u.ID, "error", err)
continue
}
actualRemoved++
@@ -385,15 +419,16 @@ func (x *Xray) RemoveUsers(users []panel.User) (int, error) {
x.users = kept
x.mu.Unlock()
x.updateDispatcherLimits(kept)
x.updateBandwidthLimits(kept)
slog.Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
nlog.Core().Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
return actualRemoved, nil
}
// UpdateUsers replaces the entire user set. If only speed/device limits
// changed, updates the dispatcher without restarting. Otherwise uses
// UserManager for hitless add/remove where supported.
func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
func (x *Xray) UpdateUsers(users []model.UserSpec) (added, removed int, err error) {
x.mu.Lock()
if x.instance == nil {
x.mu.Unlock()
@@ -407,17 +442,18 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
x.users = users
x.mu.Unlock()
x.updateDispatcherLimits(users)
slog.Info("xray: limits updated, kernel unchanged")
x.updateBandwidthLimits(users)
nlog.Core().Info("xray: limits updated, kernel unchanged")
return 0, 0, nil
}
um, umErr := x.getUserManager()
if umErr != nil {
// Protocol doesn't support UserManager → full restart
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
nc, t := x.nodeConfig, x.tls
x.mu.Unlock()
slog.Debug("xray: UpdateUsers fallback to restart", "reason", umErr)
if err = x.Start(nc, users, cf, kf); err != nil {
nlog.Core().Debug("xray: UpdateUsers fallback to restart", "reason", umErr)
if err = x.Start(nc, users, t); err != nil {
return 0, 0, err
}
return
@@ -433,17 +469,17 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
for _, u := range toRemove {
email := userEmail(u.ID)
if err := um.RemoveUser(ctx, email); err != nil {
slog.Debug("xray: RemoveUser skipped in UpdateUsers", "user", u.ID, "error", err)
nlog.Core().Debug("xray: RemoveUser skipped in UpdateUsers", "user", u.ID, "error", err)
}
}
for _, u := range toAdd {
mu, err := toMemoryUser(proto, nc, u)
if err != nil {
slog.Warn("xray: skip user in UpdateUsers", "user", u.ID, "error", err)
nlog.Core().Warn("xray: skip user in UpdateUsers", "user", u.ID, "error", err)
continue
}
if err := um.AddUser(ctx, mu); err != nil {
slog.Warn("xray: AddUser failed in UpdateUsers", "user", u.ID, "error", err)
nlog.Core().Warn("xray: AddUser failed in UpdateUsers", "user", u.ID, "error", err)
}
}
@@ -451,8 +487,9 @@ func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) {
x.users = users
x.mu.Unlock()
x.updateDispatcherLimits(users)
x.updateBandwidthLimits(users)
slog.Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
nlog.Core().Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
return
}
@@ -495,9 +532,9 @@ func (x *Xray) getUserManager() (xrayProxy.UserManager, error) {
return um, nil
}
// toMemoryUser converts a panel.User into an xray protocol.MemoryUser for the
// toMemoryUser converts a model.UserSpec into an xray protocol.MemoryUser for the
// given protocol. Each protocol needs a different Account type.
func toMemoryUser(proto string, nc *panel.NodeConfig, u panel.User) (*protocol.MemoryUser, error) {
func toMemoryUser(proto string, nc *model.NodeSpec, u model.UserSpec) (*protocol.MemoryUser, error) {
email := userEmail(u.ID)
mu := &protocol.MemoryUser{Email: email, Level: 0}
@@ -591,26 +628,26 @@ func hexEncode(dst, src []byte) {
}
// ensureGeoData downloads geo databases when routes reference geoip/geosite.
func (x *Xray) ensureGeoData(nc *panel.NodeConfig) {
func (x *Xray) ensureGeoData(nc *model.NodeSpec) {
needIP, needSite := kernel.NeedsGeoIP(nc.Routes), kernel.NeedsGeoSite(nc.Routes)
if !needIP && !needSite {
return
}
dir := x.cfg.GeoDataDir
if err := geodata.Ensure(dir, needIP, needSite, "xray"); err != nil {
slog.Warn("geo database unavailable", "error", err)
nlog.Core().Warn("geo database unavailable", "error", err)
}
os.Setenv("XRAY_LOCATION_ASSET", dir)
}
// 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) {
cfgMap := buildConfig(cfg, nc, users, certFile, keyFile)
data, err := json.Marshal(cfgMap)
func marshalConfig(cfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) ([]byte, error) {
cfgMap := buildConfig(cfg, nc, users, tls)
data, err := json.MarshalIndent(cfgMap, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal config: %w", err)
}
slog.Debug("xray config generated", "len", len(data))
nlog.Core().Debug("xray config generated", "len", len(data))
return data, nil
}
@@ -626,6 +663,7 @@ func startWithTimeout(inst *xrayCore.Instance, timeout time.Duration) error {
}
return nil
case <-time.After(timeout):
go inst.Close()
return fmt.Errorf("start xray: timeout after %v", timeout)
}
}
@@ -649,7 +687,7 @@ func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) {
if ld != nil {
ld.ResetConns()
}
slog.Debug("xray: old instance recycled")
nlog.Core().Debug("xray: old instance recycled")
}()
}
@@ -658,10 +696,7 @@ func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) {
func drainConns(ld *LimitDispatcher, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
ld.mu.RLock()
n := len(ld.conns)
ld.mu.RUnlock()
if n == 0 {
if ld.connCount.Load() == 0 {
return
}
time.Sleep(100 * time.Millisecond)
@@ -669,9 +704,9 @@ func drainConns(ld *LimitDispatcher, timeout time.Duration) {
}
// aggregateStats is a fallback path that reads xray's built-in stats counters
// when LimitDispatcher has no active connections (e.g. short-lived UDP flows).
// when LimitDispatcher is not available. Returns per-user cumulative traffic.
// Must be called with x.mu held.
func (x *Xray) aggregateStats() ([]kernel.Connection, error) {
func (x *Xray) aggregateStats() (map[int][2]int64, error) {
sm := x.instance.GetFeature(stats.ManagerType())
if sm == nil {
return nil, nil
@@ -681,7 +716,7 @@ func (x *Xray) aggregateStats() ([]kernel.Connection, error) {
return nil, nil
}
var conns []kernel.Connection
traffic := make(map[int][2]int64)
for _, u := range x.users {
email := userEmail(u.ID)
@@ -701,20 +736,50 @@ func (x *Xray) aggregateStats() ([]kernel.Connection, error) {
}
if cum := x.cumTraffic[u.ID]; cum[0] > 0 || cum[1] > 0 {
conns = append(conns, kernel.Connection{
ID: fmt.Sprintf("xray-%d", u.ID),
UserID: u.ID,
Upload: cum[0],
Download: cum[1],
})
traffic[u.ID] = cum
}
}
return conns, 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
// device and speed limits.
func (x *Xray) updateDispatcherLimits(users []panel.User) {
// device-limit admission metadata only. Speed limits are enforced by the
// patched xray-core bandwidth feature.
func (x *Xray) updateDispatcherLimits(users []model.UserSpec) {
x.mu.Lock()
ld := x.limitDispatcher
x.mu.Unlock()
@@ -724,7 +789,6 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) {
emailToUID := make(map[string]int, len(users)*2)
deviceLimits := make(map[string]int)
speedLimits := make(map[string]int)
for _, u := range users {
email := userEmail(u.ID)
@@ -734,13 +798,9 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) {
deviceLimits[email] = u.DeviceLimit
deviceLimits[u.UUID] = u.DeviceLimit
}
if u.SpeedLimit > 0 {
speedLimits[email] = u.SpeedLimit
speedLimits[u.UUID] = u.SpeedLimit
}
}
ld.UpdateLimits(emailToUID, deviceLimits, speedLimits)
ld.UpdateLimits(emailToUID, deviceLimits, nil)
}
// xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture
+278
View File
@@ -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")
}
}
+28 -121
View File
@@ -1,45 +1,38 @@
package limiter
import (
"fmt"
"log/slog"
"net/netip"
"sort"
"sync"
"sync/atomic"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
)
// KickAction represents a connection that should be closed.
type KickAction struct {
ConnID string
UserID int
Reason string
}
// Limiter enforces per-user device limits and detects removed users.
type Limiter struct {
mu sync.RWMutex
users map[int]panel.User
users map[int]model.UserSpec
// uuidDeviceLimit provides O(1) UUID→device-limit lookup for the
// kernel gate-keeping hot path (called per new connection).
uuidDeviceLimit map[string]int
deviceLimitEvents atomic.Uint64
}
func New() *Limiter {
return &Limiter{
users: make(map[int]panel.User),
users: make(map[int]model.UserSpec),
uuidDeviceLimit: make(map[string]int),
}
}
// UpdateUsers refreshes the user limit configuration and returns
// the IDs of users that were present before but are now removed.
func (l *Limiter) UpdateUsers(users []panel.User) []int {
func (l *Limiter) UpdateUsers(users []model.UserSpec) []int {
l.mu.Lock()
defer l.mu.Unlock()
newUsers := make(map[int]panel.User, len(users))
newUsers := make(map[int]model.UserSpec, len(users))
for _, u := range users {
newUsers[u.ID] = u
}
@@ -53,91 +46,19 @@ func (l *Limiter) UpdateUsers(users []panel.User) []int {
}
l.users = newUsers
// Rebuild UUID→device-limit index for O(1) lookups.
idx := make(map[string]int, len(newUsers))
for _, u := range newUsers {
if u.DeviceLimit > 0 {
idx[u.UUID] = u.DeviceLimit
}
}
l.uuidDeviceLimit = idx
return removed
}
// Check inspects connections against device limits.
// Returns a list of connections that should be closed.
func (l *Limiter) Check(conns []kernel.Connection) []KickAction {
l.mu.RLock()
defer l.mu.RUnlock()
var kicks []KickAction
// Device limit check
userIPs := make(map[int]map[string]bool)
userConnsByIP := make(map[int]map[string][]string) // user_id -> ip -> []conn_ids
for _, conn := range conns {
if conn.UserID == 0 || conn.SourceIP == "" {
continue
}
if userIPs[conn.UserID] == nil {
userIPs[conn.UserID] = make(map[string]bool)
userConnsByIP[conn.UserID] = make(map[string][]string)
}
userIPs[conn.UserID][conn.SourceIP] = true
userConnsByIP[conn.UserID][conn.SourceIP] = append(
userConnsByIP[conn.UserID][conn.SourceIP], conn.ID,
)
}
for uid, ips := range userIPs {
user, ok := l.users[uid]
if !ok || user.DeviceLimit == 0 {
continue
}
if len(ips) <= user.DeviceLimit {
continue
}
// Count a device-limit violation event for metrics reporting.
l.deviceLimitEvents.Add(1)
slog.Warn("user exceeded device limit",
"user_id", uid,
"devices", len(ips),
"limit", user.DeviceLimit,
)
// Sort IPs by numeric address so the "allowed" set is deterministic
// across calls. netip.Addr comparison avoids the lexicographic ordering
// quirks of string sort (e.g. "10.x" < "192.x", IPv6 always last).
// Parse all addresses up front to avoid O(n log n) repeated parsing.
type parsedIP struct {
raw string
addr netip.Addr
}
ipList := make([]parsedIP, 0, len(ips))
for ip := range ips {
a, _ := netip.ParseAddr(ip)
ipList = append(ipList, parsedIP{raw: ip, addr: a})
}
sort.Slice(ipList, func(i, j int) bool {
return ipList[i].addr.Compare(ipList[j].addr) < 0
})
allowed := make(map[string]bool, user.DeviceLimit)
for i := 0; i < user.DeviceLimit && i < len(ipList); i++ {
allowed[ipList[i].raw] = true
}
for ip, connIDs := range userConnsByIP[uid] {
if allowed[ip] {
continue
}
for _, connID := range connIDs {
kicks = append(kicks, KickAction{
ConnID: connID,
UserID: uid,
Reason: fmt.Sprintf("devices %d > limit %d", len(ips), user.DeviceLimit),
})
}
}
}
return kicks
}
// LimiterMetrics holds aggregated limiter statistics.
type LimiterMetrics struct {
// DeviceLimitEvents is the total number of times a user exceeded
@@ -152,26 +73,12 @@ func (l *Limiter) SnapshotMetrics() LimiterMetrics {
}
}
// KickUsers returns KickActions for all connections belonging to the given user IDs.
func (l *Limiter) KickUsers(conns []kernel.Connection, userIDs []int) []KickAction {
if len(userIDs) == 0 {
return nil
}
remove := make(map[int]bool, len(userIDs))
for _, id := range userIDs {
remove[id] = true
}
var kicks []KickAction
for _, conn := range conns {
if remove[conn.UserID] {
kicks = append(kicks, KickAction{
ConnID: conn.ID,
UserID: conn.UserID,
Reason: "user removed from panel",
})
}
}
return kicks
// GetDeviceLimitByUUID returns the device limit for a user identified by UUID.
// Returns (limit, true) if a device limit is set, (0, false) otherwise.
// This is designed to be passed as a function reference to kernel.SetDeviceLimitFunc.
func (l *Limiter) GetDeviceLimitByUUID(uuid string) (int, bool) {
l.mu.RLock()
defer l.mu.RUnlock()
limit, ok := l.uuidDeviceLimit[uuid]
return limit, ok
}
+32 -242
View File
@@ -4,8 +4,7 @@ import (
"sort"
"testing"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
)
func TestNew(t *testing.T) {
@@ -17,7 +16,7 @@ func TestNew(t *testing.T) {
func TestUpdateUsers(t *testing.T) {
l := New()
users := []panel.User{
users := []model.UserSpec{
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 2},
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0},
}
@@ -26,25 +25,18 @@ func TestUpdateUsers(t *testing.T) {
if len(removed) != 0 {
t.Errorf("first update should have no removed users, got %v", removed)
}
// Verify by running a check
conns := []kernel.Connection{{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}}
kicks := l.Check(conns)
if len(kicks) != 0 {
t.Errorf("expected no kicks, got %d", len(kicks))
}
}
func TestUpdateUsers_DetectsRemoved(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
l.UpdateUsers([]model.UserSpec{
{ID: 1, UUID: "u1"},
{ID: 2, UUID: "u2"},
{ID: 3, UUID: "u3"},
})
// Remove user 2
removed := l.UpdateUsers([]panel.User{
removed := l.UpdateUsers([]model.UserSpec{
{ID: 1, UUID: "u1"},
{ID: 3, UUID: "u3"},
})
@@ -56,14 +48,14 @@ func TestUpdateUsers_DetectsRemoved(t *testing.T) {
func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
l.UpdateUsers([]model.UserSpec{
{ID: 1, UUID: "u1"},
{ID: 2, UUID: "u2"},
{ID: 3, UUID: "u3"},
})
// Remove users 1 and 3
removed := l.UpdateUsers([]panel.User{
removed := l.UpdateUsers([]model.UserSpec{
{ID: 2, UUID: "u2"},
})
@@ -75,12 +67,12 @@ func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) {
func TestUpdateUsers_NoRemovals(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
l.UpdateUsers([]model.UserSpec{
{ID: 1, UUID: "u1"},
})
// Add user, keep existing
removed := l.UpdateUsers([]panel.User{
removed := l.UpdateUsers([]model.UserSpec{
{ID: 1, UUID: "u1"},
{ID: 2, UUID: "u2"},
})
@@ -92,248 +84,46 @@ func TestUpdateUsers_NoRemovals(t *testing.T) {
func TestUpdateUsers_ReplacesAll(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{{ID: 1, DeviceLimit: 1}})
l.UpdateUsers([]model.UserSpec{{ID: 1, DeviceLimit: 1}})
// Replace with different users
removed := l.UpdateUsers([]panel.User{{ID: 2, DeviceLimit: 1}})
removed := l.UpdateUsers([]model.UserSpec{{ID: 2, DeviceLimit: 1}})
if len(removed) != 1 || removed[0] != 1 {
t.Errorf("expected removed=[1], got %v", removed)
}
}
func TestDeviceLimit_NoKick_UnderLimit(t *testing.T) {
func TestGetDeviceLimitByUUID(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, DeviceLimit: 3},
l.UpdateUsers([]model.UserSpec{
{ID: 1, UUID: "u1", DeviceLimit: 3},
{ID: 2, UUID: "u2", DeviceLimit: 0},
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
// User with device limit
limit, ok := l.GetDeviceLimitByUUID("u1")
if !ok || limit != 3 {
t.Errorf("expected (3, true), got (%d, %v)", limit, ok)
}
kicks := l.Check(conns)
if len(kicks) != 0 {
t.Errorf("2 devices under limit 3, got %d kicks", len(kicks))
// User without device limit
_, ok = l.GetDeviceLimitByUUID("u2")
if ok {
t.Error("expected (_, false) for user with no device limit")
}
// Unknown UUID
_, ok = l.GetDeviceLimitByUUID("unknown")
if ok {
t.Error("expected (_, false) for unknown UUID")
}
}
func TestDeviceLimit_Kick_OverLimit(t *testing.T) {
func TestSnapshotMetrics(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, DeviceLimit: 2},
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
{ID: "c3", UserID: 1, SourceIP: "3.3.3.3"},
}
kicks := l.Check(conns)
if len(kicks) == 0 {
t.Fatal("expected kicks for exceeding device limit")
}
if len(kicks) < 1 {
t.Errorf("expected at least 1 kick, got %d", len(kicks))
}
}
func TestDeviceLimit_ExactlyAtLimit(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, DeviceLimit: 2},
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
}
kicks := l.Check(conns)
if len(kicks) != 0 {
t.Errorf("exactly at limit should not kick, got %d", len(kicks))
}
}
func TestDeviceLimit_UnlimitedDevices(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, DeviceLimit: 0}, // 0 = unlimited
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
{ID: "c3", UserID: 1, SourceIP: "3.3.3.3"},
{ID: "c4", UserID: 1, SourceIP: "4.4.4.4"},
{ID: "c5", UserID: 1, SourceIP: "5.5.5.5"},
}
kicks := l.Check(conns)
if len(kicks) != 0 {
t.Errorf("unlimited devices should not be kicked, got %d", len(kicks))
}
}
func TestDeviceLimit_SameIPMultipleConnections(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, DeviceLimit: 1},
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c3", UserID: 1, SourceIP: "1.1.1.1"},
}
kicks := l.Check(conns)
if len(kicks) != 0 {
t.Errorf("same IP multiple conns should count as 1 device, got %d kicks", len(kicks))
}
}
func TestDeviceLimit_EmptySourceIP(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, DeviceLimit: 1},
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: ""},
{ID: "c2", UserID: 1, SourceIP: ""},
}
kicks := l.Check(conns)
if len(kicks) != 0 {
t.Errorf("empty source IP should be ignored, got %d kicks", len(kicks))
}
}
func TestMultipleUsers_DeviceLimits(t *testing.T) {
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, DeviceLimit: 1},
{ID: 2, DeviceLimit: 1},
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 2, SourceIP: "2.2.2.2"},
{ID: "c3", UserID: 2, SourceIP: "3.3.3.3"},
}
kicks := l.Check(conns)
deviceKicks := 0
for _, k := range kicks {
if k.UserID == 2 {
deviceKicks++
}
}
if deviceKicks == 0 {
t.Error("expected device kick for user 2")
}
for _, k := range kicks {
if k.UserID == 1 {
t.Error("user 1 should not be kicked")
}
}
}
func TestNoSpeedKick(t *testing.T) {
// Speed limits should NOT cause kicks — only device limits and user removal
l := New()
l.UpdateUsers([]panel.User{
{ID: 1, SpeedLimit: 1, DeviceLimit: 0},
})
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
}
kicks := l.Check(conns)
if len(kicks) != 0 {
t.Errorf("speed limits should not cause kicks, got %d", len(kicks))
}
}
func TestKickUsers_Basic(t *testing.T) {
l := New()
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 2, SourceIP: "2.2.2.2"},
{ID: "c3", UserID: 1, SourceIP: "1.1.1.2"},
{ID: "c4", UserID: 3, SourceIP: "3.3.3.3"},
}
kicks := l.KickUsers(conns, []int{1})
if len(kicks) != 2 {
t.Fatalf("expected 2 kicks for user 1, got %d", len(kicks))
}
for _, k := range kicks {
if k.UserID != 1 {
t.Errorf("expected kick for user 1, got user %d", k.UserID)
}
if k.Reason != "user removed from panel" {
t.Errorf("unexpected reason: %s", k.Reason)
}
}
}
func TestKickUsers_MultipleUsers(t *testing.T) {
l := New()
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 2, SourceIP: "2.2.2.2"},
{ID: "c3", UserID: 3, SourceIP: "3.3.3.3"},
}
kicks := l.KickUsers(conns, []int{1, 3})
if len(kicks) != 2 {
t.Fatalf("expected 2 kicks, got %d", len(kicks))
}
kickedUsers := map[int]bool{}
for _, k := range kicks {
kickedUsers[k.UserID] = true
}
if !kickedUsers[1] || !kickedUsers[3] {
t.Errorf("expected users 1 and 3 kicked, got %v", kickedUsers)
}
}
func TestKickUsers_NoUsers(t *testing.T) {
l := New()
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
}
kicks := l.KickUsers(conns, nil)
if len(kicks) != 0 {
t.Errorf("expected no kicks for nil user list, got %d", len(kicks))
}
kicks = l.KickUsers(conns, []int{})
if len(kicks) != 0 {
t.Errorf("expected no kicks for empty user list, got %d", len(kicks))
}
}
func TestKickUsers_NoMatchingConnections(t *testing.T) {
l := New()
conns := []kernel.Connection{
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
}
kicks := l.KickUsers(conns, []int{99})
if len(kicks) != 0 {
t.Errorf("expected no kicks for non-existent user, got %d", len(kicks))
m := l.SnapshotMetrics()
if m.DeviceLimitEvents != 0 {
t.Errorf("expected 0 events initially, got %d", m.DeviceLimitEvents)
}
}
+92 -49
View File
@@ -1,14 +1,16 @@
package limiter
import (
"log/slog"
"sync"
"sync/atomic"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
"golang.org/x/time/rate"
)
// SpeedTrackerLogCallback is called when bucket updates occur.
type SpeedTrackerLogCallback func(msg string)
// SpeedTracker manages per-user token-bucket rate limiters.
// It does NOT wrap connections itself — instead, ConnTracker consults it
// via GetLimiter to embed rate limiting in the same tracked connection
@@ -22,6 +24,9 @@ type SpeedTracker struct {
// Fast-path: when no users have a speed limit, GetLimiter returns nil
// immediately without any map lookup.
hasLimits atomic.Bool
// Optional callback for logging
logFunc SpeedTrackerLogCallback
}
// NewSpeedTracker creates a bucket manager for per-user bandwidth throttling.
@@ -33,75 +38,113 @@ func NewSpeedTracker(l *Limiter) *SpeedTracker {
}
}
// UpdateBuckets performs an incremental update of rate limiter buckets.
// It reuses existing rate.Limiter instances to avoid burst resets and
// minimizes memory allocations.
// SetLogCallback sets the logging callback.
func (t *SpeedTracker) SetLogCallback(f SpeedTrackerLogCallback) {
t.logFunc = f
}
// UpdateBuckets updates the UUID→userID mapping and syncs existing limiters.
func (t *SpeedTracker) UpdateBuckets() {
currentUsers := make([]model.UserSpec, 0, 32)
t.limiter.mu.RLock()
currentUsers := make([]panel.User, 0, len(t.limiter.users))
for _, u := range t.limiter.users {
currentUsers = append(currentUsers, u)
}
t.limiter.mu.RUnlock()
t.mu.Lock()
defer t.mu.Unlock()
func() {
t.mu.Lock()
defer t.mu.Unlock()
newUUIDMap := make(map[string]int, len(currentUsers))
activeIDs := make(map[int]struct{}, len(currentUsers))
newUUIDMap := make(map[string]int, len(currentUsers))
activeIDs := make(map[int]struct{}, len(currentUsers))
for _, user := range currentUsers {
activeIDs[user.ID] = struct{}{}
if user.UUID != "" {
newUUIDMap[user.UUID] = user.ID
for _, user := range currentUsers {
activeIDs[user.ID] = struct{}{}
if user.UUID != "" {
newUUIDMap[user.UUID] = user.ID
}
// Update existing limiter if speed changed
if lim, ok := t.buckets[user.ID]; ok {
if user.SpeedLimit > 0 {
bytesPerSec := int(user.SpeedLimit) * 1_000_000 / 8
burst := bytesPerSec
if burst < 64*1024 {
burst = 64 * 1024
}
lim.SetLimit(rate.Limit(bytesPerSec))
lim.SetBurst(burst)
} else {
delete(t.buckets, user.ID)
}
}
}
if user.SpeedLimit <= 0 {
delete(t.buckets, user.ID)
continue
// Clean up buckets for removed users
for id := range t.buckets {
if _, ok := activeIDs[id]; !ok {
delete(t.buckets, id)
}
}
bytesPerSec := int(user.SpeedLimit) * 1_000_000 / 8
burst := bytesPerSec
if burst < 64*1024 {
burst = 64 * 1024
}
if cap4s := bytesPerSec * 4; cap4s > 64*1024 && burst > cap4s {
burst = cap4s
}
t.uuidMap = newUUIDMap
t.hasLimits.Store(len(t.buckets) > 0)
}()
if existing, ok := t.buckets[user.ID]; ok {
// Thread-safe in-place update. Existing connections continue
// using this limiter with the new rate immediately.
existing.SetLimit(rate.Limit(bytesPerSec))
existing.SetBurst(burst)
} else {
t.buckets[user.ID] = rate.NewLimiter(rate.Limit(bytesPerSec), burst)
}
if t.logFunc != nil {
t.logFunc("buckets updated")
}
// Remove limiters for users who are no longer in the list
for id := range t.buckets {
if _, ok := activeIDs[id]; !ok {
delete(t.buckets, id)
}
}
t.uuidMap = newUUIDMap
t.hasLimits.Store(len(t.buckets) > 0)
slog.Info("speedtracker: buckets updated", "active_limiters", len(t.buckets))
}
// GetLimiter returns the rate limiter for the given user UUID, or nil if
// no limit applies. This is called by ConnTracker for every new connection.
// no limit applies. Creates limiter on-demand if not exists.
// Thread-safe.
func (t *SpeedTracker) GetLimiter(user string) *rate.Limiter {
if !t.hasLimits.Load() {
t.mu.RLock()
uid, exists := t.uuidMap[user]
if !exists {
t.mu.RUnlock()
return nil
}
t.mu.RLock()
defer t.mu.RUnlock()
return t.buckets[t.uuidMap[user]]
if lim, ok := t.buckets[uid]; ok {
t.mu.RUnlock()
return lim
}
t.mu.RUnlock()
// Get user info from limiter
t.limiter.mu.RLock()
u, userExists := t.limiter.users[uid]
t.limiter.mu.RUnlock()
if !userExists || u.SpeedLimit <= 0 {
return nil
}
// Create limiter on-demand
bytesPerSec := int(u.SpeedLimit) * 1_000_000 / 8
burst := bytesPerSec
if burst < 64*1024 {
burst = 64 * 1024
}
if cap4s := bytesPerSec * 4; cap4s > 64*1024 && burst > cap4s {
burst = cap4s
}
lim := rate.NewLimiter(rate.Limit(bytesPerSec), burst)
t.mu.Lock()
// Double-check after acquiring write lock
if existing, ok := t.buckets[uid]; ok {
t.mu.Unlock()
return existing
}
t.buckets[uid] = lim
t.hasLimits.Store(true)
t.mu.Unlock()
return lim
}
// HasLimits returns true if any user currently has a speed limit configured.
+30 -6
View File
@@ -2,15 +2,16 @@ package limiter
import (
"testing"
"time"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/model"
)
func TestSpeedTracker_UpdateBuckets(t *testing.T) {
l := New()
st := NewSpeedTracker(l)
users := []panel.User{
users := []model.UserSpec{
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, // 3 Mbps
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0}, // unlimited
{ID: 3, UUID: "u3", SpeedLimit: 10, DeviceLimit: 2}, // 10 Mbps
@@ -59,7 +60,7 @@ func TestSpeedTracker_UpdateBuckets_PreservesExisting(t *testing.T) {
l := New()
st := NewSpeedTracker(l)
users := []panel.User{
users := []model.UserSpec{
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
}
l.UpdateUsers(users)
@@ -71,7 +72,7 @@ func TestSpeedTracker_UpdateBuckets_PreservesExisting(t *testing.T) {
}
// Update same user with different speed — should reuse same *rate.Limiter pointer
users2 := []panel.User{
users2 := []model.UserSpec{
{ID: 1, UUID: "u1", SpeedLimit: 5, DeviceLimit: 0},
}
l.UpdateUsers(users2)
@@ -96,7 +97,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
l := New()
st := NewSpeedTracker(l)
users := []panel.User{
users := []model.UserSpec{
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
{ID: 2, UUID: "u2", SpeedLimit: 5, DeviceLimit: 0},
}
@@ -108,7 +109,7 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
}
// Remove user 2
users2 := []panel.User{
users2 := []model.UserSpec{
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
}
l.UpdateUsers(users2)
@@ -121,3 +122,26 @@ func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
t.Error("expected no bucket for removed user 2")
}
}
// Regression: log callback must not run while UpdateBuckets holds t.mu.Lock,
// otherwise callbacks that call LimitedUserCount() self-deadlock on RWMutex.
func TestSpeedTracker_UpdateBuckets_LogCallbackMayCallLimitedUserCount(t *testing.T) {
l := New()
st := NewSpeedTracker(l)
l.UpdateUsers([]model.UserSpec{{ID: 1, UUID: "u1", SpeedLimit: 1}})
st.SetLogCallback(func(msg string) {
_ = st.LimitedUserCount()
})
done := make(chan struct{})
go func() {
st.UpdateBuckets()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("deadlock: UpdateBuckets did not finish (log callback vs RWMutex)")
}
}
+413
View File
@@ -0,0 +1,413 @@
// Package machine implements the machine-mode orchestrator that dynamically
// discovers nodes from the panel's machine API and manages their lifecycles.
package machine
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/controlplane"
"github.com/cedar2025/xboard-node/internal/model"
"github.com/cedar2025/xboard-node/internal/monitor"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/cedar2025/xboard-node/internal/panel"
"github.com/cedar2025/xboard-node/internal/service"
)
// nodeHandle tracks a running node service.
type nodeHandle struct {
cancel context.CancelFunc
done chan struct{}
mailbox *controlplane.NodeMailbox
}
// Orchestrator manages all nodes bound to a panel machine. It:
// - discovers nodes via GET /machine/nodes
// - starts / stops Service instances as nodes are added / removed
// - maintains a shared WS connection that demuxes events by node_id
// - reports machine-level load via POST /machine/status
type Orchestrator struct {
cfg *config.Config
client *panel.Client // machine-level client (no node_id)
mu sync.Mutex
nodes map[int]*nodeHandle // node_id → handle
// Per-node mailbox keyed by node_id. Shared WS events are aggregated here
// and each node service drains the latest state when ready.
eventsMu sync.RWMutex
mailboxes map[int]*controlplane.NodeMailbox
statuses map[int]chan<- controlplane.StatusChange
// Shared WS client (nil when WS is disabled).
ws *panel.WSClient
wsCancel context.CancelFunc
// runCtx is stored from Run() so that onWSEvent can trigger rediscover
// for sync.nodes events without blocking the main loop.
runCtx context.Context
pullInterval time.Duration
pushInterval time.Duration
}
// New creates a machine orchestrator from the given config.
func New(cfg *config.Config) *Orchestrator {
panelCfg := config.PanelConfig{
URL: cfg.Panel.URL,
Token: cfg.Machine.Token,
MachineID: cfg.Machine.MachineID,
}
return &Orchestrator{
cfg: cfg,
client: panel.NewClient(panelCfg),
nodes: make(map[int]*nodeHandle),
mailboxes: make(map[int]*controlplane.NodeMailbox),
statuses: make(map[int]chan<- controlplane.StatusChange),
}
}
// Run is the main loop. It blocks until ctx is cancelled.
func (o *Orchestrator) Run(ctx context.Context) error {
o.runCtx = ctx
nodesResp, err := o.client.GetMachineNodes()
if err != nil {
return fmt.Errorf("initial node discovery: %w", err)
}
o.applyIntervals(nodesResp.BaseConfig)
nlog.Core().Info(fmt.Sprintf("machine %d: discovered %d nodes",
o.cfg.Machine.MachineID, len(nodesResp.Nodes)))
// Start machine-level WS as early as possible so sync.nodes can reach an
// empty machine before the first node is attached.
o.tryStartWS(ctx)
// Start initial nodes.
for _, n := range nodesResp.Nodes {
o.startNode(ctx, n)
}
discoveryTicker := time.NewTicker(o.pullInterval)
statusTicker := time.NewTicker(o.pushInterval)
defer discoveryTicker.Stop()
defer statusTicker.Stop()
for {
select {
case <-ctx.Done():
o.stopAll()
return nil
case <-discoveryTicker.C:
o.rediscover(ctx)
case <-statusTicker.C:
o.reportMachineStatus()
}
}
}
// ─── Node lifecycle ──────────────────────────────────────────────────────
func (o *Orchestrator) startNode(ctx context.Context, mn panel.MachineNode) {
o.mu.Lock()
if _, exists := o.nodes[mn.ID]; exists {
o.mu.Unlock()
return
}
nodeCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
mb := controlplane.NewNodeMailbox()
o.nodes[mn.ID] = &nodeHandle{cancel: cancel, done: done, mailbox: mb}
o.mu.Unlock()
o.eventsMu.Lock()
o.mailboxes[mn.ID] = mb
o.eventsMu.Unlock()
nodeCfg := o.cfg.ExpandMachineNode(mn.ID, mn.Type)
perNodeClient := o.client.ForNode(mn.ID)
// Pre-fetch node config to detect transport-based kernel requirements.
// If the transport (e.g. xhttp) is incompatible with the configured kernel
// (e.g. singbox), auto-switch to the required kernel for this node.
if cfgSnapshot, err := perNodeClient.GetConfig(); err == nil && cfgSnapshot != nil {
if resolved := model.ResolveKernelForTransport(cfgSnapshot.Network, nodeCfg.Kernel.Type); resolved != nodeCfg.Kernel.Type {
nlog.Core().Info(fmt.Sprintf("machine: auto-switching kernel for node %d (%s→%s, transport=%s)",
mn.ID, nodeCfg.Kernel.Type, resolved, cfgSnapshot.Network))
nodeCfg.Kernel.Type = resolved
}
}
// Reset cached ETag so the subsequent GetConfig in Initial() gets a full response.
perNodeClient.ResetConfigETag()
var push controlplane.PushClient
if o.ws != nil {
push = &machineNodePush{
nodeID: mn.ID,
ws: o.ws,
}
}
// The registerFn is called by MachinePanelControlPlane.Initial() to expose
// the node mailbox + status channel to the Service.
nodeID := mn.ID
registerFn := func(st chan<- controlplane.StatusChange) *controlplane.NodeMailbox {
o.registerNode(nodeID, st)
return mb
}
cp := controlplane.NewMachinePanelControlPlane(perNodeClient, nodeCfg.Kernel, push, registerFn)
svc := service.NewWithControlPlane(nodeCfg, cp)
nlog.Core().Info(fmt.Sprintf("machine: starting node %d (%s/%s)",
mn.ID, mn.Type, mn.Name))
go func() {
defer close(done)
defer o.unregisterNode(mn.ID)
if err := svc.Run(nodeCtx); err != nil {
nlog.Core().Error("machine node exited with error",
"node_id", mn.ID, "error", err)
}
}()
}
func (o *Orchestrator) stopNode(nodeID int) {
o.mu.Lock()
h, ok := o.nodes[nodeID]
if !ok {
o.mu.Unlock()
return
}
delete(o.nodes, nodeID)
o.mu.Unlock()
o.eventsMu.Lock()
delete(o.mailboxes, nodeID)
o.eventsMu.Unlock()
nlog.Core().Info(fmt.Sprintf("machine: stopping node %d", nodeID))
h.cancel()
<-h.done
}
func (o *Orchestrator) stopAll() {
o.mu.Lock()
handles := make(map[int]*nodeHandle, len(o.nodes))
for id, h := range o.nodes {
handles[id] = h
}
o.mu.Unlock()
for id, h := range handles {
nlog.Core().Info(fmt.Sprintf("machine: stopping node %d", id))
h.cancel()
}
for _, h := range handles {
<-h.done
}
if o.wsCancel != nil {
o.wsCancel()
}
}
// ─── Node discovery ──────────────────────────────────────────────────────
func (o *Orchestrator) rediscover(ctx context.Context) {
nodesResp, err := o.client.GetMachineNodes()
if err != nil {
nlog.Core().Warn("machine node discovery failed", "error", err)
return
}
wanted := make(map[int]panel.MachineNode, len(nodesResp.Nodes))
for _, n := range nodesResp.Nodes {
wanted[n.ID] = n
}
o.mu.Lock()
var toRemove []int
for id := range o.nodes {
if _, ok := wanted[id]; !ok {
toRemove = append(toRemove, id)
}
}
o.mu.Unlock()
for _, id := range toRemove {
o.stopNode(id)
}
for _, n := range nodesResp.Nodes {
o.startNode(ctx, n) // no-op if already running
}
}
// ─── Machine status reporting ────────────────────────────────────────────
func (o *Orchestrator) reportMachineStatus() {
s := monitor.Collect()
if err := o.client.ReportMachineStatus(
s.CPU,
[2]uint64{s.MemTotal, s.MemUsed},
[2]uint64{s.SwapTotal, s.SwapUsed},
[2]uint64{s.DiskTotal, s.DiskUsed},
s.NetInSpeed, s.NetOutSpeed,
); err != nil {
nlog.Core().Warn("machine status report failed", "error", err)
}
}
// ─── WS mux ─────────────────────────────────────────────────────────────
func (o *Orchestrator) tryStartWS(ctx context.Context) {
hs, err := o.client.Handshake()
if err != nil {
nlog.Core().Warn("machine ws handshake failed, REST only", "error", err)
return
}
if !hs.WebSocket.Enabled || hs.WebSocket.WSURL == "" {
nlog.Core().Info("machine: ws disabled by panel, REST only")
return
}
wsCfg := panel.WSClientConfig{
StatusInterval: time.Duration(o.cfg.WS.StatusInterval) * time.Second,
HandshakeTimeout: time.Duration(o.cfg.WS.HandshakeTimeout) * time.Second,
BackoffInitial: time.Duration(o.cfg.WS.BackoffInitial) * time.Second,
BackoffMax: time.Duration(o.cfg.WS.BackoffMax) * time.Second,
MachineID: o.cfg.Machine.MachineID,
}
o.ws = panel.NewWSClient(
hs.WebSocket.WSURL,
o.cfg.Machine.Token,
0, // no single node_id
wsCfg,
o.onWSEvent,
o.onWSStatus,
nil, // per-node status is sent via machineNodePush
)
wsCtx, wsCancel := context.WithCancel(ctx)
o.wsCancel = wsCancel
go o.ws.Run(wsCtx)
nlog.Core().Info("machine: ws mux started")
}
// onWSEvent routes a WS event to the correct node's channel.
// sync.nodes is a machine-level event that triggers immediate rediscovery.
func (o *Orchestrator) onWSEvent(event panel.WSEvent) {
// sync.nodes is a machine-level event, not per-node
if event.Type == panel.WSEventSyncNodes {
nlog.Core().Info("machine received sync.nodes, triggering immediate rediscovery")
go o.rediscover(o.runCtx)
return
}
nodeID := event.NodeID
if nodeID == 0 {
nlog.Core().Debug("machine ws event missing node_id, dropping", "type", event.Type)
return
}
translated, err := controlplane.TranslateWSEvent(event, o.cfg.Kernel)
if err != nil {
nlog.Core().Warn("machine ws event translation failed",
"type", event.Type, "node_id", nodeID, "error", err)
return
}
o.eventsMu.RLock()
mailbox, ok := o.mailboxes[nodeID]
o.eventsMu.RUnlock()
if !ok {
nlog.Core().Debug("machine ws event for unknown node", "node_id", nodeID, "type", event.Type)
return
}
mailbox.Apply(translated)
}
// onWSStatus broadcasts WS connectivity changes to all registered nodes.
func (o *Orchestrator) onWSStatus(status panel.WSStatusChange) {
change := controlplane.StatusChange{Connected: status.Connected}
o.eventsMu.RLock()
defer o.eventsMu.RUnlock()
for _, ch := range o.statuses {
select {
case ch <- change:
default:
}
}
}
func (o *Orchestrator) registerNode(nodeID int, st chan<- controlplane.StatusChange) {
o.eventsMu.Lock()
o.statuses[nodeID] = st
o.eventsMu.Unlock()
}
func (o *Orchestrator) unregisterNode(nodeID int) {
o.eventsMu.Lock()
delete(o.mailboxes, nodeID)
delete(o.statuses, nodeID)
o.eventsMu.Unlock()
}
func (o *Orchestrator) applyIntervals(bc panel.MachineBaseConfig) {
o.pullInterval = time.Duration(bc.PullInterval) * time.Second
if o.pullInterval < 30*time.Second {
o.pullInterval = 60 * time.Second
}
o.pushInterval = time.Duration(bc.PushInterval) * time.Second
if o.pushInterval < 10*time.Second {
o.pushInterval = 60 * time.Second
}
}
// ─── Virtual PushClient ─────────────────────────────────────────────────
// machineNodePush implements controlplane.PushClient for a single node
// backed by the shared machine WS connection. Events are routed by the
// WS mux directly to the Service's channels; this adapter only provides
// connectivity status and send capabilities.
type machineNodePush struct {
nodeID int
ws *panel.WSClient
}
func (p *machineNodePush) Run(ctx context.Context) {
// The shared WS mux pushes events into our channels; we just wait.
<-ctx.Done()
}
func (p *machineNodePush) IsConnected() bool {
return p.ws != nil && p.ws.IsConnected()
}
func (p *machineNodePush) SendDeviceReport(devices map[int][]string) {
if p.ws == nil {
return
}
payload := map[string]interface{}{
"node_id": p.nodeID,
}
// Flatten into the standard format with node_id wrapper.
strDevices := make(map[string][]string, len(devices))
for uid, ips := range devices {
strDevices[fmt.Sprintf("%d", uid)] = ips
}
payload["devices"] = strDevices
data, _ := json.Marshal(payload)
p.ws.SendRaw(panel.WSEventReportDevices, data)
}
+155
View File
@@ -0,0 +1,155 @@
package model
import (
"encoding/json"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type outboundTagSource struct {
Tag string
Source string
}
func collectAdditionalOutboundTagSources(path string, rawOutbounds []map[string]any) ([]outboundTagSource, error) {
sources := collectRawOutboundTagSources(rawOutbounds)
customSources, err := collectCustomConfigOutboundTagSources(path)
if err != nil {
return nil, err
}
sources = append(sources, customSources...)
return sources, nil
}
func collectRawOutboundTagSources(outbounds []map[string]any) []outboundTagSource {
if len(outbounds) == 0 {
return nil
}
sources := make([]outboundTagSource, 0, len(outbounds))
for i, outbound := range outbounds {
tag, _ := outbound["tag"].(string)
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
sources = append(sources, outboundTagSource{Tag: tag, Source: fmt.Sprintf("kernel.custom_outbound[%d]", i)})
}
return sources
}
func collectCustomConfigOutboundTagSources(path string) ([]outboundTagSource, error) {
if strings.TrimSpace(path) == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read custom config %q: %w", path, err)
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil, nil
}
decoded := make(map[string]any)
trimmed := strings.TrimSpace(string(data))
if strings.HasPrefix(trimmed, "{") {
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("parse custom config as JSON: %w", err)
}
} else {
if err := yaml.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("parse custom config as YAML: %w", err)
}
}
items, ok := toSliceOfMaps(decoded["outbounds"])
if !ok {
return nil, nil
}
sources := make([]outboundTagSource, 0, len(items))
for i, item := range items {
tag, _ := item["tag"].(string)
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
sources = append(sources, outboundTagSource{Tag: tag, Source: fmt.Sprintf("kernel.custom_config.outbounds[%d]", i)})
}
return sources, nil
}
func toSliceOfMaps(v any) ([]map[string]any, bool) {
if v == nil {
return nil, false
}
switch items := v.(type) {
case []any:
result := make([]map[string]any, 0, len(items))
for _, item := range items {
mapped, ok := toMap(item)
if ok {
result = append(result, mapped)
}
}
return result, len(result) > 0
case []map[string]any:
return items, len(items) > 0
default:
return nil, false
}
}
func toMap(v any) (map[string]any, bool) {
switch mapped := v.(type) {
case map[string]any:
return mapped, true
case map[any]any:
result := make(map[string]any, len(mapped))
for k, val := range mapped {
result[fmt.Sprint(k)] = val
}
return result, true
default:
return nil, false
}
}
func validateOutboundTagCollisions(structured []OutboundConfig, additional []outboundTagSource) error {
seen := make(map[string]string, len(additional))
for _, source := range additional {
key := strings.ToLower(strings.TrimSpace(source.Tag))
if key == "" {
continue
}
if prev, exists := seen[key]; exists {
return fmt.Errorf("%s.tag duplicates %q from %s", source.Source, source.Tag, prev)
}
seen[key] = source.Source
}
for i, outbound := range structured {
key := strings.ToLower(strings.TrimSpace(outbound.Tag))
if key == "" {
continue
}
if prev, exists := seen[key]; exists {
return fmt.Errorf("custom_outbounds[%d].tag duplicates %q from %s", i, outbound.Tag, prev)
}
}
return nil
}
func additionalTagNames(sources []outboundTagSource) []string {
if len(sources) == 0 {
return nil
}
tags := make([]string, 0, len(sources))
for _, source := range sources {
tags = append(tags, source.Tag)
}
return tags
}
+81
View File
@@ -0,0 +1,81 @@
package model
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/panel"
)
func TestNodeSpecFromPanelValidated_AllowsTargetsFromCustomConfigOutbounds(t *testing.T) {
dir := t.TempDir()
customConfigPath := filepath.Join(dir, "custom.json")
if err := os.WriteFile(customConfigPath, []byte(`{"outbounds":[{"tag":"wg-exit","protocol":"wireguard"}]}`), 0o644); err != nil {
t.Fatalf("write custom config: %v", err)
}
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panel.OutboundConfig{{
Tag: "proxy",
Protocol: "socks",
ProxyTag: "wg-exit",
Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
}},
CustomRouteRules: []panel.CustomRouteRule{{
Action: panel.RouteAction{Type: "route", Target: "wg-exit"},
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
}},
}, config.KernelConfig{Type: "singbox", CustomConfig: customConfigPath})
if err != nil {
t.Fatalf("expected custom_config outbound tags to be available, got %v", err)
}
}
func TestNodeSpecFromPanelValidated_RejectsDuplicateTagsAcrossSources(t *testing.T) {
dir := t.TempDir()
customConfigPath := filepath.Join(dir, "custom.yaml")
if err := os.WriteFile(customConfigPath, []byte("outbounds:\n - tag: warp\n protocol: wireguard\n"), 0o644); err != nil {
t.Fatalf("write custom config: %v", err)
}
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panel.OutboundConfig{{
Tag: "warp",
Protocol: "socks",
Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
}},
}, config.KernelConfig{
Type: "singbox",
CustomConfig: customConfigPath,
CustomOutbound: []map[string]any{{
"tag": "proxy",
"protocol": "socks",
}},
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `custom_outbounds[0].tag duplicates "warp" from kernel.custom_config.outbounds[0]`) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateOutboundTagCollisions_RejectsDuplicateRawSources(t *testing.T) {
err := validateOutboundTagCollisions(nil, []outboundTagSource{
{Tag: "warp", Source: "kernel.custom_outbound[0]"},
{Tag: "warp", Source: "kernel.custom_config.outbounds[0]"},
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `kernel.custom_config.outbounds[0].tag duplicates "warp" from kernel.custom_outbound[0]`) {
t.Fatalf("unexpected error: %v", err)
}
}
+50
View File
@@ -0,0 +1,50 @@
package model
func OutboundSupportMatrix() map[string]KernelOutboundSupport {
return map[string]KernelOutboundSupport{
"xray": {
Protocols: []string{
"vmess",
"vless",
"trojan",
"shadowsocks",
"socks",
"http",
"wireguard",
},
Features: []string{
"tag",
"protocol",
"settings",
"proxy_tag",
},
},
"singbox": {
Protocols: []string{
"vmess",
"vless",
"trojan",
"shadowsocks",
"socks",
"http",
"wireguard",
"tuic",
"hysteria2",
"anytls",
"naive",
"mieru",
},
Features: []string{
"tag",
"protocol",
"settings",
"proxy_tag",
},
},
}
}
type KernelOutboundSupport struct {
Protocols []string
Features []string
}
+139
View File
@@ -0,0 +1,139 @@
package model
import (
"fmt"
"strconv"
"strings"
)
func ValidateCustomOutbounds(outbounds []OutboundConfig) error {
return ValidateCustomOutboundsForKernel(outbounds, "", nil)
}
func ValidateCustomOutboundsForKernel(outbounds []OutboundConfig, kernelType string, additionalTags []string) error {
if len(outbounds) == 0 {
return nil
}
kernelType = strings.ToLower(strings.TrimSpace(kernelType))
allowedProtocols := map[string]struct{}{}
if kernelType != "" {
support, ok := OutboundSupportMatrix()[kernelType]
if !ok {
return fmt.Errorf("unsupported kernel type %q", kernelType)
}
for _, protocol := range support.Protocols {
allowedProtocols[strings.ToLower(protocol)] = struct{}{}
}
}
seen := make(map[string]struct{}, len(outbounds))
availableTags := make(map[string]struct{}, len(outbounds)+len(additionalTags)+2)
availableTags["direct"] = struct{}{}
availableTags["block"] = struct{}{}
for _, tag := range additionalTags {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag != "" {
availableTags[tag] = struct{}{}
}
}
for i, outbound := range outbounds {
tag := strings.TrimSpace(outbound.Tag)
protocol := strings.ToLower(strings.TrimSpace(outbound.Protocol))
if tag == "" {
return fmt.Errorf("custom_outbounds[%d].tag is required", i)
}
if protocol == "" {
return fmt.Errorf("custom_outbounds[%d].protocol is required", i)
}
if kernelType != "" {
if _, ok := allowedProtocols[protocol]; !ok {
return fmt.Errorf("custom_outbounds[%d].protocol %q is not supported by kernel %q", i, outbound.Protocol, kernelType)
}
}
if err := validateOutboundSettings(i, outbound.Settings); err != nil {
return err
}
key := strings.ToLower(tag)
if _, exists := seen[key]; exists {
return fmt.Errorf("custom_outbounds[%d].tag duplicates %q", i, tag)
}
seen[key] = struct{}{}
availableTags[key] = struct{}{}
}
for i, outbound := range outbounds {
if outbound.ProxyTag == "" {
continue
}
proxyTag := strings.ToLower(strings.TrimSpace(outbound.ProxyTag))
if proxyTag == "" {
return fmt.Errorf("custom_outbounds[%d].proxy_tag must not be blank", i)
}
if proxyTag == strings.ToLower(strings.TrimSpace(outbound.Tag)) {
return fmt.Errorf("custom_outbounds[%d].proxy_tag must not reference itself", i)
}
if _, exists := availableTags[proxyTag]; !exists {
return fmt.Errorf("custom_outbounds[%d].proxy_tag references unknown outbound %q", i, outbound.ProxyTag)
}
}
return nil
}
func validateOutboundSettings(index int, settings map[string]any) error {
if len(settings) == 0 {
return fmt.Errorf("custom_outbounds[%d].settings is required", index)
}
for _, reservedKey := range []string{"tag", "protocol", "proxy_tag", "proxyTag"} {
if _, exists := settings[reservedKey]; exists {
return fmt.Errorf("custom_outbounds[%d].settings.%s is reserved", index, reservedKey)
}
}
for _, field := range []string{"server", "uuid", "password", "method", "cipher", "private_key", "secret_key", "privateKey", "secretKey"} {
if value, ok := settings[field]; ok {
if strings.TrimSpace(fmt.Sprintf("%v", value)) == "" {
return fmt.Errorf("custom_outbounds[%d].settings.%s must not be blank", index, field)
}
}
}
if value, ok := settings["server_port"]; ok {
if _, err := asPort(value); err != nil {
return fmt.Errorf("custom_outbounds[%d].settings.server_port %w", index, err)
}
}
if value, ok := settings["serverPort"]; ok {
if _, err := asPort(value); err != nil {
return fmt.Errorf("custom_outbounds[%d].settings.serverPort %w", index, err)
}
}
return nil
}
func asPort(value any) (int, error) {
switch v := value.(type) {
case int:
if v < 1 || v > 65535 {
return 0, fmt.Errorf("must be between 1 and 65535")
}
return v, nil
case int32:
return asPort(int(v))
case int64:
return asPort(int(v))
case float64:
if v != float64(int(v)) {
return 0, fmt.Errorf("must be an integer")
}
return asPort(int(v))
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
return asPort(parsed)
default:
return 0, fmt.Errorf("must be an integer")
}
}
@@ -0,0 +1,109 @@
package model
import (
"strings"
"testing"
)
func TestValidateCustomOutbounds(t *testing.T) {
tests := []struct {
name string
outbounds []OutboundConfig
kernel string
wantErr string
}{
{
name: "valid chain",
outbounds: []OutboundConfig{
{Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"}},
{Tag: "proxy", Protocol: "socks", ProxyTag: "warp", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
},
kernel: "singbox",
},
{
name: "valid native vmess settings preserved",
outbounds: []OutboundConfig{{Tag: "vmess-native", Protocol: "vmess", Settings: map[string]any{"vnext": []any{map[string]any{"address": "1.1.1.1", "port": 443}}}}},
kernel: "xray",
},
{
name: "missing tag",
outbounds: []OutboundConfig{{Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
wantErr: "custom_outbounds[0].tag is required",
},
{
name: "missing protocol",
outbounds: []OutboundConfig{{Tag: "proxy", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
wantErr: "custom_outbounds[0].protocol is required",
},
{
name: "duplicate tag",
outbounds: []OutboundConfig{
{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
{Tag: "proxy", Protocol: "http", Settings: map[string]any{"server": "3.3.3.3", "server_port": 8080}},
},
wantErr: `custom_outbounds[1].tag duplicates "proxy"`,
},
{
name: "self proxy tag",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "proxy", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
wantErr: "custom_outbounds[0].proxy_tag must not reference itself",
},
{
name: "unknown proxy tag",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
wantErr: `custom_outbounds[0].proxy_tag references unknown outbound "missing"`,
},
{
name: "built-in proxy tag allowed",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "direct", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
},
{
name: "unsupported protocol for kernel",
outbounds: []OutboundConfig{{Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443}}},
kernel: "xray",
wantErr: `custom_outbounds[0].protocol "hysteria2" is not supported by kernel "xray"`,
},
{
name: "missing settings",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks"}},
wantErr: "custom_outbounds[0].settings is required",
},
{
name: "reserved snake case key in settings",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"proxy_tag": "bad", "server": "2.2.2.2", "server_port": 1080}}},
wantErr: "custom_outbounds[0].settings.proxy_tag is reserved",
},
{
name: "reserved camel case key in settings",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"proxyTag": "bad", "server": "2.2.2.2", "server_port": 1080}}},
wantErr: "custom_outbounds[0].settings.proxyTag is reserved",
},
{
name: "invalid server_port",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 70000}}},
wantErr: "custom_outbounds[0].settings.server_port must be between 1 and 65535",
},
{
name: "invalid camel case serverPort",
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "serverPort": 70000}}},
wantErr: "custom_outbounds[0].settings.serverPort must be between 1 and 65535",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateCustomOutboundsForKernel(tc.outbounds, tc.kernel, nil)
if tc.wantErr == "" && err != nil {
t.Fatalf("expected no error, got %v", err)
}
if tc.wantErr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("unexpected error: %v", err)
}
}
})
}
}
+58
View File
@@ -0,0 +1,58 @@
package model
type CustomRouteRule struct {
Name string
Disabled bool
Match RouteMatch
Action RouteAction
}
type RouteMatch struct {
Domains []string
DomainSuffixes []string
IPCIDRs []string
Ports []string
Networks []string
SourceCIDRs []string
SourcePorts []string
}
type RouteAction struct {
Type string
Target string
}
func cloneCustomRouteRules(src []CustomRouteRule) []CustomRouteRule {
if len(src) == 0 {
return nil
}
out := make([]CustomRouteRule, 0, len(src))
for _, rule := range src {
out = append(out, CustomRouteRule{
Name: rule.Name,
Disabled: rule.Disabled,
Match: cloneRouteMatch(rule.Match),
Action: cloneRouteAction(rule.Action),
})
}
return out
}
func cloneRouteMatch(src RouteMatch) RouteMatch {
return RouteMatch{
Domains: cloneStringSlice(src.Domains),
DomainSuffixes: cloneStringSlice(src.DomainSuffixes),
IPCIDRs: cloneStringSlice(src.IPCIDRs),
Ports: cloneStringSlice(src.Ports),
Networks: cloneStringSlice(src.Networks),
SourceCIDRs: cloneStringSlice(src.SourceCIDRs),
SourcePorts: cloneStringSlice(src.SourcePorts),
}
}
func cloneRouteAction(src RouteAction) RouteAction {
return RouteAction{
Type: src.Type,
Target: src.Target,
}
}
+35
View File
@@ -0,0 +1,35 @@
package model
func RouteSupportMatrix() map[string]KernelRouteSupport {
return map[string]KernelRouteSupport{
"xray": {
Matchers: []string{
"domains",
"domain_suffixes",
"ip_cidrs",
"ports",
"networks",
"source_cidrs",
"source_ports",
},
Actions: []string{"block", "direct", "route"},
},
"singbox": {
Matchers: []string{
"domains",
"domain_suffixes",
"ip_cidrs",
"ports",
"networks",
"source_cidrs",
"source_ports",
},
Actions: []string{"block", "direct", "route"},
},
}
}
type KernelRouteSupport struct {
Matchers []string
Actions []string
}
+178
View File
@@ -0,0 +1,178 @@
package model
import (
"fmt"
"strconv"
"strings"
)
func ValidateCustomRouteRules(rules []CustomRouteRule, kernelType string, availableTags map[string]struct{}) error {
if len(rules) == 0 {
return nil
}
kernelType = strings.ToLower(strings.TrimSpace(kernelType))
matcherSupport := map[string]struct{}{}
actionSupport := map[string]struct{}{}
if kernelType != "" {
support, ok := RouteSupportMatrix()[kernelType]
if !ok {
return fmt.Errorf("unsupported kernel type %q", kernelType)
}
for _, matcher := range support.Matchers {
matcherSupport[strings.ToLower(matcher)] = struct{}{}
}
for _, action := range support.Actions {
actionSupport[strings.ToLower(action)] = struct{}{}
}
}
for i, rule := range rules {
if rule.Disabled {
continue
}
if !hasRouteMatch(rule.Match) {
return fmt.Errorf("custom_route_rules[%d].match is required", i)
}
if err := ensureRouteMatcherSupported(i, kernelType, matcherSupport, rule.Match); err != nil {
return err
}
if err := validatePortSpecs(rule.Match.Ports, fmt.Sprintf("custom_route_rules[%d].match.ports", i)); err != nil {
return err
}
if err := validatePortSpecs(rule.Match.SourcePorts, fmt.Sprintf("custom_route_rules[%d].match.source_ports", i)); err != nil {
return err
}
if err := validateNetworks(rule.Match.Networks, fmt.Sprintf("custom_route_rules[%d].match.networks", i)); err != nil {
return err
}
actionType := strings.ToLower(strings.TrimSpace(rule.Action.Type))
if actionType == "" {
return fmt.Errorf("custom_route_rules[%d].action.type is required", i)
}
if kernelType != "" {
if _, ok := actionSupport[actionType]; !ok {
return fmt.Errorf("custom_route_rules[%d].action.type %q is not supported by kernel %q", i, rule.Action.Type, kernelType)
}
}
target := strings.TrimSpace(rule.Action.Target)
switch actionType {
case "route":
if target == "" {
return fmt.Errorf("custom_route_rules[%d].action.target is required when action.type is route", i)
}
if availableTags != nil {
if _, ok := availableTags[strings.ToLower(target)]; !ok {
return fmt.Errorf("custom_route_rules[%d].action.target references unknown outbound %q", i, rule.Action.Target)
}
}
case "direct", "block":
if target != "" {
return fmt.Errorf("custom_route_rules[%d].action.target is only allowed when action.type is route", i)
}
default:
return fmt.Errorf("custom_route_rules[%d].action.type %q is not supported", i, rule.Action.Type)
}
}
return nil
}
func hasRouteMatch(match RouteMatch) bool {
for _, values := range [][]string{
match.Domains,
match.DomainSuffixes,
match.IPCIDRs,
match.Ports,
match.Networks,
match.SourceCIDRs,
match.SourcePorts,
} {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
}
return false
}
func ensureRouteMatcherSupported(index int, kernelType string, matcherSupport map[string]struct{}, match RouteMatch) error {
if kernelType == "" {
return nil
}
checks := map[string][]string{
"domains": match.Domains,
"domain_suffixes": match.DomainSuffixes,
"ip_cidrs": match.IPCIDRs,
"ports": match.Ports,
"networks": match.Networks,
"source_cidrs": match.SourceCIDRs,
"source_ports": match.SourcePorts,
}
for matcher, values := range checks {
if !hasAnyNonBlank(values) {
continue
}
if _, ok := matcherSupport[matcher]; !ok {
return fmt.Errorf("custom_route_rules[%d].match.%s is not supported by kernel %q", index, matcher, kernelType)
}
}
return nil
}
func hasAnyNonBlank(values []string) bool {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
return false
}
func validateNetworks(values []string, field string) error {
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
continue
}
if value != "tcp" && value != "udp" {
return fmt.Errorf("%s contains unsupported network %q", field, value)
}
}
return nil
}
func validatePortSpecs(values []string, field string) error {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.Contains(value, "-") || strings.Contains(value, ":") {
delimiter := "-"
if strings.Contains(value, ":") {
delimiter = ":"
}
parts := strings.Split(value, delimiter)
if len(parts) != 2 {
return fmt.Errorf("%s contains invalid port range %q", field, value)
}
start, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || start < 1 || start > 65535 {
return fmt.Errorf("%s contains invalid port range %q", field, value)
}
end, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil || end < 1 || end > 65535 || end < start {
return fmt.Errorf("%s contains invalid port range %q", field, value)
}
continue
}
port, err := strconv.Atoi(value)
if err != nil || port < 1 || port > 65535 {
return fmt.Errorf("%s contains invalid port %q", field, value)
}
}
return nil
}
@@ -0,0 +1,84 @@
package model
import (
"strings"
"testing"
)
func TestValidateCustomRouteRules(t *testing.T) {
available := map[string]struct{}{"direct": {}, "block": {}, "warp": {}, "proxy": {}}
tests := []struct {
name string
rules []CustomRouteRule
kernel string
wantErr string
}{
{
name: "valid route target",
rules: []CustomRouteRule{{
Match: RouteMatch{DomainSuffixes: []string{"example.com"}, Ports: []string{"443"}, Networks: []string{"tcp"}},
Action: RouteAction{Type: "route", Target: "warp"},
}},
kernel: "singbox",
},
{
name: "missing match",
rules: []CustomRouteRule{{Action: RouteAction{Type: "direct"}}},
kernel: "singbox",
wantErr: "custom_route_rules[0].match is required",
},
{
name: "unknown target",
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "route", Target: "missing"}}},
kernel: "singbox",
wantErr: `custom_route_rules[0].action.target references unknown outbound "missing"`,
},
{
name: "route target required",
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "route"}}},
kernel: "singbox",
wantErr: "custom_route_rules[0].action.target is required when action.type is route",
},
{
name: "direct target forbidden",
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "direct", Target: "warp"}}},
kernel: "singbox",
wantErr: "custom_route_rules[0].action.target is only allowed when action.type is route",
},
{
name: "unsupported action",
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "dns"}}},
kernel: "singbox",
wantErr: `custom_route_rules[0].action.type "dns" is not supported by kernel "singbox"`,
},
{
name: "invalid port range",
rules: []CustomRouteRule{{Match: RouteMatch{Ports: []string{"2000-1000"}}, Action: RouteAction{Type: "direct"}}},
kernel: "singbox",
wantErr: `custom_route_rules[0].match.ports contains invalid port range "2000-1000"`,
},
{
name: "invalid network",
rules: []CustomRouteRule{{Match: RouteMatch{Networks: []string{"icmp"}}, Action: RouteAction{Type: "direct"}}},
kernel: "singbox",
wantErr: `custom_route_rules[0].match.networks contains unsupported network "icmp"`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateCustomRouteRules(tc.rules, tc.kernel, available)
if tc.wantErr == "" && err != nil {
t.Fatalf("expected no error, got %v", err)
}
if tc.wantErr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("unexpected error: %v", err)
}
}
})
}
}
+276
View File
@@ -0,0 +1,276 @@
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,
})
}
customRouteRules := make([]CustomRouteRule, 0, len(nc.CustomRouteRules))
for _, rule := range nc.CustomRouteRules {
customRouteRules = append(customRouteRules, CustomRouteRule{
Name: rule.Name,
Disabled: rule.Disabled,
Match: RouteMatch{
Domains: cloneStringSlice(rule.Match.Domains),
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
Ports: cloneStringSlice(rule.Match.Ports),
Networks: cloneStringSlice(rule.Match.Networks),
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
},
Action: RouteAction{
Type: rule.Action.Type,
Target: rule.Action.Target,
},
})
}
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),
CustomRouteRules: customRouteRules,
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 NodeSpecFromPanelValidated(nc *panel.NodeConfig, kcfg config.KernelConfig) (*NodeSpec, error) {
spec := NodeSpecFromPanel(nc)
if err := ValidateNodeSpec(spec, kcfg); err != nil {
return nil, err
}
return spec, nil
}
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,
})
}
customRouteRules := make([]panel.CustomRouteRule, 0, len(n.CustomRouteRules))
for _, rule := range n.CustomRouteRules {
customRouteRules = append(customRouteRules, panel.CustomRouteRule{
Name: rule.Name,
Disabled: rule.Disabled,
Match: panel.RouteMatch{
Domains: cloneStringSlice(rule.Match.Domains),
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
Ports: cloneStringSlice(rule.Match.Ports),
Networks: cloneStringSlice(rule.Match.Networks),
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
},
Action: panel.RouteAction{
Type: rule.Action.Type,
Target: rule.Action.Target,
},
})
}
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),
CustomRouteRules: customRouteRules,
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
}
+84
View File
@@ -0,0 +1,84 @@
package model
import (
"strings"
"testing"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/panel"
)
func TestNodeSpecFromPanelValidated(t *testing.T) {
t.Run("valid custom outbounds and route targets", func(t *testing.T) {
node, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panel.OutboundConfig{
{Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"}},
{Tag: "proxy", Protocol: "socks", ProxyTag: "warp", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
},
CustomRouteRules: []panel.CustomRouteRule{{
Action: panel.RouteAction{Type: "route", Target: "proxy"},
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
}},
}, config.KernelConfig{Type: "singbox"})
if err != nil {
t.Fatalf("NodeSpecFromPanelValidated: %v", err)
}
if node == nil || len(node.CustomOutbounds) != 2 {
t.Fatalf("unexpected node spec: %#v", node)
}
})
t.Run("invalid custom outbounds", func(t *testing.T) {
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panel.OutboundConfig{{
Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
}},
}, config.KernelConfig{Type: "singbox"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("reject unsupported protocol for kernel", func(t *testing.T) {
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panel.OutboundConfig{{
Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443},
}},
}, config.KernelConfig{Type: "xray"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `protocol "hysteria2" is not supported by kernel "xray"`) {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("reject unknown route target", func(t *testing.T) {
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
Protocol: "shadowsocks",
ServerPort: 8388,
CustomOutbounds: []panel.OutboundConfig{{
Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"},
}},
CustomRouteRules: []panel.CustomRouteRule{{
Action: panel.RouteAction{Type: "route", Target: "missing"},
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
}},
}, config.KernelConfig{Type: "singbox"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), `custom_route_rules[0].action.target references unknown outbound "missing"`) {
t.Fatalf("unexpected error: %v", err)
}
})
}
+109
View File
@@ -0,0 +1,109 @@
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,
})
}
customRouteRules := make([]CustomRouteRule, 0, len(sc.Node.CustomRouteRules))
for _, rule := range sc.Node.CustomRouteRules {
customRouteRules = append(customRouteRules, CustomRouteRule{
Name: rule.Name,
Disabled: rule.Disabled,
Match: RouteMatch{
Domains: cloneStringSlice(rule.Match.Domains),
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
Ports: cloneStringSlice(rule.Match.Ports),
Networks: cloneStringSlice(rule.Match.Networks),
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
},
Action: RouteAction{Type: rule.Action.Type, Target: rule.Action.Target},
})
}
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,
CustomRouteRules: customRouteRules,
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 NodeSpecFromStandaloneValidated(cfg *config.Config) (*NodeSpec, error) {
spec := NodeSpecFromStandalone(cfg)
if err := ValidateNodeSpec(spec, cfg.Kernel); err != nil {
return nil, err
}
return spec, nil
}
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
}
+49
View File
@@ -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)
}
}
+133
View File
@@ -0,0 +1,133 @@
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
CustomRouteRules []CustomRouteRule
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
}
+100
View File
@@ -0,0 +1,100 @@
package model
import (
"fmt"
"strings"
"github.com/cedar2025/xboard-node/internal/config"
)
func ValidateNodeSpec(n *NodeSpec, kcfg config.KernelConfig) error {
if n == nil {
return nil
}
effectiveKernelType := strings.TrimSpace(kcfg.Type)
if effectiveKernelType == "" {
effectiveKernelType = strings.TrimSpace(n.KernelType)
}
kernelType, err := normalizeKernelType(effectiveKernelType)
if err != nil {
return fmt.Errorf("normalize kernel type: %w", err)
}
additionalOutboundSources, err := collectAdditionalOutboundTagSources(kcfg.CustomConfig, kcfg.CustomOutbound)
if err != nil {
return fmt.Errorf("collect additional outbound tags: %w", err)
}
if err := validateOutboundTagCollisions(n.CustomOutbounds, additionalOutboundSources); err != nil {
return fmt.Errorf("validate outbound tags: %w", err)
}
additionalTags := additionalTagNames(additionalOutboundSources)
availableTags := buildAvailableOutboundTags(n.CustomOutbounds, additionalTags)
if err := ValidateCustomOutboundsForKernel(n.CustomOutbounds, kernelType, additionalTags); err != nil {
return fmt.Errorf("validate custom outbounds: %w", err)
}
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
return fmt.Errorf("validate custom route rules: %w", err)
}
if err := validateTransportKernel(n.Network, kernelType); err != nil {
return err
}
return nil
}
// singboxUnsupportedTransports lists transport types that sing-box does not support.
var singboxUnsupportedTransports = map[string]bool{
"xhttp": true,
"splithttp": true,
}
func validateTransportKernel(network, kernelType string) error {
net := strings.ToLower(strings.TrimSpace(network))
if kernelType == "singbox" && singboxUnsupportedTransports[net] {
return fmt.Errorf("transport %q is not supported by sing-box kernel; use xray kernel instead", net)
}
return nil
}
// ResolveKernelForTransport returns the kernel type required by the given
// transport. If the configured kernel cannot handle the transport, it returns
// the kernel that can. Otherwise it returns configuredKernel unchanged.
// This is used in machine mode to auto-switch kernel per node.
func ResolveKernelForTransport(network, configuredKernel string) string {
net := strings.ToLower(strings.TrimSpace(network))
if configuredKernel == "singbox" && singboxUnsupportedTransports[net] {
return "xray"
}
return configuredKernel
}
func normalizeKernelType(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "singbox", "sing-box":
return "singbox", nil
case "xray":
return "xray", nil
default:
return "", fmt.Errorf("unsupported kernel type %q", value)
}
}
func buildAvailableOutboundTags(structured []OutboundConfig, rawTags []string) map[string]struct{} {
available := map[string]struct{}{
"direct": {},
"block": {},
}
for _, outbound := range structured {
tag := strings.ToLower(strings.TrimSpace(outbound.Tag))
if tag != "" {
available[tag] = struct{}{}
}
}
for _, tag := range rawTags {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag != "" {
available[tag] = struct{}{}
}
}
return available
}
+80 -2
View File
@@ -1,14 +1,17 @@
package monitor
import (
"log/slog"
"runtime"
"strings"
"sync"
"time"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
)
var startTime = time.Now()
@@ -18,6 +21,9 @@ func init() {
// always returns 0% because it has no prior sample. This throwaway call
// seeds the baseline so subsequent Collect() calls return real values.
cpu.Percent(500*time.Millisecond, false)
// Seed the network baseline so the first Collect() can compute rates.
collectNetSpeed()
}
// Status holds system resource metrics
@@ -36,11 +42,81 @@ type Status struct {
DiskUsed uint64
Goroutines int
// Network speed (bytes/sec), -1 means unavailable (first sample).
NetInSpeed float64
NetOutSpeed float64
// GC metrics (process-wide)
NumGC uint32
LastPauseMS float64
}
// netBaseline tracks the previous network counters for rate calculation.
var (
netMu sync.Mutex
netPrevRecv uint64
netPrevSent uint64
netPrevTime time.Time
netHasBase bool
)
// skipInterface returns true for loopback and common virtual interfaces.
func skipInterface(name string) bool {
lower := strings.ToLower(name)
for _, prefix := range []string{"lo", "docker", "veth", "br-", "virbr", "vnet", "tun", "tap"} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
// collectNetSpeed calculates network in/out bytes per second since last call.
// Returns -1, -1 on first call or if counters decreased (reboot).
func collectNetSpeed() (inSpeed, outSpeed float64) {
counters, err := net.IOCounters(true) // per-interface
if err != nil {
nlog.Core().Debug("failed to get network counters", "error", err)
return -1, -1
}
var totalRecv, totalSent uint64
for _, c := range counters {
if skipInterface(c.Name) {
continue
}
totalRecv += c.BytesRecv
totalSent += c.BytesSent
}
now := time.Now()
netMu.Lock()
defer netMu.Unlock()
if !netHasBase {
netPrevRecv, netPrevSent, netPrevTime, netHasBase = totalRecv, totalSent, now, true
return -1, -1
}
elapsed := now.Sub(netPrevTime).Seconds()
if elapsed <= 0 {
return -1, -1
}
// Counter decreased → system reboot or interface reset; reset baseline.
if totalRecv < netPrevRecv || totalSent < netPrevSent {
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
return -1, -1
}
inSpeed = float64(totalRecv-netPrevRecv) / elapsed
outSpeed = float64(totalSent-netPrevSent) / elapsed
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
return inSpeed, outSpeed
}
// Collect gathers current system metrics
func Collect() Status {
var s Status
@@ -50,7 +126,7 @@ func Collect() Status {
if cpuPercent, err := cpu.Percent(0, false); err == nil && len(cpuPercent) > 0 {
s.CPU = cpuPercent[0]
} else if err != nil {
slog.Debug("failed to get CPU usage", "error", err)
nlog.Core().Debug("failed to get CPU usage", "error", err)
}
// Per-core CPU usage (best-effort; safe if it fails).
@@ -79,6 +155,8 @@ func Collect() Status {
s.DiskUsed = diskStat.Used
}
s.NetInSpeed, s.NetOutSpeed = collectNetSpeed()
// GC metrics
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
+323
View File
@@ -0,0 +1,323 @@
package nlog
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"strings"
"sync"
"time"
)
// ANSI color codes
const (
ColorReset = "\033[0m"
ColorGray = "\033[90m" // 时间戳
ColorWhite = "\033[37m" // INFO
ColorYellow = "\033[33m" // WARN
ColorRed = "\033[31m" // ERROR
ColorCyan = "\033[36m" // 节点前缀
ColorGreen = "\033[32m" // 调试信息
ColorBlue = "\033[34m" // core 前缀
)
// NodeLog provides structured logging with node context.
// Format: LEVEL [protocol:port] message
type NodeLog struct {
prefix string // e.g., "shadowsocks:10005" or "trojan:10033"
}
// Global logger state
var (
mu sync.RWMutex
defaultNode string
nodeLoggers = make(map[string]*NodeLog)
logMu sync.RWMutex
logWriter io.Writer = os.Stdout
logMin = slog.LevelInfo
logColor = true
)
// Init configures process-wide log output (called from config.InitLogger).
// w must be non-nil. minLevel drops messages below that severity.
// color enables ANSI coloring when true (typically TTY + stdout/stderr).
func Init(w io.Writer, minLevel slog.Level, color bool) {
if w == nil {
w = os.Stdout
}
logMu.Lock()
defer logMu.Unlock()
logWriter = w
logMin = minLevel
logColor = color
}
// formatMsg appends alternating key-value pairs like slog: ("k", v, "k2", v2).
func formatMsg(msg string, args []any) string {
if len(args) == 0 {
return msg
}
var b strings.Builder
b.WriteString(msg)
for i := 0; i < len(args); i += 2 {
if i+1 < len(args) {
fmt.Fprintf(&b, " %v=%v", args[i], args[i+1])
} else {
fmt.Fprintf(&b, " %v", args[i])
}
}
return b.String()
}
// SetDefault sets the default node prefix for core-level logs.
func SetDefault(prefix string) {
mu.Lock()
defaultNode = prefix
mu.Unlock()
}
// ForNode returns a NodeLog for the given protocol and port.
func ForNode(protocol string, port int) *NodeLog {
key := fmt.Sprintf("%s:%d", normalizeProto(protocol), port)
mu.RLock()
if nl, ok := nodeLoggers[key]; ok {
mu.RUnlock()
return nl
}
mu.RUnlock()
mu.Lock()
defer mu.Unlock()
// Double check
if nl, ok := nodeLoggers[key]; ok {
return nl
}
nl := &NodeLog{prefix: key}
nodeLoggers[key] = nl
return nl
}
// Core returns a NodeLog for core/system messages.
func Core() *NodeLog {
mu.RLock()
if nl, ok := nodeLoggers["core"]; ok {
mu.RUnlock()
return nl
}
mu.RUnlock()
mu.Lock()
defer mu.Unlock()
if nl, ok := nodeLoggers["core"]; ok {
return nl
}
nl := &NodeLog{prefix: "core"}
nodeLoggers["core"] = nl
return nl
}
// normalizeProto returns the full protocol name for display.
func normalizeProto(p string) string {
switch strings.ToLower(p) {
case "shadowsocks", "ss":
return "shadowsocks"
case "vmess":
return "vmess"
case "vless":
return "vless"
case "trojan":
return "trojan"
case "hysteria2", "hysteria":
return "hysteria2"
case "tuic":
return "tuic"
case "anytls":
return "anytls"
case "naive":
return "naive"
case "http":
return "http"
case "socks":
return "socks"
default:
return p
}
}
// ─── Logging Methods ────────────────────────────────────────────────────────
func (nl *NodeLog) Debug(msg string, args ...any) {
logWithColor(slog.LevelDebug, nl.prefix, msg, args...)
}
func (nl *NodeLog) Info(msg string, args ...any) {
logWithColor(slog.LevelInfo, nl.prefix, msg, args...)
}
func (nl *NodeLog) Warn(msg string, args ...any) {
logWithColor(slog.LevelWarn, nl.prefix, msg, args...)
}
func (nl *NodeLog) Error(msg string, args ...any) {
logWithColor(slog.LevelError, nl.prefix, msg, args...)
}
// logWithColor writes one line to the configured writer (see Init).
// Format: HH:MM:SS.mmm LEVEL [prefix] message [key=value ...]
func logWithColor(level slog.Level, prefix, msg string, args ...any) {
logMu.RLock()
out := logWriter
min := logMin
color := logColor
logMu.RUnlock()
if level < min {
return
}
now := time.Now().Format("15:04:05.000")
fullMsg := formatMsg(msg, args)
var levelStr string
switch level {
case slog.LevelDebug:
levelStr = "DEBUG"
case slog.LevelInfo:
levelStr = "INFO "
case slog.LevelWarn:
levelStr = "WARN "
case slog.LevelError:
levelStr = "ERROR"
default:
levelStr = "?????"
}
if !color {
fmt.Fprintf(out, "%s %s [%s] %s\n", now, strings.TrimSpace(levelStr), prefix, fullMsg)
return
}
var levelColor string
switch level {
case slog.LevelDebug:
levelColor = ColorGreen
case slog.LevelInfo:
levelColor = ColorWhite
case slog.LevelWarn:
levelColor = ColorYellow
case slog.LevelError:
levelColor = ColorRed
default:
levelColor = ColorWhite
}
prefixColor := ColorCyan
if prefix == "core" {
prefixColor = ColorBlue
}
fmt.Fprintf(out, "%s%s%s %s%s%s %s[%s]%s %s\n",
ColorGray, now, ColorReset,
levelColor, levelStr, ColorReset,
prefixColor, prefix, ColorReset,
fullMsg,
)
}
// ─── Startup Summary ────────────────────────────────────────────────────────
// StartupSummary logs a condensed startup summary.
type StartupSummary struct {
mu sync.Mutex
nodes []nodeInfo
}
type nodeInfo struct {
Protocol string
Port int
Users int
}
func NewStartupSummary() *StartupSummary {
return &StartupSummary{}
}
func (s *StartupSummary) AddNode(protocol string, port, users int) {
s.mu.Lock()
s.nodes = append(s.nodes, nodeInfo{protocol, port, users})
s.mu.Unlock()
}
func (s *StartupSummary) Print() {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.nodes) == 0 {
Core().Warn("no nodes configured")
return
}
// Group by protocol
byProto := make(map[string][]nodeInfo)
for _, n := range s.nodes {
proto := normalizeProto(n.Protocol)
byProto[proto] = append(byProto[proto], n)
}
// Build summary line
parts := make([]string, 0, len(byProto))
for proto, nodes := range byProto {
ports := make([]string, 0, len(nodes))
for _, n := range nodes {
ports = append(ports, fmt.Sprintf("%d", n.Port))
}
parts = append(parts, fmt.Sprintf("%s:%s", proto, strings.Join(ports, ",")))
}
Core().Info(fmt.Sprintf("started %d nodes: %s", len(s.nodes), strings.Join(parts, " | ")))
}
// ─── Helpers ────────────────────────────────────────────────────────────────
// ConfigUpdated logs a config update event.
func ConfigUpdated(nl *NodeLog, users int) {
nl.Info(fmt.Sprintf("config updated, %d users", users))
}
// HotReload logs a hot-reload event (no restart).
func HotReload(nl *NodeLog, what string) {
nl.Debug(fmt.Sprintf("hot-reload: %s", what))
}
// FullRestart logs a full kernel restart.
func FullRestart(nl *NodeLog, reason string) {
nl.Info(fmt.Sprintf("kernel restart: %s", reason))
}
// ReportPushed logs a report push event.
func ReportPushed(users, online int) {
Core().Info(fmt.Sprintf("report pushed: %d users, %d online", users, online))
}
// TrackerStats logs tracker statistics.
func TrackerStats(conns, users int) {
Core().Debug(fmt.Sprintf("tracker: %d conns, %d users online", conns, users))
}
// Context-aware logging
type ctxKey struct{}
func WithNode(ctx context.Context, nl *NodeLog) context.Context {
return context.WithValue(ctx, ctxKey{}, nl)
}
func FromContext(ctx context.Context) *NodeLog {
if nl, ok := ctx.Value(ctxKey{}).(*NodeLog); ok {
return nl
}
return Core()
}
+126 -32
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"reflect"
@@ -16,6 +15,7 @@ import (
"time"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/go-viper/mapstructure/v2"
)
@@ -25,12 +25,15 @@ var (
onlineMapPool = sync.Pool{New: func() interface{} { return make(map[string]int) }}
)
// Client communicates with the Xboard panel API
// Client communicates with the Xboard panel API.
// When machineID > 0 the client uses machine-level authentication
// (machine_id + token) instead of the legacy (token + node_type) scheme.
type Client struct {
baseURL string
token string
nodeID int
nodeType string
machineID int
httpClient *http.Client
configETag string
@@ -40,13 +43,14 @@ type Client struct {
apiFailure atomic.Uint64
}
// NewClient creates a new panel API client
// NewClient creates a new panel API client.
func NewClient(cfg config.PanelConfig) *Client {
return &Client{
baseURL: strings.TrimRight(cfg.URL, "/"),
token: cfg.Token,
nodeID: cfg.NodeID,
nodeType: cfg.NodeType,
baseURL: strings.TrimRight(cfg.URL, "/"),
token: cfg.Token,
nodeID: cfg.NodeID,
nodeType: cfg.NodeType,
machineID: cfg.MachineID,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
@@ -58,6 +62,26 @@ func NewClient(cfg config.PanelConfig) *Client {
}
}
// ForNode returns a new client bound to a specific node_id, sharing the
// same base URL, auth and HTTP transport. ETags and metric counters start fresh.
func (c *Client) ForNode(nodeID int) *Client {
return &Client{
baseURL: c.baseURL,
token: c.token,
nodeID: nodeID,
nodeType: c.nodeType,
machineID: c.machineID,
httpClient: c.httpClient,
}
}
// ResetConfigETag clears the cached config ETag so the next GetConfig call
// returns a full response instead of 304. Used by machine mode after a
// pre-fetch to probe the transport type.
func (c *Client) ResetConfigETag() {
c.configETag = ""
}
// Handshake calls the new v2 API to get WS config + initial data in one shot.
func (c *Client) Handshake() (*HandshakeResponse, error) {
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
@@ -174,9 +198,26 @@ func decodeWeakRaw(input map[string]interface{}, output interface{}) error {
return decoder.Decode(input)
}
// configPath returns the API path for config fetching.
// Machine mode uses the v2 endpoint; legacy mode uses the v1 UniProxy endpoint.
func (c *Client) configPath() string {
if c.machineID > 0 {
return "/api/v2/server/config"
}
return "/api/v1/server/UniProxy/config"
}
// userPath returns the API path for user fetching.
func (c *Client) userPath() string {
if c.machineID > 0 {
return "/api/v2/server/user"
}
return "/api/v1/server/UniProxy/user"
}
// GetConfig fetches node configuration. Returns nil if not modified (304).
func (c *Client) GetConfig() (*NodeConfig, error) {
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/config", nil, c.configETag)
resp, err := c.doRequest("GET", c.configPath(), nil, c.configETag)
if err != nil {
return nil, fmt.Errorf("get config: %w", err)
}
@@ -214,7 +255,7 @@ func (c *Client) GetConfig() (*NodeConfig, error) {
// GetUsers fetches available users. Returns nil if not modified (304).
func (c *Client) GetUsers() ([]User, error) {
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/user", nil, c.userETag)
resp, err := c.doRequest("GET", c.userPath(), nil, c.userETag)
if err != nil {
return nil, fmt.Errorf("get users: %w", err)
}
@@ -280,14 +321,78 @@ func (c *Client) ResetETags() {
c.userETag = ""
}
// postJSON marshals a map payload (with auth fields injected) and POSTs it.
// Auth fields are injected before marshalling — single marshal pass.
func (c *Client) postJSON(path string, payload map[string]interface{}) error {
payload["token"] = c.token
payload["node_id"] = c.nodeID
if c.nodeType != "" {
payload["node_type"] = c.nodeType
// GetMachineNodes fetches the list of active nodes bound to this machine.
func (c *Client) GetMachineNodes() (*MachineNodesResponse, error) {
resp, err := c.doRequest("POST", "/api/v2/server/machine/nodes", nil, "")
if err != nil {
return nil, fmt.Errorf("get machine nodes: %w", err)
}
defer drainAndClose(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("machine nodes status %d: %s", resp.StatusCode, body)
}
var out MachineNodesResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode machine nodes: %w", err)
}
return &out, nil
}
// ReportMachineStatus sends machine-level load metrics to the panel.
// netIn/netOut are bytes/sec; negative values mean "unavailable" (first sample).
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64, netIn, netOut float64) error {
payload := map[string]interface{}{
"cpu": cpu,
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
}
if netIn >= 0 && netOut >= 0 {
payload["net"] = map[string]interface{}{"in_speed": netIn, "out_speed": netOut}
}
return c.postJSON("/api/v2/server/machine/status", payload)
}
// injectAuth writes authentication fields into a payload map.
func (c *Client) injectAuth(m map[string]interface{}) {
m["token"] = c.token
if c.machineID > 0 {
m["machine_id"] = c.machineID
if c.nodeID > 0 {
m["node_id"] = c.nodeID
}
} else {
m["node_id"] = c.nodeID
if c.nodeType != "" {
m["node_type"] = c.nodeType
}
}
}
// authQuery builds URL query parameters for GET requests.
func (c *Client) authQuery() url.Values {
q := url.Values{}
q.Set("token", c.token)
if c.machineID > 0 {
q.Set("machine_id", strconv.Itoa(c.machineID))
if c.nodeID > 0 {
q.Set("node_id", strconv.Itoa(c.nodeID))
}
} else {
q.Set("node_id", strconv.Itoa(c.nodeID))
if c.nodeType != "" {
q.Set("node_type", c.nodeType)
}
}
return q
}
// postJSON marshals a map payload (with auth fields injected) and POSTs it.
func (c *Client) postJSON(path string, payload map[string]interface{}) error {
c.injectAuth(payload)
body, err := json.Marshal(payload)
if err != nil {
@@ -313,23 +418,12 @@ func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string)
var bodyReader io.Reader
if method == "GET" {
q := url.Values{}
q.Set("token", c.token)
q.Set("node_id", strconv.Itoa(c.nodeID))
if c.nodeType != "" {
q.Set("node_type", c.nodeType)
}
fullURL += "?" + q.Encode()
fullURL += "?" + c.authQuery().Encode()
} else if body != nil {
bodyReader = bytes.NewReader(body)
} else {
authOnly := map[string]interface{}{
"token": c.token,
"node_id": c.nodeID,
}
if c.nodeType != "" {
authOnly["node_type"] = c.nodeType
}
authOnly := make(map[string]interface{})
c.injectAuth(authOnly)
merged, _ := json.Marshal(authOnly)
bodyReader = bytes.NewReader(merged)
}
@@ -345,7 +439,7 @@ func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string)
req.Header.Set("If-None-Match", ifNoneMatch)
}
slog.Debug("panel request", "method", method, "path", path)
nlog.Core().Debug("panel request", "method", method, "path", path)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -359,7 +453,7 @@ func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string)
c.apiFailure.Add(1)
}
slog.Debug("panel response", "method", method, "path", path, "status", resp.StatusCode)
nlog.Core().Debug("panel response", "method", method, "path", path, "status", resp.StatusCode)
return resp, nil
}
+86 -7
View File
@@ -62,8 +62,29 @@ type Settings struct {
PullInterval int `json:"pull_interval"`
}
// MachineNode is a single entry returned by GET /api/v2/server/machine/nodes.
type MachineNode struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
}
// MachineNodesResponse is the response from GET /api/v2/server/machine/nodes.
type MachineNodesResponse struct {
Nodes []MachineNode `json:"nodes"`
BaseConfig MachineBaseConfig `json:"base_config"`
}
// MachineBaseConfig holds polling intervals for machine mode.
type MachineBaseConfig struct {
PushInterval int `json:"push_interval"`
PullInterval int `json:"pull_interval"`
}
// NodeConfig is the response from GET /api/v1/server/UniProxy/config
type NodeConfig struct {
// NodeID is populated in machine-mode WS events for routing.
NodeID int `json:"node_id,omitempty"`
Protocol string `json:"protocol"`
ListenIP string `json:"listen_ip"`
ServerPort int `json:"server_port"`
@@ -73,10 +94,11 @@ type NodeConfig struct {
Routes []RouteRule `json:"routes"`
// Kernel settings (Xboard extension)
KernelType string `json:"kernel_type,omitempty"` // "singbox" or "xray"
KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc.
CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"`
CustomRoutes []map[string]any `json:"custom_routes,omitempty"`
KernelType string `json:"kernel_type,omitempty"` // "singbox" or "xray"
KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc.
CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"`
CustomRoutes []map[string]any `json:"custom_routes,omitempty"`
CustomRouteRules []CustomRouteRule `json:"custom_route_rules,omitempty"`
// Certificate settings (Xboard extension)
CertConfig *CertConfig `json:"cert_config,omitempty"`
@@ -92,6 +114,7 @@ type NodeConfig struct {
// VMess / VLESS
TLS int `json:"tls,omitempty"`
Flow string `json:"flow,omitempty"`
Decryption string `json:"decryption,omitempty"`
TLSSettings map[string]interface{} `json:"tls_settings,omitempty"`
// Trojan
@@ -118,10 +141,26 @@ type NodeConfig struct {
// Multiplex
Multiplex *MultiplexConfig `json:"multiplex,omitempty"`
// Proxy Protocol
// Proxy Protocol (supports both top-level and networkSettings for compatibility)
AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"`
}
// GetProxyProtocol returns true if AcceptProxyProtocol is set either at node level
// or in networkSettings (for panel compatibility).
func (nc *NodeConfig) GetProxyProtocol() bool {
if nc.AcceptProxyProtocol {
return true
}
if nc.NetworkSettings != nil {
if v, ok := nc.NetworkSettings["acceptProxyProtocol"]; ok {
if b, ok := v.(bool); ok {
return b
}
}
}
return false
}
type MultiplexConfig struct {
Enabled bool `json:"enabled"`
Protocol string `json:"protocol"`
@@ -138,9 +177,11 @@ type BrutalConfig struct {
DownMbps int `json:"down_mbps"`
}
// CertConfig holds certificate automation settings from the panel
// CertConfig holds certificate automation settings from the panel.
// The panel may send the mode field as either "cert_mode" or "mode";
// a custom UnmarshalJSON handles both.
type CertConfig struct {
CertMode string `json:"cert_mode"` // none, dns, http, self, file
CertMode string `json:"cert_mode"` // none, dns, http, self, file, content
Domain string `json:"domain"` // Certificate domain
Email string `json:"email"` // ACME email
DNSProvider string `json:"dns_provider"` // dns mode: cloudflare, alidns, etc.
@@ -152,6 +193,22 @@ type CertConfig struct {
KeyContent string `json:"key_content"` // content mode: private key raw string
}
func (c *CertConfig) UnmarshalJSON(data []byte) error {
type plain CertConfig
var raw struct {
plain
ModeFallback string `json:"mode"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*c = CertConfig(raw.plain)
if c.CertMode == "" && raw.ModeFallback != "" {
c.CertMode = raw.ModeFallback
}
return nil
}
// OutboundConfig defines a custom outbound for kernel
type OutboundConfig struct {
Tag string `json:"tag"` // Unique tag for routing
@@ -172,6 +229,28 @@ type RouteRule struct {
ActionValue string `json:"action_value,omitempty"`
}
type CustomRouteRule struct {
Name string `json:"name,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Match RouteMatch `json:"match,omitempty"`
Action RouteAction `json:"action"`
}
type RouteMatch struct {
Domains []string `json:"domains,omitempty"`
DomainSuffixes []string `json:"domain_suffixes,omitempty"`
IPCIDRs []string `json:"ip_cidrs,omitempty"`
Ports []string `json:"ports,omitempty"`
Networks []string `json:"networks,omitempty"`
SourceCIDRs []string `json:"source_cidrs,omitempty"`
SourcePorts []string `json:"source_ports,omitempty"`
}
type RouteAction struct {
Type string `json:"type"`
Target string `json:"target,omitempty"`
}
// User represents a user returned by the panel
type User struct {
ID int `json:"id"`
+226 -51
View File
@@ -4,13 +4,13 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math/rand"
"net/url"
"strconv"
"sync/atomic"
"time"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/gorilla/websocket"
)
@@ -19,6 +19,9 @@ const (
WSEventSyncConfig = "sync.config"
WSEventSyncUsers = "sync.users"
WSEventSyncUserDelta = "sync.user.delta"
WSEventSyncDevices = "sync.devices" // panel → node: global device state
WSEventSyncNodes = "sync.nodes" // panel → machine: node list changed
WSEventReportDevices = "report.devices" // node → panel: report device snapshot
)
// WSEvent is a parsed data event delivered to the service layer.
@@ -28,6 +31,13 @@ type WSEvent struct {
Users []User
DeltaAction string // "add" or "remove" (only for sync.user.delta)
DeltaUsers []User // users affected by the delta
// Device sync fields
DeviceUsers map[int][]string // userID -> IPs (for sync.devices)
NodeID int
// Machine node discovery fields (for sync.nodes)
Nodes []MachineNode
}
// WSStatusChange notifies the service when WS connectivity changes.
@@ -42,21 +52,45 @@ type wsMessage struct {
Timestamp int64 `json:"timestamp,omitempty"`
}
// Payload structures for data events
// Payload structures for data events.
type syncConfigPayload struct {
Config NodeConfig `json:"config"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
type syncUsersPayload struct {
Users []User `json:"users"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
type syncUserDeltaPayload struct {
Action string `json:"action"`
Users []User `json:"users"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
// syncDevicesPayload carries global device state from panel.
type syncDevicesPayload struct {
Users map[int][]string `json:"users"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
// syncNodesPayload carries the updated node list for a machine.
type syncNodesPayload struct {
Nodes []MachineNode `json:"nodes"`
}
// WSClientConfig holds WebSocket client tuning options.
type WSClientConfig struct {
StatusInterval time.Duration
HandshakeTimeout time.Duration
BackoffInitial time.Duration
BackoffMax time.Duration
MachineID int
}
// WSClient connects to the panel's Workerman WS server using native WebSocket.
@@ -70,17 +104,37 @@ type WSClient struct {
onStatus func(WSStatusChange)
onPing func() map[string]interface{}
cfg WSClientConfig
connected atomic.Bool
// writeCh allows sending messages from outside the connect loop.
// It is set in connect() and cleared on disconnect.
writeCh chan wsMessage
}
// NewWSClient creates a new WebSocket client.
// wsURL is the base WebSocket URL (e.g. "ws://panel.example.com:8076").
// token and nodeID are used for authentication via query parameters.
func NewWSClient(wsURL string, token string, nodeID int, onEvent func(WSEvent), onStatus func(WSStatusChange), onPing func() map[string]interface{}) *WSClient {
func NewWSClient(wsURL string, token string, nodeID int, cfg WSClientConfig, onEvent func(WSEvent), onStatus func(WSStatusChange), onPing func() map[string]interface{}) *WSClient {
// Apply defaults
if cfg.StatusInterval == 0 {
cfg.StatusInterval = 10 * time.Second
}
if cfg.HandshakeTimeout == 0 {
cfg.HandshakeTimeout = 15 * time.Second
}
if cfg.BackoffInitial == 0 {
cfg.BackoffInitial = time.Second
}
if cfg.BackoffMax == 0 {
cfg.BackoffMax = 60 * time.Second
}
return &WSClient{
wsURL: wsURL,
token: token,
nodeID: nodeID,
cfg: cfg,
onEvent: onEvent,
onStatus: onStatus,
onPing: onPing,
@@ -97,7 +151,7 @@ func (w *WSClient) notifyStatus(connected bool) {
// Run connects and reconnects until ctx is cancelled.
func (w *WSClient) Run(ctx context.Context) {
backoff := time.Second
backoff := w.cfg.BackoffInitial
for {
start := time.Now()
err := w.connect(ctx)
@@ -108,7 +162,7 @@ func (w *WSClient) Run(ctx context.Context) {
}
if err != nil {
slog.Warn("ws disconnected", "error", err)
nlog.Core().Warn("ws disconnected", "error", err)
if !wasConnected {
w.notifyStatus(false)
}
@@ -122,7 +176,7 @@ func (w *WSClient) Run(ctx context.Context) {
// Reset backoff if the connection was up for a meaningful duration.
if time.Since(start) > 2*time.Minute {
backoff = time.Second
backoff = w.cfg.BackoffInitial
}
// Apply exponential backoff with jitter to prevent thundering herd.
@@ -136,27 +190,30 @@ func (w *WSClient) Run(ctx context.Context) {
return
case <-timer.C:
}
if backoff < 60*time.Second {
backoff = min(backoff*2, 60*time.Second)
if backoff < w.cfg.BackoffMax {
backoff = min(backoff*2, w.cfg.BackoffMax)
}
}
}
func (w *WSClient) connect(ctx context.Context) error {
// Build WS URL with auth query params
u, err := url.Parse(w.wsURL)
if err != nil {
return fmt.Errorf("parse ws url: %w", err)
}
q := u.Query()
q.Set("token", w.token)
q.Set("node_id", strconv.Itoa(w.nodeID))
if w.cfg.MachineID > 0 {
q.Set("machine_id", strconv.Itoa(w.cfg.MachineID))
} else {
q.Set("node_id", strconv.Itoa(w.nodeID))
}
u.RawQuery = q.Encode()
slog.Debug("ws connecting", "url", u.Redacted())
nlog.Core().Debug("ws connecting", "url", u.String())
dialer := websocket.Dialer{
HandshakeTimeout: 15 * time.Second,
HandshakeTimeout: w.cfg.HandshakeTimeout,
}
conn, _, err := dialer.DialContext(ctx, u.String(), nil)
if err != nil {
@@ -171,12 +228,15 @@ func (w *WSClient) connect(ctx context.Context) error {
if err := conn.ReadJSON(&firstMsg); err != nil {
return fmt.Errorf("read auth response: %w", err)
}
nlog.Core().Debug("ws recv", "event", firstMsg.Event, "data", string(firstMsg.Data))
if firstMsg.Event == "error" {
var errData struct {
Message string `json:"message"`
}
json.Unmarshal(firstMsg.Data, &errData)
if err := json.Unmarshal(firstMsg.Data, &errData); err != nil {
return fmt.Errorf("auth failed (unable to parse error: %v)", err)
}
return fmt.Errorf("auth failed: %s", errData.Message)
}
@@ -185,19 +245,22 @@ func (w *WSClient) connect(ctx context.Context) error {
// Process it and continue
w.connected.Store(true)
w.notifyStatus(true)
slog.Info("ws connected (implicit auth)")
w.handleMessage(firstMsg)
} else {
w.connected.Store(true)
w.notifyStatus(true)
slog.Info("ws connected and authenticated")
}
// Ping interval: send pong responses to server pings.
// We also use this timer to trigger periodic status pushes (10s interval).
const reportInterval = 10 * time.Second
// We also use this timer to trigger periodic status pushes.
reportTicker := time.NewTicker(w.cfg.StatusInterval)
defer reportTicker.Stop()
// writeCh decouples data collection from network I/O.
writeCh := make(chan wsMessage, 16)
w.writeCh = writeCh
defer func() { w.writeCh = nil }()
msgCh := make(chan wsMessage, 16)
errCh := make(chan error, 1)
done := make(chan struct{})
go func() {
@@ -211,20 +274,18 @@ func (w *WSClient) connect(ctx context.Context) error {
}
return
}
select {
case msgCh <- msg:
case <-ctx.Done():
return
nlog.Core().Debug("ws recv", "event", msg.Event, "data", string(msg.Data))
w.handleMessage(msg)
if msg.Event == "ping" {
select {
case writeCh <- wsMessage{Event: "pong"}:
default:
nlog.Core().Warn("ws write channel full, skipping pong")
}
}
}
}()
reportTicker := time.NewTicker(reportInterval)
defer reportTicker.Stop()
// writeCh decouples data collection from network I/O.
writeCh := make(chan wsMessage, 8)
for {
select {
case <-ctx.Done():
@@ -237,17 +298,6 @@ func (w *WSClient) connect(ctx context.Context) error {
case err := <-errCh:
return fmt.Errorf("read: %w", err)
case msg := <-msgCh:
w.handleMessage(msg)
if msg.Event == "ping" {
// Queue an immediate pong
select {
case writeCh <- wsMessage{Event: "pong"}:
default:
slog.Warn("ws write channel full, skipping pong")
}
}
case <-reportTicker.C:
// Send periodic node.status via WebSocket
if w.onPing != nil {
@@ -260,12 +310,13 @@ func (w *WSClient) connect(ctx context.Context) error {
select {
case writeCh <- msg:
default:
slog.Warn("ws write channel full, skipping status push (network slow?)")
nlog.Core().Warn("ws write channel full, skipping status push (network slow?)")
}
}
case msg := <-writeCh:
// Perform the actual network write asynchronously in this loop.
nlog.Core().Debug("ws send", "event", msg.Event, "data", string(msg.Data))
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteJSON(msg); err != nil {
return fmt.Errorf("write: %w", err)
@@ -278,10 +329,10 @@ func (w *WSClient) handleMessage(msg wsMessage) {
switch msg.Event {
case "ping":
// Server ping — handled by the pong timer reset above
slog.Debug("ws received ping")
nlog.Core().Debug("ws received ping")
case "auth.success":
slog.Debug("ws auth confirmed")
nlog.Core().Debug("ws auth confirmed")
case WSEventSyncConfig:
w.handleDataEvent(msg)
@@ -292,8 +343,14 @@ func (w *WSClient) handleMessage(msg wsMessage) {
case WSEventSyncUserDelta:
w.handleDataEvent(msg)
case WSEventSyncDevices:
w.handleDataEvent(msg)
case WSEventSyncNodes:
w.handleDataEvent(msg)
default:
slog.Debug("ws unknown event", "event", msg.Event)
nlog.Core().Debug("ws unknown event", "event", msg.Event)
}
}
@@ -312,33 +369,151 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
switch msg.Event {
case WSEventSyncConfig:
slog.Debug("ws sync config event received")
nlog.Core().Debug("ws sync config event received")
var p syncConfigPayload
if err := decodeData(msg.Data, &p); err != nil && p.Config.Protocol != "" {
slog.Warn("ws: cannot decode config payload", "error", err)
if err := decodeData(msg.Data, &p); err != nil {
nlog.Core().Warn("ws: cannot decode config payload", "error", err)
return
}
if p.Config.Protocol == "" {
nlog.Core().Warn("ws: config payload missing protocol")
return
}
event.Config = &p.Config
if p.NodeID > 0 {
event.NodeID = p.NodeID
} else if p.Config.NodeID > 0 {
event.NodeID = p.Config.NodeID
}
case WSEventSyncUsers:
slog.Debug("ws sync users event received")
nlog.Core().Debug("ws sync users event received")
var p syncUsersPayload
if err := decodeData(msg.Data, &p); err != nil && len(p.Users) > 0 {
slog.Warn("ws: cannot decode users payload", "error", err)
if err := decodeData(msg.Data, &p); err != nil {
nlog.Core().Warn("ws: cannot decode users payload", "error", err)
return
}
if len(p.Users) == 0 {
nlog.Core().Warn("ws: users payload empty")
return
}
event.Users = p.Users
event.NodeID = p.NodeID
case WSEventSyncUserDelta:
slog.Debug("ws sync user delta event received")
nlog.Core().Debug("ws sync user delta event received")
var p syncUserDeltaPayload
if err := decodeData(msg.Data, &p); err != nil {
slog.Warn("ws: cannot decode user delta payload", "error", err)
nlog.Core().Warn("ws: cannot decode user delta payload", "error", err)
return
}
if p.Action == "" {
nlog.Core().Warn("ws: user delta payload missing action")
return
}
if len(p.Users) == 0 {
nlog.Core().Warn("ws: user delta payload has no users")
return
}
event.DeltaAction = p.Action
event.DeltaUsers = p.Users
event.NodeID = p.NodeID
case WSEventSyncDevices:
nlog.Core().Debug("ws sync devices event received")
var p syncDevicesPayload
if err := decodeData(msg.Data, &p); err != nil {
nlog.Core().Warn("ws: cannot decode devices payload", "error", err)
return
}
event.DeviceUsers = p.Users
event.NodeID = p.NodeID
case WSEventSyncNodes:
nlog.Core().Info("ws sync nodes event received (machine node list changed)")
var p syncNodesPayload
if err := decodeData(msg.Data, &p); err != nil {
nlog.Core().Warn("ws: cannot decode nodes payload", "error", err)
return
}
event.Nodes = p.Nodes
}
w.onEvent(event)
}
// SendDeviceReport sends local device snapshot to panel via WS.
func (w *WSClient) SendDeviceReport(devices map[int][]string) {
w.SendDeviceReportForNode(0, devices)
}
// SendDeviceReportForNode sends a device report tagged with a specific node_id.
// nodeID == 0 omits the field (legacy single-node mode).
func (w *WSClient) SendDeviceReportForNode(nodeID int, devices map[int][]string) {
if !w.connected.Load() {
return
}
var payload interface{}
if nodeID > 0 {
payload = map[string]interface{}{"node_id": nodeID, "devices": devices}
} else {
payload = devices
}
d, err := json.Marshal(payload)
if err != nil {
return
}
msg := wsMessage{
Event: WSEventReportDevices,
Data: d,
Timestamp: time.Now().Unix(),
}
select {
case w.writeCh <- msg:
default:
nlog.Core().Warn("ws write channel full, skipping device report")
}
}
// SendNodeStatus sends a node.status event with the given node_id (machine mode).
func (w *WSClient) SendNodeStatus(nodeID int, stats map[string]interface{}) {
if !w.connected.Load() || stats == nil {
return
}
if nodeID > 0 {
stats["node_id"] = nodeID
}
data, _ := json.Marshal(stats)
msg := wsMessage{
Event: "node.status",
Data: data,
Timestamp: time.Now().Unix(),
}
select {
case w.writeCh <- msg:
default:
nlog.Core().Warn("ws write channel full, skipping node status")
}
}
// SendRaw sends a raw message to the panel via WebSocket.
func (w *WSClient) SendRaw(event string, data json.RawMessage) {
if !w.connected.Load() {
return
}
msg := wsMessage{
Event: event,
Data: data,
Timestamp: time.Now().Unix(),
}
select {
case w.writeCh <- msg:
default:
nlog.Core().Warn("ws write channel full, skipping raw message", "event", event)
}
}
+4 -4
View File
@@ -86,7 +86,7 @@ func TestWSClient_ConnectAndReceiveDataEvents(t *testing.T) {
var mu sync.Mutex
var received []WSEvent
ws := NewWSClient("ws://"+host, "test-token", 1, func(event WSEvent) {
ws := NewWSClient("ws://"+host, "test-token", 1, WSClientConfig{}, func(event WSEvent) {
mu.Lock()
received = append(received, event)
mu.Unlock()
@@ -187,7 +187,7 @@ func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
ws := NewWSClient("ws://"+host, "reconnect-token", 1, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
ws := NewWSClient("ws://"+host, "reconnect-token", 1, WSClientConfig{}, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -218,7 +218,7 @@ func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
}
func TestWSClient_FallbackWhenNoServer(t *testing.T) {
ws := NewWSClient("ws://127.0.0.1:19999", "fallback-token", 1, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
ws := NewWSClient("ws://127.0.0.1:19999", "fallback-token", 1, WSClientConfig{}, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@@ -250,7 +250,7 @@ func TestWSClient_UserDeltaEvent(t *testing.T) {
var mu sync.Mutex
var received []WSEvent
ws := NewWSClient("ws://"+host, "delta-token", 1, func(event WSEvent) {
ws := NewWSClient("ws://"+host, "delta-token", 1, WSClientConfig{}, func(event WSEvent) {
mu.Lock()
received = append(received, event)
mu.Unlock()
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
package service
import (
"context"
"errors"
"strings"
"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, tls kernel.TLSCert) error {
_, _, _ = nodeConfig, users, tls
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, tls kernel.TLSCert) error {
_, _, _ = nodeConfig, users, tls
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 {
sharedLimiter := limiter.New()
s := &Service{
kernel: k,
limiter: sharedLimiter,
speedTracker: limiter.NewSpeedTracker(sharedLimiter),
cert: cert.NewManager(config.CertConfig{}),
}
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 TestValidateNodeRuntimeRejectsUnsupportedDNSProvider(t *testing.T) {
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
err := validateNodeRuntime(cfg, []string{"http"}, &model.NodeSpec{
Protocol: "http",
CertConfig: &config.CertConfig{
CertMode: "dns",
DNSProvider: "3123123",
Domain: "example.com",
},
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() == "" {
t.Fatal("expected non-empty error")
}
if got := err.Error(); !strings.HasPrefix(got, `unsupported cert_config.dns_provider "3123123" (supported: `) {
t.Fatalf("unexpected error: %v", got)
}
}
func TestValidateNodeRuntimeAllowsSelfManagedTLSBeforeFilesExist(t *testing.T) {
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
err := validateNodeRuntime(cfg, []string{"anytls", "hysteria"}, &model.NodeSpec{
Protocol: "anytls",
CertConfig: &config.CertConfig{
CertMode: "self",
Domain: "example.com",
},
}, kernel.TLSCert{})
if err != nil {
t.Fatalf("expected self-managed TLS config to pass validation, got %v", err)
}
}
func TestValidateNodeRuntimeAllowsSingboxRealityWithRequiredFields(t *testing.T) {
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
Protocol: "vless",
TLS: 2,
TLSSettings: map[string]any{
"private_key": "test-key",
"server_name": "example.com",
},
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
if err != nil {
t.Fatalf("expected sing-box reality validation to pass, got %v", err)
}
}
func TestValidateNodeRuntimeRejectsRealityWithoutTLSSettings(t *testing.T) {
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
Protocol: "vless",
TLS: 2,
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
if err == nil {
t.Fatal("expected error, got nil")
}
if got := err.Error(); got != "reality tls requires tls_settings" {
t.Fatalf("unexpected error: %v", got)
}
}
func TestValidateNodeRuntimeRejectsRealityWithoutPrivateKey(t *testing.T) {
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
Protocol: "vless",
TLS: 2,
TLSSettings: map[string]any{
"server_name": "example.com",
},
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
if err == nil {
t.Fatal("expected error, got nil")
}
if got := err.Error(); got != "reality tls requires tls_settings.private_key" {
t.Fatalf("unexpected error: %v", got)
}
}
func TestValidateNodeRuntimeRejectsRealityWithoutServerNameOrDest(t *testing.T) {
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
Protocol: "vless",
TLS: 2,
TLSSettings: map[string]any{
"private_key": "test-key",
},
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
if err == nil {
t.Fatal("expected error, got nil")
}
if got := err.Error(); got != "reality tls requires tls_settings.server_name or tls_settings.dest" {
t.Fatalf("unexpected error: %v", got)
}
}
+215 -181
View File
@@ -1,275 +1,309 @@
package tracker
import (
"log/slog"
"crypto/sha256"
"encoding/hex"
"sort"
"sync"
"sync/atomic"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/nlog"
)
const (
// DefaultMaxConnState caps how many connection-state entries we maintain.
// Prevents unbounded memory growth from rogue / DDoS connection floods.
DefaultMaxConnState = 100_000
// snapshot is an immutable point-in-time view of tracker state.
// It is swapped atomically so readers never block writers.
type snapshot struct {
traffic map[int][2]int64 // userID → [upload, download] delta
aliveIPs map[int]map[string]bool // userID → set of source IPs
online map[int]int // userID → distinct IP count
connCount int
inSpeed int64
outSpeed int64
}
// DefaultMaxAliveIPsPerUser caps the per-user IP set tracked per push interval.
// 200 IPs per user is generous for any legitimate use case.
DefaultMaxAliveIPsPerUser = 200
)
// Tracker tracks per-connection traffic deltas and accumulates per-user totals.
// Tracker computes per-user traffic deltas from cumulative counters
// provided by the kernel, and accumulates totals for panel reporting.
//
// Thread safety model: Tracker is designed for SINGLE-GOROUTINE use only.
// All methods (Process, Flush*, Restore*, CurrentOnline, speed accessors) are
// called exclusively from the service goroutine's select loop. No internal
// locking is provided. Do NOT call Tracker methods from multiple goroutines.
// Architecture: the kernel maintains per-user atomic counters and IP sets.
// Each tick, the service calls Process() with cumulative per-user traffic.
// Tracker computes deltas against the previous cycle's values — O(users),
// not O(connections).
//
// Hot-path optimisation (②): activeConns is allocated once and cleared in-place
// every Process() call instead of being reallocated, saving GC pressure at high
// connection counts.
//
// OOM protection (④): connState and aliveIPs are capped at configurable maximums.
// Thread safety: Process() acquires mu to update internal state, then
// atomically publishes a new snapshot. All read methods (Flush*,
// CurrentOnline, LogStats, *Speed) read the snapshot lock-free.
// This eliminates contention between the 10s Process tick and the 60s
// flush/push tick.
type Tracker struct {
connState map[string][2]int64 // conn_id → last [upload, download]
traffic map[int][2]int64 // user_id → accumulated [upload, download]
aliveIPs map[int]map[string]bool // user_id → set of source IPs
online map[int]int // user_id → current active connection count
mu sync.Mutex
// ② Reused buffer: cleared each Process() call, not reallocated.
activeConns map[string]struct{}
// lastSeen stores the cumulative traffic from the previous Process() call.
// Protected by mu — only written by Process.
lastSeen map[int][2]int64 // userID → [upload, download] cumulative
// ④ Limits (set at construction, never mutated).
maxConnState int
maxAliveIPsPerUser int
// pending accumulates deltas between flushes.
// Protected by mu — written by Process, drained by FlushTraffic.
pendingTraffic map[int][2]int64
// lastActiveConns stores the last observed number of active connections.
// This is updated on each Process() call and read by the service when
// building node metrics for panel reporting.
lastActiveConns int
totalConns int64 // cumulative number of connections seen since startup
// live holds the current snapshot, swapped atomically.
// Readers load this pointer without any lock.
live atomic.Pointer[snapshot]
// lastUserCount is used to pre-size intervalTraffic each cycle, avoiding
// repeated map rehashing under heavy user counts.
lastUserCount int
// aliveIPsBuf is a reusable buffer for FlushAliveIPs output.
// Avoids allocating a new map+slice every 60s.
aliveIPsBuf map[int][]string
inSpeed int64
outSpeed int64
// lastAliveIPsHash detects changes to avoid duplicate reports.
lastAliveIPsHash string
}
func New() *Tracker {
return NewWithLimits(DefaultMaxConnState, DefaultMaxAliveIPsPerUser)
t := &Tracker{
lastSeen: make(map[int][2]int64),
pendingTraffic: make(map[int][2]int64),
aliveIPsBuf: make(map[int][]string),
}
// Publish initial empty snapshot.
t.live.Store(&snapshot{
traffic: make(map[int][2]int64),
aliveIPs: make(map[int]map[string]bool),
online: make(map[int]int),
})
return t
}
func NewWithLimits(maxConnState, maxAliveIPsPerUser int) *Tracker {
return &Tracker{
connState: make(map[string][2]int64),
traffic: make(map[int][2]int64),
aliveIPs: make(map[int]map[string]bool),
online: make(map[int]int),
activeConns: make(map[string]struct{}),
maxConnState: maxConnState,
maxAliveIPsPerUser: maxAliveIPsPerUser,
}
}
// Process computes per-user traffic deltas from cumulative kernel counters.
// Also stores alive IPs and connection count. O(users).
//
// This is the only writer to lastSeen and pendingTraffic.
// After computing deltas, it publishes a new snapshot for lock-free reads.
func (t *Tracker) Process(
cumTraffic map[int][2]int64,
kernelAliveIPs map[int]map[string]bool,
connCount int,
) {
t.mu.Lock()
defer t.mu.Unlock()
// Process calculates per-user traffic deltas for this interval.
// Internally accumulates totals for panel push and tracks alive IPs.
// Returns per-user interval traffic [upload, download] for the limiter.
func (t *Tracker) Process(conns []kernel.Connection) map[int][2]int64 {
// Pre-size using last cycle's user count to avoid rehashing under heavy load.
intervalTraffic := make(map[int][2]int64, t.lastUserCount)
var cycleIn, cycleOut int64
// Clear activeConns in-place (no alloc, backing array retained).
for k := range t.activeConns {
delete(t.activeConns, k)
}
for uid, cum := range cumTraffic {
prev := t.lastSeen[uid]
deltaUp := cum[0] - prev[0]
deltaDown := cum[1] - prev[1]
// Rebuild aliveIPs each cycle so only currently-active IPs are reported.
// Previously, IPs accumulated across cycles leading to stale entries.
for uid := range t.aliveIPs {
for ip := range t.aliveIPs[uid] {
delete(t.aliveIPs[uid], ip)
// Guard against counter reset (kernel restart).
if deltaUp < 0 {
deltaUp = cum[0]
}
}
// Clear online counts
for uid := range t.online {
delete(t.online, uid)
}
for _, conn := range conns {
t.activeConns[conn.ID] = struct{}{}
if conn.UserID == 0 {
continue
if deltaDown < 0 {
deltaDown = cum[1]
}
t.online[conn.UserID]++
var deltaUp, deltaDown int64
prev, exists := t.connState[conn.ID]
if exists {
deltaUp = conn.Upload - prev[0]
deltaDown = conn.Download - prev[1]
if deltaUp < 0 {
deltaUp = conn.Upload
}
if deltaDown < 0 {
deltaDown = conn.Download
}
} else {
// ④ New connection: only track if below the state cap.
if len(t.connState) >= t.maxConnState {
slog.Debug("connState cap reached, skipping new connection",
"cap", t.maxConnState, "conn_id", conn.ID)
continue
}
deltaUp = conn.Upload
deltaDown = conn.Download
t.totalConns++
}
t.connState[conn.ID] = [2]int64{conn.Upload, conn.Download}
t.lastSeen[uid] = cum
if deltaUp > 0 || deltaDown > 0 {
cur := t.traffic[conn.UserID]
cur := t.pendingTraffic[uid]
cur[0] += deltaUp
cur[1] += deltaDown
t.traffic[conn.UserID] = cur
t.pendingTraffic[uid] = cur
it := intervalTraffic[conn.UserID]
it[0] += deltaUp
it[1] += deltaDown
intervalTraffic[conn.UserID] = it
}
// ④ Track alive IPs, capped per user.
if conn.SourceIP != "" {
ips := t.aliveIPs[conn.UserID]
if ips == nil {
ips = make(map[string]bool)
t.aliveIPs[conn.UserID] = ips
}
if len(ips) < t.maxAliveIPsPerUser {
ips[conn.SourceIP] = true
}
cycleOut += deltaUp
cycleIn += deltaDown
}
}
// Cleanup stale connState entries for connections that are no longer active.
for id := range t.connState {
if _, active := t.activeConns[id]; !active {
delete(t.connState, id)
}
// Compute online from alive IPs.
online := make(map[int]int, len(kernelAliveIPs))
for uid, ips := range kernelAliveIPs {
online[uid] = len(ips)
}
// Snapshot current active connection count for metrics reporting.
t.lastActiveConns = len(t.connState)
// Derive speed directly from intervalTraffic deltas accumulated this cycle.
// This avoids the negative-diff bug that occurs when connState entries for
// closed connections are removed, which made cumulative totals drop and
// clamped speed to 0 for one cycle after a mass disconnect.
var cycleIn, cycleOut int64
for _, it := range intervalTraffic {
cycleOut += it[0]
cycleIn += it[1]
}
t.inSpeed = cycleIn
t.outSpeed = cycleOut
// Update hint for next cycle's map pre-sizing.
if n := len(intervalTraffic); n > 0 {
t.lastUserCount = n
}
return intervalTraffic
// Publish new snapshot (readers will see this atomically).
t.live.Store(&snapshot{
traffic: copyTrafficMap(t.pendingTraffic),
aliveIPs: kernelAliveIPs, // kernel provides fresh copy each tick
online: online,
connCount: connCount,
inSpeed: cycleIn,
outSpeed: cycleOut,
})
}
// FlushTraffic returns accumulated per-user traffic and resets the counter.
// FlushTraffic returns accumulated per-user traffic and resets the pending buffer.
// Lock-free: reads from live snapshot, then acquires mu only to drain.
func (t *Tracker) FlushTraffic() map[int][2]int64 {
data := t.traffic
t.traffic = make(map[int][2]int64, len(data))
t.mu.Lock()
data := t.pendingTraffic
t.pendingTraffic = make(map[int][2]int64, len(data))
t.mu.Unlock()
return data
}
// RestoreTraffic adds traffic back (used when push to panel fails).
func (t *Tracker) RestoreTraffic(data map[int][2]int64) {
t.mu.Lock()
for uid, d := range data {
cur := t.traffic[uid]
cur := t.pendingTraffic[uid]
cur[0] += d[0]
cur[1] += d[1]
t.traffic[uid] = cur
t.pendingTraffic[uid] = cur
}
t.mu.Unlock()
}
// HasTraffic returns true if there is accumulated traffic to report.
func (t *Tracker) HasTraffic() bool {
return len(t.traffic) > 0
t.mu.Lock()
defer t.mu.Unlock()
return len(t.pendingTraffic) > 0
}
// FlushAliveIPs returns per-user alive IPs and resets the tracker.
// FlushAliveIPs returns per-user alive IPs.
// Reuses internal buffer. Returns nil if unchanged.
func (t *Tracker) FlushAliveIPs() map[int][]string {
data := make(map[int][]string, len(t.aliveIPs))
for uid, ips := range t.aliveIPs {
s := t.live.Load()
t.mu.Lock()
defer t.mu.Unlock()
// Calculate hash of current aliveIPs
currentHash := calcAliveIPsHash(s.aliveIPs)
// If no changes, return nil to avoid duplicate reporting
if currentHash == t.lastAliveIPsHash {
return nil
}
t.lastAliveIPsHash = currentHash
// Clear old buffer entries.
for k := range t.aliveIPsBuf {
delete(t.aliveIPsBuf, k)
}
// Fill buffer from snapshot.
for uid, ips := range s.aliveIPs {
buf := t.aliveIPsBuf[uid]
if buf == nil {
buf = make([]string, 0, len(ips))
}
buf = buf[:0]
for ip := range ips {
buf = append(buf, ip)
}
t.aliveIPsBuf[uid] = buf
}
return t.aliveIPsBuf
}
// calcAliveIPsHash computes a deterministic hash for change detection.
func calcAliveIPsHash(aliveIPs map[int]map[string]bool) string {
if len(aliveIPs) == 0 {
return ""
}
h := sha256.New()
// Sort user IDs for consistent hashing
userIDs := make([]int, 0, len(aliveIPs))
for uid := range aliveIPs {
userIDs = append(userIDs, uid)
}
sort.Ints(userIDs)
for _, uid := range userIDs {
ips := aliveIPs[uid]
// Sort IPs for consistent hashing
ipList := make([]string, 0, len(ips))
for ip := range ips {
ipList = append(ipList, ip)
}
data[uid] = ipList
sort.Strings(ipList)
// Write user ID and IPs to hash
h.Write([]byte{byte(uid >> 24), byte(uid >> 16), byte(uid >> 8), byte(uid)})
for _, ip := range ipList {
h.Write([]byte(ip))
}
}
t.aliveIPs = make(map[int]map[string]bool, len(t.aliveIPs))
return data
return hex.EncodeToString(h.Sum(nil))
}
// CurrentOnline returns a map of user_id to active connection count.
// CurrentOnline returns a snapshot copy of user_id → device count (distinct IPs).
// Lock-free: reads from live snapshot.
func (t *Tracker) CurrentOnline() map[int]int {
return t.online
s := t.live.Load()
cp := make(map[int]int, len(s.online))
for k, v := range s.online {
cp[k] = v
}
return cp
}
// RestoreAliveIPs merges alive IPs back in (used when push to panel fails).
// Note: this operates on the buffer, which will be overwritten next Process().
func (t *Tracker) RestoreAliveIPs(data map[int][]string) {
t.mu.Lock()
defer t.mu.Unlock()
for uid, ipList := range data {
ips := t.aliveIPs[uid]
ips := t.aliveIPsBuf[uid]
if ips == nil {
ips = make(map[string]bool, len(ipList))
t.aliveIPs[uid] = ips
ips = make([]string, 0, len(ipList))
}
// Use map for O(n) dedup instead of O(n²) linear search
existMap := make(map[string]struct{}, len(ips)+len(ipList))
for _, existing := range ips {
existMap[existing] = struct{}{}
}
for _, ip := range ipList {
if len(ips) < t.maxAliveIPsPerUser {
ips[ip] = true
if _, exists := existMap[ip]; !exists {
ips = append(ips, ip)
existMap[ip] = struct{}{}
}
}
t.aliveIPsBuf[uid] = ips
}
}
// LogStats logs current tracking statistics.
// Lock-free: reads from live snapshot.
func (t *Tracker) LogStats() {
slog.Debug("tracker stats",
"active_connections", len(t.connState),
"users_with_traffic", len(t.traffic),
"users_with_ips", len(t.aliveIPs),
)
s := t.live.Load()
nlog.TrackerStats(s.connCount, len(s.online))
}
// ActiveConnections returns the last observed active connection count.
// This is cheap and safe because Tracker is only mutated from the service
// goroutine; reads happen from the same goroutine when pushing reports.
// Lock-free: reads from live snapshot.
func (t *Tracker) ActiveConnections() int {
return t.lastActiveConns
return t.live.Load().connCount
}
// TotalConnections returns the cumulative connection count since startup.
// TotalConnections is deprecated — no longer tracked per-connection.
// Returns 0 for backward compatibility.
func (t *Tracker) TotalConnections() int64 {
return t.totalConns
return 0
}
// InboundSpeed returns the last observed inbound (download) speed in bytes/second.
// Process() is called every 10 seconds, so we divide the 10-second total by 10.
// Lock-free: reads from live snapshot.
func (t *Tracker) InboundSpeed() int64 {
return t.inSpeed / 10
return t.live.Load().inSpeed / 10
}
// OutboundSpeed returns the last observed outbound (upload) speed in bytes/second.
// Process() is called every 10 seconds, so we divide the 10-second total by 10.
// Lock-free: reads from live snapshot.
func (t *Tracker) OutboundSpeed() int64 {
return t.outSpeed / 10
return t.live.Load().outSpeed / 10
}
// copyTrafficMap creates a shallow copy of the traffic map.
func copyTrafficMap(src map[int][2]int64) map[int][2]int64 {
dst := make(map[int][2]int64, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
+84 -130
View File
@@ -2,24 +2,26 @@ package tracker
import (
"testing"
"github.com/cedar2025/xboard-node/internal/kernel"
)
func TestProcess_InitialTraffic(t *testing.T) {
tr := New()
conns := []kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 2, Upload: 50, Download: 80, SourceIP: "2.2.2.2"},
cumTraffic := map[int][2]int64{
1: {100, 200},
2: {50, 80},
}
interval := tr.Process(conns)
if interval[1] != [2]int64{100, 200} {
t.Errorf("user 1 interval: got %v", interval[1])
aliveIPs := map[int]map[string]bool{
1: {"1.1.1.1": true},
2: {"2.2.2.2": true},
}
if interval[2] != [2]int64{50, 80} {
t.Errorf("user 2 interval: got %v", interval[2])
tr.Process(cumTraffic, aliveIPs, 2)
flushed := tr.FlushTraffic()
if flushed[1] != [2]int64{100, 200} {
t.Errorf("user 1 traffic: got %v", flushed[1])
}
if flushed[2] != [2]int64{50, 80} {
t.Errorf("user 2 traffic: got %v", flushed[2])
}
}
@@ -27,18 +29,15 @@ func TestProcess_DeltaCalculation(t *testing.T) {
tr := New()
// First tick
conns := []kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
}
tr.Process(conns)
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
// Second tick — counters advanced
conns[0].Upload = 300
conns[0].Download = 500
interval := tr.Process(conns)
tr.Process(map[int][2]int64{1: {300, 500}}, nil, 1)
if interval[1] != [2]int64{200, 300} {
t.Errorf("delta: got %v, want [200,300]", interval[1])
flushed := tr.FlushTraffic()
// Total: first tick 100/200 + delta 200/300 = 300/500
if flushed[1] != [2]int64{300, 500} {
t.Errorf("accumulated: got %v, want [300,500]", flushed[1])
}
}
@@ -46,73 +45,52 @@ func TestProcess_CounterReset(t *testing.T) {
tr := New()
// First tick with high counters
conns := []kernel.Connection{
{ID: "c1", UserID: 1, Upload: 1000, Download: 2000},
}
tr.Process(conns)
tr.Process(map[int][2]int64{1: {1000, 2000}}, nil, 1)
tr.FlushTraffic() // clear accumulator
// Counter reset (new value < old value) — treat as fresh
conns[0].Upload = 50
conns[0].Download = 80
interval := tr.Process(conns)
tr.Process(map[int][2]int64{1: {50, 80}}, nil, 1)
if interval[1] != [2]int64{50, 80} {
t.Errorf("counter reset: got %v, want [50,80]", interval[1])
flushed := tr.FlushTraffic()
if flushed[1] != [2]int64{50, 80} {
t.Errorf("counter reset: got %v, want [50,80]", flushed[1])
}
}
func TestProcess_ConnectionCleanup(t *testing.T) {
func TestProcess_UserDisappears(t *testing.T) {
tr := New()
// Tick 1: connection exists
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
})
// Tick 1: user present
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
// Tick 2: connection gone — should clean up state
tr.Process([]kernel.Connection{})
// Tick 2: user gone
tr.Process(map[int][2]int64{}, nil, 0)
// Tick 3: same conn ID reappears — should count full bytes (not delta)
interval := tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 50, Download: 30},
})
// Tick 3: user reappears — should count full bytes (treated as new)
tr.Process(map[int][2]int64{1: {50, 30}}, nil, 1)
if interval[1] != [2]int64{50, 30} {
t.Errorf("after cleanup: got %v, want [50,30]", interval[1])
flushed := tr.FlushTraffic()
// First tick: 100/200, tick 2: 0, tick 3: 50/30 = total 150/230
if flushed[1] != [2]int64{150, 230} {
t.Errorf("after disappear: got %v, want [150,230]", flushed[1])
}
}
func TestProcess_ZeroUserID(t *testing.T) {
func TestProcess_ZeroTraffic(t *testing.T) {
tr := New()
interval := tr.Process([]kernel.Connection{
{ID: "c1", UserID: 0, Upload: 100, Download: 200},
})
tr.Process(map[int][2]int64{}, nil, 0)
if len(interval) != 0 {
t.Errorf("expected no interval traffic for userID=0, got %v", interval)
}
}
func TestProcess_MultipleConnectionsSameUser(t *testing.T) {
tr := New()
conns := []kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
{ID: "c2", UserID: 1, Upload: 50, Download: 80},
}
interval := tr.Process(conns)
if interval[1] != [2]int64{150, 280} {
t.Errorf("aggregated: got %v, want [150,280]", interval[1])
if tr.HasTraffic() {
t.Error("expected no traffic for empty input")
}
}
func TestFlushTraffic(t *testing.T) {
tr := New()
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
{ID: "c2", UserID: 2, Upload: 50, Download: 80},
})
tr.Process(map[int][2]int64{
1: {100, 200},
2: {50, 80},
}, nil, 2)
flushed := tr.FlushTraffic()
if flushed[1] != [2]int64{100, 200} {
@@ -134,15 +112,9 @@ func TestFlushTraffic(t *testing.T) {
func TestRestoreTraffic(t *testing.T) {
tr := New()
// Generate some traffic
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
})
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
flushed := tr.FlushTraffic()
// Restore (simulating failed push)
tr.RestoreTraffic(flushed)
if !tr.HasTraffic() {
@@ -158,27 +130,19 @@ func TestRestoreTraffic(t *testing.T) {
func TestRestoreTraffic_Additive(t *testing.T) {
tr := New()
// Generate traffic
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
})
// Generate more traffic
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 200, Download: 400},
})
// Two ticks accumulate
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
tr.Process(map[int][2]int64{1: {200, 400}}, nil, 1)
// Flush and restore
first := tr.FlushTraffic()
tr.RestoreTraffic(first)
// Generate additional traffic
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 250, Download: 500},
})
tr.Process(map[int][2]int64{1: {250, 500}}, nil, 1)
final := tr.FlushTraffic()
// Should be restored(200,400) + delta(50,100) = (250,500)
// Restored: 200/400 (initial 100/200 + delta 100/200) + new delta 50/100 = 250/500
if final[1][0] != 250 || final[1][1] != 500 {
t.Errorf("additive restore: got %v", final[1])
}
@@ -186,37 +150,37 @@ func TestRestoreTraffic_Additive(t *testing.T) {
func TestFlushAliveIPs(t *testing.T) {
tr := New()
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 1, Upload: 50, Download: 80, SourceIP: "2.2.2.2"},
{ID: "c3", UserID: 2, Upload: 30, Download: 40, SourceIP: "3.3.3.3"},
})
aliveIPs := tr.FlushAliveIPs()
if len(aliveIPs[1]) != 2 {
t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1]))
aliveIPs := map[int]map[string]bool{
1: {"1.1.1.1": true, "2.2.2.2": true},
2: {"3.3.3.3": true},
}
if len(aliveIPs[2]) != 1 {
t.Errorf("user 2 IPs: got %d, want 1", len(aliveIPs[2]))
tr.Process(map[int][2]int64{1: {100, 200}, 2: {30, 40}}, aliveIPs, 3)
flushed := tr.FlushAliveIPs()
if len(flushed[1]) != 2 {
t.Errorf("user 1 IPs: got %d, want 2", len(flushed[1]))
}
if len(flushed[2]) != 1 {
t.Errorf("user 2 IPs: got %d, want 1", len(flushed[2]))
}
// After flush, should be empty
aliveIPs2 := tr.FlushAliveIPs()
if len(aliveIPs2) != 0 {
t.Errorf("expected empty after second flush, got %v", aliveIPs2)
// Without another Process call, hash is same → returns nil (skip duplicate)
flushed2 := tr.FlushAliveIPs()
if flushed2 != nil {
t.Errorf("expected nil on duplicate flush, got %v", flushed2)
}
}
func TestFlushAliveIPs_DedupSameIP(t *testing.T) {
tr := New()
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200, SourceIP: "1.1.1.1"},
{ID: "c2", UserID: 1, Upload: 50, Download: 80, SourceIP: "1.1.1.1"},
})
aliveIPs := map[int]map[string]bool{
1: {"1.1.1.1": true},
}
tr.Process(map[int][2]int64{1: {100, 200}}, aliveIPs, 2)
aliveIPs := tr.FlushAliveIPs()
if len(aliveIPs[1]) != 1 {
t.Errorf("expected dedup to 1 IP, got %d", len(aliveIPs[1]))
flushed := tr.FlushAliveIPs()
if len(flushed[1]) != 1 {
t.Errorf("expected 1 IP, got %d", len(flushed[1]))
}
}
@@ -226,9 +190,7 @@ func TestHasTraffic(t *testing.T) {
t.Error("new tracker should not have traffic")
}
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
})
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
if !tr.HasTraffic() {
t.Error("should have traffic after process")
}
@@ -242,15 +204,13 @@ func TestHasTraffic(t *testing.T) {
func TestProcess_NoTrafficDelta(t *testing.T) {
tr := New()
conns := []kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
}
tr.Process(conns)
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
tr.FlushTraffic() // clear
// Same values — no delta
interval := tr.Process(conns)
if len(interval) != 0 {
t.Errorf("expected no interval traffic for zero delta, got %v", interval)
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
if tr.HasTraffic() {
t.Error("expected no traffic for zero delta")
}
}
@@ -258,17 +218,11 @@ func TestTrafficAccumulation(t *testing.T) {
tr := New()
// Tick 1
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 100, Download: 200},
})
// Tick 2
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 300, Download: 500},
})
// Tick 3
tr.Process([]kernel.Connection{
{ID: "c1", UserID: 1, Upload: 350, Download: 600},
})
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
// Tick 2 (cumulative 300, 500 → delta 200, 300)
tr.Process(map[int][2]int64{1: {300, 500}}, nil, 1)
// Tick 3 (cumulative 350, 600 → delta 50, 100)
tr.Process(map[int][2]int64{1: {350, 600}}, nil, 1)
flushed := tr.FlushTraffic()
// Total: 100+200+50 = 350 upload, 200+300+100 = 600 download