Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a36a5feb6a |
@@ -1,128 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.7'
|
||||
cache: true
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.7'
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || github.sha }}
|
||||
run: |
|
||||
if [ "${{ matrix.goarch }}" = "amd64" ]; then
|
||||
make build-linux
|
||||
else
|
||||
make build-linux-arm64
|
||||
fi
|
||||
|
||||
- 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
|
||||
needs: test
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/cedar2025/xboard-node:latest
|
||||
ghcr.io/cedar2025/xboard-node:${{ github.sha }}
|
||||
${{ startsWith(github.ref, 'refs/tags/') && format('ghcr.io/cedar2025/xboard-node:{0}', github.ref_name) || '' }}
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/dev'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'dev' }}
|
||||
make_latest: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
files: |
|
||||
dist/xboard-node-linux-amd64
|
||||
dist/xboard-node-linux-arm64
|
||||
dist/xbctl-linux-amd64
|
||||
dist/xbctl-linux-arm64
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# Binaries
|
||||
/xboard-node
|
||||
/xboard-node-linux-*
|
||||
*.exe
|
||||
|
||||
# Config files (may contain secrets)
|
||||
config.yml
|
||||
|
||||
# Generated sing-box config
|
||||
singbox.json
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
config.json
|
||||
benchmark/
|
||||
third_party
|
||||
bench-results
|
||||
internal/kernel/singbox/config_bench_test.go
|
||||
internal/kernel/xray/config_bench_test.go
|
||||
internal/service/service_bench_test.go
|
||||
internal/tracker/tracker_bench_test.go
|
||||
cmd/xboard-node-bench-compare
|
||||
cmd/xboard-node-bench
|
||||
.env
|
||||
@@ -1,106 +0,0 @@
|
||||
# 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,直接用
|
||||
```
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
# Build stage
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
|
||||
-X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev) \
|
||||
-X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-tags "with_quic with_utls with_wireguard with_clash_api" \
|
||||
-o xboard-node ./cmd/xboard-node
|
||||
|
||||
# Runtime stage — sing-box & xray-core are embedded as Go libraries
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=builder /build/xboard-node /usr/local/bin/xboard-node
|
||||
|
||||
RUN mkdir -p /etc/xboard-node
|
||||
|
||||
WORKDIR /etc/xboard-node
|
||||
|
||||
# Config can be provided via file mount OR environment variables.
|
||||
# Env var mode (no config file needed):
|
||||
# docker run -d --network=host \
|
||||
# -e apiHost=https://panel.example.com \
|
||||
# -e apiKey=YOUR_TOKEN \
|
||||
# -e nodeID=1 \
|
||||
# ghcr.io/cedar2025/xboard-node:latest
|
||||
#
|
||||
# Supported env vars:
|
||||
# apiHost / API_HOST → panel URL
|
||||
# apiKey / API_KEY → server token
|
||||
# nodeID / NODE_ID → node ID
|
||||
# nodeType / NODE_TYPE → node type (optional)
|
||||
# kernel / KERNEL_TYPE → singbox (default) or xray
|
||||
# domain / DOMAIN → TLS domain (enables auto_tls)
|
||||
# certFile / CERT_FILE → TLS cert path
|
||||
# keyFile / KEY_FILE → TLS key path
|
||||
# logLevel / LOG_LEVEL → log level
|
||||
|
||||
ENTRYPOINT ["xboard-node"]
|
||||
CMD ["-c", "/etc/xboard-node/config.yml"]
|
||||
@@ -1,45 +0,0 @@
|
||||
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 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
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v -race -count=1 ./internal/...
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -f xboard-node xbctl xboard-node-linux-* xbctl-linux-*
|
||||
|
||||
# Build Docker image
|
||||
docker:
|
||||
docker build -t xboard-node:$(VERSION) -t xboard-node:latest .
|
||||
|
||||
# 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; \
|
||||
echo "Config copied to /etc/xboard-node/config.yml - please edit it"; \
|
||||
fi
|
||||
@@ -1,70 +1,36 @@
|
||||
# xboard-node
|
||||
# xboard-node — Docker Compose
|
||||
|
||||
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
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d --restart=always --network=host \
|
||||
-e apiHost=https://panel.com -e apiKey=TOKEN -e nodeID=1 \
|
||||
ghcr.io/cedar2025/xboard-node:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
## Quick start
|
||||
|
||||
```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
|
||||
# Edit config/config.yml: panel.url, panel.token, panel.node_id
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Installer (Linux systemd)
|
||||
## Update / logs / stop
|
||||
|
||||
```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
|
||||
cd compose
|
||||
docker compose pull && docker compose up -d
|
||||
docker compose logs -f
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Layout
|
||||
|
||||
Legacy single-panel config is fully compatible. Appending bindings auto-migrates to `instances` format. See `config.yml.example`.
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `compose.yml` | Service definition |
|
||||
| `config/config.yml.example` | Template (safe to commit) |
|
||||
| `config/config.yml` | Your secrets (**do not commit**; add to `.gitignore` upstream) |
|
||||
| `.env.example` | Optional env overrides |
|
||||
|
||||
## Extensions
|
||||
## Host network
|
||||
|
||||
- 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)
|
||||
`network_mode: host` is set so the node uses the host network stack (same as `docker run --network host`). Free the ports your protocols need.
|
||||
|
||||
## License
|
||||
## Image
|
||||
|
||||
MPL-2.0.
|
||||
Default: `ghcr.io/cedar2025/xboard-node:latest`.
|
||||
|
||||
-1580
File diff suppressed because it is too large
Load Diff
@@ -1,297 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("c", "config.yml", "config file path")
|
||||
showVersion := flag.Bool("v", false, "show version")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("xboard-node %s (built %s)\n", version, buildTime)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
rootCfg, err := config.LoadRoot(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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(instances[0].Runtime)
|
||||
|
||||
runWithReload(rootCfg, *configPath)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to start health check listener", "port", port, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
healthSrv = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
healthPort = port
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
initialInstances, err := initialRoot.NormalizeInstances()
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to normalize initial config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
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.
|
||||
// Both settings can also be overridden by environment variables (GOMEMLIMIT /
|
||||
// GOGC) — the env vars take precedence because Go's runtime reads them before
|
||||
// we can call these functions, but we set them here for completeness and so
|
||||
// the values are logged.
|
||||
func applyRuntimeConfig(rt config.RuntimeConfig) {
|
||||
// GOGC
|
||||
if rt.GoGCPercent > 0 {
|
||||
prev := debug.SetGCPercent(rt.GoGCPercent)
|
||||
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 {
|
||||
nlog.Core().Warn("runtime: invalid gomemlimit, ignoring", "value", rt.GoMemLimit, "error", err)
|
||||
} else {
|
||||
prev := debug.SetMemoryLimit(limit)
|
||||
nlog.Core().Info("runtime: GOMEMLIMIT set",
|
||||
"limit", rt.GoMemLimit,
|
||||
"bytes", limit,
|
||||
"prev_bytes", prev,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseMemLimit converts a human-readable size string to bytes.
|
||||
// Supported suffixes: B, KiB, MiB, GiB, TiB (case-insensitive).
|
||||
func parseMemLimit(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
suffixes := []struct {
|
||||
suffix string
|
||||
mult int64
|
||||
}{
|
||||
{"TiB", 1 << 40},
|
||||
{"GiB", 1 << 30},
|
||||
{"MiB", 1 << 20},
|
||||
{"KiB", 1 << 10},
|
||||
{"B", 1},
|
||||
}
|
||||
upper := strings.ToUpper(s)
|
||||
for _, sf := range suffixes {
|
||||
if strings.HasSuffix(upper, strings.ToUpper(sf.suffix)) {
|
||||
numStr := strings.TrimSuffix(upper, strings.ToUpper(sf.suffix))
|
||||
numStr = strings.TrimSpace(numStr)
|
||||
var n int64
|
||||
if _, err := fmt.Sscanf(numStr, "%d", &n); err != nil {
|
||||
return 0, fmt.Errorf("parse number %q: %w", numStr, err)
|
||||
}
|
||||
return n * sf.mult, nil
|
||||
}
|
||||
}
|
||||
// No suffix: treat as raw bytes.
|
||||
var n int64
|
||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
|
||||
return 0, fmt.Errorf("unrecognised size format %q", s)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# XrayR-style: ./config on the host is mounted to /etc/xboard-node in the container.
|
||||
# Run all commands from this directory: cd compose && docker compose up -d
|
||||
# See README.md in this folder.
|
||||
|
||||
services:
|
||||
xboard-node:
|
||||
image: ghcr.io/cedar2025/xboard-node:latest
|
||||
container_name: xboard-node
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config:/etc/xboard-node
|
||||
command: ["-c", "/etc/xboard-node/config.yml"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
# Optional: override panel settings without editing config.yml (still need a valid config file to load).
|
||||
# environment:
|
||||
# API_HOST: https://panel.example.com
|
||||
# API_KEY: your-server-token
|
||||
# NODE_ID: "1"
|
||||
# KERNEL_TYPE: singbox
|
||||
@@ -1,26 +0,0 @@
|
||||
# Xboard Node Configuration
|
||||
# Top-level settings are inherited by all instances. Instance overrides take precedence.
|
||||
|
||||
# ── Single node (simplest) ──
|
||||
# panel:
|
||||
# url: "https://panel.example.com"
|
||||
# token: "your-api-key"
|
||||
# node_id: 1
|
||||
|
||||
# ── 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,82 @@
|
||||
# Xboard Node Configuration
|
||||
# Copy this file to config.yml and modify as needed
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# MINIMAL CONFIGURATION (Recommended)
|
||||
# Most settings (Node ID, Kernel, TLS, Rules) can be managed via the Panel.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Panel 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"
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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,266 +0,0 @@
|
||||
module github.com/cedar2025/xboard-node
|
||||
|
||||
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.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.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
|
||||
github.com/cretz/bine v0.2.0 // indirect
|
||||
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/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.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.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
|
||||
github.com/sagernet/cronet-go v0.0.0-20260309100020-c128886ff3fc // indirect
|
||||
github.com/sagernet/cronet-go/all v0.0.0-20260309100020-c128886ff3fc // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260309101654-0cbdcfddded9 // indirect
|
||||
github.com/sagernet/fswatch v0.1.1 // indirect
|
||||
github.com/sagernet/gvisor v0.0.0-20241123041152-536d05261cff // indirect
|
||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
|
||||
github.com/sagernet/nftables v0.3.0-beta.4 // indirect
|
||||
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 // indirect
|
||||
github.com/sagernet/sing-mux v0.3.4 // indirect
|
||||
github.com/sagernet/sing-quic v0.6.0 // indirect
|
||||
github.com/sagernet/sing-shadowsocks v0.2.8 // indirect
|
||||
github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect
|
||||
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect
|
||||
github.com/sagernet/sing-tun v0.8.3-0.20260311132553-5485872f601f // indirect
|
||||
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect
|
||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect
|
||||
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e // indirect
|
||||
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c // indirect
|
||||
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect
|
||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
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-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.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/oauth2 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-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.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
|
||||
)
|
||||
|
||||
replace github.com/sagernet/sing-box => github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295
|
||||
|
||||
replace github.com/xtls/xray-core => github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965
|
||||
-849
@@ -1,849 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
APP_NAME="xboard-node"
|
||||
INSTALL_ROOT="/etc/xboard-node"
|
||||
BACKUP_DIR="${INSTALL_ROOT}/backups"
|
||||
INSTALL_META="${INSTALL_ROOT}/install-meta.json"
|
||||
CONFIG_FILE="${INSTALL_ROOT}/config.yml"
|
||||
CREDENTIALS_FILE="${INSTALL_ROOT}/credentials.env"
|
||||
BINARY_PATH="/usr/local/bin/xboard-node"
|
||||
SERVICE_NAME="xboard-node.service"
|
||||
SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}"
|
||||
CLI_PATH="/usr/local/bin/xbctl"
|
||||
INSTALLER_COPY_PATH="${INSTALL_ROOT}/install.sh"
|
||||
CLI_BINARY_SOURCE=""
|
||||
DEFAULT_HEALTH_PORT=65530
|
||||
DEFAULT_KERNEL="singbox"
|
||||
DEFAULT_MODE="node"
|
||||
DEFAULT_ACTION="install"
|
||||
DEFAULT_RELEASE_VERSION="latest"
|
||||
DEFAULT_LOG_LEVEL="info"
|
||||
DEFAULT_KERNEL_LOG_LEVEL="warn"
|
||||
DEFAULT_DOWNLOAD_BASE="http://64.204.66.12:3000/will/xboard-node/releases/download"
|
||||
|
||||
ACTION="${DEFAULT_ACTION}"
|
||||
MODE=""
|
||||
PANEL_URL=""
|
||||
TOKEN=""
|
||||
NODE_ID=""
|
||||
NODE_TYPE=""
|
||||
MACHINE_ID=""
|
||||
KERNEL_TYPE="${DEFAULT_KERNEL}"
|
||||
RELEASE_VERSION="${DEFAULT_RELEASE_VERSION}"
|
||||
HEALTH_PORT="${DEFAULT_HEALTH_PORT}"
|
||||
HEALTH_ENABLED=1
|
||||
RUNTIME_GOMEMLIMIT=""
|
||||
RUNTIME_GOGC=""
|
||||
BINARY_SOURCE=""
|
||||
CLI_BINARY_SOURCE=""
|
||||
FORCE_RECONFIGURE=0
|
||||
PURGE=0
|
||||
YES=0
|
||||
ARCH=""
|
||||
OS=""
|
||||
DOWNLOAD_URL=""
|
||||
CURRENT_STATE="fresh"
|
||||
TMP_DIR=""
|
||||
BACKUP_PATH=""
|
||||
SERVICE_EXISTED=0
|
||||
CLEANUP_DONE=0
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_step() { echo -e "${CYAN}[STEP]${NC} ${BOLD}$1${NC}"; }
|
||||
|
||||
cleanup_tmp() {
|
||||
if [ "$CLEANUP_DONE" -eq 1 ]; then
|
||||
return
|
||||
fi
|
||||
CLEANUP_DONE=1
|
||||
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
load_health_port_from_config() {
|
||||
local cfg_path="$1"
|
||||
if [ ! -f "$cfg_path" ]; then
|
||||
return
|
||||
fi
|
||||
local parsed
|
||||
if [ -x "$CLI_PATH" ]; then
|
||||
parsed=$("$CLI_PATH" config health-port --config "$cfg_path" 2>/dev/null)
|
||||
else
|
||||
parsed=$(grep -m1 'health_port:' "$cfg_path" 2>/dev/null | sed 's/.*health_port:[[:space:]]*//' | tr -cd '0-9')
|
||||
fi
|
||||
if [ -n "$parsed" ] && [ "$parsed" -ge 0 ] 2>/dev/null; then
|
||||
HEALTH_PORT="$parsed"
|
||||
if [ "$HEALTH_PORT" -eq 0 ]; then
|
||||
HEALTH_ENABLED=0
|
||||
else
|
||||
HEALTH_ENABLED=1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
rollback_install() {
|
||||
log_warn "Rolling back installation"
|
||||
if [ -n "$BACKUP_PATH" ] && [ -d "$BACKUP_PATH" ]; then
|
||||
if [ -f "$BACKUP_PATH/xboard-node" ]; then
|
||||
install -m 755 "$BACKUP_PATH/xboard-node" "$BINARY_PATH"
|
||||
else
|
||||
rm -f "$BINARY_PATH"
|
||||
fi
|
||||
if [ -f "$BACKUP_PATH/config.yml" ]; then
|
||||
install -m 600 "$BACKUP_PATH/config.yml" "$CONFIG_FILE"
|
||||
else
|
||||
rm -f "$CONFIG_FILE"
|
||||
fi
|
||||
if [ -f "$BACKUP_PATH/credentials.env" ]; then
|
||||
install -m 600 "$BACKUP_PATH/credentials.env" "$CREDENTIALS_FILE"
|
||||
else
|
||||
rm -f "$CREDENTIALS_FILE"
|
||||
fi
|
||||
if [ -f "$BACKUP_PATH/install-meta.json" ]; then
|
||||
install -m 644 "$BACKUP_PATH/install-meta.json" "$INSTALL_META"
|
||||
else
|
||||
rm -f "$INSTALL_META"
|
||||
fi
|
||||
if [ -f "$BACKUP_PATH/xbctl" ]; then
|
||||
install -m 755 "$BACKUP_PATH/xbctl" "$CLI_PATH"
|
||||
else
|
||||
rm -f "$CLI_PATH"
|
||||
fi
|
||||
if [ -f "$BACKUP_PATH/${SERVICE_NAME}" ]; then
|
||||
install -m 644 "$BACKUP_PATH/${SERVICE_NAME}" "$SERVICE_PATH"
|
||||
else
|
||||
rm -f "$SERVICE_PATH"
|
||||
fi
|
||||
fi
|
||||
load_health_port_from_config "$CONFIG_FILE"
|
||||
systemctl daemon-reload || true
|
||||
if [ "$SERVICE_EXISTED" -eq 1 ] || [ -f "$SERVICE_PATH" ]; then
|
||||
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
systemctl restart "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
if ! wait_for_health; then
|
||||
log_error "Rollback completed but restored service did not become healthy"
|
||||
show_recent_logs
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
log_warn "Rollback complete"
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local exit_code=$?
|
||||
local line_no=${1:-unknown}
|
||||
if [ "$exit_code" -ne 0 ]; then
|
||||
log_error "Install failed at line ${line_no} (exit=${exit_code})"
|
||||
if [ -n "$BACKUP_PATH" ]; then
|
||||
rollback_install || true
|
||||
fi
|
||||
fi
|
||||
cleanup_tmp
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap 'on_error $LINENO' ERR
|
||||
trap cleanup_tmp EXIT
|
||||
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
|
||||
xboard-node Installer
|
||||
|
||||
ACTIONS:
|
||||
install Install or reconcile the configured deployment (default)
|
||||
upgrade Upgrade binary and restart service
|
||||
uninstall Remove installed service and binary (config kept unless --purge)
|
||||
status Show current installation status
|
||||
help Show this help
|
||||
|
||||
MODES (auto-detected from --node-id or --machine-id if omitted):
|
||||
--mode node Panel single-node mode (default)
|
||||
--mode machine Panel machine mode
|
||||
|
||||
REQUIRED FOR NODE MODE:
|
||||
--panel, -a Panel URL
|
||||
--token, -t Panel server token
|
||||
--node-id, -n Node ID
|
||||
|
||||
REQUIRED FOR MACHINE MODE:
|
||||
--panel, -a Panel URL
|
||||
--token, -t Machine token
|
||||
--machine-id Machine ID
|
||||
|
||||
OPTIONAL:
|
||||
--node-type, -T Explicit node type for node mode
|
||||
--kernel, -k singbox or xray (default: singbox)
|
||||
--version Release version or latest (default: latest)
|
||||
--binary Use a local xboard-node binary path instead of downloading
|
||||
--xbctl-binary Use a local xbctl binary path instead of downloading
|
||||
--health-port Local health port (default: 65530, use 0 to disable)
|
||||
--gomemlimit Runtime GOMEMLIMIT value, e.g. 256MiB
|
||||
--gogc Runtime GOGC value, e.g. 50
|
||||
--force-reconfigure Overwrite an existing install even if mode/target changed
|
||||
--purge With uninstall, delete /etc/xboard-node too
|
||||
--yes, -y Non-interactive confirmation for destructive operations
|
||||
|
||||
EXAMPLES:
|
||||
sudo bash install.sh --panel https://panel.example.com --token TOKEN --node-id 1
|
||||
sudo bash install.sh --panel https://panel.example.com --token TOKEN --machine-id 1
|
||||
sudo bash install.sh upgrade
|
||||
sudo bash install.sh uninstall --purge --yes
|
||||
|
||||
HELP
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
local positional=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
install|upgrade|uninstall|status|help)
|
||||
ACTION="$1"
|
||||
shift
|
||||
;;
|
||||
--mode)
|
||||
MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--panel|-a|--api)
|
||||
PANEL_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--token|-t)
|
||||
TOKEN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--node-id|-n)
|
||||
NODE_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--node-type|-T)
|
||||
NODE_TYPE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--machine-id)
|
||||
MACHINE_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--kernel|-k)
|
||||
KERNEL_TYPE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
RELEASE_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--binary)
|
||||
BINARY_SOURCE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--xbctl-binary)
|
||||
CLI_BINARY_SOURCE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--health-port)
|
||||
HEALTH_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--gomemlimit)
|
||||
RUNTIME_GOMEMLIMIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--gogc)
|
||||
RUNTIME_GOGC="$2"
|
||||
shift 2
|
||||
;;
|
||||
--force-reconfigure)
|
||||
FORCE_RECONFIGURE=1
|
||||
shift
|
||||
;;
|
||||
--purge)
|
||||
PURGE=1
|
||||
shift
|
||||
;;
|
||||
--yes|-y)
|
||||
YES=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
ACTION="help"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
positional+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ${#positional[@]} -gt 0 ] && [ "$ACTION" = "install" ]; then
|
||||
ACTION="${positional[0]}"
|
||||
fi
|
||||
|
||||
case "$KERNEL_TYPE" in
|
||||
singbox|SingBox|SINGBOX) KERNEL_TYPE="singbox" ;;
|
||||
xray|Xray|XRAY) KERNEL_TYPE="xray" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
# Auto-detect mode from arguments when --mode is not specified.
|
||||
if [ -z "$MODE" ]; then
|
||||
if [ -n "$MACHINE_ID" ]; then
|
||||
MODE="machine"
|
||||
else
|
||||
MODE="node"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
node|machine) ;;
|
||||
*)
|
||||
log_error "Unsupported mode: $MODE"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
log_error "Please run as root or with sudo"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
local raw
|
||||
raw=$(uname -m)
|
||||
case "$raw" in
|
||||
x86_64|amd64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*)
|
||||
log_error "Unsupported architecture: $raw"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS="$ID"
|
||||
else
|
||||
OS="unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_systemd() {
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
log_error "systemd is required for this installer"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d /run/systemd/system ]; then
|
||||
log_error "This host does not appear to be running systemd"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_with_retry() {
|
||||
local attempts="$1"
|
||||
local delay="$2"
|
||||
shift 2
|
||||
local i=1
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
log_warn "Command failed, retrying in ${delay}s: $*"
|
||||
sleep "$delay"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
case "$OS" in
|
||||
ubuntu|debian)
|
||||
DEBIAN_FRONTEND=noninteractive run_with_retry 10 3 apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive run_with_retry 10 3 apt-get install -y -qq curl wget ca-certificates >/dev/null 2>&1
|
||||
;;
|
||||
centos|rhel|rocky|almalinux|fedora)
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
run_with_retry 5 3 dnf install -y -q curl wget ca-certificates >/dev/null 2>&1
|
||||
else
|
||||
run_with_retry 5 3 yum install -y -q curl wget ca-certificates >/dev/null 2>&1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log_warn "OS ${OS} is not in the official support set; continuing best-effort"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$INSTALL_ROOT" "$BACKUP_DIR"
|
||||
chmod 700 "$INSTALL_ROOT"
|
||||
}
|
||||
|
||||
validate_positive_int() {
|
||||
local label="$1"
|
||||
local value="$2"
|
||||
if ! [[ "$value" =~ ^[0-9]+$ ]] || [ "$value" -le 0 ]; then
|
||||
log_error "${label} must be a positive integer, got: ${value}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_install_request() {
|
||||
if [ -z "$PANEL_URL" ]; then
|
||||
log_error "Panel URL is required"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$TOKEN" ]; then
|
||||
log_error "Token is required"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$HEALTH_PORT" =~ ^[0-9]+$ ]]; then
|
||||
log_error "health-port must be a non-negative integer"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$HEALTH_PORT" -eq 0 ]; then
|
||||
HEALTH_ENABLED=0
|
||||
fi
|
||||
case "$KERNEL_TYPE" in
|
||||
singbox|xray) ;;
|
||||
*)
|
||||
log_error "Kernel must be singbox or xray"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$MODE" in
|
||||
node)
|
||||
validate_positive_int "Node ID" "$NODE_ID"
|
||||
;;
|
||||
machine)
|
||||
validate_positive_int "Machine ID" "$MACHINE_ID"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_current_state() {
|
||||
local has_binary=0 has_config=0 has_service=0
|
||||
[ -x "$BINARY_PATH" ] && has_binary=1
|
||||
[ -f "$CONFIG_FILE" ] && has_config=1
|
||||
[ -f "$SERVICE_PATH" ] && has_service=1
|
||||
|
||||
if [ "$has_binary" -eq 0 ] && [ "$has_config" -eq 0 ] && [ "$has_service" -eq 0 ]; then
|
||||
CURRENT_STATE="fresh"
|
||||
elif [ "$has_binary" -eq 1 ] && [ "$has_config" -eq 1 ] && [ "$has_service" -eq 1 ]; then
|
||||
CURRENT_STATE="installed"
|
||||
else
|
||||
CURRENT_STATE="partial"
|
||||
fi
|
||||
}
|
||||
|
||||
require_reconfigure_confirmation() {
|
||||
return
|
||||
}
|
||||
|
||||
select_binary_source() {
|
||||
if [ -n "$BINARY_SOURCE" ]; then
|
||||
if [ ! -f "$BINARY_SOURCE" ]; then
|
||||
log_error "Binary source not found: $BINARY_SOURCE"
|
||||
exit 1
|
||||
fi
|
||||
echo "$BINARY_SOURCE"
|
||||
return
|
||||
fi
|
||||
if [ -f "./xboard-node" ]; then
|
||||
echo "./xboard-node"
|
||||
return
|
||||
fi
|
||||
if [ -f "./xboard-node-linux-${ARCH}" ]; then
|
||||
echo "./xboard-node-linux-${ARCH}"
|
||||
return
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
resolve_download_url() {
|
||||
local artifact="$1"
|
||||
# Gitea release download URL: {base}/{tag}/{file}
|
||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/${RELEASE_VERSION}/${artifact}"
|
||||
}
|
||||
|
||||
stage_binary() {
|
||||
local staged="$TMP_DIR/xboard-node"
|
||||
local local_src
|
||||
local_src=$(select_binary_source)
|
||||
if [ -n "$local_src" ]; then
|
||||
log_step "Using local binary: ${local_src}"
|
||||
cp "$local_src" "$staged"
|
||||
else
|
||||
resolve_download_url "xboard-node-linux-${ARCH}"
|
||||
log_step "Downloading binary: ${DOWNLOAD_URL}"
|
||||
if ! curl -fsSL "$DOWNLOAD_URL" -o "$staged"; then
|
||||
log_error "Failed to download binary from ${DOWNLOAD_URL}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
chmod +x "$staged"
|
||||
if ! "$staged" -v >/dev/null 2>&1; then
|
||||
log_error "Downloaded binary failed version check"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
stage_xbctl() {
|
||||
local staged="$TMP_DIR/xbctl"
|
||||
local local_src=""
|
||||
if [ -n "$CLI_BINARY_SOURCE" ]; then
|
||||
if [ ! -f "$CLI_BINARY_SOURCE" ]; then
|
||||
log_error "xbctl binary source not found: $CLI_BINARY_SOURCE"
|
||||
exit 1
|
||||
fi
|
||||
local_src="$CLI_BINARY_SOURCE"
|
||||
elif [ -f "./xbctl" ]; then
|
||||
local_src="./xbctl"
|
||||
elif [ -f "./xbctl-linux-${ARCH}" ]; then
|
||||
local_src="./xbctl-linux-${ARCH}"
|
||||
fi
|
||||
if [ -n "$local_src" ]; then
|
||||
log_step "Using local xbctl binary: ${local_src}"
|
||||
cp "$local_src" "$staged"
|
||||
else
|
||||
resolve_download_url "xbctl-linux-${ARCH}"
|
||||
log_step "Downloading xbctl: ${DOWNLOAD_URL}"
|
||||
if ! curl -fsSL "$DOWNLOAD_URL" -o "$staged"; then
|
||||
log_error "Failed to download xbctl from ${DOWNLOAD_URL}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
chmod +x "$staged"
|
||||
if ! "$staged" version > /dev/null 2>&1; then
|
||||
log_error "Downloaded xbctl failed version check"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
render_config() {
|
||||
local init_args=(
|
||||
config init
|
||||
--mode "$MODE"
|
||||
--panel-url "$PANEL_URL"
|
||||
--kernel "${KERNEL_TYPE:-singbox}"
|
||||
--health-port "${HEALTH_PORT:-0}"
|
||||
--token "$TOKEN"
|
||||
--version "$RELEASE_VERSION"
|
||||
--output "$TMP_DIR/config.yml"
|
||||
--credentials-out "$TMP_DIR/credentials.env"
|
||||
--meta "$TMP_DIR/install-meta.json"
|
||||
--install-root "$INSTALL_ROOT"
|
||||
)
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
init_args+=(--config "$CONFIG_FILE")
|
||||
fi
|
||||
if [ -f "$CREDENTIALS_FILE" ]; then
|
||||
init_args+=(--credentials-in "$CREDENTIALS_FILE")
|
||||
fi
|
||||
if [ "$MODE" = "machine" ]; then
|
||||
init_args+=(--machine-id "$MACHINE_ID")
|
||||
else
|
||||
init_args+=(--node-id "$NODE_ID")
|
||||
if [ -n "$NODE_TYPE" ]; then
|
||||
init_args+=(--node-type "$NODE_TYPE")
|
||||
fi
|
||||
fi
|
||||
if [ -n "$RUNTIME_GOMEMLIMIT" ]; then
|
||||
init_args+=(--gomemlimit "$RUNTIME_GOMEMLIMIT")
|
||||
fi
|
||||
if [ -n "$RUNTIME_GOGC" ] && [ "$RUNTIME_GOGC" -gt 0 ] 2>/dev/null; then
|
||||
init_args+=(--gogc "$RUNTIME_GOGC")
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$("$TMP_DIR/xbctl" "${init_args[@]}") || {
|
||||
log_error "xbctl config init failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
INSTANCE_ID=$(echo "$output" | grep '^INSTANCE_ID=' | cut -d= -f2-)
|
||||
chmod 600 "$TMP_DIR/credentials.env"
|
||||
}
|
||||
|
||||
render_service() {
|
||||
cat >"$TMP_DIR/${SERVICE_NAME}" <<EOF_UNIT
|
||||
[Unit]
|
||||
Description=Xboard Node Backend
|
||||
Documentation=http://64.204.66.12:3000/will/xboard-node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${INSTALL_ROOT}
|
||||
EnvironmentFile=-${CREDENTIALS_FILE}
|
||||
ExecStart=${BINARY_PATH} -c ${CONFIG_FILE}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=1048576
|
||||
NoNewPrivileges=true
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF_UNIT
|
||||
}
|
||||
|
||||
backup_existing_state() {
|
||||
BACKUP_PATH="${BACKUP_DIR}/$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BACKUP_PATH"
|
||||
if [ -x "$BINARY_PATH" ]; then
|
||||
cp "$BINARY_PATH" "$BACKUP_PATH/xboard-node"
|
||||
fi
|
||||
if [ -x "$CLI_PATH" ]; then
|
||||
cp "$CLI_PATH" "$BACKUP_PATH/xbctl"
|
||||
fi
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
cp "$CONFIG_FILE" "$BACKUP_PATH/config.yml"
|
||||
fi
|
||||
if [ -f "$CREDENTIALS_FILE" ]; then
|
||||
cp "$CREDENTIALS_FILE" "$BACKUP_PATH/credentials.env"
|
||||
fi
|
||||
if [ -f "$INSTALL_META" ]; then
|
||||
cp "$INSTALL_META" "$BACKUP_PATH/install-meta.json"
|
||||
fi
|
||||
if [ -f "$SERVICE_PATH" ]; then
|
||||
cp "$SERVICE_PATH" "$BACKUP_PATH/${SERVICE_NAME}"
|
||||
SERVICE_EXISTED=1
|
||||
else
|
||||
SERVICE_EXISTED=0
|
||||
fi
|
||||
}
|
||||
|
||||
stop_existing_service() {
|
||||
if [ -f "$SERVICE_PATH" ] || systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1; then
|
||||
systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
install_staged_files() {
|
||||
stop_existing_service
|
||||
install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH"
|
||||
install -m 600 "$TMP_DIR/config.yml" "$CONFIG_FILE"
|
||||
install -m 600 "$TMP_DIR/credentials.env" "$CREDENTIALS_FILE"
|
||||
install -m 644 "$TMP_DIR/install-meta.json" "$INSTALL_META"
|
||||
if [ -f "$0" ] && [ "$(realpath "$0")" != "$(realpath "$INSTALLER_COPY_PATH" 2>/dev/null || echo "$INSTALLER_COPY_PATH")" ]; then
|
||||
install -m 755 "$0" "$INSTALLER_COPY_PATH"
|
||||
fi
|
||||
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
|
||||
ln -sf "$CLI_PATH" /usr/bin/xbctl 2>/dev/null || true
|
||||
install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
if ! systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
if [ "$HEALTH_ENABLED" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
local attempt=0
|
||||
local max_attempts=30
|
||||
while [ "$attempt" -lt "$max_attempts" ]; do
|
||||
if ! systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
if curl -fsS "http://127.0.0.1:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
show_recent_logs() {
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
journalctl -u "$SERVICE_NAME" -n 30 --no-pager || true
|
||||
fi
|
||||
}
|
||||
|
||||
start_service() {
|
||||
if systemctl is-enabled "$SERVICE_NAME" >/dev/null 2>&1; then
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
else
|
||||
systemctl start "$SERVICE_NAME"
|
||||
fi
|
||||
if ! wait_for_health; then
|
||||
log_error "Service failed health check"
|
||||
show_recent_logs
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
perform_install() {
|
||||
validate_install_request
|
||||
detect_current_state
|
||||
require_reconfigure_confirmation
|
||||
TMP_DIR=$(mktemp -d)
|
||||
ensure_dirs
|
||||
stage_binary
|
||||
stage_xbctl
|
||||
render_config
|
||||
render_service
|
||||
backup_existing_state
|
||||
install_staged_files
|
||||
start_service
|
||||
|
||||
log_info "Installation succeeded"
|
||||
log_info "Service: ${SERVICE_NAME}"
|
||||
log_info "Config: ${CONFIG_FILE}"
|
||||
log_info "Credentials: ${CREDENTIALS_FILE}"
|
||||
if [ "$HEALTH_ENABLED" -eq 1 ]; then
|
||||
log_info "Health: http://127.0.0.1:${HEALTH_PORT}/healthz"
|
||||
fi
|
||||
log_info "CLI: ${CLI_PATH} (run '${CLI_PATH} list' if xbctl is not in PATH)"
|
||||
}
|
||||
|
||||
perform_upgrade() {
|
||||
detect_current_state
|
||||
if [ "$CURRENT_STATE" = "fresh" ]; then
|
||||
log_warn "No existing install found; falling back to install"
|
||||
perform_install
|
||||
return
|
||||
fi
|
||||
TMP_DIR=$(mktemp -d)
|
||||
ensure_dirs
|
||||
stage_binary
|
||||
stage_xbctl
|
||||
render_service
|
||||
backup_existing_state
|
||||
install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH"
|
||||
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
|
||||
ln -sf "$CLI_PATH" /usr/bin/xbctl 2>/dev/null || true
|
||||
install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH"
|
||||
systemctl daemon-reload
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
if ! wait_for_health; then
|
||||
log_error "Upgrade health check failed"
|
||||
show_recent_logs
|
||||
return 1
|
||||
fi
|
||||
log_info "Upgrade succeeded"
|
||||
}
|
||||
|
||||
confirm_uninstall() {
|
||||
if [ "$YES" -eq 1 ]; then
|
||||
return
|
||||
fi
|
||||
echo
|
||||
read -r -p "Proceed with uninstall? [y/N]: " answer
|
||||
if ! [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||
log_warn "Uninstall cancelled"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
perform_uninstall() {
|
||||
confirm_uninstall
|
||||
if [ -f "$SERVICE_PATH" ]; then
|
||||
systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
rm -f "$SERVICE_PATH"
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
rm -f "$BINARY_PATH"
|
||||
rm -f "$CLI_PATH"
|
||||
rm -f /usr/bin/xbctl 2>/dev/null || true
|
||||
if [ "$PURGE" -eq 1 ]; then
|
||||
rm -rf "$INSTALL_ROOT"
|
||||
log_info "Removed ${INSTALL_ROOT}"
|
||||
else
|
||||
rm -f "$INSTALL_META"
|
||||
log_info "Config preserved under ${INSTALL_ROOT}"
|
||||
fi
|
||||
log_info "Uninstall complete"
|
||||
}
|
||||
|
||||
perform_status() {
|
||||
detect_current_state
|
||||
echo
|
||||
echo -e "${BOLD}xboard-node install status${NC}"
|
||||
echo " state: ${CURRENT_STATE}"
|
||||
if [ -f "$INSTALL_META" ]; then
|
||||
echo " meta: ${INSTALL_META}"
|
||||
if [ -x "$CLI_PATH" ]; then
|
||||
"$CLI_PATH" list 2>/dev/null || true
|
||||
else
|
||||
# Simple key extraction from JSON (no Python needed)
|
||||
local val
|
||||
for key in config_mode version latest_instance_id instance_count updated_at; do
|
||||
val=$(sed -n "s/.*\"${key}\": *\"\{0,1\}\([^\"]*\)\"\{0,1\}.*/\1/p" "$INSTALL_META" | head -1)
|
||||
val="${val%,}" # strip trailing comma from numeric JSON values
|
||||
[ -n "$val" ] && echo " ${key}: ${val}"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
if [ -f "$SERVICE_PATH" ]; then
|
||||
echo " service: ${SERVICE_NAME}"
|
||||
systemctl status "$SERVICE_NAME" --no-pager || true
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
case "$ACTION" in
|
||||
help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
status)
|
||||
ensure_systemd
|
||||
perform_status
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
check_root
|
||||
detect_arch
|
||||
detect_os
|
||||
ensure_systemd
|
||||
install_dependencies
|
||||
|
||||
case "$ACTION" in
|
||||
install)
|
||||
perform_install
|
||||
;;
|
||||
upgrade)
|
||||
perform_upgrade
|
||||
;;
|
||||
uninstall)
|
||||
perform_uninstall
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown action: $ACTION"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,549 +0,0 @@
|
||||
package cert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
|
||||
"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.
|
||||
//
|
||||
// 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 generated in memory.
|
||||
// - "file" — User-provided certificate and key file paths (CertFile/KeyFile).
|
||||
// - "content" — Certificate and key PEM content pushed from the panel.
|
||||
// - "none" — No TLS. The node will handle plain connections.
|
||||
//
|
||||
// All modes normalise to in-memory PEM. Kernels never see file paths —
|
||||
// they always receive PEM bytes via TLSCert().
|
||||
//
|
||||
// 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
|
||||
|
||||
// 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 {
|
||||
return &Manager{cfg: cfg}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Returns true when the PEM material changed (caller should restart kernel).
|
||||
func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) {
|
||||
if newCfg.CertDir == "" {
|
||||
newCfg.CertDir = m.cfg.CertDir
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
if err := m.Start(ctx); err != nil {
|
||||
return false, fmt.Errorf("cert reconfigure: %w", err)
|
||||
}
|
||||
|
||||
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.
|
||||
func (m *Manager) resolveMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(m.cfg.CertMode))
|
||||
if mode != "" {
|
||||
return mode
|
||||
}
|
||||
// Backward compat: auto_tls: true → "http"
|
||||
if m.cfg.AutoTLS {
|
||||
return "http"
|
||||
}
|
||||
// Inline PEM content provided → "content"
|
||||
if m.cfg.CertContent != "" && m.cfg.KeyContent != "" {
|
||||
return "content"
|
||||
}
|
||||
// If cert/key file paths are provided → "file"
|
||||
if m.cfg.CertFile != "" && m.cfg.KeyFile != "" {
|
||||
return "file"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
switch mode {
|
||||
case "none", "":
|
||||
return nil
|
||||
case "file":
|
||||
return m.startFile()
|
||||
case "self":
|
||||
return m.startSelfSigned()
|
||||
case "content":
|
||||
return m.startContent()
|
||||
case "http":
|
||||
return m.startACME(ctx, nil)
|
||||
case "dns":
|
||||
solver, err := m.buildDNSSolver()
|
||||
if err != nil {
|
||||
return fmt.Errorf("build dns solver: %w", err)
|
||||
}
|
||||
return m.startACME(ctx, solver)
|
||||
default:
|
||||
return fmt.Errorf("unknown cert_mode: %q (supported: http, dns, self, file, content, none)", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop is a no-op: certmagic's background goroutine is cancelled via the
|
||||
// context passed to ManageAsync.
|
||||
func (m *Manager) Stop() {}
|
||||
|
||||
// ─── Mode: file ────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Manager) startFile() error {
|
||||
certPEM, err := os.ReadFile(m.cfg.CertFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cert file: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(m.cfg.KeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key file: %w", err)
|
||||
}
|
||||
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 {
|
||||
// Reuse persisted self-signed cert if available and valid.
|
||||
if m.loadPersistedPEM() {
|
||||
return nil
|
||||
}
|
||||
|
||||
domain := m.cfg.Domain
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
// Add SANs
|
||||
if ip := net.ParseIP(domain); ip != nil {
|
||||
template.IPAddresses = []net.IP{ip}
|
||||
} else {
|
||||
template.DNSNames = []string{domain}
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create certificate: %w", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
m.storePEM(certPEM, keyPEM)
|
||||
nlog.Core().Info("self-signed certificate generated (in-memory)", "domain", domain, "valid_years", 10)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Mode: content ────────────────────────────────────────────────────────
|
||||
|
||||
// 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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
// Idempotent for unchanged config: Reconfigure already tore down stale ACME state.
|
||||
if m.acmeStarted {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.cfg.Domain == "" {
|
||||
return fmt.Errorf("cert.domain is required for ACME modes (http/dns)")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.cfg.CertDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create cert dir: %w", err)
|
||||
}
|
||||
|
||||
storage := &certmagic.FileStorage{Path: m.cfg.CertDir}
|
||||
|
||||
var magic *certmagic.Config
|
||||
cache := certmagic.NewCache(certmagic.CacheOptions{
|
||||
GetConfigForCert: func(_ certmagic.Certificate) (*certmagic.Config, error) {
|
||||
return magic, nil
|
||||
},
|
||||
})
|
||||
|
||||
magic = certmagic.New(cache, certmagic.Config{
|
||||
Storage: storage,
|
||||
OnEvent: func(evtCtx context.Context, event string, data map[string]any) error {
|
||||
// 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
|
||||
},
|
||||
})
|
||||
|
||||
issuer := certmagic.ACMEIssuer{
|
||||
CA: certmagic.LetsEncryptProductionCA,
|
||||
Email: m.cfg.Email,
|
||||
}
|
||||
|
||||
if dnsSolver != nil {
|
||||
// DNS-01 mode: no HTTP port needed, supports wildcards.
|
||||
issuer.DNS01Solver = dnsSolver
|
||||
issuer.DisableHTTPChallenge = true
|
||||
issuer.DisableTLSALPNChallenge = true
|
||||
} else {
|
||||
// HTTP-01 mode.
|
||||
httpPort := m.cfg.HTTPPort
|
||||
if httpPort == 0 {
|
||||
httpPort = 80
|
||||
}
|
||||
issuer.AltHTTPPort = httpPort
|
||||
issuer.DisableTLSALPNChallenge = true
|
||||
}
|
||||
|
||||
magic.Issuers = []certmagic.Issuer{
|
||||
certmagic.NewACMEIssuer(magic, issuer),
|
||||
}
|
||||
m.magic = magic
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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.
|
||||
func (m *Manager) buildDNSSolver() (*certmagic.DNS01Solver, error) {
|
||||
provider, err := m.newDNSProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &certmagic.DNS01Solver{DNSManager: certmagic.DNSManager{DNSProvider: provider}}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) newDNSProvider() (certmagic.DNSProvider, error) {
|
||||
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{}
|
||||
}
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("load cert %q: %w", certKey, err)
|
||||
}
|
||||
keyPEM, err := storage.Load(ctx, keyKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load key %q: %w", keyKey, err)
|
||||
}
|
||||
if err := validateKeyPair(certPEM, keyPEM); err != nil {
|
||||
return fmt.Errorf("invalid cert/key pair on disk: %w", err)
|
||||
}
|
||||
m.storePEM(certPEM, keyPEM)
|
||||
return nil
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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, ", "))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,906 +0,0 @@
|
||||
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"`
|
||||
// Nodes enables multi-node mode. When set, Panel.NodeID is ignored and
|
||||
// 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.
|
||||
type NodeEntry struct {
|
||||
NodeID int `yaml:"node_id"`
|
||||
NodeType string `yaml:"node_type,omitempty"`
|
||||
// Kernel allows per-node overrides (e.g. config_dir). nil = inherit global.
|
||||
Kernel *KernelOverride `yaml:"kernel,omitempty"`
|
||||
// Cert allows per-node certificate overrides. nil = inherit global.
|
||||
Cert *CertConfig `yaml:"cert,omitempty"`
|
||||
}
|
||||
|
||||
// KernelOverride holds the subset of KernelConfig that is useful to override
|
||||
// per-node. Only non-zero fields replace the global value.
|
||||
type KernelOverride struct {
|
||||
ConfigDir string `yaml:"config_dir,omitempty"`
|
||||
GeoDataDir string `yaml:"geo_data_dir,omitempty"`
|
||||
LogLevel string `yaml:"log_level,omitempty"`
|
||||
CustomConfig string `yaml:"custom_config,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeConfig tunes Go runtime memory behaviour.
|
||||
// These knobs let operators trade CPU against memory on constrained machines
|
||||
// without recompiling.
|
||||
//
|
||||
// Example (config.yml):
|
||||
//
|
||||
// runtime:
|
||||
// gomemlimit: "30MiB" # soft RSS cap; GC becomes more aggressive above this
|
||||
// gogc: 50 # halve GC target → lower peak RSS, slightly more CPU
|
||||
type RuntimeConfig struct {
|
||||
// GoMemLimit is a human-readable soft memory limit passed to runtime/debug.SetMemoryLimit.
|
||||
// Valid suffixes: B, KiB, MiB, GiB, TiB. Empty = no limit (default).
|
||||
// Recommended starting point: set to ~80% of the machine's available RAM.
|
||||
GoMemLimit string `yaml:"gomemlimit"`
|
||||
|
||||
// GoGCPercent overrides GOGC (default 100).
|
||||
// Lower values (e.g. 50) trigger GC more often → lower memory, slightly higher CPU.
|
||||
// 0 means "use the default (100)".
|
||||
GoGCPercent int `yaml:"gogc"`
|
||||
}
|
||||
|
||||
type PanelConfig struct {
|
||||
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"`
|
||||
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 {
|
||||
Type string `yaml:"type"` // "singbox" or "xray"
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
|
||||
// GeoDataDir is the directory that contains GeoIP/GeoSite database files.
|
||||
// For sing-box: geoip.db and geosite.db (geoip2-format).
|
||||
// For xray: geoip.dat and geosite.dat.
|
||||
// Defaults to config_dir when empty. You only need to set this if your
|
||||
// geo database files live somewhere other than config_dir.
|
||||
GeoDataDir string `yaml:"geo_data_dir"`
|
||||
|
||||
// CustomOutbound adds outbound entries to the generated kernel config.
|
||||
// Each item is a raw kernel-native outbound object (sing-box or xray format).
|
||||
CustomOutbound []map[string]any `yaml:"custom_outbound"`
|
||||
|
||||
// CustomRoute adds route rules to the generated kernel config.
|
||||
// Each item is a raw kernel-native route rule object.
|
||||
CustomRoute []map[string]any `yaml:"custom_route"`
|
||||
|
||||
// CustomConfig is the path to a kernel-native config file (JSON or YAML)
|
||||
// that is deep-merged into the auto-generated config. This enables full
|
||||
// customization of dns, outbounds, endpoints, route, experimental, etc.
|
||||
// Compatible with V2bX OriginalPath format.
|
||||
CustomConfig string `yaml:"custom_config"`
|
||||
}
|
||||
|
||||
type CertConfig struct {
|
||||
AutoTLS bool `yaml:"auto_tls"`
|
||||
Domain string `yaml:"domain"`
|
||||
Email string `yaml:"email"`
|
||||
CertFile string `yaml:"cert_file"`
|
||||
KeyFile string `yaml:"key_file"`
|
||||
CertDir string `yaml:"cert_dir"`
|
||||
HTTPPort int `yaml:"http_port"` // port for HTTP-01 challenge (default: 80)
|
||||
|
||||
// CertMode selects the TLS certificate strategy:
|
||||
// "" - auto-detect: if CertFile is set → file; if AutoTLS → http; else none
|
||||
// "http" - ACME HTTP-01 challenge (requires port 80)
|
||||
// "dns" - ACME DNS-01 challenge (requires DNSProvider + DNSEnv)
|
||||
// "self" - generate a self-signed certificate (valid 10 years)
|
||||
// "file" - use manually provided CertFile/KeyFile paths
|
||||
// "content"- cert/key PEM provided directly in CertContent/KeyContent
|
||||
// "none" - no TLS
|
||||
CertMode string `yaml:"cert_mode"`
|
||||
|
||||
// DNSProvider specifies the DNS provider for DNS-01 challenge.
|
||||
// Supported: "cloudflare", "alidns"
|
||||
DNSProvider string `yaml:"dns_provider"`
|
||||
|
||||
// DNSEnv passes credentials to the DNS provider as key=value pairs.
|
||||
// Example for cloudflare: {"CF_API_TOKEN": "xxxx"}
|
||||
DNSEnv map[string]string `yaml:"dns_env"`
|
||||
|
||||
// CertContent / KeyContent hold PEM-encoded certificate and private key.
|
||||
// Used when CertMode == "content" (e.g. panel pushes a cert directly).
|
||||
// The values are written to CertDir and then referenced as files by the kernel.
|
||||
CertContent string `yaml:"cert_content,omitempty"`
|
||||
KeyContent string `yaml:"key_content,omitempty"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
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).
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
baseDir := configBaseDir(path)
|
||||
|
||||
cfg.applyEnvOverrides()
|
||||
cfg.resolveEnvRefs()
|
||||
cfg.setDefaultsFrom(baseDir)
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// envFirst returns the first non-empty value among the given env var names.
|
||||
func envFirst(names ...string) string {
|
||||
for _, n := range names {
|
||||
if v := os.Getenv(n); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Config) applyEnvOverrides() {
|
||||
if v := envFirst("apiHost", "API_HOST"); v != "" {
|
||||
c.Panel.URL = v
|
||||
}
|
||||
if v := envFirst("apiKey", "API_KEY"); v != "" {
|
||||
c.Panel.Token = v
|
||||
}
|
||||
if v := envFirst("nodeID", "NODE_ID"); v != "" {
|
||||
if id, err := strconv.Atoi(v); err == nil {
|
||||
c.Panel.NodeID = id
|
||||
}
|
||||
}
|
||||
if v := envFirst("nodeType", "NODE_TYPE"); v != "" {
|
||||
c.Panel.NodeType = v
|
||||
}
|
||||
if v := envFirst("kernel", "KERNEL_TYPE"); v != "" {
|
||||
c.Kernel.Type = v
|
||||
}
|
||||
if v := envFirst("certFile", "CERT_FILE"); v != "" {
|
||||
c.Cert.CertFile = v
|
||||
}
|
||||
if v := envFirst("keyFile", "KEY_FILE"); v != "" {
|
||||
c.Cert.KeyFile = v
|
||||
}
|
||||
if v := envFirst("domain", "DOMAIN"); v != "" {
|
||||
c.Cert.Domain = v
|
||||
c.Cert.AutoTLS = true
|
||||
}
|
||||
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) 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 = baseDir
|
||||
}
|
||||
if c.Kernel.GeoDataDir == "" {
|
||||
c.Kernel.GeoDataDir = c.Kernel.ConfigDir
|
||||
}
|
||||
if c.Kernel.LogLevel == "" {
|
||||
c.Kernel.LogLevel = "warn"
|
||||
}
|
||||
if c.Log.Level == "" {
|
||||
c.Log.Level = "info"
|
||||
}
|
||||
if c.Log.Output == "" {
|
||||
c.Log.Output = "stdout"
|
||||
}
|
||||
if c.Cert.CertDir == "" {
|
||||
c.Cert.CertDir = filepath.Join(c.Kernel.ConfigDir, "certs")
|
||||
}
|
||||
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.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 {
|
||||
case "singbox", "xray":
|
||||
default:
|
||||
return fmt.Errorf("kernel.type must be 'singbox' or 'xray', got '%s'", c.Kernel.Type)
|
||||
}
|
||||
if c.Cert.AutoTLS && c.Cert.Domain == "" {
|
||||
return fmt.Errorf("cert.domain is required when cert.auto_tls is enabled")
|
||||
}
|
||||
if c.Node.PushInterval < 0 {
|
||||
return fmt.Errorf("node.push_interval must not be negative")
|
||||
}
|
||||
if c.Node.PullInterval < 0 {
|
||||
return fmt.Errorf("node.pull_interval must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpandNodes returns one *Config per node to run.
|
||||
// Single-node mode (Nodes empty): returns a slice containing the receiver.
|
||||
// Multi-node mode: returns one derived *Config per NodeEntry, each inheriting
|
||||
// shared settings and applying per-node overrides.
|
||||
func (c *Config) ExpandNodes() []*Config {
|
||||
if len(c.Nodes) == 0 {
|
||||
return []*Config{c}
|
||||
}
|
||||
|
||||
result := make([]*Config, 0, len(c.Nodes))
|
||||
for _, entry := range c.Nodes {
|
||||
nodeCfg := *c // shallow copy — safe because slices/maps are not mutated
|
||||
nodeCfg.Nodes = nil
|
||||
nodeCfg.Panel.NodeID = entry.NodeID
|
||||
nodeCfg.Panel.NodeType = entry.NodeType
|
||||
|
||||
// Per-node kernel overrides
|
||||
if entry.Kernel != nil {
|
||||
if entry.Kernel.ConfigDir != "" {
|
||||
nodeCfg.Kernel.ConfigDir = entry.Kernel.ConfigDir
|
||||
if nodeCfg.Kernel.GeoDataDir == c.Kernel.ConfigDir {
|
||||
// GeoDataDir was defaulted to ConfigDir — keep it pointing at
|
||||
// the new ConfigDir unless the user set it explicitly.
|
||||
nodeCfg.Kernel.GeoDataDir = entry.Kernel.ConfigDir
|
||||
}
|
||||
}
|
||||
if entry.Kernel.GeoDataDir != "" {
|
||||
nodeCfg.Kernel.GeoDataDir = entry.Kernel.GeoDataDir
|
||||
}
|
||||
if entry.Kernel.LogLevel != "" {
|
||||
nodeCfg.Kernel.LogLevel = entry.Kernel.LogLevel
|
||||
}
|
||||
if entry.Kernel.CustomConfig != "" {
|
||||
nodeCfg.Kernel.CustomConfig = entry.Kernel.CustomConfig
|
||||
}
|
||||
} else {
|
||||
// Auto-derive a unique config_dir per node to avoid conflicts.
|
||||
nodeCfg.Kernel.ConfigDir = fmt.Sprintf("%s/node-%d", c.Kernel.ConfigDir, entry.NodeID)
|
||||
if nodeCfg.Kernel.GeoDataDir == c.Kernel.ConfigDir {
|
||||
// Share the geo data dir with the base dir to avoid re-downloading.
|
||||
nodeCfg.Kernel.GeoDataDir = c.Kernel.GeoDataDir
|
||||
}
|
||||
}
|
||||
|
||||
// Per-node cert overrides
|
||||
if entry.Cert != nil {
|
||||
nodeCfg.Cert = *entry.Cert
|
||||
}
|
||||
if nodeCfg.Cert.CertDir == "" {
|
||||
nodeCfg.Cert.CertDir = filepath.Join(nodeCfg.Kernel.ConfigDir, "certs")
|
||||
}
|
||||
|
||||
result = append(result, &nodeCfg)
|
||||
}
|
||||
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 minLevel slog.Level
|
||||
switch cfg.Level {
|
||||
case "debug":
|
||||
minLevel = slog.LevelDebug
|
||||
case "warn":
|
||||
minLevel = slog.LevelWarn
|
||||
case "error":
|
||||
minLevel = slog.LevelError
|
||||
default:
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,545 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTemp(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoad_ValidConfig(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "secret-token"
|
||||
node_id: 5
|
||||
node_type: "v2ray"
|
||||
kernel:
|
||||
type: singbox
|
||||
log_level: warn
|
||||
log:
|
||||
level: debug
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Panel.URL != "https://panel.example.com" {
|
||||
t.Errorf("url: got %q", cfg.Panel.URL)
|
||||
}
|
||||
if cfg.Panel.Token != "secret-token" {
|
||||
t.Errorf("token: got %q", cfg.Panel.Token)
|
||||
}
|
||||
if cfg.Panel.NodeID != 5 {
|
||||
t.Errorf("node_id: got %d", cfg.Panel.NodeID)
|
||||
}
|
||||
if cfg.Panel.NodeType != "v2ray" {
|
||||
t.Errorf("node_type: got %q", cfg.Panel.NodeType)
|
||||
}
|
||||
if cfg.Kernel.Type != "singbox" {
|
||||
t.Errorf("kernel.type: got %q", cfg.Kernel.Type)
|
||||
}
|
||||
if cfg.Log.Level != "debug" {
|
||||
t.Errorf("log.level: got %q", cfg.Log.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Kernel.Type != "singbox" {
|
||||
t.Errorf("default kernel.type: got %q, want singbox", cfg.Kernel.Type)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
if cfg.Log.Level != "info" {
|
||||
t.Errorf("default log.level: got %q, want info", cfg.Log.Level)
|
||||
}
|
||||
if cfg.Log.Output != "stdout" {
|
||||
t.Errorf("default log.output: got %q, want stdout", cfg.Log.Output)
|
||||
}
|
||||
if cfg.Cert.HTTPPort != 80 {
|
||||
t.Errorf("default http_port: got %d, want 80", cfg.Cert.HTTPPort)
|
||||
}
|
||||
expectedCertDir := filepath.Join(expectedDir, "certs")
|
||||
if cfg.Cert.CertDir != expectedCertDir {
|
||||
t.Errorf("default cert_dir: got %q, want %q", cfg.Cert.CertDir, expectedCertDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingURL(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingToken(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
node_id: 1
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidNodeID(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 0
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for node_id=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_NegativeNodeID(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: -1
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative node_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidKernelType(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
kernel:
|
||||
type: "invalid"
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid kernel type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_XrayKernel(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
kernel:
|
||||
type: xray
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Kernel.Type != "xray" {
|
||||
t.Errorf("kernel.type: got %q, want xray", cfg.Kernel.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AutoTLS_NoDomain(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
cert:
|
||||
auto_tls: true
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for auto_tls without domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AutoTLS_WithDomain(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
cert:
|
||||
auto_tls: true
|
||||
domain: "node.example.com"
|
||||
email: "admin@example.com"
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.Cert.AutoTLS {
|
||||
t.Error("auto_tls should be true")
|
||||
}
|
||||
if cfg.Cert.Domain != "node.example.com" {
|
||||
t.Errorf("domain: got %q", cfg.Cert.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileNotFound(t *testing.T) {
|
||||
_, err := Load("/nonexistent/path/config.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidYAML(t *testing.T) {
|
||||
path := writeTemp(t, "{{{{invalid yaml}}}")
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomCert(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
cert:
|
||||
cert_file: "/custom/cert.pem"
|
||||
key_file: "/custom/key.pem"
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Cert.CertFile != "/custom/cert.pem" {
|
||||
t.Errorf("cert_file: got %q", cfg.Cert.CertFile)
|
||||
}
|
||||
if cfg.Cert.KeyFile != "/custom/key.pem" {
|
||||
t.Errorf("key_file: got %q", cfg.Cert.KeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomIntervals(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
node:
|
||||
push_interval: 30
|
||||
pull_interval: 60
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Node.PushInterval != 30 {
|
||||
t.Errorf("push_interval: got %d", cfg.Node.PushInterval)
|
||||
}
|
||||
if cfg.Node.PullInterval != 60 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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) })
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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{} }
|
||||
@@ -1,32 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadCustomConfig reads a custom config file (JSON or YAML) and returns it
|
||||
// as a generic map. Returns nil if path is empty or file does not exist.
|
||||
func LoadCustomConfig(path string) (map[string]any, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
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)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as JSON: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := yaml.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as YAML: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
nlog.Core().Info("loaded custom config", "path", path, "keys", mapKeys(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MergeAppendList appends items from src list into dst, returning the merged list.
|
||||
// Each item is converted to map[string]any for JSON-marshal compatibility.
|
||||
func MergeAppendList(dst []map[string]any, srcRaw any) []map[string]any {
|
||||
srcList, ok := toSliceOfMaps(srcRaw)
|
||||
if !ok {
|
||||
return dst
|
||||
}
|
||||
return append(dst, srcList...)
|
||||
}
|
||||
|
||||
// MergePrependList prepends items from src list before dst items.
|
||||
func MergePrependList(dst []map[string]any, srcRaw any) []map[string]any {
|
||||
srcList, ok := toSliceOfMaps(srcRaw)
|
||||
if !ok {
|
||||
return dst
|
||||
}
|
||||
return append(srcList, dst...)
|
||||
}
|
||||
|
||||
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 {
|
||||
if m, ok := toMap(item); ok {
|
||||
result = append(result, m)
|
||||
}
|
||||
}
|
||||
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 m := v.(type) {
|
||||
case map[string]any:
|
||||
return m, true
|
||||
case map[any]any:
|
||||
result := make(map[string]any, len(m))
|
||||
for k, val := range m {
|
||||
result[fmt.Sprint(k)] = val
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
// NeedsGeoIP returns true when any panel route rule contains a "geoip:" match entry.
|
||||
func NeedsGeoIP(routes []model.RouteRule) bool {
|
||||
for _, r := range routes {
|
||||
for _, m := range r.Match {
|
||||
if strings.HasPrefix(m, "geoip:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NeedsGeoSite returns true when any panel route rule contains a "geosite:" match entry.
|
||||
func NeedsGeoSite(routes []model.RouteRule) bool {
|
||||
for _, r := range routes {
|
||||
for _, m := range r.Match {
|
||||
if strings.HasPrefix(m, "geosite:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
)
|
||||
|
||||
const (
|
||||
singboxGeoIPURL = "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db"
|
||||
singboxGeoSiteURL = "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db"
|
||||
xrayGeoIPURL = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||
xrayGeoSiteURL = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
func Ensure(dir string, needGeoIP, needGeoSite bool, kernelType string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create geo_data_dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
url string
|
||||
}
|
||||
|
||||
var files []entry
|
||||
if kernelType == "singbox" {
|
||||
if needGeoIP {
|
||||
files = append(files, entry{"geoip.db", singboxGeoIPURL})
|
||||
}
|
||||
if needGeoSite {
|
||||
files = append(files, entry{"geosite.db", singboxGeoSiteURL})
|
||||
}
|
||||
} else {
|
||||
if needGeoIP {
|
||||
files = append(files, entry{"geoip.dat", xrayGeoIPURL})
|
||||
}
|
||||
if needGeoSite {
|
||||
files = append(files, entry{"geosite.dat", xrayGeoSiteURL})
|
||||
}
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, f := range files {
|
||||
if err := ensureFile(dir, f.name, f.url); err != nil {
|
||||
nlog.Core().Warn("geo database auto-download failed", "file", f.name, "error", err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func ensureFile(dir, name, url string) error {
|
||||
dst := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
nlog.Core().Info("geo database ready", "file", name, "size_kb", fi.Size()/1024)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func atomicDownload(dst, url string) error {
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http get %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected HTTP status from %s: %s", url, resp.Status)
|
||||
}
|
||||
|
||||
tmp := dst + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write geo database: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return fmt.Errorf("rename to destination: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"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
|
||||
// groups. User operations (AddUsers/RemoveUsers) are designed to be atomic
|
||||
// and non-disruptive — they MUST NOT restart listeners or drop existing
|
||||
// connections. Only Start and Reload may (re)bind ports.
|
||||
//
|
||||
// Implementors: singbox.SingBox, xray.Xray
|
||||
type Kernel interface {
|
||||
// ─── Identity ───────────────────────────────────────────────────────
|
||||
// Name returns the kernel identifier (e.g. "sing-box", "xray").
|
||||
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 *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.
|
||||
IsRunning() bool
|
||||
// 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 *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 []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 []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 []model.UserSpec) (added, removed int, err error)
|
||||
|
||||
// ─── Observability ──────────────────────────────────────────────────
|
||||
// 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 *model.NodeSpec, users []model.UserSpec) string {
|
||||
h := sha256.New()
|
||||
configData, _ := json.Marshal(nc)
|
||||
h.Write(configData)
|
||||
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 {
|
||||
fmt.Fprintf(h, "%d:%s,", u.ID, u.UUID)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// 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 []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]model.UserSpec, len(newUsers))
|
||||
for _, u := range newUsers {
|
||||
newMap[u.ID] = u
|
||||
}
|
||||
|
||||
for _, u := range newUsers {
|
||||
old, exists := oldMap[u.ID]
|
||||
if !exists || old.UUID != u.UUID {
|
||||
toAdd = append(toAdd, u)
|
||||
}
|
||||
}
|
||||
for _, u := range oldUsers {
|
||||
if _, exists := newMap[u.ID]; !exists {
|
||||
toRemove = append(toRemove, u)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,889 +0,0 @@
|
||||
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 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(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "shadowsocks")
|
||||
assertMapValue(t, inbound, "method", "aes-128-gcm")
|
||||
assertMapValue(t, inbound, "listen_port", 111)
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("users: got %d, want 2", len(users))
|
||||
}
|
||||
assertMapValue(t, users[0], "name", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_Shadowsocks2022(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 222,
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "base64serverkey==",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
|
||||
assertMapValue(t, inbound, "password", "base64serverkey==")
|
||||
}
|
||||
|
||||
// --- VMess ---
|
||||
|
||||
func TestBuildInbound_VMess(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "vmess")
|
||||
assertMapValue(t, inbound, "tag", "vmess-in")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "alterId", 0)
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
}
|
||||
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", []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) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 80,
|
||||
TLS: 0,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Error("should not have TLS when tls=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithWebSocket(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 80,
|
||||
Network: "ws",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/ws",
|
||||
"headers": map[string]interface{}{
|
||||
"Host": "example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "ws")
|
||||
assertMapValue(t, transport, "path", "/ws")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
Network: "grpc",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"serviceName": "mygrpc",
|
||||
},
|
||||
}
|
||||
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",
|
||||
ServerPort: 443,
|
||||
Network: "h2",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/h2path",
|
||||
"host": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "http")
|
||||
assertMapValue(t, transport, "path", "/h2path")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
Network: "httpupgrade",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/upgrade",
|
||||
"host": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "httpupgrade")
|
||||
assertMapValue(t, transport, "path", "/upgrade")
|
||||
assertMapValue(t, transport, "host", "example.com")
|
||||
}
|
||||
|
||||
// --- VLESS ---
|
||||
|
||||
func TestBuildInbound_VLESS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "vless")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
Flow: "xtls-rprx-vision",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VLESS_Reality(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "test-private-key",
|
||||
"short_id": "abc123",
|
||||
"dest": "www.example.com:443",
|
||||
"server_name": "www.example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
|
||||
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", "www.example.com")
|
||||
assertMapValue(t, handshake, "server_port", 443)
|
||||
}
|
||||
|
||||
func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "pk",
|
||||
"short_id": []interface{}{"id1", "id2"},
|
||||
"dest": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
reality := inbound["tls"].(M)["reality"].(M)
|
||||
ids := reality["short_id"].([]string)
|
||||
if len(ids) != 2 {
|
||||
t.Errorf("short_id: got %d entries, want 2", len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trojan ---
|
||||
|
||||
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(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
assertMapValue(t, tls, "server_name", "cdn.example.com")
|
||||
|
||||
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) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 80,
|
||||
TLS: 0,
|
||||
}
|
||||
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.Fatal("expected no tls when cert paths are empty and tls=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInbound_Trojan_WithTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
// --- Hysteria ---
|
||||
|
||||
func TestBuildInbound_Hysteria2(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "hysteria",
|
||||
ServerPort: 444,
|
||||
Version: 2,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "hysteria2")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "hysteria",
|
||||
ServerPort: 444,
|
||||
Version: 2,
|
||||
Obfs: "salamander",
|
||||
ObfsPassword: "secret",
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
func TestBuildInbound_Hysteria1(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "hysteria",
|
||||
ServerPort: 444,
|
||||
Version: 0,
|
||||
UpMbps: 100,
|
||||
DownMbps: 200,
|
||||
}
|
||||
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)
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "auth_str", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
// --- TUIC ---
|
||||
|
||||
func TestBuildInbound_TUIC(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "tuic",
|
||||
ServerPort: 555,
|
||||
CongestionControl: "bbr",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "tuic")
|
||||
assertMapValue(t, inbound, "congestion_control", "bbr")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
// --- AnyTLS ---
|
||||
|
||||
func TestBuildInbound_AnyTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "anytls",
|
||||
ServerPort: 443,
|
||||
PaddingScheme: "stop=8\n0=30-30",
|
||||
}
|
||||
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")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "anytls",
|
||||
ServerPort: 443,
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Naive ---
|
||||
|
||||
func TestBuildInbound_Naive(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "naive",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "naive")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "username", "1")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
// --- Socks ---
|
||||
|
||||
func TestBuildInbound_Socks(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "socks")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "username", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Error("socks should not have TLS")
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP ---
|
||||
|
||||
func TestBuildInbound_HTTP(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "http",
|
||||
ServerPort: 8080,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "http")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "username", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "http",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
// --- Unknown protocol ---
|
||||
|
||||
func TestBuildInbound_Unknown(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "unknown-proto",
|
||||
ServerPort: 999,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
if inbound != nil {
|
||||
t.Errorf("unknown protocol should return nil, got %v", inbound)
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildConfig ---
|
||||
|
||||
func TestBuildConfig(t *testing.T) {
|
||||
kcfg := config.KernelConfig{LogLevel: "info"}
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("empty JSON output")
|
||||
}
|
||||
|
||||
logCfg := cfg["log"].(M)
|
||||
assertMapValue(t, logCfg, "level", "info")
|
||||
assertMapValue(t, logCfg, "timestamp", true)
|
||||
|
||||
outbounds := cfg["outbounds"].([]M)
|
||||
if len(outbounds) != 2 {
|
||||
t.Errorf("outbounds: got %d, want 2", len(outbounds))
|
||||
}
|
||||
assertMapValue(t, outbounds[0], "type", "direct")
|
||||
assertMapValue(t, outbounds[1], "type", "block")
|
||||
|
||||
inbounds := cfg["inbounds"].([]M)
|
||||
if len(inbounds) != 1 {
|
||||
t.Fatalf("inbounds: got %d, want 1", len(inbounds))
|
||||
}
|
||||
assertMapValue(t, inbounds[0], "type", "shadowsocks")
|
||||
}
|
||||
|
||||
func TestBuildConfig_OutboundPriority(t *testing.T) {
|
||||
kcfg := config.KernelConfig{
|
||||
LogLevel: "info",
|
||||
CustomOutbound: []map[string]any{
|
||||
{"tag": "block", "type": "dns"}, // Local static override
|
||||
},
|
||||
}
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
CustomOutbounds: []panel.OutboundConfig{
|
||||
{Tag: "direct", Protocol: "socks", Settings: map[string]any{"address": "1.2.3.4"}}, // Panel override
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
// (no auto-generated defaults for these tags)
|
||||
if len(outbounds) != 2 {
|
||||
t.Errorf("outbounds: got %d, want 2", len(outbounds))
|
||||
}
|
||||
|
||||
foundDirect := false
|
||||
foundBlock := false
|
||||
|
||||
for _, o := range outbounds {
|
||||
tag := o["tag"].(string)
|
||||
if tag == "direct" {
|
||||
foundDirect = true
|
||||
if o["type"] != "socks" {
|
||||
t.Errorf("expected 'direct' outbound to be type 'socks' (panel priority), got %v", o["type"])
|
||||
}
|
||||
}
|
||||
if tag == "block" {
|
||||
foundBlock = true
|
||||
if o["type"] != "dns" {
|
||||
t.Errorf("expected 'block' outbound to be type 'dns' (static config priority), got %v", o["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDirect || !foundBlock {
|
||||
t.Errorf("missing outbounds: direct=%v, block=%v", foundDirect, foundBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
protocols := []struct {
|
||||
name string
|
||||
nc *panel.NodeConfig
|
||||
}{
|
||||
{"shadowsocks", &panel.NodeConfig{Protocol: "shadowsocks", ServerPort: 111, Cipher: "aes-128-gcm"}},
|
||||
{"vmess", &panel.NodeConfig{Protocol: "vmess", ServerPort: 443}},
|
||||
{"vless", &panel.NodeConfig{Protocol: "vless", ServerPort: 443}},
|
||||
{"trojan", &panel.NodeConfig{Protocol: "trojan", ServerPort: 443}},
|
||||
{"hysteria2", &panel.NodeConfig{Protocol: "hysteria", ServerPort: 444, Version: 2}},
|
||||
{"hysteria1", &panel.NodeConfig{Protocol: "hysteria", ServerPort: 444, Version: 1}},
|
||||
{"tuic", &panel.NodeConfig{Protocol: "tuic", ServerPort: 555}},
|
||||
{"anytls", &panel.NodeConfig{Protocol: "anytls", ServerPort: 443}},
|
||||
{"naive", &panel.NodeConfig{Protocol: "naive", ServerPort: 443}},
|
||||
{"socks", &panel.NodeConfig{Protocol: "socks", ServerPort: 1080}},
|
||||
{"http", &panel.NodeConfig{Protocol: "http", ServerPort: 8080}},
|
||||
}
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("round-trip %s: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
func TestBuildRoutes_Default(t *testing.T) {
|
||||
route := buildRoutes(nil, nil, nil)
|
||||
assertMapValue(t, route, "final", "direct")
|
||||
|
||||
rules := route["rules"].([]M)
|
||||
if len(rules) < 2 {
|
||||
t.Fatalf("expected at least 2 default rules, got %d", len(rules))
|
||||
}
|
||||
assertMapValue(t, rules[0], "outbound", "block")
|
||||
assertMapValue(t, rules[1], "outbound", "block")
|
||||
}
|
||||
|
||||
func TestBuildRoutes_WithCustomRules(t *testing.T) {
|
||||
rules := []panel.RouteRule{
|
||||
{ID: 1, Match: []string{"blocked.com"}, Action: "block"},
|
||||
{ID: 2, Match: []string{"10.0.0.0/8"}, Action: "block"},
|
||||
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
|
||||
}
|
||||
route := buildRoutes(testRouteRules(rules), nil, nil)
|
||||
allRules := route["rules"].([]M)
|
||||
|
||||
if len(allRules) != 5 {
|
||||
t.Fatalf("rules count: got %d, want 5", len(allRules))
|
||||
}
|
||||
|
||||
assertMapValue(t, allRules[2], "outbound", "block")
|
||||
if _, ok := allRules[2]["domain_suffix"]; !ok {
|
||||
t.Error("domain rule should use domain_suffix")
|
||||
}
|
||||
|
||||
assertMapValue(t, allRules[3], "outbound", "block")
|
||||
if _, ok := allRules[3]["ip_cidr"]; !ok {
|
||||
t.Error("IP rule should use ip_cidr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoutes_MultiMatch(t *testing.T) {
|
||||
// A single route with mixed domain + CIDR matches should produce separate rules
|
||||
// Wildcard *.evil.com should become evil.com for domain_suffix
|
||||
rules := []panel.RouteRule{
|
||||
{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(testRouteRules(rules), nil, nil)
|
||||
allRules := route["rules"].([]M)
|
||||
|
||||
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
|
||||
if len(allRules) != 5 {
|
||||
t.Fatalf("rules count: got %d, want 5", len(allRules))
|
||||
}
|
||||
|
||||
// Rule #2 (index 2): domains from first route (wildcards stripped)
|
||||
domains := allRules[2]["domain_suffix"].([]string)
|
||||
if len(domains) != 2 || domains[0] != "evil.com" || domains[1] != "bad.org" {
|
||||
t.Errorf("domain_suffix: got %v, want [evil.com bad.org]", domains)
|
||||
}
|
||||
assertMapValue(t, allRules[2], "outbound", "block")
|
||||
|
||||
// Rule #3 (index 3): CIDRs from first route
|
||||
cidrs := allRules[3]["ip_cidr"].([]string)
|
||||
if len(cidrs) != 1 || cidrs[0] != "192.168.1.0/24" {
|
||||
t.Errorf("ip_cidr: got %v, want [192.168.1.0/24]", cidrs)
|
||||
}
|
||||
assertMapValue(t, allRules[3], "outbound", "block")
|
||||
|
||||
// Rule #4 (index 4): direct rule (wildcard stripped)
|
||||
directDomains := allRules[4]["domain_suffix"].([]string)
|
||||
if len(directDomains) != 1 || directDomains[0] != "bypass.com" {
|
||||
t.Errorf("direct domain_suffix: got %v, want [bypass.com]", directDomains)
|
||||
}
|
||||
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(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", []string{"CERT"})
|
||||
assertMapValue(t, tls, "key", []string{"KEY"})
|
||||
}
|
||||
|
||||
func TestBuildTLSConfig_NoCert(t *testing.T) {
|
||||
nc := &panel.NodeConfig{ServerName: "example.com"}
|
||||
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(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, tls, "server_name", "fallback.com")
|
||||
}
|
||||
|
||||
func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
ServerName: "original.com",
|
||||
TLSSettings: map[string]interface{}{
|
||||
"server_name": "override.com",
|
||||
},
|
||||
}
|
||||
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, tls, "server_name", "override.com")
|
||||
}
|
||||
|
||||
// --- Transport ---
|
||||
|
||||
func TestApplyTransport_TCP(t *testing.T) {
|
||||
base := M{}
|
||||
nc := &panel.NodeConfig{Network: "tcp"}
|
||||
applyTransport(base, testNodeSpec(nc))
|
||||
if _, exists := base["transport"]; exists {
|
||||
t.Error("tcp should not add transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_Empty(t *testing.T) {
|
||||
base := M{}
|
||||
nc := &panel.NodeConfig{Network: ""}
|
||||
applyTransport(base, testNodeSpec(nc))
|
||||
if _, exists := base["transport"]; exists {
|
||||
t.Error("empty network should not add transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_WS_MaxEarlyData(t *testing.T) {
|
||||
base := M{}
|
||||
nc := &panel.NodeConfig{
|
||||
Network: "ws",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/ws",
|
||||
"max_early_data": 2048,
|
||||
"early_data_header_name": "Sec-WebSocket-Protocol",
|
||||
},
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func assertMapValue(t *testing.T, m M, key string, expected interface{}) {
|
||||
t.Helper()
|
||||
val, ok := m[key]
|
||||
if !ok {
|
||||
t.Errorf("key %q not found in map", key)
|
||||
return
|
||||
}
|
||||
switch e := expected.(type) {
|
||||
case int:
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
if v != e {
|
||||
t.Errorf("%s: got %d, want %d", key, v, e)
|
||||
}
|
||||
case float64:
|
||||
if int(v) != e {
|
||||
t.Errorf("%s: got %v, want %d", key, v, e)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s: got %v (type %T), want %d", key, val, val, e)
|
||||
}
|
||||
default:
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,843 +0,0 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
singM "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
)
|
||||
|
||||
// ipPool caches ipSnapshot maps to reduce allocations.
|
||||
var ipPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return make(map[string]struct{}, 16)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Per-user statistics ────────────────────────────────────────────────────
|
||||
|
||||
// userStats holds per-user traffic counters and alive IP tracking.
|
||||
// Traffic counters are lock-free atomics (read on every packet).
|
||||
// IP tracking uses a lightweight mutex (only touched at connect/disconnect).
|
||||
type userStats struct {
|
||||
upload atomic.Int64
|
||||
download atomic.Int64
|
||||
|
||||
mu sync.RWMutex // RWMutex for concurrent reads
|
||||
ips map[string]int // sourceIP → refcount (number of active conns from that IP)
|
||||
connCount int // total active connections
|
||||
}
|
||||
|
||||
// addConn registers a new connection from sourceIP.
|
||||
func (u *userStats) addConn(sourceIP string) {
|
||||
u.mu.Lock()
|
||||
u.connCount++
|
||||
u.ips[sourceIP]++
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
// removeConn unregisters a connection from sourceIP.
|
||||
func (u *userStats) removeConn(sourceIP string) {
|
||||
u.mu.Lock()
|
||||
u.connCount--
|
||||
u.ips[sourceIP]--
|
||||
if u.ips[sourceIP] <= 0 {
|
||||
delete(u.ips, sourceIP)
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
// distinctIPs returns the number of distinct IPs currently connected.
|
||||
func (u *userStats) distinctIPs() int {
|
||||
u.mu.Lock()
|
||||
n := len(u.ips)
|
||||
u.mu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// ipSnapshot returns a pooled copy of the current IP set.
|
||||
func (u *userStats) ipSnapshot() map[string]struct{} {
|
||||
cp := ipPool.Get().(map[string]struct{})
|
||||
for k := range cp {
|
||||
delete(cp, k)
|
||||
}
|
||||
u.mu.Lock()
|
||||
for ip := range u.ips {
|
||||
cp[ip] = struct{}{}
|
||||
}
|
||||
u.mu.Unlock()
|
||||
return cp
|
||||
}
|
||||
|
||||
// releaseIPSnapshot returns the snapshot to pool.
|
||||
// Large maps are discarded to prevent memory bloat.
|
||||
func releaseIPSnapshot(m map[string]struct{}) {
|
||||
// Discard large maps to prevent pool memory bloat
|
||||
if len(m) > 64 {
|
||||
return
|
||||
}
|
||||
ipPool.Put(m)
|
||||
}
|
||||
|
||||
// aliveIPList returns the set of alive IPs as a boolean map (for reporting).
|
||||
func (u *userStats) aliveIPList() map[string]bool {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if len(u.ips) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]bool, len(u.ips))
|
||||
for ip := range u.ips {
|
||||
result[ip] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── ConnTracker ────────────────────────────────────────────────────────────
|
||||
|
||||
// ConnTracker provides per-user traffic counting, alive IP tracking, device
|
||||
// limit gate-keeping, and per-user speed limiting for the sing-box kernel.
|
||||
//
|
||||
// Architecture: instead of maintaining a per-connection map (O(connections)),
|
||||
// it maintains per-user atomic counters and IP refcount sets (O(users)).
|
||||
// This means:
|
||||
// - GetUserTraffic is O(users), not O(connections)
|
||||
// - No Snapshot/pending buffer needed
|
||||
// - No per-connection map lock contention
|
||||
// - Bytes are counted directly into per-user atomics via CountFunc callbacks
|
||||
//
|
||||
// The per-connection wrapper (trackedConn) is still needed for:
|
||||
// - Hooking into sing's ReadCounter/WriteCounter for zero-copy byte counting
|
||||
// - Close callback to decrement IP refcounts
|
||||
// - Per-connection rate limit token accumulation (amortized WaitN)
|
||||
type ConnTracker struct {
|
||||
usersMu sync.RWMutex
|
||||
users map[int]*userStats // userID → stats
|
||||
uuidMap map[string]int // UUID → userID (for lookup in RoutedConnection)
|
||||
connMap map[string]io.Closer // connID → conn (TCP + UDP, for force-close)
|
||||
uuidConns map[string]map[string]struct{} // UUID → set of connIDs (reverse index for CloseByUUID)
|
||||
|
||||
idCounter atomic.Int64
|
||||
|
||||
// speedLimitFunc resolves a user UUID to a *rate.Limiter.
|
||||
speedLimitFunc atomic.Pointer[func(uuid string) *rate.Limiter]
|
||||
|
||||
// deviceLimitFunc resolves a user UUID to their device limit.
|
||||
deviceLimitFunc atomic.Pointer[func(uuid string) (int, bool)]
|
||||
|
||||
// Multi-node device state from panel
|
||||
globalDevices map[int]map[string]bool // userID → IP → exists
|
||||
globalMu sync.RWMutex
|
||||
globalLastUpdate time.Time
|
||||
}
|
||||
|
||||
// NewConnTracker creates a tracker.
|
||||
func NewConnTracker(_ int) *ConnTracker {
|
||||
return &ConnTracker{
|
||||
users: make(map[int]*userStats),
|
||||
uuidMap: make(map[string]int),
|
||||
connMap: make(map[string]io.Closer),
|
||||
uuidConns: make(map[string]map[string]struct{}),
|
||||
globalDevices: make(map[int]map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSpeedLimitFunc configures the per-user speed limit lookup.
|
||||
func (t *ConnTracker) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
|
||||
t.speedLimitFunc.Store(&fn)
|
||||
}
|
||||
|
||||
// SetDeviceLimitFunc configures the per-user device limit lookup for gate-keeping.
|
||||
func (t *ConnTracker) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) {
|
||||
t.deviceLimitFunc.Store(&fn)
|
||||
}
|
||||
|
||||
// SetUserMap replaces the UUID→userID mapping and ensures per-user stats
|
||||
// structs exist for all users. Old users that are no longer present keep
|
||||
// their stats until their connections drain.
|
||||
func (t *ConnTracker) SetUserMap(m map[string]int) {
|
||||
t.usersMu.Lock()
|
||||
t.uuidMap = m
|
||||
for _, uid := range m {
|
||||
if _, ok := t.users[uid]; !ok {
|
||||
t.users[uid] = &userStats{ips: make(map[string]int)}
|
||||
}
|
||||
}
|
||||
t.usersMu.Unlock()
|
||||
}
|
||||
|
||||
// UpdateGlobalDevices syncs global device state from panel.
|
||||
func (t *ConnTracker) UpdateGlobalDevices(users map[int][]string) {
|
||||
t.globalMu.Lock()
|
||||
t.globalDevices = make(map[int]map[string]bool, len(users))
|
||||
for uid, ips := range users {
|
||||
m := make(map[string]bool, len(ips))
|
||||
for _, ip := range ips {
|
||||
m[ip] = true
|
||||
}
|
||||
t.globalDevices[uid] = m
|
||||
}
|
||||
t.globalLastUpdate = time.Now()
|
||||
t.globalMu.Unlock()
|
||||
nlog.Core().Debug("global device state updated", "users", len(users))
|
||||
}
|
||||
|
||||
// ClearGlobalDevices resets global device state (on WS disconnect).
|
||||
func (t *ConnTracker) ClearGlobalDevices() {
|
||||
t.globalMu.Lock()
|
||||
t.globalDevices = make(map[int]map[string]bool)
|
||||
t.globalLastUpdate = time.Time{}
|
||||
t.globalMu.Unlock()
|
||||
nlog.Core().Debug("global device state cleared")
|
||||
}
|
||||
|
||||
// ─── adapter.ConnectionTracker ──────────────────────────────────────────────
|
||||
|
||||
// RoutedConnection wraps a TCP conn to count bytes per-user, track IPs,
|
||||
// and optionally rate-limit. Gate-keeps device limits at connection time.
|
||||
func (t *ConnTracker) RoutedConnection(
|
||||
ctx context.Context, conn net.Conn,
|
||||
metadata adapter.InboundContext,
|
||||
_ adapter.Rule, _ adapter.Outbound,
|
||||
) net.Conn {
|
||||
uuid := metadata.User
|
||||
sourceIP := metadata.Source.Addr.String()
|
||||
|
||||
t.usersMu.RLock()
|
||||
uid := t.uuidMap[uuid]
|
||||
us := t.users[uid]
|
||||
t.usersMu.RUnlock()
|
||||
|
||||
// Device limit gate-keeping
|
||||
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
||||
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
||||
if t.checkDeviceGate(us, uid, sourceIP, limit) {
|
||||
nlog.Core().Info("singbox: device limit gate-keep, rejecting connection",
|
||||
"user", uuid, "ip", sourceIP, "limit", limit)
|
||||
conn.Close()
|
||||
return conn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register connection
|
||||
if us != nil {
|
||||
us.addConn(sourceIP)
|
||||
}
|
||||
|
||||
connID := t.nextID()
|
||||
|
||||
// Store conn reference for force-close support
|
||||
t.usersMu.Lock()
|
||||
t.connMap[connID] = conn
|
||||
if _, ok := t.uuidConns[uuid]; !ok {
|
||||
t.uuidConns[uuid] = make(map[string]struct{})
|
||||
}
|
||||
t.uuidConns[uuid][connID] = struct{}{}
|
||||
t.usersMu.Unlock()
|
||||
|
||||
var lim *rate.Limiter
|
||||
if slf := t.speedLimitFunc.Load(); slf != nil {
|
||||
lim = (*slf)(uuid)
|
||||
}
|
||||
|
||||
return &trackedConn{
|
||||
Conn: conn,
|
||||
tracker: t,
|
||||
us: us,
|
||||
userID: uid,
|
||||
connID: connID,
|
||||
uuid: uuid,
|
||||
sourceIP: sourceIP,
|
||||
limiter: lim,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// RoutedPacketConnection wraps UDP with per-user counting and force-close support.
|
||||
func (t *ConnTracker) RoutedPacketConnection(
|
||||
ctx context.Context, conn N.PacketConn,
|
||||
metadata adapter.InboundContext,
|
||||
_ adapter.Rule, _ adapter.Outbound,
|
||||
) N.PacketConn {
|
||||
uuid := metadata.User
|
||||
sourceIP := metadata.Source.Addr.String()
|
||||
|
||||
t.usersMu.RLock()
|
||||
uid := t.uuidMap[uuid]
|
||||
us := t.users[uid]
|
||||
t.usersMu.RUnlock()
|
||||
|
||||
// Device limit gate-keeping
|
||||
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
||||
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
||||
if t.checkDeviceGate(us, uid, sourceIP, limit) {
|
||||
nlog.Core().Info("singbox: device limit gate-keep, rejecting UDP connection",
|
||||
"user", uuid, "ip", sourceIP, "limit", limit)
|
||||
conn.Close()
|
||||
return conn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if us != nil {
|
||||
us.addConn(sourceIP)
|
||||
}
|
||||
|
||||
connID := t.nextID()
|
||||
|
||||
// Store for force-close support (same as TCP)
|
||||
t.usersMu.Lock()
|
||||
t.connMap[connID] = conn
|
||||
if _, ok := t.uuidConns[uuid]; !ok {
|
||||
t.uuidConns[uuid] = make(map[string]struct{})
|
||||
}
|
||||
t.uuidConns[uuid][connID] = struct{}{}
|
||||
t.usersMu.Unlock()
|
||||
|
||||
var lim *rate.Limiter
|
||||
if slf := t.speedLimitFunc.Load(); slf != nil {
|
||||
lim = (*slf)(uuid)
|
||||
}
|
||||
|
||||
return &trackedPacketConn{
|
||||
PacketConn: conn,
|
||||
tracker: t,
|
||||
us: us,
|
||||
userID: uid,
|
||||
connID: connID,
|
||||
uuid: uuid,
|
||||
sourceIP: sourceIP,
|
||||
limiter: lim,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// checkDeviceGate rejects connections exceeding device limit.
|
||||
// Strategy: merge local + global state when fresh; local-only when stale.
|
||||
func (t *ConnTracker) checkDeviceGate(us *userStats, userID int, sourceIP string, limit int) bool {
|
||||
if us == nil || limit <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
us.mu.RLock()
|
||||
localIPs := make(map[string]bool, len(us.ips))
|
||||
for ip := range us.ips {
|
||||
localIPs[ip] = true
|
||||
}
|
||||
localCount := len(us.ips)
|
||||
us.mu.RUnlock()
|
||||
|
||||
// Already known locally
|
||||
if localIPs[sourceIP] {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check global state freshness
|
||||
t.globalMu.RLock()
|
||||
globalStale := time.Since(t.globalLastUpdate) > 60*time.Second
|
||||
globalIPs := t.globalDevices[userID]
|
||||
t.globalMu.RUnlock()
|
||||
|
||||
// Stale or missing global state → local-only check
|
||||
if globalStale || globalIPs == nil {
|
||||
if localCount < limit {
|
||||
return false
|
||||
}
|
||||
ipList := make([]string, 0, localCount+1)
|
||||
for ip := range localIPs {
|
||||
ipList = append(ipList, ip)
|
||||
}
|
||||
ipList = append(ipList, sourceIP)
|
||||
sort.Strings(ipList)
|
||||
for i := 0; i < limit && i < len(ipList); i++ {
|
||||
if ipList[i] == sourceIP {
|
||||
return false
|
||||
}
|
||||
}
|
||||
nlog.Core().Debug("device limit: local over limit, rejecting",
|
||||
"userID", userID, "ip", sourceIP, "localIPs", localCount, "limit", limit)
|
||||
return true
|
||||
}
|
||||
|
||||
// Known globally (from other node)
|
||||
if globalIPs[sourceIP] {
|
||||
return false
|
||||
}
|
||||
|
||||
// Merge local + global
|
||||
allIPs := make(map[string]bool)
|
||||
for ip := range localIPs {
|
||||
allIPs[ip] = true
|
||||
}
|
||||
for ip := range globalIPs {
|
||||
allIPs[ip] = true
|
||||
}
|
||||
|
||||
if len(allIPs) < limit {
|
||||
return false
|
||||
}
|
||||
|
||||
// Over limit → lexicographic selection
|
||||
ipList := make([]string, 0, len(allIPs)+1)
|
||||
for ip := range allIPs {
|
||||
ipList = append(ipList, ip)
|
||||
}
|
||||
ipList = append(ipList, sourceIP)
|
||||
sort.Strings(ipList)
|
||||
|
||||
for i := 0; i < limit && i < len(ipList); i++ {
|
||||
if ipList[i] == sourceIP {
|
||||
return false
|
||||
}
|
||||
}
|
||||
nlog.Core().Debug("device limit: total over limit, rejecting",
|
||||
"userID", userID, "ip", sourceIP, "totalIPs", len(allIPs), "limit", limit)
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *ConnTracker) nextID() string {
|
||||
return "sb-" + formatInt36(t.idCounter.Add(1))
|
||||
}
|
||||
|
||||
func formatInt36(n int64) string {
|
||||
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [13]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = digits[n%36]
|
||||
n /= 36
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
// GetUserTraffic returns per-user cumulative traffic and alive IPs.
|
||||
// This is O(users), not O(connections).
|
||||
func (t *ConnTracker) GetUserTraffic() (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int) {
|
||||
t.usersMu.RLock()
|
||||
traffic = make(map[int][2]int64, len(t.users))
|
||||
aliveIPs = make(map[int]map[string]bool, len(t.users))
|
||||
for uid, us := range t.users {
|
||||
up := us.upload.Load()
|
||||
down := us.download.Load()
|
||||
if up > 0 || down > 0 {
|
||||
traffic[uid] = [2]int64{up, down}
|
||||
}
|
||||
|
||||
ips := us.aliveIPList()
|
||||
if ips != nil {
|
||||
aliveIPs[uid] = ips
|
||||
}
|
||||
|
||||
us.mu.Lock()
|
||||
connCount += us.connCount
|
||||
us.mu.Unlock()
|
||||
}
|
||||
t.usersMu.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// CloseByID force-closes a connection by its ID.
|
||||
func (t *ConnTracker) CloseByID(id string) bool {
|
||||
t.usersMu.RLock()
|
||||
conn, ok := t.connMap[id]
|
||||
t.usersMu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CloseByUUID force-closes ALL connections for a given user UUID.
|
||||
func (t *ConnTracker) CloseByUUID(uuid string) int {
|
||||
t.usersMu.RLock()
|
||||
ids, ok := t.uuidConns[uuid]
|
||||
if !ok || len(ids) == 0 {
|
||||
t.usersMu.RUnlock()
|
||||
return 0
|
||||
}
|
||||
conns := make([]io.Closer, 0, len(ids))
|
||||
for id := range ids {
|
||||
if c, ok := t.connMap[id]; ok {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
}
|
||||
t.usersMu.RUnlock()
|
||||
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
return len(conns)
|
||||
}
|
||||
|
||||
// ActiveCount returns the total number of active connections.
|
||||
func (t *ConnTracker) ActiveCount() int {
|
||||
t.usersMu.RLock()
|
||||
total := 0
|
||||
for _, us := range t.users {
|
||||
us.mu.Lock()
|
||||
total += us.connCount
|
||||
us.mu.Unlock()
|
||||
}
|
||||
t.usersMu.RUnlock()
|
||||
return total
|
||||
}
|
||||
|
||||
// removeConnRef removes the connection reference from connMap and uuidConns on close.
|
||||
func (t *ConnTracker) removeConnRef(connID, uuid string) {
|
||||
t.usersMu.Lock()
|
||||
delete(t.connMap, connID)
|
||||
if ids, ok := t.uuidConns[uuid]; ok {
|
||||
delete(ids, connID)
|
||||
if len(ids) == 0 {
|
||||
delete(t.uuidConns, uuid)
|
||||
}
|
||||
}
|
||||
t.usersMu.Unlock()
|
||||
}
|
||||
|
||||
// ─── trackedConn (TCP) ──────────────────────────────────────────────────────
|
||||
|
||||
// RateLimitedWriter wraps a Writer with non-blocking rate limiting.
|
||||
// Uses AllowN for instant checks and context-aware waiting when needed.
|
||||
type RateLimitedWriter struct {
|
||||
writer io.Writer
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// Write implements io.Writer with rate limiting.
|
||||
func (w *RateLimitedWriter) Write(b []byte) (int, error) {
|
||||
// Truncate to burst size
|
||||
if burst := w.limiter.Burst(); len(b) > burst {
|
||||
b = b[:burst]
|
||||
}
|
||||
|
||||
// Fast path: check if tokens available (non-blocking)
|
||||
if w.limiter.AllowN(time.Now(), len(b)) {
|
||||
return w.writer.Write(b)
|
||||
}
|
||||
|
||||
// Slow path: wait for tokens with context cancellation support
|
||||
resv := w.limiter.ReserveN(time.Now(), len(b))
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available, proceed
|
||||
case <-w.ctx.Done():
|
||||
resv.Cancel()
|
||||
return 0, w.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return w.writer.Write(b)
|
||||
}
|
||||
|
||||
// RateLimitedReadCloser wraps a Reader with non-blocking rate limiting.
|
||||
type RateLimitedReadCloser struct {
|
||||
reader io.Reader
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// Read implements io.Reader with rate limiting.
|
||||
func (r *RateLimitedReadCloser) Read(b []byte) (int, error) {
|
||||
// Truncate to burst size
|
||||
if burst := r.limiter.Burst(); len(b) > burst {
|
||||
b = b[:burst]
|
||||
}
|
||||
|
||||
n, err := r.reader.Read(b)
|
||||
if n > 0 && r.limiter != nil {
|
||||
// Fast path: check if tokens available
|
||||
if !r.limiter.AllowN(time.Now(), n) {
|
||||
// Slow path: wait with context cancellation
|
||||
resv := r.limiter.ReserveN(time.Now(), n)
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available
|
||||
case <-r.ctx.Done():
|
||||
resv.Cancel()
|
||||
return n, r.ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
type trackedConn struct {
|
||||
net.Conn
|
||||
tracker *ConnTracker
|
||||
us *userStats // per-user stats (upload/download atomics + IP tracking)
|
||||
userID int // user ID for device tracking
|
||||
connID string
|
||||
uuid string
|
||||
sourceIP string
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *trackedConn) Read(b []byte) (int, error) {
|
||||
if c.limiter != nil {
|
||||
if burst := c.limiter.Burst(); len(b) > burst {
|
||||
b = b[:burst]
|
||||
}
|
||||
}
|
||||
n, err := c.Conn.Read(b)
|
||||
if n > 0 {
|
||||
if c.us != nil {
|
||||
c.us.upload.Add(int64(n)) // 从入站读取 = 用户上传
|
||||
}
|
||||
if c.limiter != nil {
|
||||
// Non-blocking rate limiting
|
||||
if !c.limiter.AllowN(time.Now(), n) {
|
||||
resv := c.limiter.ReserveN(time.Now(), n)
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available
|
||||
case <-c.ctx.Done():
|
||||
resv.Cancel()
|
||||
return n, c.ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *trackedConn) Write(b []byte) (int, error) {
|
||||
// Apply rate limiting before write
|
||||
if c.limiter != nil {
|
||||
if burst := c.limiter.Burst(); len(b) > burst {
|
||||
b = b[:burst]
|
||||
}
|
||||
// Non-blocking check first
|
||||
if !c.limiter.AllowN(time.Now(), len(b)) {
|
||||
resv := c.limiter.ReserveN(time.Now(), len(b))
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available
|
||||
case <-c.ctx.Done():
|
||||
resv.Cancel()
|
||||
return 0, c.ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n, err := c.Conn.Write(b)
|
||||
if n > 0 && c.us != nil {
|
||||
c.us.download.Add(int64(n)) // 向入站写入 = 用户下载
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *trackedConn) Close() error {
|
||||
if c.closed.CompareAndSwap(false, true) {
|
||||
if c.us != nil {
|
||||
c.us.removeConn(c.sourceIP)
|
||||
}
|
||||
c.tracker.removeConnRef(c.connID, c.uuid)
|
||||
}
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// makeCountFunc builds a CountFunc for zero-copy byte counting via sing's
|
||||
// ReadCounter/WriteCounter unwrap interfaces.
|
||||
func (c *trackedConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
||||
if c.limiter == nil {
|
||||
return func(n int64) { counter.Add(n) }
|
||||
}
|
||||
return func(n int64) {
|
||||
counter.Add(n)
|
||||
// Non-blocking rate limiting with context cancellation
|
||||
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available
|
||||
case <-c.ctx.Done():
|
||||
resv.Cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *trackedConn) UnwrapReader() (io.Reader, []N.CountFunc) {
|
||||
if c.us == nil {
|
||||
return c.Conn, nil
|
||||
}
|
||||
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.us.upload)} // 从入站读取 = 用户上传
|
||||
}
|
||||
|
||||
func (c *trackedConn) UnwrapWriter() (io.Writer, []N.CountFunc) {
|
||||
if c.us == nil {
|
||||
return c.Conn, nil
|
||||
}
|
||||
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.us.download)} // 向入站写入 = 用户下载
|
||||
}
|
||||
|
||||
func (c *trackedConn) Upstream() any { return c.Conn }
|
||||
func (c *trackedConn) ReaderReplaceable() bool { return true }
|
||||
func (c *trackedConn) WriterReplaceable() bool { return true }
|
||||
|
||||
// ─── trackedPacketConn (UDP / QUIC) ─────────────────────────────────────────
|
||||
|
||||
type trackedPacketConn struct {
|
||||
N.PacketConn
|
||||
tracker *ConnTracker
|
||||
us *userStats
|
||||
userID int
|
||||
connID string
|
||||
uuid string
|
||||
sourceIP string
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, error) {
|
||||
dest, err := c.PacketConn.ReadPacket(buffer)
|
||||
if err == nil {
|
||||
n := int64(buffer.Len())
|
||||
if c.us != nil {
|
||||
c.us.upload.Add(n) // 从入站读取 = 用户上传
|
||||
}
|
||||
if c.limiter != nil {
|
||||
// Non-blocking rate limiting with context cancellation
|
||||
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available
|
||||
case <-c.ctx.Done():
|
||||
resv.Cancel()
|
||||
return dest, c.ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dest, err
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) WritePacket(buffer *buf.Buffer, dest singM.Socksaddr) error {
|
||||
n := int64(buffer.Len())
|
||||
|
||||
// Apply rate limiting before write
|
||||
if c.limiter != nil {
|
||||
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available
|
||||
case <-c.ctx.Done():
|
||||
resv.Cancel()
|
||||
return c.ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := c.PacketConn.WritePacket(buffer, dest)
|
||||
if err == nil && c.us != nil {
|
||||
c.us.download.Add(n) // 向入站写入 = 用户下载
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) Close() error {
|
||||
if c.closed.CompareAndSwap(false, true) {
|
||||
if c.us != nil {
|
||||
c.us.removeConn(c.sourceIP)
|
||||
}
|
||||
c.tracker.removeConnRef(c.connID, c.uuid)
|
||||
}
|
||||
return c.PacketConn.Close()
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
||||
if c.limiter == nil {
|
||||
return func(n int64) { counter.Add(n) }
|
||||
}
|
||||
return func(n int64) {
|
||||
counter.Add(n)
|
||||
// Non-blocking rate limiting with context cancellation
|
||||
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||
if delay := resv.Delay(); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Tokens available
|
||||
case <-c.ctx.Done():
|
||||
resv.Cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) UnwrapPacketReader() (N.PacketReader, []N.CountFunc) {
|
||||
if c.us == nil {
|
||||
return c.PacketConn, nil
|
||||
}
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.upload)}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) {
|
||||
if c.us == nil {
|
||||
return c.PacketConn, nil
|
||||
}
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.download)}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) Upstream() any { return c.PacketConn }
|
||||
func (c *trackedPacketConn) ReaderReplaceable() bool { return true }
|
||||
func (c *trackedPacketConn) WriterReplaceable() bool { return true }
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
box "github.com/sagernet/sing-box"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/include"
|
||||
singLog "github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
singJSON "github.com/sagernet/sing/common/json"
|
||||
"github.com/sagernet/sing/service"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
)
|
||||
|
||||
// drainTimeout is how long stop() waits for in-flight connections to finish
|
||||
// naturally after closing all listen sockets, before hard-killing them.
|
||||
const drainTimeout = 5 * time.Second
|
||||
|
||||
// SingBox implements kernel.Kernel by embedding sing-box as a Go library.
|
||||
//
|
||||
// User operations (AddUsers/RemoveUsers/UpdateUsers) use sing-box's native
|
||||
// UpdatableInbound interface, which hot-swaps user credentials without
|
||||
// restarting listeners — zero connection disruption.
|
||||
type SingBox struct {
|
||||
cfg config.KernelConfig
|
||||
|
||||
mu sync.RWMutex
|
||||
box *box.Box
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
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).
|
||||
// Survives Reload (hot-swap) since live connections persist.
|
||||
connTracker *ConnTracker
|
||||
|
||||
// speedLimitFunc resolves a user UUID to a *rate.Limiter.
|
||||
// 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
|
||||
}
|
||||
|
||||
func New(cfg config.KernelConfig) *SingBox {
|
||||
return &SingBox{cfg: cfg}
|
||||
}
|
||||
|
||||
var _ kernel.Kernel = (*SingBox)(nil)
|
||||
|
||||
func (s *SingBox) Name() string { return "sing-box" }
|
||||
|
||||
func (s *SingBox) Capabilities() kernel.Capabilities {
|
||||
return kernel.Capabilities{
|
||||
PerUserSpeedLimit: true,
|
||||
DeviceLimit: true,
|
||||
BuiltInTrafficStats: false,
|
||||
AliveIPTracking: true,
|
||||
ForceCloseConnection: false,
|
||||
ForceCloseUser: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SingBox) Protocols() []string {
|
||||
return []string{
|
||||
"vmess", "vless", "trojan", "shadowsocks",
|
||||
"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, tls)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
nlog.Core().Debug("sing-box config generated", "len", len(data))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx = include.Context(ctx)
|
||||
|
||||
opts, err := singJSON.UnmarshalExtendedContext[option.Options](ctx, data)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("parse sing-box options: %w", err)
|
||||
}
|
||||
|
||||
// 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,
|
||||
Options: opts,
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("create sing-box instance: %w", err)
|
||||
}
|
||||
|
||||
if err := instance.Start(); err != nil {
|
||||
instance.Close()
|
||||
cancel()
|
||||
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.tls = tls
|
||||
|
||||
// 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)
|
||||
|
||||
// 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 *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.box == nil {
|
||||
return fmt.Errorf("not running")
|
||||
}
|
||||
|
||||
cfgMap := buildConfig(s.cfg, nodeConfig, users, tls)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
opts, err := singJSON.UnmarshalExtendedContext[option.Options](s.ctx, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse options: %w", err)
|
||||
}
|
||||
|
||||
im := service.FromContext[adapter.InboundManager](s.ctx)
|
||||
if im == nil {
|
||||
return fmt.Errorf("inbound manager not available")
|
||||
}
|
||||
|
||||
router := service.FromContext[adapter.Router](s.ctx)
|
||||
if router == nil {
|
||||
return fmt.Errorf("router not available")
|
||||
}
|
||||
|
||||
// Update routing rules
|
||||
if err := router.UpdateRules(opts.Route.Rules, opts.Route.RuleSet); err != nil {
|
||||
nlog.Core().Debug("routing reload failed", "error", err)
|
||||
} else {
|
||||
nlog.Core().Debug("sing-box routing reloaded")
|
||||
}
|
||||
|
||||
nopFactory := singLog.NewNOPFactory()
|
||||
|
||||
// Configuration hash check for inbound reconstruction
|
||||
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
|
||||
if !configChanged {
|
||||
if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type {
|
||||
var err error
|
||||
switch v := existing.(type) {
|
||||
case adapter.UpdatableInbound[option.VMessUser]:
|
||||
if opts, ok := inb.Options.(*option.VMessInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.VLESSUser]:
|
||||
if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TrojanUser]:
|
||||
if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.Hysteria2User]:
|
||||
if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableShadowsocksInbound:
|
||||
if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok {
|
||||
err = v.UpdateUsersByOptions(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TUICUser]:
|
||||
if opts, ok := inb.Options.(*option.TUICInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.AnyTLSUser]:
|
||||
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[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:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.SocksInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.HTTPMixedInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
nlog.Core().Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Config mismatch or incremental update failure: Reconstruct Inbound.
|
||||
// Remove the existing inbound first so im.Create() can bind the same port.
|
||||
// im.Create() cannot atomically swap a TCP listener — it tries to start the
|
||||
// new socket before the old one is closed, causing "address already in use".
|
||||
// The brief listen gap (< 1 ms) is far less disruptive than a full restart.
|
||||
_ = im.Remove(tag) // ignore error when tag doesn't exist yet
|
||||
logger := nopFactory.NewLogger(fmt.Sprintf("inbound/%s[%s]", inb.Type, tag))
|
||||
if err := im.Create(s.ctx, router, logger, tag, inb.Type, inb.Options); err != nil {
|
||||
return fmt.Errorf("recreate inbound %s: %w", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trackers remain registered on the Router (which survives ReloadUsers).
|
||||
// Only update the user map — do NOT re-register or traffic is double-counted.
|
||||
if s.connTracker != nil {
|
||||
s.connTracker.SetUserMap(buildUserMap(users))
|
||||
}
|
||||
|
||||
nlog.Core().Debug("sing-box reloaded", "users", len(users))
|
||||
s.users = users
|
||||
s.nodeConfig = nodeConfig
|
||||
s.tls = tls
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerTracker wires the ConnTracker to the current Router exactly once.
|
||||
// The ConnTracker handles both byte counting and optional rate limiting
|
||||
// in a single wrapper, so no additional trackers are needed.
|
||||
func (s *SingBox) registerTracker(ctx context.Context) {
|
||||
if s.trackerRegistered {
|
||||
return
|
||||
}
|
||||
router := service.FromContext[adapter.Router](ctx)
|
||||
if router == nil {
|
||||
return
|
||||
}
|
||||
if s.connTracker != nil {
|
||||
router.AppendTracker(s.connTracker)
|
||||
}
|
||||
s.trackerRegistered = true
|
||||
}
|
||||
|
||||
func (s *SingBox) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stop()
|
||||
}
|
||||
|
||||
func (s *SingBox) stop() {
|
||||
if s.box == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: close all listen sockets so no new connections are accepted.
|
||||
// im.Close() sets m.started=false so the subsequent box.Close() call is a no-op
|
||||
// for inbounds (avoids double-close), but box.Close() still tears down routers
|
||||
// and outbounds.
|
||||
if im := service.FromContext[adapter.InboundManager](s.ctx); im != nil {
|
||||
_ = im.Close()
|
||||
}
|
||||
|
||||
// Step 2: wait for in-flight connections to drain naturally (best-effort).
|
||||
if s.connTracker != nil {
|
||||
deadline := time.Now().Add(drainTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if s.connTracker.ActiveCount() == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: hard-close everything that is still open.
|
||||
s.box.Close()
|
||||
s.box = nil
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
s.ctx = nil
|
||||
}
|
||||
|
||||
func (s *SingBox) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.box != nil
|
||||
}
|
||||
|
||||
// connTrackerLocked returns the connTracker under a read-optimised lock.
|
||||
func (s *SingBox) connTrackerSafe() *ConnTracker {
|
||||
s.mu.Lock()
|
||||
ct := s.connTracker
|
||||
s.mu.Unlock()
|
||||
return ct
|
||||
}
|
||||
|
||||
// SetSpeedLimitFunc configures per-user bandwidth throttling.
|
||||
func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.speedLimitFunc = fn
|
||||
if s.connTracker != nil {
|
||||
s.connTracker.SetSpeedLimitFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 []model.UserSpec) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.box == nil {
|
||||
return 0, fmt.Errorf("not running")
|
||||
}
|
||||
|
||||
existing := make(map[int]struct{}, len(s.users))
|
||||
for _, u := range s.users {
|
||||
existing[u.ID] = struct{}{}
|
||||
}
|
||||
var toAdd []model.UserSpec
|
||||
for _, u := range users {
|
||||
if _, dup := existing[u.ID]; !dup {
|
||||
toAdd = append(toAdd, u)
|
||||
}
|
||||
}
|
||||
if len(toAdd) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
merged := append(append([]model.UserSpec{}, s.users...), toAdd...)
|
||||
if err := s.reloadInboundsLocked(merged); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.users = merged
|
||||
return len(toAdd), nil
|
||||
}
|
||||
|
||||
// RemoveUsers hot-swaps users out of running inbounds. Zero connection disruption
|
||||
// for remaining users.
|
||||
func (s *SingBox) RemoveUsers(users []model.UserSpec) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.box == nil {
|
||||
return 0, fmt.Errorf("not running")
|
||||
}
|
||||
|
||||
removeSet := make(map[int]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
removeSet[u.ID] = struct{}{}
|
||||
}
|
||||
var kept []model.UserSpec
|
||||
removed := 0
|
||||
for _, u := range s.users {
|
||||
if _, rm := removeSet[u.ID]; rm {
|
||||
removed++
|
||||
} else {
|
||||
kept = append(kept, u)
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err := s.reloadInboundsLocked(kept); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.users = kept
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// UpdateUsers replaces the entire user set atomically via hot-swap.
|
||||
func (s *SingBox) UpdateUsers(users []model.UserSpec) (added, removed int, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.box == nil {
|
||||
return 0, 0, fmt.Errorf("not running")
|
||||
}
|
||||
|
||||
toAdd, toRemove := kernel.UserDiff(s.users, users)
|
||||
added, removed = len(toAdd), len(toRemove)
|
||||
|
||||
if added == 0 && removed == 0 {
|
||||
// Only limits may have changed — update tracker map.
|
||||
if s.connTracker != nil {
|
||||
s.connTracker.SetUserMap(buildUserMap(users))
|
||||
}
|
||||
s.users = users
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
if err = s.reloadInboundsLocked(users); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
s.users = users
|
||||
return
|
||||
}
|
||||
|
||||
// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound.
|
||||
// Must be called with s.mu held.
|
||||
func (s *SingBox) reloadInboundsLocked(users []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)
|
||||
}
|
||||
|
||||
opts, err := singJSON.UnmarshalExtendedContext[option.Options](s.ctx, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse options: %w", err)
|
||||
}
|
||||
|
||||
im := service.FromContext[adapter.InboundManager](s.ctx)
|
||||
if im == nil {
|
||||
return fmt.Errorf("inbound manager not available")
|
||||
}
|
||||
|
||||
router := service.FromContext[adapter.Router](s.ctx)
|
||||
if router == nil {
|
||||
return fmt.Errorf("router not available")
|
||||
}
|
||||
|
||||
nopFactory := singLog.NewNOPFactory()
|
||||
|
||||
for _, inb := range opts.Inbounds {
|
||||
tag := inb.Tag
|
||||
if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type {
|
||||
var err error
|
||||
switch v := existing.(type) {
|
||||
case adapter.UpdatableInbound[option.VMessUser]:
|
||||
if opts, ok := inb.Options.(*option.VMessInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.VLESSUser]:
|
||||
if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TrojanUser]:
|
||||
if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.Hysteria2User]:
|
||||
if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableShadowsocksInbound:
|
||||
if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok {
|
||||
err = v.UpdateUsersByOptions(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TUICUser]:
|
||||
if opts, ok := inb.Options.(*option.TUICInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.AnyTLSUser]:
|
||||
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[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:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.SocksInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.HTTPMixedInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
nlog.Core().Warn("incremental update failed, recreating inbound", "tag", tag, "error", err)
|
||||
}
|
||||
|
||||
_ = im.Remove(tag)
|
||||
logger := nopFactory.NewLogger(fmt.Sprintf("inbound/%s[%s]", inb.Type, tag))
|
||||
if err := im.Create(s.ctx, router, logger, tag, inb.Type, inb.Options); err != nil {
|
||||
return fmt.Errorf("recreate inbound %s: %w", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.connTracker != nil {
|
||||
s.connTracker.SetUserMap(buildUserMap(users))
|
||||
}
|
||||
|
||||
nlog.Core().Debug("sing-box users hot-swapped", "users", len(users))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Observability ──────────────────────────────────────────────────────────
|
||||
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, 0, nil
|
||||
}
|
||||
traffic, aliveIPs, connCount = ct.GetUserTraffic()
|
||||
return traffic, aliveIPs, connCount, nil
|
||||
}
|
||||
|
||||
// CloseConnection force-closes a specific connection by its ID.
|
||||
func (s *SingBox) CloseConnection(_ context.Context, connID string) error {
|
||||
ct := s.connTrackerSafe()
|
||||
if ct == nil {
|
||||
return fmt.Errorf("not running")
|
||||
}
|
||||
if !ct.CloseByID(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 []model.UserSpec) map[string]int {
|
||||
m := make(map[string]int, len(users))
|
||||
for _, u := range users {
|
||||
m[u.UUID] = u.ID
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,881 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
)
|
||||
|
||||
// M is a shorthand for building JSON-like maps
|
||||
type M = map[string]interface{}
|
||||
|
||||
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
var outbounds []M
|
||||
tags := make(map[string]bool)
|
||||
|
||||
// Panel-defined custom outbounds (structured, converted to Xray native)
|
||||
for _, co := range nc.CustomOutbounds {
|
||||
outbounds = append(outbounds, outboundConfigToXray(co))
|
||||
tags[strings.ToLower(co.Tag)] = true
|
||||
}
|
||||
|
||||
// Static outbounds from local config file
|
||||
for _, co := range kcfg.CustomOutbound {
|
||||
tag, _ := co["tag"].(string)
|
||||
if tag != "" {
|
||||
tags[strings.ToLower(tag)] = true
|
||||
}
|
||||
outbounds = append(outbounds, M(co))
|
||||
}
|
||||
|
||||
// Add default outbounds only if not already defined (Issue #1: Panel priority)
|
||||
if !tags["direct"] {
|
||||
outbounds = append([]M{{"protocol": "freedom", "tag": "direct"}}, outbounds...)
|
||||
}
|
||||
if !tags["block"] {
|
||||
// block is often added after direct but before others for safety
|
||||
outbounds = append(outbounds, M{"protocol": "blackhole", "tag": "block"})
|
||||
}
|
||||
|
||||
cfg := M{
|
||||
"log": M{
|
||||
"loglevel": xrayLogLevel(kcfg.LogLevel),
|
||||
"error": "",
|
||||
"access": "",
|
||||
},
|
||||
"stats": M{},
|
||||
"policy": M{
|
||||
"levels": M{
|
||||
"0": M{
|
||||
"statsUserUplink": true,
|
||||
"statsUserDownlink": true,
|
||||
},
|
||||
},
|
||||
"system": M{
|
||||
"statsInboundUplink": true,
|
||||
"statsInboundDownlink": true,
|
||||
"statsOutboundUplink": true,
|
||||
"statsOutboundDownlink": true,
|
||||
},
|
||||
},
|
||||
"outbounds": outbounds,
|
||||
}
|
||||
|
||||
inbound := buildInbound(nc, users, tc)
|
||||
if inbound != nil {
|
||||
cfg["inbounds"] = []M{inbound}
|
||||
} else {
|
||||
nlog.Core().Warn("xray: unsupported protocol, no inbound configured — node will not accept connections",
|
||||
"protocol", nc.Protocol,
|
||||
"supported", "vmess, vless, trojan, shadowsocks, hysteria, socks, http")
|
||||
}
|
||||
|
||||
// Merge panel routes and static config routes
|
||||
cfg["routing"] = buildRouting(nc.Routes, nc.CustomRouteRules, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
|
||||
mergeCustomXray(cfg, kcfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// 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 model.OutboundConfig) M {
|
||||
m := M{
|
||||
"protocol": oc.Protocol,
|
||||
"tag": oc.Tag,
|
||||
}
|
||||
if len(oc.Settings) > 0 {
|
||||
m["settings"] = oc.Settings
|
||||
}
|
||||
if oc.ProxyTag != "" {
|
||||
m["proxySettings"] = M{"tag": oc.ProxyTag}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func mergeRouteList(a, b []map[string]any) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(a)+len(b))
|
||||
res = append(res, a...)
|
||||
res = append(res, b...)
|
||||
return res
|
||||
}
|
||||
|
||||
// mergeCustomXray deep-merges a custom Xray config file into the generated config.
|
||||
// Merge strategy (compatible with V2bX/XrayR custom DNS etc.):
|
||||
// - dns: custom replaces auto-generated
|
||||
// - outbounds: custom entries appended
|
||||
// - routing.rules: custom rules prepended (matched first)
|
||||
// - policy, api: custom deep-merges
|
||||
// - inbounds: NOT merged (panel-managed, authoritative)
|
||||
func mergeCustomXray(cfg M, kcfg config.KernelConfig) {
|
||||
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to load custom xray config", "error", err)
|
||||
return
|
||||
}
|
||||
if custom == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// dns — custom replaces (same as V2bX DnsConfigPath)
|
||||
if v, ok := custom["dns"]; ok {
|
||||
cfg["dns"] = v
|
||||
}
|
||||
|
||||
// outbounds — append custom entries
|
||||
if v, ok := custom["outbounds"]; ok {
|
||||
if existing, ok := cfg["outbounds"].([]M); ok {
|
||||
cfg["outbounds"] = kernel.MergeAppendList(existing, v)
|
||||
}
|
||||
}
|
||||
|
||||
// routing — merge sub-fields
|
||||
if customRouting, ok := custom["routing"]; ok {
|
||||
if customRoutingMap, ok := customRouting.(map[string]any); ok {
|
||||
mergeCustomXrayRouting(cfg, customRoutingMap)
|
||||
}
|
||||
}
|
||||
|
||||
// other top-level keys (policy, api, transport, etc.) — custom overrides,
|
||||
// but we protect auto-generated inbounds, stats, log, routing, outbounds
|
||||
protected := map[string]bool{
|
||||
"inbounds": true, "outbounds": true, "routing": true,
|
||||
"dns": true, "log": true, "stats": true, "policy": true,
|
||||
}
|
||||
for k, v := range custom {
|
||||
if !protected[k] {
|
||||
cfg[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeCustomXrayRouting(cfg M, customRouting map[string]any) {
|
||||
routing, ok := cfg["routing"].(M)
|
||||
if !ok {
|
||||
routing = M{}
|
||||
cfg["routing"] = routing
|
||||
}
|
||||
|
||||
// rules — custom rules prepended (so they match before panel rules)
|
||||
if v, ok := customRouting["rules"]; ok {
|
||||
if existing, ok := routing["rules"].([]M); ok {
|
||||
routing["rules"] = kernel.MergePrependList(existing, v)
|
||||
}
|
||||
}
|
||||
|
||||
// domainStrategy, balancers, etc. — custom overrides
|
||||
for k, v := range customRouting {
|
||||
if k == "rules" {
|
||||
continue
|
||||
}
|
||||
routing[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func xrayLogLevel(singboxLevel string) string {
|
||||
switch singboxLevel {
|
||||
case "trace", "debug":
|
||||
return "debug"
|
||||
case "info":
|
||||
return "info"
|
||||
case "warn":
|
||||
return "warning"
|
||||
case "error":
|
||||
return "error"
|
||||
case "fatal", "panic":
|
||||
return "error"
|
||||
default:
|
||||
return "warning"
|
||||
}
|
||||
}
|
||||
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
listenAddr := "::"
|
||||
if nc.ListenIP != "" {
|
||||
listenAddr = nc.ListenIP
|
||||
}
|
||||
base := M{
|
||||
"tag": nc.Protocol + "-in",
|
||||
"listen": listenAddr,
|
||||
"port": nc.ServerPort,
|
||||
"protocol": nc.Protocol,
|
||||
"streamSettings": M{
|
||||
"sockopt": M{
|
||||
"reusePort": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
switch nc.Protocol {
|
||||
case "vmess":
|
||||
return buildVMess(base, nc, users, tc)
|
||||
case "vless":
|
||||
return buildVLESS(base, nc, users, tc)
|
||||
case "trojan":
|
||||
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, tc)
|
||||
case "hysteria":
|
||||
return buildHysteria(base, nc, users, tc)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// userEmail returns the stats-tracking email for a user.
|
||||
// Format: "user@<id>" so we can parse back the user ID from stats counters.
|
||||
func userEmail(userID int) string {
|
||||
return fmt.Sprintf("user@%d", userID)
|
||||
}
|
||||
|
||||
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{
|
||||
"id": u.UUID,
|
||||
"alterId": 0,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{"clients": clients}
|
||||
|
||||
applyStreamSettings(base, nc, tc)
|
||||
return base
|
||||
}
|
||||
|
||||
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{
|
||||
"id": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
}
|
||||
if nc.Flow != "" {
|
||||
client["flow"] = nc.Flow
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
decryption := "none"
|
||||
if nc.Decryption != "" {
|
||||
decryption = nc.Decryption
|
||||
}
|
||||
base["settings"] = M{
|
||||
"clients": clients,
|
||||
"decryption": decryption,
|
||||
}
|
||||
|
||||
applyStreamSettings(base, nc, tc)
|
||||
return base
|
||||
}
|
||||
|
||||
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, 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
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
clients := make([]M, 0, len(users))
|
||||
|
||||
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 []model.UserSpec) M {
|
||||
base["protocol"] = "socks"
|
||||
accounts := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
accounts = append(accounts, M{
|
||||
"user": u.UUID,
|
||||
"pass": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{
|
||||
"auth": "password",
|
||||
"accounts": accounts,
|
||||
"udp": true,
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
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 {
|
||||
accounts = append(accounts, M{
|
||||
"user": u.UUID,
|
||||
"pass": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{
|
||||
"accounts": accounts,
|
||||
}
|
||||
|
||||
if nc.TLS == 1 {
|
||||
applyStreamSettings(base, nc, tc)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// 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
|
||||
network := nc.Network
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
ss["network"] = network
|
||||
|
||||
switch network {
|
||||
case "ws":
|
||||
wsSettings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
wsSettings["path"] = v
|
||||
}
|
||||
headers := M{}
|
||||
if v, ok := nc.NetworkSettings["headers"]; ok {
|
||||
if headersMap, ok := v.(map[string]interface{}); ok {
|
||||
for k, val := range headersMap {
|
||||
headers[k] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
headers["Host"] = v
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
wsSettings["headers"] = headers
|
||||
}
|
||||
}
|
||||
ss["wsSettings"] = wsSettings
|
||||
|
||||
case "grpc":
|
||||
grpcSettings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["serviceName"]; ok {
|
||||
grpcSettings["serviceName"] = v
|
||||
} else if v, ok := nc.NetworkSettings["service_name"]; ok {
|
||||
grpcSettings["serviceName"] = v
|
||||
}
|
||||
}
|
||||
ss["grpcSettings"] = grpcSettings
|
||||
|
||||
case "httpupgrade":
|
||||
huSettings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
huSettings["path"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
huSettings["host"] = v
|
||||
}
|
||||
}
|
||||
ss["httpupgradeSettings"] = huSettings
|
||||
|
||||
case "h2", "http":
|
||||
ss["network"] = "h2"
|
||||
h2Settings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
h2Settings["path"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
h2Settings["host"] = []interface{}{v}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// TLS
|
||||
if nc.TLS == 1 {
|
||||
tlsSettings := M{}
|
||||
serverName := nc.ServerName
|
||||
if serverName == "" && nc.Host != "" {
|
||||
serverName = nc.Host
|
||||
}
|
||||
if serverName != "" {
|
||||
tlsSettings["serverName"] = serverName
|
||||
}
|
||||
if nc.TLSSettings != nil {
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
|
||||
tlsSettings["serverName"] = sn
|
||||
}
|
||||
// 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,
|
||||
// but providing a placeholder helps documentation.
|
||||
}
|
||||
ss["security"] = "tls"
|
||||
ss["tlsSettings"] = tlsSettings
|
||||
} else if nc.TLS == 2 {
|
||||
ss["security"] = "reality"
|
||||
ss["realitySettings"] = buildRealitySettings(nc)
|
||||
}
|
||||
|
||||
// Proxy Protocol
|
||||
if nc.GetProxyProtocol() {
|
||||
sockopt, ok := base["streamSettings"].(M)["sockopt"].(M)
|
||||
if !ok {
|
||||
sockopt = M{}
|
||||
}
|
||||
sockopt["acceptProxyProtocol"] = true
|
||||
ss["sockopt"] = sockopt
|
||||
}
|
||||
|
||||
base["streamSettings"] = ss
|
||||
}
|
||||
|
||||
func buildRealitySettings(nc *model.NodeSpec) M {
|
||||
reality := M{"show": false}
|
||||
|
||||
if nc.TLSSettings == nil {
|
||||
return reality
|
||||
}
|
||||
|
||||
if pk, ok := nc.TLSSettings["private_key"]; ok {
|
||||
reality["privateKey"] = pk
|
||||
}
|
||||
if sid, ok := nc.TLSSettings["short_id"]; ok {
|
||||
switch v := sid.(type) {
|
||||
case string:
|
||||
reality["shortIds"] = []string{v}
|
||||
case []interface{}:
|
||||
ids := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
ids = append(ids, fmt.Sprintf("%v", item))
|
||||
}
|
||||
reality["shortIds"] = ids
|
||||
}
|
||||
}
|
||||
|
||||
if dest, ok := nc.TLSSettings["dest"]; ok {
|
||||
destStr := fmt.Sprintf("%v", dest)
|
||||
reality["dest"] = destStr
|
||||
}
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok {
|
||||
reality["serverNames"] = []string{fmt.Sprintf("%v", sn)}
|
||||
if _, exists := reality["dest"]; !exists {
|
||||
reality["dest"] = fmt.Sprintf("%v:443", sn)
|
||||
}
|
||||
}
|
||||
|
||||
return reality
|
||||
}
|
||||
|
||||
func buildRouting(rules []model.RouteRule, customRouteRules []model.CustomRouteRule, customRules []map[string]any) M {
|
||||
var xrayRules []M
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"ip": []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",
|
||||
},
|
||||
"outboundTag": "block",
|
||||
})
|
||||
|
||||
for _, rule := range rules {
|
||||
xrayRules = append(xrayRules, compilePanelRouteRule(rule)...)
|
||||
}
|
||||
|
||||
return M{
|
||||
"domainStrategy": "AsIs",
|
||||
"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,574 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
var testKernelCfg = config.KernelConfig{
|
||||
Type: "xray",
|
||||
LogLevel: "warn",
|
||||
}
|
||||
|
||||
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",
|
||||
CustomOutbound: []map[string]any{
|
||||
{"tag": "block", "protocol": "dns"}, // Local static override
|
||||
},
|
||||
}
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
CustomOutbounds: []panel.OutboundConfig{
|
||||
{Tag: "direct", Protocol: "socks", Settings: map[string]any{"address": "1.2.3.4"}}, // Panel override
|
||||
},
|
||||
}
|
||||
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
outbounds := cfg["outbounds"].([]M)
|
||||
|
||||
// Since we overrode both 'direct' and 'block', the result should contain
|
||||
// exactly these two custom outbounds, without auto-generated defaults.
|
||||
if len(outbounds) != 2 {
|
||||
t.Errorf("outbounds: got %d, want 2", len(outbounds))
|
||||
}
|
||||
|
||||
foundDirect := false
|
||||
foundBlock := false
|
||||
|
||||
for _, o := range outbounds {
|
||||
tag := o["tag"].(string)
|
||||
if tag == "direct" {
|
||||
foundDirect = true
|
||||
if o["protocol"] != "socks" {
|
||||
t.Errorf("expected 'direct' protocol to be 'socks' (panel priority), got %v", o["protocol"])
|
||||
}
|
||||
}
|
||||
if tag == "block" {
|
||||
foundBlock = true
|
||||
if o["protocol"] != "dns" {
|
||||
t.Errorf("expected 'block' protocol to be 'dns' (static config priority), got %v", o["protocol"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDirect || !foundBlock {
|
||||
t.Errorf("missing outbounds: direct=%v, block=%v", foundDirect, foundBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
protocols := []struct {
|
||||
name string
|
||||
nc panel.NodeConfig
|
||||
}{
|
||||
{
|
||||
name: "vmess",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
Network: "ws",
|
||||
TLS: 1,
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/vmess",
|
||||
"host": "example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vless",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
Network: "tcp",
|
||||
TLS: 2,
|
||||
Flow: "xtls-rprx-vision",
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "test-pk",
|
||||
"short_id": "abcd",
|
||||
"server_name": "www.example.com",
|
||||
"dest": "www.example.com:443",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trojan",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 443,
|
||||
Network: "grpc",
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"service_name": "trojan-grpc",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shadowsocks-aes",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "aes-128-gcm",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shadowsocks-2022",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "test-server-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "socks",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "http",
|
||||
ServerPort: 8080,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check required top-level fields
|
||||
for _, key := range []string{"log", "stats", "policy", "outbounds", "routing"} {
|
||||
if _, ok := parsed[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := parsed["inbounds"]; !ok {
|
||||
t.Error("missing inbounds")
|
||||
}
|
||||
|
||||
t.Logf("config size: %d bytes", len(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_VMess_Users(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
|
||||
if ib["protocol"] != "vmess" {
|
||||
t.Errorf("expected protocol vmess, got %v", ib["protocol"])
|
||||
}
|
||||
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
clients := settings["clients"].([]interface{})
|
||||
|
||||
if len(clients) != 2 {
|
||||
t.Fatalf("expected 2 clients, got %d", len(clients))
|
||||
}
|
||||
|
||||
c1 := clients[0].(map[string]interface{})
|
||||
if c1["email"] != "user@1" {
|
||||
t.Errorf("expected email user@1, got %v", c1["email"])
|
||||
}
|
||||
if c1["id"] != "279d4f89-3a2c-488d-a67c-2d39a72acdde" {
|
||||
t.Errorf("unexpected UUID: %v", c1["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
Flow: "xtls-rprx-vision",
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "pk",
|
||||
"server_name": "example.com",
|
||||
},
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
clients := settings["clients"].([]interface{})
|
||||
c1 := clients[0].(map[string]interface{})
|
||||
|
||||
if c1["flow"] != "xtls-rprx-vision" {
|
||||
t.Errorf("expected flow xtls-rprx-vision, got %v", c1["flow"])
|
||||
}
|
||||
|
||||
ss := ib["streamSettings"].(map[string]interface{})
|
||||
if ss["security"] != "reality" {
|
||||
t.Errorf("expected security reality, got %v", ss["security"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_Default(t *testing.T) {
|
||||
routing := buildRouting(nil, nil, nil)
|
||||
rules := routing["rules"].([]M)
|
||||
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("expected 1 default rule, got %d", len(rules))
|
||||
}
|
||||
|
||||
if rules[0]["outboundTag"] != "block" {
|
||||
t.Errorf("expected block outbound, got %v", rules[0]["outboundTag"])
|
||||
}
|
||||
ips := rules[0]["ip"].([]string)
|
||||
if len(ips) < 5 {
|
||||
t.Errorf("expected multiple private CIDRs, got %d", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_WithRules(t *testing.T) {
|
||||
rules := []panel.RouteRule{
|
||||
{
|
||||
ID: 1,
|
||||
Match: []string{"*.baidu.com", "*.qq.com", "10.0.0.0/8"},
|
||||
Action: "block",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Match: []string{"*.google.com"},
|
||||
Action: "direct",
|
||||
},
|
||||
}
|
||||
|
||||
routing := buildRouting(testRouteRules(rules), nil, nil)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
|
||||
// 1 default + 2 domain rules + 1 IP rule = 4
|
||||
if len(xrayRules) != 4 {
|
||||
t.Fatalf("expected 4 rules, got %d", len(xrayRules))
|
||||
}
|
||||
|
||||
// Rule 1: domains block
|
||||
r1 := xrayRules[1]
|
||||
domains := r1["domain"].([]string)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("expected 2 domains, got %d", len(domains))
|
||||
}
|
||||
if domains[0] != "domain:baidu.com" || domains[1] != "domain:qq.com" {
|
||||
t.Errorf("unexpected domains: %v", domains)
|
||||
}
|
||||
if r1["outboundTag"] != "block" {
|
||||
t.Errorf("expected block, got %v", r1["outboundTag"])
|
||||
}
|
||||
|
||||
// Rule 2: IP block
|
||||
r2 := xrayRules[2]
|
||||
ips := r2["ip"].([]string)
|
||||
if len(ips) != 1 || ips[0] != "10.0.0.0/8" {
|
||||
t.Errorf("unexpected IPs: %v", ips)
|
||||
}
|
||||
|
||||
// Rule 3: direct
|
||||
r3 := xrayRules[3]
|
||||
if r3["outboundTag"] != "direct" {
|
||||
t.Errorf("expected direct, got %v", r3["outboundTag"])
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
expected string
|
||||
}{
|
||||
{"debug", "debug"},
|
||||
{"info", "info"},
|
||||
{"warn", "warning"},
|
||||
{"error", "error"},
|
||||
{"", "warning"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := xrayLogLevel(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("xrayLogLevel(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_StatsEnabled(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
// Verify stats is enabled
|
||||
if _, ok := parsed["stats"]; !ok {
|
||||
t.Error("stats not enabled")
|
||||
}
|
||||
|
||||
// Verify policy enables user stats
|
||||
policy := parsed["policy"].(map[string]interface{})
|
||||
levels := policy["levels"].(map[string]interface{})
|
||||
level0 := levels["0"].(map[string]interface{})
|
||||
|
||||
if v, ok := level0["statsUserUplink"]; !ok || v != true {
|
||||
t.Error("statsUserUplink not enabled")
|
||||
}
|
||||
if v, ok := level0["statsUserDownlink"]; !ok || v != true {
|
||||
t.Error("statsUserDownlink not enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_Shadowsocks_MultiUserTraditional(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
|
||||
if settings["method"] != "aes-128-gcm" {
|
||||
t.Errorf("expected method aes-128-gcm, got %v", settings["method"])
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "server-key",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
|
||||
if settings["password"] != "server-key" {
|
||||
t.Errorf("expected server key, got %v", settings["password"])
|
||||
}
|
||||
clients := settings["clients"].([]interface{})
|
||||
if len(clients) != 2 {
|
||||
t.Fatalf("expected 2 clients, got %d", len(clients))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_SocksStats(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
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")
|
||||
}
|
||||
|
||||
a1 := accounts[0].(map[string]interface{})
|
||||
if a1["email"] != "user@1" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
_ "unsafe"
|
||||
|
||||
xrayDispatcher "github.com/xtls/xray-core/app/dispatcher"
|
||||
"github.com/xtls/xray-core/common"
|
||||
"github.com/xtls/xray-core/common/buf"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/common/session"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
"github.com/xtls/xray-core/transport"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
)
|
||||
|
||||
// Access xray's internal config creator registry so we can replace the
|
||||
// default dispatcher factory with ours. This runs AFTER xray's init()
|
||||
// functions because our package imports xray (dependency order guarantee).
|
||||
//
|
||||
//go:linkname typeCreatorRegistry github.com/xtls/xray-core/common.typeCreatorRegistry
|
||||
var typeCreatorRegistry map[reflect.Type]common.ConfigCreator
|
||||
|
||||
var origDispatcherFactory common.ConfigCreator
|
||||
|
||||
// globalLimitDispatcher is set when the factory creates a LimitDispatcher.
|
||||
// The Xray kernel reads it to configure limits and get connections.
|
||||
var globalLimitDispatcher atomic.Pointer[LimitDispatcher]
|
||||
|
||||
func init() {
|
||||
configType := reflect.TypeOf((*xrayDispatcher.Config)(nil))
|
||||
origDispatcherFactory = typeCreatorRegistry[configType]
|
||||
typeCreatorRegistry[configType] = limitDispatcherFactory
|
||||
}
|
||||
|
||||
func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{}, error) {
|
||||
orig, err := origDispatcherFactory(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inner, ok := orig.(routing.Dispatcher)
|
||||
if !ok {
|
||||
return orig, nil
|
||||
}
|
||||
ld := &LimitDispatcher{
|
||||
inner: orig,
|
||||
innerDisp: inner,
|
||||
limitedIPs: make(map[string]map[string]int),
|
||||
}
|
||||
globalLimitDispatcher.Store(ld)
|
||||
nlog.Core().Debug("xray: limit dispatcher installed")
|
||||
return ld, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
limitedIPs map[string]map[string]int // email → sourceIP → refcount
|
||||
deviceLimits map[string]int // email → max devices
|
||||
emailToUID map[string]int // email → panel user ID
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 ──────────────────────────────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (*transport.Link, error) {
|
||||
email, sourceIP, isTCP, err := d.identifyAndCheck(ctx, dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
link, err := d.innerDisp.Dispatch(ctx, dest)
|
||||
if err != nil {
|
||||
if email != "" && isTCP {
|
||||
d.delConn(email, sourceIP)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
d.trackLink(link, email, sourceIP, isTCP)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) DispatchLink(ctx context.Context, dest net.Destination, link *transport.Link) error {
|
||||
email, sourceIP, isTCP, err := d.identifyAndCheck(ctx, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
d.trackLink(link, email, sourceIP, isTCP)
|
||||
}
|
||||
return d.innerDisp.DispatchLink(ctx, dest, link)
|
||||
}
|
||||
|
||||
// identifyAndCheck extracts user identity from the session context, enforces
|
||||
// device limits, and returns the user's email, source IP, and TCP flag.
|
||||
// Returns a non-nil error only when the connection should be rejected.
|
||||
func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destination) (email, sourceIP string, isTCP bool, err error) {
|
||||
si := session.InboundFromContext(ctx)
|
||||
if si == nil || si.User == nil || len(si.User.Email) == 0 {
|
||||
return "", "", false, nil
|
||||
}
|
||||
email = si.User.Email
|
||||
sourceIP = si.Source.Address.IP().String()
|
||||
isTCP = dest.Network == net.Network_TCP
|
||||
|
||||
if d.checkDeviceLimit(email, sourceIP, isTCP) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 isTCP {
|
||||
d.delConn(email, sourceIP)
|
||||
}
|
||||
d.connCount.Add(-1)
|
||||
}
|
||||
|
||||
link.Writer = &closeTrackingWriter{
|
||||
Writer: link.Writer,
|
||||
onClose: onClose,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── features.Feature (delegated) ───────────────────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) Type() interface{} { return routing.DispatcherType() }
|
||||
|
||||
func (d *LimitDispatcher) Start() error {
|
||||
if s, ok := d.inner.(interface{ Start() error }); ok {
|
||||
return s.Start()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) Close() error {
|
||||
if c, ok := d.inner.(interface{ Close() error }); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Limit management (called by Xray kernel) ──────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, _ map[string]int) {
|
||||
d.mu.Lock()
|
||||
d.emailToUID = emailToUID
|
||||
d.deviceLimits = deviceLimits
|
||||
d.mu.Unlock()
|
||||
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) ResetConns() {
|
||||
d.mu.Lock()
|
||||
d.limitedIPs = make(map[string]map[string]int)
|
||||
d.mu.Unlock()
|
||||
|
||||
// Clear unlimited IPs
|
||||
d.unlimitedIPs.Range(func(key, _ interface{}) bool {
|
||||
d.unlimitedIPs.Delete(key)
|
||||
return true
|
||||
})
|
||||
|
||||
d.connCount.Store(0)
|
||||
}
|
||||
|
||||
// GetConnectionState returns dispatcher-tracked alive IPs and connection count.
|
||||
// Traffic bytes are intentionally left to xray's built-in stats pipeline.
|
||||
func (d *LimitDispatcher) GetConnectionState() (aliveIPs map[int]map[string]bool, connCount int) {
|
||||
d.mu.RLock()
|
||||
emailToUID := d.emailToUID
|
||||
limitedIPs := d.limitedIPs
|
||||
d.mu.RUnlock()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.limitedIPs[email] = ips
|
||||
}
|
||||
|
||||
if ips[sourceIP] > 0 {
|
||||
if isTCP {
|
||||
ips[sourceIP]++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ips) < limit {
|
||||
if isTCP {
|
||||
ips[sourceIP]++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Over limit — deterministic: allow lowest IPs lexicographically.
|
||||
ipList := make([]string, 0, len(ips)+1)
|
||||
for ip := range ips {
|
||||
ipList = append(ipList, ip)
|
||||
}
|
||||
ipList = append(ipList, sourceIP)
|
||||
sort.Strings(ipList)
|
||||
|
||||
for i := 0; i < limit && i < len(ipList); i++ {
|
||||
if ipList[i] == sourceIP {
|
||||
if isTCP {
|
||||
ips[sourceIP]++
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
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.limitedIPs[email]; ok {
|
||||
ips[sourceIP]--
|
||||
if ips[sourceIP] <= 0 {
|
||||
delete(ips, sourceIP)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
delete(d.limitedIPs, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type closeTrackingWriter struct {
|
||||
buf.Writer
|
||||
onClose func()
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (w *closeTrackingWriter) Close() error {
|
||||
if w.closed.CompareAndSwap(false, true) {
|
||||
w.onClose()
|
||||
}
|
||||
return common.Close(w.Writer)
|
||||
}
|
||||
|
||||
func (w *closeTrackingWriter) Interrupt() {
|
||||
if w.closed.CompareAndSwap(false, true) {
|
||||
w.onClose()
|
||||
}
|
||||
common.Interrupt(w.Writer)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/xtls/xray-core/common/buf"
|
||||
"github.com/xtls/xray-core/transport"
|
||||
)
|
||||
|
||||
func newTestDispatcher() *LimitDispatcher {
|
||||
return &LimitDispatcher{
|
||||
limitedIPs: make(map[string]map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
}
|
||||
|
||||
emailToUID := make(map[string]int)
|
||||
deviceLimits := make(map[string]int)
|
||||
speedLimits := make(map[string]int)
|
||||
for _, u := range users {
|
||||
email := userEmail(u.ID)
|
||||
emailToUID[email] = u.ID
|
||||
if u.DeviceLimit > 0 {
|
||||
deviceLimits[email] = u.DeviceLimit
|
||||
}
|
||||
if u.SpeedLimit > 0 {
|
||||
speedLimits[email] = u.SpeedLimit
|
||||
}
|
||||
}
|
||||
ld.UpdateLimits(emailToUID, deviceLimits, speedLimits)
|
||||
|
||||
email1 := userEmail(1)
|
||||
|
||||
// First IP should be allowed
|
||||
if ld.checkDeviceLimit(email1, "1.1.1.1", true) {
|
||||
t.Error("first IP should be allowed")
|
||||
}
|
||||
|
||||
// Second IP should be allowed (limit=2)
|
||||
if ld.checkDeviceLimit(email1, "2.2.2.2", true) {
|
||||
t.Error("second IP should be allowed")
|
||||
}
|
||||
|
||||
// Third unique IP should be rejected
|
||||
if !ld.checkDeviceLimit(email1, "3.3.3.3", true) {
|
||||
t.Error("third IP should be rejected (limit=2)")
|
||||
}
|
||||
|
||||
// Same IP as first should be allowed (already connected)
|
||||
if ld.checkDeviceLimit(email1, "1.1.1.1", true) {
|
||||
t.Error("same IP should always be allowed")
|
||||
}
|
||||
|
||||
// User 2 has no device limit — should always be allowed
|
||||
email2 := userEmail(2)
|
||||
for i := 0; i < 10; i++ {
|
||||
ip := "10.0.0." + string(rune('0'+i))
|
||||
if ld.checkDeviceLimit(email2, ip, true) {
|
||||
t.Errorf("user with no device limit should always be allowed (ip=%s)", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_DelConn(t *testing.T) {
|
||||
ld := newTestDispatcher()
|
||||
|
||||
email := userEmail(1)
|
||||
deviceLimits := map[string]int{email: 2}
|
||||
ld.UpdateLimits(map[string]int{email: 1}, deviceLimits, nil)
|
||||
|
||||
// Add 2 IPs
|
||||
ld.checkDeviceLimit(email, "1.1.1.1", true)
|
||||
ld.checkDeviceLimit(email, "2.2.2.2", true)
|
||||
|
||||
// Third should be rejected
|
||||
if !ld.checkDeviceLimit(email, "3.3.3.3", true) {
|
||||
t.Error("third IP should be rejected")
|
||||
}
|
||||
|
||||
// Remove first IP
|
||||
ld.delConn(email, "1.1.1.1")
|
||||
|
||||
// Now third IP should be allowed
|
||||
if ld.checkDeviceLimit(email, "3.3.3.3", true) {
|
||||
t.Error("after deleting one IP, new IP should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
ic2 := &ipCounter{}
|
||||
r3 := &atomic.Int64{}
|
||||
r3.Store(1)
|
||||
ic2.ips.Store("3.3.3.3", r3)
|
||||
ld.unlimitedIPs.Store(email2, ic2)
|
||||
|
||||
ld.connCount.Store(5)
|
||||
|
||||
aliveIPs, connCount := ld.GetConnectionState()
|
||||
|
||||
if connCount != 5 {
|
||||
t.Errorf("expected connCount=5, got %d", connCount)
|
||||
}
|
||||
if len(aliveIPs[1]) != 2 {
|
||||
t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1]))
|
||||
}
|
||||
if len(aliveIPs[2]) != 1 {
|
||||
t.Errorf("user 2 IPs: got %d, want 1", len(aliveIPs[2]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_ResetConns(t *testing.T) {
|
||||
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()
|
||||
|
||||
ld.mu.RLock()
|
||||
ipCount := len(ld.limitedIPs)
|
||||
ld.mu.RUnlock()
|
||||
if ipCount != 0 {
|
||||
t.Error("limitedIPs 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")
|
||||
}
|
||||
}
|
||||
@@ -1,809 +0,0 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/protocol"
|
||||
"github.com/xtls/xray-core/common/uuid"
|
||||
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"
|
||||
ss2022 "github.com/xtls/xray-core/proxy/shadowsocks_2022"
|
||||
"github.com/xtls/xray-core/proxy/trojan"
|
||||
"github.com/xtls/xray-core/proxy/vless"
|
||||
"github.com/xtls/xray-core/proxy/vmess"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
_ "github.com/xtls/xray-core/main/distro/all"
|
||||
|
||||
"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/nlog"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// drainTimeout is how long Stop waits for in-flight connections to finish
|
||||
// before hard-killing the instance. Skipped during hot-reload for speed.
|
||||
drainTimeout = 5 * time.Second
|
||||
// startTimeout caps how long instance.Start() may block.
|
||||
startTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Xray implements kernel.Kernel by embedding xray-core as a Go library.
|
||||
//
|
||||
// Lifecycle: Start → running → Stop / Reload
|
||||
//
|
||||
// Lock discipline:
|
||||
// - mu is held only for brief field swaps (microseconds).
|
||||
// - instance.Start() and instance.Close() run OUTSIDE the lock.
|
||||
// - running (atomic) gates fast-path checks in IsRunning / GetConnections.
|
||||
type Xray struct {
|
||||
cfg config.KernelConfig
|
||||
|
||||
// mu protects instance, limitDispatcher, users, protocol, inboundTag,
|
||||
// lastKernelHash, and cumTraffic. Never held during slow I/O.
|
||||
mu sync.Mutex
|
||||
instance *xrayCore.Instance
|
||||
limitDispatcher *LimitDispatcher
|
||||
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.
|
||||
running atomic.Bool
|
||||
}
|
||||
|
||||
func New(cfg config.KernelConfig) *Xray {
|
||||
return &Xray{
|
||||
cfg: cfg,
|
||||
cumTraffic: make(map[int][2]int64),
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"hysteria",
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Start builds a new xray-core instance and atomically replaces the old
|
||||
// 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 – 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, tls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pbConfig, err := serial.LoadJSONConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse xray config: %w", err)
|
||||
}
|
||||
|
||||
// ── Phase 2: Create instance (global lock for LD capture) ───────────
|
||||
xrayCreationMu.Lock()
|
||||
inst, err := xrayCore.New(pbConfig)
|
||||
ld := globalLimitDispatcher.Load()
|
||||
xrayCreationMu.Unlock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create xray: %w", err)
|
||||
}
|
||||
|
||||
// ── Phase 3: Start new (no lock, potentially slow) ──────────────────
|
||||
if err := startWithTimeout(inst, startTimeout); err != nil {
|
||||
inst.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// ── 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.tls = tls
|
||||
x.protocol = nodeConfig.Protocol
|
||||
x.inboundTag = nodeConfig.Protocol + "-in"
|
||||
x.cumTraffic = make(map[int][2]int64)
|
||||
x.lastKernelHash = kernel.ComputeHash(nodeConfig, users)
|
||||
x.running.Store(true)
|
||||
x.mu.Unlock()
|
||||
|
||||
// ── Phase 5: Recycle old (background, non-blocking) ─────────────────
|
||||
closeOld(old, oldLD)
|
||||
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
|
||||
nlog.Core().Info("xray started",
|
||||
"users", len(users),
|
||||
"protocol", nodeConfig.Protocol,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload updates dispatcher limits and handles configuration changes.
|
||||
// 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 *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 && tlsEqual(x.tls, tls)
|
||||
if same {
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: limits updated, kernel configuration unchanged")
|
||||
return nil
|
||||
}
|
||||
x.mu.Unlock()
|
||||
|
||||
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.
|
||||
func (x *Xray) Stop() {
|
||||
x.running.Store(false)
|
||||
|
||||
x.mu.Lock()
|
||||
inst := x.instance
|
||||
ld := x.limitDispatcher
|
||||
x.instance = nil
|
||||
x.limitDispatcher = nil
|
||||
x.mu.Unlock()
|
||||
|
||||
if ld != nil {
|
||||
drainConns(ld, drainTimeout)
|
||||
}
|
||||
closeOld(inst, ld)
|
||||
}
|
||||
|
||||
func (x *Xray) IsRunning() bool { return x.running.Load() }
|
||||
|
||||
func tlsEqual(a, b kernel.TLSCert) bool {
|
||||
return bytes.Equal(a.CertPEM, b.CertPEM) && bytes.Equal(a.KeyPEM, b.KeyPEM)
|
||||
}
|
||||
|
||||
// ─── 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 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) 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) ────────────────────────
|
||||
|
||||
// AddUsers adds new users to the running kernel via xray's UserManager API.
|
||||
// For protocols that support UserManager (vmess, vless, trojan, shadowsocks),
|
||||
// this is truly hitless — no restart, no connection disruption.
|
||||
// For unsupported protocols (socks, http), falls back to full restart.
|
||||
//
|
||||
// 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 []model.UserSpec) (int, error) {
|
||||
x.mu.Lock()
|
||||
if x.instance == nil {
|
||||
x.mu.Unlock()
|
||||
return 0, fmt.Errorf("not running")
|
||||
}
|
||||
|
||||
// Merge: overwrite existing users' properties, collect truly new ones.
|
||||
userMap := make(map[int]model.UserSpec, len(x.users))
|
||||
for _, u := range x.users {
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
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([]model.UserSpec, 0, len(userMap))
|
||||
for _, u := range userMap {
|
||||
merged = append(merged, u)
|
||||
}
|
||||
|
||||
if len(toAdd) == 0 {
|
||||
// No new kernel users, but properties (limits) may have changed.
|
||||
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, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
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
|
||||
}
|
||||
|
||||
proto := x.protocol
|
||||
nc := x.nodeConfig
|
||||
x.mu.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
added := 0
|
||||
for _, u := range toAdd {
|
||||
mu, err := toMemoryUser(proto, nc, u)
|
||||
if err != nil {
|
||||
nlog.Core().Warn("xray: skip user, cannot build account", "user", u.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := um.AddUser(ctx, mu); err != nil {
|
||||
nlog.Core().Warn("xray: AddUser failed", "user", u.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
added++
|
||||
}
|
||||
|
||||
// Update bookkeeping with full merged list (new users + updated properties).
|
||||
x.mu.Lock()
|
||||
x.users = merged
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(merged)
|
||||
x.updateBandwidthLimits(merged)
|
||||
|
||||
nlog.Core().Info("xray: users added via UserManager", "added", added, "total", len(merged))
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// 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 []model.UserSpec) (int, error) {
|
||||
x.mu.Lock()
|
||||
if x.instance == nil {
|
||||
x.mu.Unlock()
|
||||
return 0, fmt.Errorf("not running")
|
||||
}
|
||||
removeSet := make(map[int]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
removeSet[u.ID] = struct{}{}
|
||||
}
|
||||
var kept []model.UserSpec
|
||||
removed := 0
|
||||
for _, u := range x.users {
|
||||
if _, rm := removeSet[u.ID]; rm {
|
||||
removed++
|
||||
} else {
|
||||
kept = append(kept, u)
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
x.mu.Unlock()
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if len(kept) == 0 {
|
||||
x.mu.Unlock()
|
||||
x.Stop()
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
um, err := x.getUserManager()
|
||||
if err != nil {
|
||||
nc, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
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
|
||||
}
|
||||
x.mu.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
actualRemoved := 0
|
||||
for _, u := range users {
|
||||
email := userEmail(u.ID)
|
||||
if err := um.RemoveUser(ctx, email); err != nil {
|
||||
nlog.Core().Debug("xray: RemoveUser skipped", "user", u.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
actualRemoved++
|
||||
}
|
||||
|
||||
x.mu.Lock()
|
||||
x.users = kept
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(kept)
|
||||
x.updateBandwidthLimits(kept)
|
||||
|
||||
nlog.Core().Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept))
|
||||
return actualRemoved, nil
|
||||
}
|
||||
|
||||
// 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 []model.UserSpec) (added, removed int, err error) {
|
||||
x.mu.Lock()
|
||||
if x.instance == nil {
|
||||
x.mu.Unlock()
|
||||
return 0, 0, fmt.Errorf("not running")
|
||||
}
|
||||
toAdd, toRemove := kernel.UserDiff(x.users, users)
|
||||
added, removed = len(toAdd), len(toRemove)
|
||||
|
||||
if added == 0 && removed == 0 {
|
||||
// Only limits changed — update dispatcher without restart.
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(users)
|
||||
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, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: UpdateUsers fallback to restart", "reason", umErr)
|
||||
if err = x.Start(nc, users, t); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
proto := x.protocol
|
||||
nc := x.nodeConfig
|
||||
x.mu.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Remove first, then add (order matters for UUID changes on same ID)
|
||||
for _, u := range toRemove {
|
||||
email := userEmail(u.ID)
|
||||
if err := um.RemoveUser(ctx, email); err != nil {
|
||||
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 {
|
||||
nlog.Core().Warn("xray: skip user in UpdateUsers", "user", u.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := um.AddUser(ctx, mu); err != nil {
|
||||
nlog.Core().Warn("xray: AddUser failed in UpdateUsers", "user", u.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
x.mu.Lock()
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
|
||||
nlog.Core().Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users))
|
||||
return
|
||||
}
|
||||
|
||||
var _ kernel.Kernel = (*Xray)(nil)
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
// getUserManager extracts the UserManager from the running xray instance's
|
||||
// inbound handler. Must be called with x.mu held.
|
||||
// Returns error for protocols that don't support UserManager (socks, http).
|
||||
func (x *Xray) getUserManager() (xrayProxy.UserManager, error) {
|
||||
if x.instance == nil {
|
||||
return nil, fmt.Errorf("instance is nil")
|
||||
}
|
||||
|
||||
im := x.instance.GetFeature(inbound.ManagerType())
|
||||
if im == nil {
|
||||
return nil, fmt.Errorf("inbound manager not available")
|
||||
}
|
||||
inboundMgr, ok := im.(inbound.Manager)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("inbound manager type assertion failed")
|
||||
}
|
||||
|
||||
handler, err := inboundMgr.GetHandler(context.Background(), x.inboundTag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get handler %q: %w", x.inboundTag, err)
|
||||
}
|
||||
|
||||
gi, ok := handler.(xrayProxy.GetInbound)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("handler does not implement GetInbound")
|
||||
}
|
||||
|
||||
um, ok := gi.GetInbound().(xrayProxy.UserManager)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("protocol %q does not support UserManager", x.protocol)
|
||||
}
|
||||
|
||||
return um, nil
|
||||
}
|
||||
|
||||
// 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 *model.NodeSpec, u model.UserSpec) (*protocol.MemoryUser, error) {
|
||||
email := userEmail(u.ID)
|
||||
mu := &protocol.MemoryUser{Email: email, Level: 0}
|
||||
|
||||
switch proto {
|
||||
case "vmess":
|
||||
id, err := uuid.ParseString(u.UUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse vmess UUID: %w", err)
|
||||
}
|
||||
mu.Account = &vmess.MemoryAccount{
|
||||
ID: protocol.NewID(id),
|
||||
Security: protocol.SecurityType_AUTO,
|
||||
}
|
||||
|
||||
case "vless":
|
||||
id, err := uuid.ParseString(u.UUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse vless UUID: %w", err)
|
||||
}
|
||||
account := &vless.MemoryAccount{
|
||||
ID: protocol.NewID(id),
|
||||
Encryption: "none",
|
||||
}
|
||||
if nc.Flow != "" {
|
||||
account.Flow = nc.Flow
|
||||
}
|
||||
mu.Account = account
|
||||
|
||||
case "trojan":
|
||||
mu.Account = &trojan.MemoryAccount{
|
||||
Password: u.UUID,
|
||||
Key: trojanHexSha224(u.UUID),
|
||||
}
|
||||
|
||||
case "shadowsocks":
|
||||
if strings.HasPrefix(nc.Cipher, "2022-blake3-") {
|
||||
// 2022-blake3 multi-user mode
|
||||
mu.Account = &ss2022.MemoryAccount{Key: u.UUID}
|
||||
} else {
|
||||
// Traditional SS — build via getCipher path
|
||||
ct := parseCipherType(nc.Cipher)
|
||||
cipherObj, err := (&shadowsocks.Account{CipherType: ct, Password: u.UUID}).AsAccount()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build ss account: %w", err)
|
||||
}
|
||||
mu.Account = cipherObj
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("protocol %q does not support MemoryUser", proto)
|
||||
}
|
||||
|
||||
return mu, nil
|
||||
}
|
||||
|
||||
// parseCipherType maps a cipher name string to the xray CipherType enum.
|
||||
func parseCipherType(cipher string) shadowsocks.CipherType {
|
||||
switch cipher {
|
||||
case "aes-128-gcm":
|
||||
return shadowsocks.CipherType_AES_128_GCM
|
||||
case "aes-256-gcm":
|
||||
return shadowsocks.CipherType_AES_256_GCM
|
||||
case "chacha20-ietf-poly1305", "chacha20-poly1305":
|
||||
return shadowsocks.CipherType_CHACHA20_POLY1305
|
||||
case "xchacha20-ietf-poly1305", "xchacha20-poly1305":
|
||||
return shadowsocks.CipherType_XCHACHA20_POLY1305
|
||||
case "none", "plain":
|
||||
return shadowsocks.CipherType_NONE
|
||||
default:
|
||||
return shadowsocks.CipherType_AES_256_GCM
|
||||
}
|
||||
}
|
||||
|
||||
// trojanHexSha224 computes the SHA-224 hex key used by trojan protocol.
|
||||
func trojanHexSha224(password string) []byte {
|
||||
// Replicates the exact logic from xray-core/proxy/trojan/config.go hexSha224
|
||||
h := sha256.New224()
|
||||
h.Write([]byte(password))
|
||||
buf := make([]byte, 56)
|
||||
hexEncode(buf, h.Sum(nil))
|
||||
return buf
|
||||
}
|
||||
|
||||
// hexEncode is a minimal hex encoder (avoids importing encoding/hex).
|
||||
func hexEncode(dst, src []byte) {
|
||||
const hextable = "0123456789abcdef"
|
||||
for i, v := range src {
|
||||
dst[i*2] = hextable[v>>4]
|
||||
dst[i*2+1] = hextable[v&0x0f]
|
||||
}
|
||||
}
|
||||
|
||||
// ensureGeoData downloads geo databases when routes reference geoip/geosite.
|
||||
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 {
|
||||
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 *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)
|
||||
}
|
||||
nlog.Core().Debug("xray config generated", "len", len(data))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// startWithTimeout runs instance.Start() with a bounded deadline.
|
||||
func startWithTimeout(inst *xrayCore.Instance, timeout time.Duration) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- inst.Start() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return fmt.Errorf("start xray: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
go inst.Close()
|
||||
return fmt.Errorf("start xray: timeout after %v", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// closeOld shuts down a previously running instance and its dispatcher.
|
||||
// Recycling happens in a background goroutine to prevent the main thread
|
||||
// from blocking on slow connection draining, enabling "hitless" reload.
|
||||
func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) {
|
||||
if inst == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
// 1. Drain connections gracefully (best effort, e.g. 5 minutes)
|
||||
// We use a much longer timeout here than the default Stop() because
|
||||
// it's running in background and doesn't block new user connections.
|
||||
if ld != nil {
|
||||
drainConns(ld, 5*time.Minute)
|
||||
}
|
||||
// 2. Hard close
|
||||
inst.Close()
|
||||
if ld != nil {
|
||||
ld.ResetConns()
|
||||
}
|
||||
nlog.Core().Debug("xray: old instance recycled")
|
||||
}()
|
||||
}
|
||||
|
||||
// drainConns waits up to timeout for the dispatcher's active connections to
|
||||
// reach zero. Used only during graceful Stop, not during hot-reload.
|
||||
func drainConns(ld *LimitDispatcher, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if ld.connCount.Load() == 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateStats is a fallback path that reads xray's built-in stats counters
|
||||
// when LimitDispatcher is not available. Returns per-user cumulative traffic.
|
||||
// Must be called with x.mu held.
|
||||
func (x *Xray) aggregateStats() (map[int][2]int64, error) {
|
||||
sm := x.instance.GetFeature(stats.ManagerType())
|
||||
if sm == nil {
|
||||
return nil, nil
|
||||
}
|
||||
mgr, ok := sm.(stats.Manager)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
traffic := make(map[int][2]int64)
|
||||
for _, u := range x.users {
|
||||
email := userEmail(u.ID)
|
||||
|
||||
var dUp, dDown int64
|
||||
if c := mgr.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>uplink", email)); c != nil {
|
||||
dUp = c.Set(0)
|
||||
}
|
||||
if c := mgr.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>downlink", email)); c != nil {
|
||||
dDown = c.Set(0)
|
||||
}
|
||||
|
||||
if dUp > 0 || dDown > 0 {
|
||||
cum := x.cumTraffic[u.ID]
|
||||
cum[0] += dUp
|
||||
cum[1] += dDown
|
||||
x.cumTraffic[u.ID] = cum
|
||||
}
|
||||
|
||||
if cum := x.cumTraffic[u.ID]; cum[0] > 0 || cum[1] > 0 {
|
||||
traffic[u.ID] = cum
|
||||
}
|
||||
}
|
||||
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-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()
|
||||
if ld == nil {
|
||||
return
|
||||
}
|
||||
|
||||
emailToUID := make(map[string]int, len(users)*2)
|
||||
deviceLimits := make(map[string]int)
|
||||
|
||||
for _, u := range users {
|
||||
email := userEmail(u.ID)
|
||||
emailToUID[email] = u.ID
|
||||
emailToUID[u.UUID] = u.ID
|
||||
if u.DeviceLimit > 0 {
|
||||
deviceLimits[email] = u.DeviceLimit
|
||||
deviceLimits[u.UUID] = u.DeviceLimit
|
||||
}
|
||||
}
|
||||
|
||||
ld.UpdateLimits(emailToUID, deviceLimits, nil)
|
||||
}
|
||||
|
||||
// xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture
|
||||
// so that concurrent Xray instances in multi-node mode each capture their
|
||||
// own LimitDispatcher.
|
||||
var xrayCreationMu sync.Mutex
|
||||
@@ -1,278 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
// Limiter enforces per-user device limits and detects removed users.
|
||||
type Limiter struct {
|
||||
mu sync.RWMutex
|
||||
users map[int]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]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 []model.UserSpec) []int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
newUsers := make(map[int]model.UserSpec, len(users))
|
||||
for _, u := range users {
|
||||
newUsers[u.ID] = u
|
||||
}
|
||||
|
||||
// Find removed users
|
||||
var removed []int
|
||||
for id := range l.users {
|
||||
if _, ok := newUsers[id]; !ok {
|
||||
removed = append(removed, id)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// LimiterMetrics holds aggregated limiter statistics.
|
||||
type LimiterMetrics struct {
|
||||
// DeviceLimitEvents is the total number of times a user exceeded
|
||||
// the configured device limit (since process start).
|
||||
DeviceLimitEvents uint64
|
||||
}
|
||||
|
||||
// SnapshotMetrics returns a snapshot of limiter metrics.
|
||||
func (l *Limiter) SnapshotMetrics() LimiterMetrics {
|
||||
return LimiterMetrics{
|
||||
DeviceLimitEvents: l.deviceLimitEvents.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
l := New()
|
||||
if l == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers(t *testing.T) {
|
||||
l := New()
|
||||
users := []model.UserSpec{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 2},
|
||||
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0},
|
||||
}
|
||||
removed := l.UpdateUsers(users)
|
||||
|
||||
if len(removed) != 0 {
|
||||
t.Errorf("first update should have no removed users, got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_DetectsRemoved(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]model.UserSpec{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 2, UUID: "u2"},
|
||||
{ID: 3, UUID: "u3"},
|
||||
})
|
||||
|
||||
// Remove user 2
|
||||
removed := l.UpdateUsers([]model.UserSpec{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 3, UUID: "u3"},
|
||||
})
|
||||
|
||||
if len(removed) != 1 || removed[0] != 2 {
|
||||
t.Errorf("expected removed=[2], got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]model.UserSpec{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 2, UUID: "u2"},
|
||||
{ID: 3, UUID: "u3"},
|
||||
})
|
||||
|
||||
// Remove users 1 and 3
|
||||
removed := l.UpdateUsers([]model.UserSpec{
|
||||
{ID: 2, UUID: "u2"},
|
||||
})
|
||||
|
||||
sort.Ints(removed)
|
||||
if len(removed) != 2 || removed[0] != 1 || removed[1] != 3 {
|
||||
t.Errorf("expected removed=[1,3], got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_NoRemovals(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]model.UserSpec{
|
||||
{ID: 1, UUID: "u1"},
|
||||
})
|
||||
|
||||
// Add user, keep existing
|
||||
removed := l.UpdateUsers([]model.UserSpec{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 2, UUID: "u2"},
|
||||
})
|
||||
|
||||
if len(removed) != 0 {
|
||||
t.Errorf("expected no removals, got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_ReplacesAll(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]model.UserSpec{{ID: 1, DeviceLimit: 1}})
|
||||
|
||||
// Replace with different users
|
||||
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 TestGetDeviceLimitByUUID(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]model.UserSpec{
|
||||
{ID: 1, UUID: "u1", DeviceLimit: 3},
|
||||
{ID: 2, UUID: "u2", DeviceLimit: 0},
|
||||
})
|
||||
|
||||
// User with device limit
|
||||
limit, ok := l.GetDeviceLimitByUUID("u1")
|
||||
if !ok || limit != 3 {
|
||||
t.Errorf("expected (3, true), got (%d, %v)", limit, ok)
|
||||
}
|
||||
|
||||
// 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 TestSnapshotMetrics(t *testing.T) {
|
||||
l := New()
|
||||
m := l.SnapshotMetrics()
|
||||
if m.DeviceLimitEvents != 0 {
|
||||
t.Errorf("expected 0 events initially, got %d", m.DeviceLimitEvents)
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"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
|
||||
// wrapper that does byte counting.
|
||||
type SpeedTracker struct {
|
||||
limiter *Limiter
|
||||
mu sync.RWMutex
|
||||
buckets map[int]*rate.Limiter // userID → shared rate limiter
|
||||
uuidMap map[string]int // UUID → userID
|
||||
|
||||
// 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.
|
||||
func NewSpeedTracker(l *Limiter) *SpeedTracker {
|
||||
return &SpeedTracker{
|
||||
limiter: l,
|
||||
buckets: make(map[int]*rate.Limiter),
|
||||
uuidMap: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
for _, u := range t.limiter.users {
|
||||
currentUsers = append(currentUsers, u)
|
||||
}
|
||||
t.limiter.mu.RUnlock()
|
||||
|
||||
func() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up buckets for removed users
|
||||
for id := range t.buckets {
|
||||
if _, ok := activeIDs[id]; !ok {
|
||||
delete(t.buckets, id)
|
||||
}
|
||||
}
|
||||
|
||||
t.uuidMap = newUUIDMap
|
||||
t.hasLimits.Store(len(t.buckets) > 0)
|
||||
}()
|
||||
|
||||
if t.logFunc != nil {
|
||||
t.logFunc("buckets updated")
|
||||
}
|
||||
}
|
||||
|
||||
// GetLimiter returns the rate limiter for the given user UUID, or nil if
|
||||
// no limit applies. Creates limiter on-demand if not exists.
|
||||
// Thread-safe.
|
||||
func (t *SpeedTracker) GetLimiter(user string) *rate.Limiter {
|
||||
t.mu.RLock()
|
||||
uid, exists := t.uuidMap[user]
|
||||
if !exists {
|
||||
t.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
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.
|
||||
func (t *SpeedTracker) HasLimits() bool {
|
||||
return t.hasLimits.Load()
|
||||
}
|
||||
|
||||
// LimitedUserCount returns the number of users with active speed limits.
|
||||
func (t *SpeedTracker) LimitedUserCount() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.buckets)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
func TestSpeedTracker_UpdateBuckets(t *testing.T) {
|
||||
l := New()
|
||||
st := NewSpeedTracker(l)
|
||||
|
||||
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
|
||||
}
|
||||
l.UpdateUsers(users)
|
||||
st.UpdateBuckets()
|
||||
|
||||
// User 1: 3 Mbps = 375000 bytes/sec
|
||||
b1 := st.GetLimiter("u1")
|
||||
if b1 == nil {
|
||||
t.Fatal("expected bucket for user 1")
|
||||
}
|
||||
expectedRate := float64(3 * 1000000 / 8)
|
||||
if float64(b1.Limit()) != expectedRate {
|
||||
t.Errorf("user 1 rate: got %v, want %v", float64(b1.Limit()), expectedRate)
|
||||
}
|
||||
// Burst should be max(bytesPerSec, 64KB) = max(375000, 65536) = 375000
|
||||
expectedBurst := 3 * 1000000 / 8
|
||||
if b1.Burst() != expectedBurst {
|
||||
t.Errorf("user 1 burst: got %v, want %v", b1.Burst(), expectedBurst)
|
||||
}
|
||||
|
||||
// User 2: no speed limit, no bucket
|
||||
b2 := st.GetLimiter("u2")
|
||||
if b2 != nil {
|
||||
t.Fatal("expected no bucket for user 2")
|
||||
}
|
||||
|
||||
// User 3: 10 Mbps = 1250000 bytes/sec
|
||||
b3 := st.GetLimiter("u3")
|
||||
if b3 == nil {
|
||||
t.Fatal("expected bucket for user 3")
|
||||
}
|
||||
expectedRate3 := float64(10 * 1000000 / 8)
|
||||
if float64(b3.Limit()) != expectedRate3 {
|
||||
t.Errorf("user 3 rate: got %v, want %v", float64(b3.Limit()), expectedRate3)
|
||||
}
|
||||
// Burst should be max(1250000, 64KB) = 1250000
|
||||
expectedBurst3 := 10 * 1000000 / 8
|
||||
if b3.Burst() != expectedBurst3 {
|
||||
t.Errorf("user 3 burst: got %v, want %v", b3.Burst(), expectedBurst3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTracker_UpdateBuckets_PreservesExisting(t *testing.T) {
|
||||
l := New()
|
||||
st := NewSpeedTracker(l)
|
||||
|
||||
users := []model.UserSpec{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users)
|
||||
st.UpdateBuckets()
|
||||
|
||||
b1 := st.GetLimiter("u1")
|
||||
if b1 == nil {
|
||||
t.Fatal("expected bucket for user 1")
|
||||
}
|
||||
|
||||
// Update same user with different speed — should reuse same *rate.Limiter pointer
|
||||
users2 := []model.UserSpec{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 5, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users2)
|
||||
st.UpdateBuckets()
|
||||
|
||||
b1After := st.GetLimiter("u1")
|
||||
if b1After == nil {
|
||||
t.Fatal("expected bucket for user 1 after update")
|
||||
}
|
||||
if b1 != b1After {
|
||||
t.Error("expected same rate.Limiter instance to be reused")
|
||||
}
|
||||
|
||||
// Rate should be updated
|
||||
expectedRate := float64(5 * 1000000 / 8)
|
||||
if float64(b1After.Limit()) != expectedRate {
|
||||
t.Errorf("user 1 updated rate: got %v, want %v", float64(b1After.Limit()), expectedRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
|
||||
l := New()
|
||||
st := NewSpeedTracker(l)
|
||||
|
||||
users := []model.UserSpec{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||
{ID: 2, UUID: "u2", SpeedLimit: 5, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users)
|
||||
st.UpdateBuckets()
|
||||
|
||||
if st.GetLimiter("u1") == nil || st.GetLimiter("u2") == nil {
|
||||
t.Fatal("expected buckets for both users")
|
||||
}
|
||||
|
||||
// Remove user 2
|
||||
users2 := []model.UserSpec{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users2)
|
||||
st.UpdateBuckets()
|
||||
|
||||
if st.GetLimiter("u1") == nil {
|
||||
t.Fatal("expected bucket for user 1")
|
||||
}
|
||||
if st.GetLimiter("u2") != nil {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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,172 +0,0 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"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()
|
||||
|
||||
func init() {
|
||||
// Warm up the CPU sampler. The first cpu.Percent call with interval=0
|
||||
// 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
|
||||
type Status struct {
|
||||
Uptime uint64
|
||||
CPU float64
|
||||
CPUPerCore []float64
|
||||
Load1 float64
|
||||
Load5 float64
|
||||
Load15 float64
|
||||
MemTotal uint64
|
||||
MemUsed uint64
|
||||
SwapTotal uint64
|
||||
SwapUsed uint64
|
||||
DiskTotal uint64
|
||||
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
|
||||
|
||||
s.Uptime = uint64(time.Since(startTime).Seconds())
|
||||
|
||||
if cpuPercent, err := cpu.Percent(0, false); err == nil && len(cpuPercent) > 0 {
|
||||
s.CPU = cpuPercent[0]
|
||||
} else if err != nil {
|
||||
nlog.Core().Debug("failed to get CPU usage", "error", err)
|
||||
}
|
||||
|
||||
// Per-core CPU usage (best-effort; safe if it fails).
|
||||
if perCore, err := cpu.Percent(0, true); err == nil && len(perCore) > 0 {
|
||||
s.CPUPerCore = perCore
|
||||
}
|
||||
|
||||
if loadAvg, err := load.Avg(); err == nil {
|
||||
s.Load1 = loadAvg.Load1
|
||||
s.Load5 = loadAvg.Load5
|
||||
s.Load15 = loadAvg.Load15
|
||||
}
|
||||
|
||||
if vmStat, err := mem.VirtualMemory(); err == nil {
|
||||
s.MemTotal = vmStat.Total
|
||||
s.MemUsed = vmStat.Used
|
||||
}
|
||||
|
||||
if swapStat, err := mem.SwapMemory(); err == nil {
|
||||
s.SwapTotal = swapStat.Total
|
||||
s.SwapUsed = swapStat.Used
|
||||
}
|
||||
|
||||
if diskStat, err := disk.Usage("/"); err == nil {
|
||||
s.DiskTotal = diskStat.Total
|
||||
s.DiskUsed = diskStat.Used
|
||||
}
|
||||
|
||||
s.NetInSpeed, s.NetOutSpeed = collectNetSpeed()
|
||||
|
||||
// GC metrics
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
s.Goroutines = runtime.NumGoroutine()
|
||||
s.NumGC = ms.NumGC
|
||||
if ms.NumGC > 0 {
|
||||
// PauseNs is a ring buffer of the most recent GC pause times.
|
||||
idx := (ms.NumGC - 1) % uint32(len(ms.PauseNs))
|
||||
s.LastPauseMS = float64(ms.PauseNs[idx]) / 1e6
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
trafficMapPool = sync.Pool{New: func() interface{} { return make(map[string][2]int64) }}
|
||||
aliveMapPool = sync.Pool{New: func() interface{} { return make(map[string][]string) }}
|
||||
onlineMapPool = sync.Pool{New: func() interface{} { return make(map[string]int) }}
|
||||
)
|
||||
|
||||
// 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
|
||||
userETag string
|
||||
|
||||
apiSuccess atomic.Uint64
|
||||
apiFailure atomic.Uint64
|
||||
}
|
||||
|
||||
// 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,
|
||||
machineID: cfg.MachineID,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("handshake: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("handshake status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var hs HandshakeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&hs); err != nil {
|
||||
return nil, fmt.Errorf("decode handshake: %w", err)
|
||||
}
|
||||
return &hs, nil
|
||||
}
|
||||
|
||||
// Report sends consolidated traffic + alive + status data to the panel.
|
||||
// The optional metrics map allows the node to submit richer telemetry
|
||||
// (active connections, per-core CPU, GC stats, limiter hits, etc.)
|
||||
// without changing the core schema of status.
|
||||
func (c *Client) Report(traffic map[int][2]int64, alive map[int][]string, online map[int]int,
|
||||
cpu float64, mem, swap, disk [2]uint64,
|
||||
metrics map[string]interface{},
|
||||
) error {
|
||||
payload := make(map[string]interface{})
|
||||
|
||||
if len(traffic) > 0 {
|
||||
t := trafficMapPool.Get().(map[string][2]int64)
|
||||
for uid, d := range traffic {
|
||||
t[strconv.Itoa(uid)] = d
|
||||
}
|
||||
payload["traffic"] = t
|
||||
defer func() {
|
||||
for k := range t {
|
||||
delete(t, k)
|
||||
}
|
||||
trafficMapPool.Put(t)
|
||||
}()
|
||||
}
|
||||
|
||||
if len(alive) > 0 {
|
||||
a := aliveMapPool.Get().(map[string][]string)
|
||||
for uid, ips := range alive {
|
||||
a[strconv.Itoa(uid)] = ips
|
||||
}
|
||||
payload["alive"] = a
|
||||
defer func() {
|
||||
for k := range a {
|
||||
delete(a, k)
|
||||
}
|
||||
aliveMapPool.Put(a)
|
||||
}()
|
||||
}
|
||||
|
||||
if len(online) > 0 {
|
||||
o := onlineMapPool.Get().(map[string]int)
|
||||
for uid, count := range online {
|
||||
o[strconv.Itoa(uid)] = count
|
||||
}
|
||||
payload["online"] = o
|
||||
defer func() {
|
||||
for k := range o {
|
||||
delete(o, k)
|
||||
}
|
||||
onlineMapPool.Put(o)
|
||||
}()
|
||||
}
|
||||
|
||||
status := 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]},
|
||||
}
|
||||
payload["status"] = status
|
||||
|
||||
if len(metrics) > 0 {
|
||||
payload["metrics"] = metrics
|
||||
}
|
||||
|
||||
return c.postJSON("/api/v2/server/report", payload)
|
||||
}
|
||||
|
||||
// decodeWeakRaw decodes an interface (from JSON map) into a struct using weak type conversion.
|
||||
func decodeWeakRaw(input map[string]interface{}, output interface{}) error {
|
||||
config := &mapstructure.DecoderConfig{
|
||||
Metadata: nil,
|
||||
Result: output,
|
||||
WeaklyTypedInput: true,
|
||||
TagName: "json",
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
func(f, t reflect.Type, v interface{}) (interface{}, error) {
|
||||
// Handle []interface{} -> StringOrArray
|
||||
if t == reflect.TypeOf(StringOrArray("")) && f.Kind() == reflect.Slice {
|
||||
arr, _ := v.([]interface{})
|
||||
var strs []string
|
||||
for _, x := range arr {
|
||||
if s, ok := x.(string); ok {
|
||||
strs = append(strs, s)
|
||||
}
|
||||
}
|
||||
return StringOrArray(strings.Join(strs, "\n")), nil
|
||||
}
|
||||
return v, nil
|
||||
},
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
),
|
||||
}
|
||||
decoder, _ := mapstructure.NewDecoder(config)
|
||||
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(c.fetchMethod(), c.configPath(), nil, c.configETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode raw config: %w", err)
|
||||
}
|
||||
|
||||
var cfg NodeConfig
|
||||
// Use mapstructure for weak type conversion (string -> int, bool -> string, etc.)
|
||||
if err := decodeWeakRaw(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("weak decode config: %w", err)
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if cfg.Protocol == "" {
|
||||
return nil, fmt.Errorf("invalid config: missing protocol")
|
||||
}
|
||||
|
||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
||||
c.configETag = etag
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// GetUsers fetches available users. Returns nil if not modified (304).
|
||||
func (c *Client) GetUsers() ([]User, error) {
|
||||
resp, err := c.doRequest(c.fetchMethod(), c.userPath(), nil, c.userETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get users: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var usersResp UsersResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&usersResp); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
||||
c.userETag = etag
|
||||
}
|
||||
return usersResp.Users, nil
|
||||
}
|
||||
|
||||
// PushTraffic submits per-user traffic data
|
||||
func (c *Client) PushTraffic(data map[int][2]int64) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := make(map[string]interface{}, len(data))
|
||||
for uid, traffic := range data {
|
||||
payload[strconv.Itoa(uid)] = traffic
|
||||
}
|
||||
return c.postJSON("/api/v1/server/UniProxy/push", payload)
|
||||
}
|
||||
|
||||
// PushAlive submits online user IPs
|
||||
func (c *Client) PushAlive(data map[int][]string) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := make(map[string]interface{}, len(data))
|
||||
for uid, ips := range data {
|
||||
payload[strconv.Itoa(uid)] = ips
|
||||
}
|
||||
return c.postJSON("/api/v1/server/UniProxy/alive", payload)
|
||||
}
|
||||
|
||||
// PushStatus submits system status to the panel
|
||||
func (c *Client) PushStatus(cpu float64, mem, swap, disk [2]uint64) 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]},
|
||||
}
|
||||
return c.postJSON("/api/v1/server/UniProxy/status", payload)
|
||||
}
|
||||
|
||||
// ResetETags clears cached ETags, forcing full responses
|
||||
func (c *Client) ResetETags() {
|
||||
c.configETag = ""
|
||||
c.userETag = ""
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest("POST", path, body, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("post %s: %w", path, err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
c.apiSuccess.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string) (*http.Response, error) {
|
||||
fullURL := c.baseURL + path
|
||||
|
||||
var bodyReader io.Reader
|
||||
if method == "GET" {
|
||||
fullURL += "?" + c.authQuery().Encode()
|
||||
} else if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
} else {
|
||||
authOnly := make(map[string]interface{})
|
||||
c.injectAuth(authOnly)
|
||||
merged, _ := json.Marshal(authOnly)
|
||||
bodyReader = bytes.NewReader(merged)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if ifNoneMatch != "" {
|
||||
req.Header.Set("If-None-Match", ifNoneMatch)
|
||||
}
|
||||
|
||||
nlog.Core().Debug("panel request", "method", method, "path", path)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.apiFailure.Add(1)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
c.apiSuccess.Add(1)
|
||||
} else {
|
||||
c.apiFailure.Add(1)
|
||||
}
|
||||
|
||||
nlog.Core().Debug("panel response", "method", method, "path", path, "status", resp.StatusCode)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// drainAndClose reads any remaining bytes (up to 512B) and closes the body.
|
||||
// This allows the HTTP transport to reuse the underlying TCP connection.
|
||||
func drainAndClose(body io.ReadCloser) {
|
||||
io.CopyN(io.Discard, body, 512)
|
||||
body.Close()
|
||||
}
|
||||
|
||||
// APIMetrics holds aggregated API call statistics.
|
||||
type APIMetrics struct {
|
||||
Success uint64
|
||||
Failure uint64
|
||||
}
|
||||
|
||||
// SnapshotMetrics returns a snapshot of API metrics.
|
||||
func (c *Client) SnapshotMetrics() APIMetrics {
|
||||
return APIMetrics{
|
||||
Success: c.apiSuccess.Load(),
|
||||
Failure: c.apiFailure.Load(),
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
)
|
||||
|
||||
func newTestServer(handler http.HandlerFunc) (*httptest.Server, *Client) {
|
||||
ts := httptest.NewServer(handler)
|
||||
client := NewClient(config.PanelConfig{
|
||||
URL: ts.URL,
|
||||
Token: "test-token",
|
||||
NodeID: 1,
|
||||
})
|
||||
return ts, client
|
||||
}
|
||||
|
||||
func TestGetConfig_Success(t *testing.T) {
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/server/UniProxy/config" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("token") != "test-token" {
|
||||
t.Errorf("missing token in query")
|
||||
}
|
||||
if r.URL.Query().Get("node_id") != "1" {
|
||||
t.Errorf("missing node_id in query")
|
||||
}
|
||||
w.Header().Set("ETag", `"etag-1"`)
|
||||
json.NewEncoder(w).Encode(NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
})
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
cfg, err := client.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if cfg.Protocol != "shadowsocks" {
|
||||
t.Errorf("protocol: got %q", cfg.Protocol)
|
||||
}
|
||||
if cfg.ServerPort != 111 {
|
||||
t.Errorf("server_port: got %d", cfg.ServerPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_NotModified(t *testing.T) {
|
||||
callCount := 0
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
w.Header().Set("ETag", `"etag-1"`)
|
||||
json.NewEncoder(w).Encode(NodeConfig{Protocol: "shadowsocks"})
|
||||
return
|
||||
}
|
||||
if r.Header.Get("If-None-Match") != `"etag-1"` {
|
||||
t.Errorf("expected If-None-Match header, got %q", r.Header.Get("If-None-Match"))
|
||||
}
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
// First call — get config
|
||||
cfg, err := client.GetConfig()
|
||||
if err != nil || cfg == nil {
|
||||
t.Fatalf("first GetConfig: err=%v cfg=%v", err, cfg)
|
||||
}
|
||||
|
||||
// Second call — should be 304
|
||||
cfg, err = client.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("second GetConfig: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config for 304")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_ServerError(t *testing.T) {
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("internal error"))
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
_, err := client.GetConfig()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_Success(t *testing.T) {
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/server/UniProxy/user" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("ETag", `"users-etag-1"`)
|
||||
json.NewEncoder(w).Encode(UsersResponse{
|
||||
Users: []User{
|
||||
{ID: 1, UUID: "uuid-1", SpeedLimit: 3, DeviceLimit: 2},
|
||||
{ID: 2, UUID: "uuid-2", SpeedLimit: 0, DeviceLimit: 0},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
users, err := client.GetUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUsers: %v", err)
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("users count: got %d", len(users))
|
||||
}
|
||||
if users[0].UUID != "uuid-1" {
|
||||
t.Errorf("user[0].uuid: got %q", users[0].UUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_NotModified(t *testing.T) {
|
||||
callCount := 0
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
w.Header().Set("ETag", `"u-etag"`)
|
||||
json.NewEncoder(w).Encode(UsersResponse{Users: []User{{ID: 1, UUID: "u1"}}})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
users, _ := client.GetUsers()
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("first call: got %d users", len(users))
|
||||
}
|
||||
users, err := client.GetUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("second GetUsers: %v", err)
|
||||
}
|
||||
if users != nil {
|
||||
t.Error("expected nil for 304")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushTraffic_Success(t *testing.T) {
|
||||
var received map[string]interface{}
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/server/UniProxy/push" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
data := map[int][2]int64{
|
||||
1: {1024, 2048},
|
||||
2: {512, 256},
|
||||
}
|
||||
if err := client.PushTraffic(data); err != nil {
|
||||
t.Fatalf("PushTraffic: %v", err)
|
||||
}
|
||||
if received == nil {
|
||||
t.Fatal("server received nil payload")
|
||||
}
|
||||
// Verify token was injected
|
||||
if received["token"] != "test-token" {
|
||||
t.Errorf("token: got %v", received["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushTraffic_Empty(t *testing.T) {
|
||||
called := false
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
if err := client.PushTraffic(nil); err != nil {
|
||||
t.Fatalf("PushTraffic nil: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("should not call server for empty traffic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushAlive_Success(t *testing.T) {
|
||||
var received map[string]interface{}
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/server/UniProxy/alive" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
data := map[int][]string{
|
||||
1: {"1.2.3.4", "5.6.7.8"},
|
||||
}
|
||||
if err := client.PushAlive(data); err != nil {
|
||||
t.Fatalf("PushAlive: %v", err)
|
||||
}
|
||||
if received == nil {
|
||||
t.Fatal("server received nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushStatus_Success(t *testing.T) {
|
||||
var received map[string]interface{}
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/server/UniProxy/status" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
err := client.PushStatus(50.0, [2]uint64{8192, 4096}, [2]uint64{2048, 1024}, [2]uint64{100000, 50000})
|
||||
if err != nil {
|
||||
t.Fatalf("PushStatus: %v", err)
|
||||
}
|
||||
if received["cpu"].(float64) != 50.0 {
|
||||
t.Errorf("cpu: got %v", received["cpu"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetETags(t *testing.T) {
|
||||
callCount := 0
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount <= 2 {
|
||||
w.Header().Set("ETag", `"e1"`)
|
||||
json.NewEncoder(w).Encode(NodeConfig{Protocol: "ss"})
|
||||
return
|
||||
}
|
||||
// After reset, should NOT send If-None-Match
|
||||
if r.Header.Get("If-None-Match") != "" {
|
||||
t.Errorf("expected no If-None-Match after reset, got %q", r.Header.Get("If-None-Match"))
|
||||
}
|
||||
json.NewEncoder(w).Encode(NodeConfig{Protocol: "ss"})
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
client.GetConfig()
|
||||
client.ResetETags()
|
||||
client.GetConfig()
|
||||
}
|
||||
|
||||
func TestPushTraffic_ServerError(t *testing.T) {
|
||||
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte("unavailable"))
|
||||
})
|
||||
defer ts.Close()
|
||||
|
||||
data := map[int][2]int64{1: {100, 200}}
|
||||
err := client.PushTraffic(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 503")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_NodeTypeInQuery(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("node_type") != "vmess" {
|
||||
t.Errorf("node_type: got %q, want vmess", r.URL.Query().Get("node_type"))
|
||||
}
|
||||
json.NewEncoder(w).Encode(NodeConfig{Protocol: "vmess"})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(config.PanelConfig{
|
||||
URL: ts.URL,
|
||||
Token: "tok",
|
||||
NodeID: 1,
|
||||
NodeType: "vmess",
|
||||
})
|
||||
client.GetConfig()
|
||||
}
|
||||
|
||||
func TestStringOrArrayDecodeHook(t *testing.T) {
|
||||
// Test case: []interface{} -> StringOrArray
|
||||
input := map[string]interface{}{
|
||||
"padding_scheme": []interface{}{"stop=8", "0=30-30", "1=100-400"},
|
||||
}
|
||||
|
||||
type TestConfig struct {
|
||||
PaddingScheme StringOrArray `json:"padding_scheme"`
|
||||
}
|
||||
|
||||
var cfg TestConfig
|
||||
if err := decodeWeakRaw(input, &cfg); err != nil {
|
||||
t.Fatalf("decodeWeakRaw failed: %v", err)
|
||||
}
|
||||
|
||||
want := "stop=8\n0=30-30\n1=100-400"
|
||||
if string(cfg.PaddingScheme) != want {
|
||||
t.Errorf("got %q, want %q", cfg.PaddingScheme, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringOrArrayDecodeHook_String(t *testing.T) {
|
||||
// Test case: string -> StringOrArray
|
||||
input := map[string]interface{}{
|
||||
"padding_scheme": "stop=8\n0=30-30",
|
||||
}
|
||||
|
||||
type TestConfig struct {
|
||||
PaddingScheme StringOrArray `json:"padding_scheme"`
|
||||
}
|
||||
|
||||
var cfg TestConfig
|
||||
if err := decodeWeakRaw(input, &cfg); err != nil {
|
||||
t.Fatalf("decodeWeakRaw failed: %v", err)
|
||||
}
|
||||
|
||||
want := "stop=8\n0=30-30"
|
||||
if string(cfg.PaddingScheme) != want {
|
||||
t.Errorf("got %q, want %q", cfg.PaddingScheme, want)
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StringOrArray is a type that can unmarshal from either a JSON string or an array of strings.
|
||||
// When it's an array, the elements are joined with newlines.
|
||||
type StringOrArray string
|
||||
|
||||
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
*s = StringOrArray(str)
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal(data, &arr); err == nil {
|
||||
*s = StringOrArray(strings.Join(arr, "\n"))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("StringOrArray: expected string or []string, got %s", string(data))
|
||||
}
|
||||
|
||||
// HandshakeResponse is the response from POST /api/v2/server/handshake
|
||||
type HandshakeResponse struct {
|
||||
WebSocket WSConfig `json:"websocket"`
|
||||
Settings Settings `json:"settings"`
|
||||
}
|
||||
|
||||
func (h *HandshakeResponse) UnmarshalJSON(data []byte) error {
|
||||
type Alias HandshakeResponse
|
||||
aux := &struct {
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(h),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
// If settings is an empty array [], skip unmarshaling it into the struct
|
||||
if len(aux.Settings) > 0 && string(aux.Settings) != "[]" {
|
||||
if err := json.Unmarshal(aux.Settings, &h.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WSConfig holds WebSocket connection settings from the panel
|
||||
type WSConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
WSURL string `json:"ws_url,omitempty"`
|
||||
}
|
||||
|
||||
// Settings holds panel-defined intervals
|
||||
type Settings struct {
|
||||
PushInterval int `json:"push_interval"`
|
||||
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"`
|
||||
Network string `json:"network"`
|
||||
NetworkSettings map[string]interface{} `json:"networkSettings"`
|
||||
BaseConfig BaseConfig `json:"base_config"`
|
||||
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"`
|
||||
CustomRouteRules []CustomRouteRule `json:"custom_route_rules,omitempty"`
|
||||
|
||||
// Certificate settings (Xboard extension)
|
||||
CertConfig *CertConfig `json:"cert_config,omitempty"`
|
||||
AutoTLS bool `json:"auto_tls,omitempty"` // Deprecated: use CertConfig
|
||||
Domain string `json:"domain,omitempty"` // Deprecated: use CertConfig
|
||||
|
||||
// Shadowsocks
|
||||
Cipher string `json:"cipher,omitempty"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
PluginOpt string `json:"plugin_opts,omitempty"`
|
||||
ServerKey string `json:"server_key,omitempty"`
|
||||
|
||||
// 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
|
||||
Host string `json:"host,omitempty"`
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
|
||||
// Hysteria
|
||||
Version int `json:"version,omitempty"`
|
||||
UpMbps int `json:"up_mbps,omitempty"`
|
||||
DownMbps int `json:"down_mbps,omitempty"`
|
||||
Obfs string `json:"obfs,omitempty"`
|
||||
ObfsPassword string `json:"obfs-password,omitempty"`
|
||||
|
||||
// TUIC
|
||||
CongestionControl string `json:"congestion_control,omitempty"`
|
||||
|
||||
// AnyTLS
|
||||
PaddingScheme StringOrArray `json:"padding_scheme,omitempty"`
|
||||
|
||||
// Mieru
|
||||
Transport string `json:"transport,omitempty"`
|
||||
TrafficPattern string `json:"traffic_pattern,omitempty"`
|
||||
|
||||
// Multiplex
|
||||
Multiplex *MultiplexConfig `json:"multiplex,omitempty"`
|
||||
|
||||
// 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"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
MinStreams int `json:"min_streams"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
Padding bool `json:"padding"`
|
||||
Brutal *BrutalConfig `json:"brutal,omitempty"`
|
||||
}
|
||||
|
||||
type BrutalConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
UpMbps int `json:"up_mbps"`
|
||||
DownMbps int `json:"down_mbps"`
|
||||
}
|
||||
|
||||
// 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, content
|
||||
Domain string `json:"domain"` // Certificate domain
|
||||
Email string `json:"email"` // ACME email
|
||||
DNSProvider string `json:"dns_provider"` // dns mode: cloudflare, alidns, etc.
|
||||
DNSEnv map[string]string `json:"dns_env"` // Provider-specific API keys/tokens
|
||||
HTTPPort int `json:"http_port"` // ACME HTTP-01 local port (default 80)
|
||||
CertFile string `json:"cert_file"` // file mode: path to cert file
|
||||
KeyFile string `json:"key_file"` // file mode: path to key file
|
||||
CertContent string `json:"cert_content"` // content mode: certificate raw string
|
||||
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
|
||||
Protocol string `json:"protocol"` // vmess, vless, shadowsocks, wireguard, etc.
|
||||
Settings map[string]any `json:"settings,omitempty"` // Protocol-specific settings
|
||||
ProxyTag string `json:"proxy_tag,omitempty"` // Chain proxy: next outbound tag
|
||||
}
|
||||
|
||||
type BaseConfig struct {
|
||||
PushInterval int `json:"push_interval"`
|
||||
PullInterval int `json:"pull_interval"`
|
||||
}
|
||||
|
||||
type RouteRule struct {
|
||||
ID int `json:"id"`
|
||||
Match []string `json:"match"`
|
||||
Action string `json:"action"`
|
||||
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"`
|
||||
UUID string `json:"uuid"`
|
||||
SpeedLimit int `json:"speed_limit"` // Mbps, 0 = unlimited
|
||||
DeviceLimit int `json:"device_limit"` // max devices, 0 = unlimited
|
||||
}
|
||||
|
||||
type UsersResponse struct {
|
||||
Users []User `json:"users"`
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStringOrArray_UnmarshalString(t *testing.T) {
|
||||
input := `"hello world"`
|
||||
var s StringOrArray
|
||||
if err := json.Unmarshal([]byte(input), &s); err != nil {
|
||||
t.Fatalf("unmarshal string: %v", err)
|
||||
}
|
||||
if string(s) != "hello world" {
|
||||
t.Errorf("got %q, want %q", s, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringOrArray_UnmarshalArray(t *testing.T) {
|
||||
input := `["stop=8","0=30-30","1=100-400"]`
|
||||
var s StringOrArray
|
||||
if err := json.Unmarshal([]byte(input), &s); err != nil {
|
||||
t.Fatalf("unmarshal array: %v", err)
|
||||
}
|
||||
want := "stop=8\n0=30-30\n1=100-400"
|
||||
if string(s) != want {
|
||||
t.Errorf("got %q, want %q", s, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringOrArray_UnmarshalEmptyArray(t *testing.T) {
|
||||
input := `[]`
|
||||
var s StringOrArray
|
||||
if err := json.Unmarshal([]byte(input), &s); err != nil {
|
||||
t.Fatalf("unmarshal empty array: %v", err)
|
||||
}
|
||||
if string(s) != "" {
|
||||
t.Errorf("got %q, want empty", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringOrArray_UnmarshalNull(t *testing.T) {
|
||||
input := `null`
|
||||
var s StringOrArray
|
||||
if err := json.Unmarshal([]byte(input), &s); err != nil {
|
||||
t.Fatalf("unmarshal null: %v", err)
|
||||
}
|
||||
if string(s) != "" {
|
||||
t.Errorf("got %q, want empty", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeConfig_UnmarshalWithPaddingSchemeArray(t *testing.T) {
|
||||
input := `{
|
||||
"protocol": "anytls",
|
||||
"server_port": 443,
|
||||
"padding_scheme": ["stop=8", "0=30-30", "1=100-400"]
|
||||
}`
|
||||
var nc NodeConfig
|
||||
if err := json.Unmarshal([]byte(input), &nc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if nc.Protocol != "anytls" {
|
||||
t.Errorf("protocol: got %q, want %q", nc.Protocol, "anytls")
|
||||
}
|
||||
want := "stop=8\n0=30-30\n1=100-400"
|
||||
if string(nc.PaddingScheme) != want {
|
||||
t.Errorf("padding_scheme: got %q, want %q", nc.PaddingScheme, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeConfig_UnmarshalWithPaddingSchemeString(t *testing.T) {
|
||||
input := `{
|
||||
"protocol": "anytls",
|
||||
"server_port": 443,
|
||||
"padding_scheme": "stop=8\n0=30-30"
|
||||
}`
|
||||
var nc NodeConfig
|
||||
if err := json.Unmarshal([]byte(input), &nc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
want := "stop=8\n0=30-30"
|
||||
if string(nc.PaddingScheme) != want {
|
||||
t.Errorf("padding_scheme: got %q, want %q", nc.PaddingScheme, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeConfig_UnmarshalFullPanelResponse(t *testing.T) {
|
||||
input := `{
|
||||
"protocol": "shadowsocks",
|
||||
"listen_ip": "0.0.0.0",
|
||||
"server_port": 111,
|
||||
"cipher": "aes-128-gcm",
|
||||
"network": null,
|
||||
"networkSettings": null,
|
||||
"server_name": "",
|
||||
"base_config": {
|
||||
"push_interval": 60,
|
||||
"pull_interval": 60
|
||||
}
|
||||
}`
|
||||
var nc NodeConfig
|
||||
if err := json.Unmarshal([]byte(input), &nc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if nc.Protocol != "shadowsocks" {
|
||||
t.Errorf("protocol: got %q", nc.Protocol)
|
||||
}
|
||||
if nc.ServerPort != 111 {
|
||||
t.Errorf("server_port: got %d", nc.ServerPort)
|
||||
}
|
||||
if nc.Cipher != "aes-128-gcm" {
|
||||
t.Errorf("cipher: got %q", nc.Cipher)
|
||||
}
|
||||
if nc.BaseConfig.PushInterval != 60 {
|
||||
t.Errorf("push_interval: got %d", nc.BaseConfig.PushInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersResponse_Unmarshal(t *testing.T) {
|
||||
input := `{
|
||||
"users": [
|
||||
{"id": 1, "uuid": "abc-123", "speed_limit": 3, "device_limit": 2},
|
||||
{"id": 2, "uuid": "def-456", "speed_limit": 0, "device_limit": 0}
|
||||
]
|
||||
}`
|
||||
var resp UsersResponse
|
||||
if err := json.Unmarshal([]byte(input), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.Users) != 2 {
|
||||
t.Fatalf("users count: got %d, want 2", len(resp.Users))
|
||||
}
|
||||
if resp.Users[0].SpeedLimit != 3 {
|
||||
t.Errorf("user[0].speed_limit: got %d", resp.Users[0].SpeedLimit)
|
||||
}
|
||||
if resp.Users[1].DeviceLimit != 0 {
|
||||
t.Errorf("user[1].device_limit: got %d", resp.Users[1].DeviceLimit)
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WSEvent types
|
||||
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.
|
||||
type WSEvent struct {
|
||||
Type string
|
||||
Config *NodeConfig
|
||||
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.
|
||||
type WSStatusChange struct {
|
||||
Connected bool
|
||||
}
|
||||
|
||||
// wsMessage is the JSON envelope for all WS messages.
|
||||
type wsMessage struct {
|
||||
Event string `json:"event"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Authentication is done via query parameters (token + node_id) during the
|
||||
// WebSocket handshake — no separate auth step needed.
|
||||
type WSClient struct {
|
||||
wsURL string // base WS URL, e.g. ws://panel.example.com:8076
|
||||
token string
|
||||
nodeID int
|
||||
onEvent func(WSEvent)
|
||||
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, 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,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WSClient) IsConnected() bool { return w.connected.Load() }
|
||||
|
||||
func (w *WSClient) notifyStatus(connected bool) {
|
||||
if w.onStatus != nil {
|
||||
w.onStatus(WSStatusChange{Connected: connected})
|
||||
}
|
||||
}
|
||||
|
||||
// Run connects and reconnects until ctx is cancelled.
|
||||
func (w *WSClient) Run(ctx context.Context) {
|
||||
backoff := w.cfg.BackoffInitial
|
||||
for {
|
||||
start := time.Now()
|
||||
err := w.connect(ctx)
|
||||
|
||||
wasConnected := w.connected.Swap(false)
|
||||
if wasConnected {
|
||||
w.notifyStatus(false)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
nlog.Core().Warn("ws disconnected", "error", err)
|
||||
if !wasConnected {
|
||||
w.notifyStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Reset backoff if the connection was up for a meaningful duration.
|
||||
if time.Since(start) > 2*time.Minute {
|
||||
backoff = w.cfg.BackoffInitial
|
||||
}
|
||||
|
||||
// Apply exponential backoff with jitter to prevent thundering herd.
|
||||
jitter := time.Duration(rand.Int63n(int64(backoff / 5)))
|
||||
wait := backoff + jitter
|
||||
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if backoff < w.cfg.BackoffMax {
|
||||
backoff = min(backoff*2, w.cfg.BackoffMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WSClient) connect(ctx context.Context) error {
|
||||
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)
|
||||
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()
|
||||
|
||||
nlog.Core().Debug("ws connecting", "url", u.String())
|
||||
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: w.cfg.HandshakeTimeout,
|
||||
}
|
||||
conn, _, err := dialer.DialContext(ctx, u.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetReadLimit(10 << 20) // 10MB max message size
|
||||
|
||||
// Read first message — expect auth.success or error
|
||||
var firstMsg wsMessage
|
||||
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"`
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if firstMsg.Event != "auth.success" {
|
||||
// It might be a data event already (server pushed sync before auth.success)
|
||||
// Process it and continue
|
||||
w.connected.Store(true)
|
||||
w.notifyStatus(true)
|
||||
w.handleMessage(firstMsg)
|
||||
} else {
|
||||
w.connected.Store(true)
|
||||
w.notifyStatus(true)
|
||||
}
|
||||
|
||||
// Ping interval: send pong responses to server pings.
|
||||
// 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 }()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
var msg wsMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
select {
|
||||
case errCh <- err:
|
||||
default:
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
<-done
|
||||
return nil
|
||||
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("read: %w", err)
|
||||
|
||||
case <-reportTicker.C:
|
||||
// Send periodic node.status via WebSocket
|
||||
if w.onPing != nil {
|
||||
msg := wsMessage{Event: "node.status"}
|
||||
if stats := w.onPing(); stats != nil {
|
||||
data, _ := json.Marshal(stats)
|
||||
msg.Data = data
|
||||
msg.Timestamp = time.Now().Unix()
|
||||
}
|
||||
select {
|
||||
case writeCh <- msg:
|
||||
default:
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WSClient) handleMessage(msg wsMessage) {
|
||||
switch msg.Event {
|
||||
case "ping":
|
||||
// Server ping — handled by the pong timer reset above
|
||||
nlog.Core().Debug("ws received ping")
|
||||
|
||||
case "auth.success":
|
||||
nlog.Core().Debug("ws auth confirmed")
|
||||
|
||||
case WSEventSyncConfig:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
case WSEventSyncUsers:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
case WSEventSyncUserDelta:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
case WSEventSyncDevices:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
case WSEventSyncNodes:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
default:
|
||||
nlog.Core().Debug("ws unknown event", "event", msg.Event)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
var event WSEvent
|
||||
event.Type = msg.Event
|
||||
|
||||
// Helper to unmarshal and decode with weak Typing
|
||||
decodeData := func(data []byte, target interface{}) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return decodeWeakRaw(raw, target)
|
||||
}
|
||||
|
||||
switch msg.Event {
|
||||
case WSEventSyncConfig:
|
||||
nlog.Core().Debug("ws sync config event received")
|
||||
var p syncConfigPayload
|
||||
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:
|
||||
nlog.Core().Debug("ws sync users event received")
|
||||
var p syncUsersPayload
|
||||
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:
|
||||
nlog.Core().Debug("ws sync user delta event received")
|
||||
var p syncUserDeltaPayload
|
||||
if err := decodeData(msg.Data, &p); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// fakeWSServer simulates a minimal Workerman-style WS server for testing.
|
||||
// It authenticates via token/node_id query params, sends auth.success, then
|
||||
// delivers the provided events and handles pongs until the client disconnects.
|
||||
func fakeWSServer(t *testing.T, events []wsMessage) *httptest.Server {
|
||||
t.Helper()
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Validate query params
|
||||
q := r.URL.Query()
|
||||
if q.Get("token") == "" || q.Get("node_id") == "" {
|
||||
http.Error(w, "missing auth params", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Logf("upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Send auth.success
|
||||
if err := conn.WriteJSON(wsMessage{Event: "auth.success"}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Send test events
|
||||
for _, evt := range events {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if err := conn.WriteJSON(evt); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pongs/messages until connection closes
|
||||
for {
|
||||
var msg wsMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Event == "pong" {
|
||||
// Client pong — acknowledged
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestWSClient_ConnectAndReceiveDataEvents(t *testing.T) {
|
||||
configPayload := syncConfigPayload{
|
||||
Config: NodeConfig{Protocol: "vless", ServerPort: 443},
|
||||
Timestamp: 1234,
|
||||
}
|
||||
usersPayload := syncUsersPayload{
|
||||
Users: []User{{ID: 1, UUID: "abc", SpeedLimit: 100, DeviceLimit: 3}},
|
||||
Timestamp: 1235,
|
||||
}
|
||||
|
||||
configData, _ := json.Marshal(configPayload)
|
||||
usersData, _ := json.Marshal(usersPayload)
|
||||
|
||||
events := []wsMessage{
|
||||
{Event: WSEventSyncConfig, Data: configData},
|
||||
{Event: WSEventSyncUsers, Data: usersData},
|
||||
}
|
||||
server := fakeWSServer(t, events)
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []WSEvent
|
||||
|
||||
ws := NewWSClient("ws://"+host, "test-token", 1, WSClientConfig{}, func(event WSEvent) {
|
||||
mu.Lock()
|
||||
received = append(received, event)
|
||||
mu.Unlock()
|
||||
}, nil, func() map[string]interface{} { return nil })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
go ws.Run(ctx)
|
||||
|
||||
// Wait for events
|
||||
deadline := time.After(1500 * time.Millisecond)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(received)
|
||||
mu.Unlock()
|
||||
if n >= 2 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for events, got %d", n)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if len(received) != 2 {
|
||||
t.Fatalf("expected 2 events, got %d", len(received))
|
||||
}
|
||||
|
||||
// Verify config event
|
||||
if received[0].Type != WSEventSyncConfig {
|
||||
t.Errorf("event[0].Type = %q, want %q", received[0].Type, WSEventSyncConfig)
|
||||
}
|
||||
if received[0].Config == nil {
|
||||
t.Fatal("event[0].Config is nil")
|
||||
}
|
||||
if received[0].Config.Protocol != "vless" {
|
||||
t.Errorf("config.Protocol = %q, want %q", received[0].Config.Protocol, "vless")
|
||||
}
|
||||
if received[0].Config.ServerPort != 443 {
|
||||
t.Errorf("config.ServerPort = %d, want 443", received[0].Config.ServerPort)
|
||||
}
|
||||
|
||||
// Verify users event
|
||||
if received[1].Type != WSEventSyncUsers {
|
||||
t.Errorf("event[1].Type = %q, want %q", received[1].Type, WSEventSyncUsers)
|
||||
}
|
||||
if len(received[1].Users) != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", len(received[1].Users))
|
||||
}
|
||||
if received[1].Users[0].UUID != "abc" {
|
||||
t.Errorf("user.UUID = %q, want %q", received[1].Users[0].UUID, "abc")
|
||||
}
|
||||
|
||||
if !ws.IsConnected() {
|
||||
t.Error("expected IsConnected() = true while server is running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
|
||||
var mu sync.Mutex
|
||||
connectCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
connectCount++
|
||||
count := connectCount
|
||||
mu.Unlock()
|
||||
|
||||
// Send auth.success
|
||||
conn.WriteJSON(wsMessage{Event: "auth.success"})
|
||||
|
||||
// Close immediately on first connection to trigger reconnect
|
||||
if count == 1 {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Keep second connection alive
|
||||
for {
|
||||
var msg wsMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
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()
|
||||
|
||||
go ws.Run(ctx)
|
||||
|
||||
// Wait for reconnection
|
||||
deadline := time.After(4 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := connectCount
|
||||
mu.Unlock()
|
||||
if n >= 2 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
mu.Lock()
|
||||
t.Fatalf("expected at least 2 connections, got %d", connectCount)
|
||||
mu.Unlock()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
if !ws.IsConnected() {
|
||||
t.Error("expected IsConnected() = true after reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSClient_FallbackWhenNoServer(t *testing.T) {
|
||||
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()
|
||||
|
||||
go ws.Run(ctx)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if ws.IsConnected() {
|
||||
t.Error("expected IsConnected() = false when no server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSClient_UserDeltaEvent(t *testing.T) {
|
||||
deltaPayload := syncUserDeltaPayload{
|
||||
Action: "add",
|
||||
Users: []User{{ID: 42, UUID: "delta-uuid"}},
|
||||
Timestamp: 1236,
|
||||
}
|
||||
deltaData, _ := json.Marshal(deltaPayload)
|
||||
|
||||
events := []wsMessage{
|
||||
{Event: WSEventSyncUserDelta, Data: deltaData},
|
||||
}
|
||||
server := fakeWSServer(t, events)
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []WSEvent
|
||||
|
||||
ws := NewWSClient("ws://"+host, "delta-token", 1, WSClientConfig{}, func(event WSEvent) {
|
||||
mu.Lock()
|
||||
received = append(received, event)
|
||||
mu.Unlock()
|
||||
}, nil, func() map[string]interface{} { return nil })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
go ws.Run(ctx)
|
||||
|
||||
deadline := time.After(1500 * time.Millisecond)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(received)
|
||||
mu.Unlock()
|
||||
if n >= 1 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for delta event")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if received[0].Type != WSEventSyncUserDelta {
|
||||
t.Errorf("event.Type = %q, want %q", received[0].Type, WSEventSyncUserDelta)
|
||||
}
|
||||
if received[0].DeltaAction != "add" {
|
||||
t.Errorf("event.DeltaAction = %q, want %q", received[0].DeltaAction, "add")
|
||||
}
|
||||
if len(received[0].DeltaUsers) != 1 || received[0].DeltaUsers[0].ID != 42 {
|
||||
t.Errorf("unexpected DeltaUsers: %+v", received[0].DeltaUsers)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user