Compare commits
36 Commits
v1.13-patched
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a13468942 | |||
| 8722fd2658 | |||
| 4bab53f20f | |||
| 0e1f97ee03 | |||
| 34125823df | |||
| 4927383faa | |||
| fd26f8de88 | |||
| 1924a56b13 | |||
| fce60b8ad3 | |||
| 0a29338e1f | |||
| 68ca2d2716 | |||
| 07c37023ab | |||
| 71c53e1071 | |||
| 65d97f3da1 | |||
| 42948ba37c | |||
| 680795cdfc | |||
| 17823891a2 | |||
| c5bcbc3299 | |||
| 598b6b531b | |||
| e9672fbdbe | |||
| f7ce8f22d4 | |||
| 19aa20be87 | |||
| 920b0b1e09 | |||
| 95f582c74f | |||
| 4b10271ce0 | |||
| 691ca93c82 | |||
| b51cecfe17 | |||
| 7e3ad7cb23 | |||
| 191e58b9ee | |||
| 3f1234a05c | |||
| 4676caac96 | |||
| e8e2abbc37 | |||
| 4f1f41aa5d | |||
| bfc24c348a | |||
| aaad2babfd | |||
| ea832c8907 |
@@ -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
@@ -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
|
||||
@@ -0,0 +1,106 @@
|
||||
# xboard-node fork 运维手册
|
||||
|
||||
## 仓库说明
|
||||
|
||||
JP01 Gitea 自维护 fork,**不是上游镜像**,独立分支。
|
||||
|
||||
- 本仓库:`http://103.112.1.141:3000/will/xboard-node`(dev 分支)
|
||||
- 上游(仅供 cherry-pick 参考):`https://github.com/cedar2025/Xboard-Node`
|
||||
- 工作目录:`/tmp/xboard-node`(JP01)
|
||||
|
||||
---
|
||||
|
||||
## 已应用补丁
|
||||
|
||||
| 补丁 | 文件 | 说明 | 是否可移除 |
|
||||
|------|------|------|-----------|
|
||||
| #32 | `internal/service/service.go` | ban 用户前调 `CloseUserConnections()`,立即断连 | 待上游合并后移除 |
|
||||
| #55 | `internal/kernel/singbox/conntracker.go` | UDP PacketConn upload/download 计数对调 | 待上游合并后移除 |
|
||||
| 自改 | `cmd/xbctl/main.go` | `downloadBase` 指向 JP01 Gitea releases | 永久保留 |
|
||||
| 自改 | `cmd/xbctl/main.go` | `downloadToken` + `downloadFile` 加 Authorization header,支持 private 仓库下载 | 永久保留 |
|
||||
| 自改 | `install.sh` | binary 下载地址改为 JP01 Gitea releases | 永久保留 |
|
||||
|
||||
### xbctl private 仓库下载(2026-06-24)
|
||||
|
||||
**背景**:Gitea 仓库改为 private 后,`xbctl upgrade` 下载时 HTTP 401。
|
||||
|
||||
**改动位置**:`cmd/xbctl/main.go`
|
||||
|
||||
```go
|
||||
// 新增常量
|
||||
downloadToken = "cf6634b57d2c60c66030dbb29d77c4ed25370e94"
|
||||
|
||||
// downloadFile 改用 NewRequest,加 Authorization header
|
||||
func downloadFile(url, dest string) error {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if downloadToken != "" {
|
||||
req.Header.Set("Authorization", "token "+downloadToken)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:token 是只读 `read:repository` 权限,泄露只能读仓库,不能写。
|
||||
|
||||
---
|
||||
|
||||
## 编译命令
|
||||
|
||||
```bash
|
||||
cd /tmp/xboard-node
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
|
||||
# xboard-node(必须带这些 tags)
|
||||
CGO_ENABLED=0 /usr/local/go/bin/go build \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-tags "with_quic with_utls with_wireguard with_acme with_clash_api" \
|
||||
-o xboard-node-linux-amd64 ./cmd/xboard-node/
|
||||
|
||||
# xbctl
|
||||
CGO_ENABLED=0 /usr/local/go/bin/go build \
|
||||
-ldflags "-s -w" \
|
||||
-o xbctl-linux-amd64 ./cmd/xbctl/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 发布流程
|
||||
|
||||
```bash
|
||||
TOKEN=cf6634b57d2c60c66030dbb29d77c4ed25370e94
|
||||
RELEASE_ID=14 # v1.13-patched
|
||||
|
||||
# 删旧 asset
|
||||
OLD_ID=$(curl -s "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $TOKEN" | python3 -c "
|
||||
import sys,json
|
||||
for a in json.load(sys.stdin):
|
||||
if a['name'] == 'xboard-node-linux-amd64': print(a['id'])")
|
||||
curl -s -X DELETE "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets/$OLD_ID" \
|
||||
-H "Authorization: token $TOKEN"
|
||||
|
||||
# 上传新 asset
|
||||
curl -s -X POST "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets?name=xboard-node-linux-amd64" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/octet-stream" \
|
||||
--data-binary @xboard-node-linux-amd64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 节点部署
|
||||
|
||||
| 节点 | IP | MCP |
|
||||
|------|----|-----|
|
||||
| dmit-tyo | 154.31.113.157 | mcp__dmit-tyo__exec |
|
||||
| us-unlock | 103.152.113.30 | mcp__us-unlock__exec |
|
||||
| vmiss-us01 | 38.47.112.162 | mcp__vmiss-us04__exec |
|
||||
|
||||
节点上执行升级:
|
||||
```bash
|
||||
xbctl upgrade # 自动带 token,直接用
|
||||
```
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1580
File diff suppressed because it is too large
Load Diff
+176
-71
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
|
||||
Regular → Executable
+714
-600
File diff suppressed because it is too large
Load Diff
+267
-179
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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{} }
|
||||
@@ -0,0 +1,32 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
)
|
||||
|
||||
func TestLocalControlPlaneInitial(t *testing.T) {
|
||||
cp := NewLocalControlPlane(&config.Config{
|
||||
Kernel: config.KernelConfig{Type: "singbox", LogLevel: "warn"},
|
||||
Standalone: &config.StandaloneConfig{
|
||||
Enabled: true,
|
||||
Node: config.StandaloneNodeConfig{Protocol: "shadowsocks", ServerPort: 8388, Cipher: "aes-128-gcm"},
|
||||
Users: []config.StandaloneUser{{ID: 1, UUID: "secret"}},
|
||||
},
|
||||
})
|
||||
bootstrap, err := cp.Initial(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Initial: %v", err)
|
||||
}
|
||||
if bootstrap.Push != nil {
|
||||
t.Fatal("local control plane should not create push client")
|
||||
}
|
||||
if bootstrap.Config == nil || bootstrap.Config.Protocol != "shadowsocks" {
|
||||
t.Fatalf("config: %#v", bootstrap.Config)
|
||||
}
|
||||
if len(bootstrap.Users) != 1 || bootstrap.Users[0].UUID != "secret" {
|
||||
t.Fatalf("users: %#v", bootstrap.Users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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,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:") {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+452
-150
@@ -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,56 @@ 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: detect structured format (match+action) and compile them,
|
||||
// otherwise pass through as raw sing-box rules.
|
||||
for _, cr := range custom {
|
||||
if _, hasMatch := cr["match"]; hasMatch {
|
||||
if parsed, ok := tryParseAsCustomRouteRule(cr); ok {
|
||||
rules = append(rules, compileCustomRouteRule(parsed)...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
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 +202,241 @@ 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
|
||||
}
|
||||
|
||||
|
||||
// tryParseAsCustomRouteRule attempts to convert a raw map (from custom_routes)
|
||||
// into a structured CustomRouteRule. Returns false if the map doesn't match
|
||||
// the structured format.
|
||||
func tryParseAsCustomRouteRule(cr map[string]any) (model.CustomRouteRule, bool) {
|
||||
var rule model.CustomRouteRule
|
||||
|
||||
// Parse name
|
||||
if name, ok := cr["name"].(string); ok {
|
||||
rule.Name = name
|
||||
}
|
||||
if disabled, ok := cr["disabled"].(bool); ok {
|
||||
rule.Disabled = disabled
|
||||
}
|
||||
|
||||
// Parse match
|
||||
matchRaw, ok := cr["match"].(map[string]any)
|
||||
if !ok {
|
||||
return rule, false
|
||||
}
|
||||
rule.Match = parseRouteMatch(matchRaw)
|
||||
|
||||
// Parse action
|
||||
switch action := cr["action"].(type) {
|
||||
case string:
|
||||
rule.Action = model.RouteAction{Type: action}
|
||||
case map[string]any:
|
||||
if t, ok := action["type"].(string); ok {
|
||||
rule.Action.Type = t
|
||||
}
|
||||
if target, ok := action["target"].(string); ok {
|
||||
rule.Action.Target = target
|
||||
}
|
||||
default:
|
||||
return rule, false
|
||||
}
|
||||
|
||||
return rule, true
|
||||
}
|
||||
|
||||
func parseRouteMatch(m map[string]any) model.RouteMatch {
|
||||
var match model.RouteMatch
|
||||
if v, ok := m["domains"].([]any); ok {
|
||||
match.Domains = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["domain_suffixes"].([]any); ok {
|
||||
match.DomainSuffixes = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["ip_cidrs"].([]any); ok {
|
||||
match.IPCIDRs = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["ports"].([]any); ok {
|
||||
match.Ports = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["networks"].([]any); ok {
|
||||
match.Networks = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["source_cidrs"].([]any); ok {
|
||||
match.SourceCIDRs = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["source_ports"].([]any); ok {
|
||||
match.SourcePorts = toStringSlice(v)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func toStringSlice(arr []any) []string {
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
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 +511,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 +522,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 +546,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 +566,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 +632,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 +665,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 +679,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 +756,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 +769,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 +786,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 +799,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 +812,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 +836,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 +857,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 +869,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 +906,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 +932,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 +1034,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 +1061,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
)
|
||||
|
||||
func TestNodeSpecFromStandalone(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Kernel: config.KernelConfig{Type: "xray", LogLevel: "info"},
|
||||
Standalone: &config.StandaloneConfig{
|
||||
Enabled: true,
|
||||
Node: config.StandaloneNodeConfig{
|
||||
Protocol: "vless",
|
||||
ListenIP: "::",
|
||||
ServerPort: 8443,
|
||||
Network: "ws",
|
||||
NetworkSettings: map[string]any{"path": "/ws"},
|
||||
TLS: 1,
|
||||
Host: "node.example.com",
|
||||
ServerName: "node.example.com",
|
||||
AcceptProxyProtocol: true,
|
||||
PaddingScheme: "stop=8\n0=30-30",
|
||||
Routes: []config.StandaloneRouteRule{{ID: 1, Match: []string{"example.com"}, Action: "direct"}},
|
||||
Multiplex: &config.StandaloneMultiplexConfig{Enabled: true, Protocol: "smux", MaxConnections: 4},
|
||||
},
|
||||
Users: []config.StandaloneUser{{ID: 7, UUID: "11111111-1111-1111-1111-111111111111", SpeedLimit: 50, DeviceLimit: 2}},
|
||||
},
|
||||
}
|
||||
|
||||
nc := NodeSpecFromStandalone(cfg)
|
||||
users := UserSpecsFromStandalone(cfg)
|
||||
if nc.Protocol != "vless" || nc.ServerPort != 8443 {
|
||||
t.Fatalf("unexpected node spec: %#v", nc)
|
||||
}
|
||||
if nc.NetworkSettings["path"] != "/ws" {
|
||||
t.Fatalf("network_settings.path: got %v", nc.NetworkSettings["path"])
|
||||
}
|
||||
if nc.PaddingScheme != "stop=8\n0=30-30" {
|
||||
t.Fatalf("padding_scheme: got %q", nc.PaddingScheme)
|
||||
}
|
||||
if nc.Multiplex == nil || !nc.Multiplex.Enabled {
|
||||
t.Fatal("expected multiplex config to be copied")
|
||||
}
|
||||
if len(users) != 1 || users[0].DeviceLimit != 2 {
|
||||
t.Fatalf("users: %#v", users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+130
-32
@@ -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,30 @@ 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.
|
||||
// fetchMethod returns POST for all API calls (token stays in body, not URL).
|
||||
// v2 already used POST; v1 legacy endpoints need panel route update to match.
|
||||
func (c *Client) fetchMethod() string { return "POST" }
|
||||
|
||||
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(c.fetchMethod(), c.configPath(), nil, c.configETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config: %w", err)
|
||||
}
|
||||
@@ -214,7 +259,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(c.fetchMethod(), c.userPath(), nil, c.userETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get users: %w", err)
|
||||
}
|
||||
@@ -280,14 +325,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 +422,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 +443,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 +457,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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+687
-331
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user