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
|
|
||||||
-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,76 +1,36 @@
|
|||||||
# xboard-node
|
# xboard-node — Docker Compose
|
||||||
|
|
||||||
Node backend for [Xboard](https://github.com/cedar2025/Xboard). Supports `sing-box` / `xray-core` dual kernels.
|
## Quick start
|
||||||
|
|
||||||
> **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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone -b compose --depth 1 https://github.com/cedar2025/xboard-node.git
|
git clone -b compose --depth 1 https://github.com/cedar2025/xboard-node.git
|
||||||
cd xboard-node
|
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
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installer (Linux systemd)
|
## Update / logs / stop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Node mode
|
cd compose
|
||||||
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
|
docker compose pull && docker compose up -d
|
||||||
sudo bash -s -- --mode node --panel https://panel.example.com --token TOKEN --node-id 1
|
docker compose logs -f
|
||||||
|
docker compose down
|
||||||
# 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
|
|
||||||
|
|
||||||
# Run again with another --panel to append a binding. No manual instance management needed.
|
|
||||||
# Upgrade
|
|
||||||
curl -fsSL .../install.sh | sudo bash -s -- upgrade
|
|
||||||
# Uninstall
|
|
||||||
curl -fsSL .../install.sh | sudo bash -s -- uninstall --purge --yes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## xbctl
|
## Layout
|
||||||
|
|
||||||
Run `xbctl` after installation for help. Common commands:
|
| 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 |
|
||||||
|
|
||||||
```bash
|
## Host network
|
||||||
xbctl list # list all instances
|
|
||||||
xbctl status # running status
|
|
||||||
xbctl bind add-node --panel URL --token TOKEN --node-id 1
|
|
||||||
xbctl bind add-machine --panel URL --token TOKEN --machine-id 1
|
|
||||||
xbctl bind remove-node --panel URL --node-id 1
|
|
||||||
xbctl service restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
`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.
|
||||||
|
|
||||||
Legacy single-panel config is fully compatible. Appending bindings auto-migrates to `instances` format. See `config.yml.example`.
|
## Image
|
||||||
|
|
||||||
## Extensions
|
Default: `ghcr.io/cedar2025/xboard-node:latest`.
|
||||||
|
|
||||||
- Custom routes: [docs-custom-routes.md](docs-custom-routes.md)
|
|
||||||
- Custom outbounds: [docs-custom-outbounds.md](docs-custom-outbounds.md)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MPL-2.0.
|
|
||||||
|
|||||||
-1138
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,180 +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/cloudflare v0.2.2
|
|
||||||
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/term v0.41.0
|
|
||||||
golang.org/x/time v0.12.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
filippo.io/edwards25519 v1.1.0 // 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/caddyserver/zerossl v0.1.5 // 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/ebitengine/purego v0.10.0 // indirect
|
|
||||||
github.com/enfein/mieru/v3 v3.29.0 // 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-ole/go-ole v1.3.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/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/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // 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/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/libdns/acmedns v0.5.0 // indirect
|
|
||||||
github.com/libdns/libdns v1.1.1 // indirect
|
|
||||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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/openai/openai-go/v3 v3.26.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/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/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.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/net v0.52.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/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/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
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
|
||||||
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
||||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
|
||||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
|
||||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
|
||||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
|
||||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
|
||||||
github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
|
|
||||||
github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
|
|
||||||
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU=
|
|
||||||
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
|
||||||
github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc=
|
|
||||||
github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg=
|
|
||||||
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
|
||||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
|
||||||
github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965 h1:fuJYmFZ1N58sIA9LqzOcEhJrxeWsvBKPOR4mvZLtWig=
|
|
||||||
github.com/cedar2025/Xray-core v0.0.0-20260409213332-f47935539965/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60=
|
|
||||||
github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295 h1:bmtrfItcjIJ/jGfz17Ic1WBzjPP3aBdAGgagL8X5IPQ=
|
|
||||||
github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260316103356-2e665cb7e295/go.mod h1:DTyWqWrjh/DqOHynCUgMhW8fgrkctleficRKwQmh668=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/cilium/ebpf v0.21.0 h1:4dpx1J/B/1apeTmWBH5BkVLayHTkFrMovVPnHEk+l3k=
|
|
||||||
github.com/cilium/ebpf v0.21.0/go.mod h1:1kHKv6Kvh5a6TePP5vvvoMa1bclRyzUXELSs272fmIQ=
|
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
|
||||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
|
|
||||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
|
||||||
github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
|
|
||||||
github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
|
|
||||||
github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM=
|
|
||||||
github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc=
|
|
||||||
github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw=
|
|
||||||
github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
|
|
||||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
|
|
||||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
|
||||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
|
||||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
|
||||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
|
||||||
github.com/enfein/mieru/v3 v3.29.0 h1:i5Hwl5spEWg4ydvYW86zWSYVJ2uGTf5sLYQmFXHdulQ=
|
|
||||||
github.com/enfein/mieru/v3 v3.29.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
|
||||||
github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
|
|
||||||
github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
|
||||||
github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
|
|
||||||
github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY=
|
|
||||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
|
|
||||||
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
|
|
||||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
|
||||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
|
||||||
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
|
|
||||||
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
|
||||||
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
|
||||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
|
|
||||||
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
|
||||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
|
|
||||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
|
||||||
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
|
||||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
|
||||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
|
||||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY=
|
|
||||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
|
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
|
||||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
|
||||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
|
||||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
|
||||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
|
|
||||||
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
|
|
||||||
github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
|
|
||||||
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
|
|
||||||
github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE=
|
|
||||||
github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ=
|
|
||||||
github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM=
|
|
||||||
github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
|
|
||||||
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
|
|
||||||
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
|
||||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
|
||||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
|
||||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
|
||||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
|
||||||
github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco=
|
|
||||||
github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg=
|
|
||||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
|
||||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
|
||||||
github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg=
|
|
||||||
github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
|
||||||
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
|
|
||||||
github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
|
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
|
||||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
|
||||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
|
||||||
github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE=
|
|
||||||
github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
|
||||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
|
||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
|
||||||
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
|
|
||||||
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
|
||||||
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
|
||||||
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
|
||||||
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
|
||||||
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
|
||||||
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0=
|
|
||||||
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
|
|
||||||
github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
|
|
||||||
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
|
|
||||||
github.com/sagernet/cronet-go v0.0.0-20260309100020-c128886ff3fc h1:YK7PwJT0irRAEui9ASdXSxcE2BOVQipWMF/A1Ogt+7c=
|
|
||||||
github.com/sagernet/cronet-go v0.0.0-20260309100020-c128886ff3fc/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw=
|
|
||||||
github.com/sagernet/cronet-go/all v0.0.0-20260309100020-c128886ff3fc h1:EJPHOqk23IuBsTjXK9OXqkNxPbKOBWKRmviQoCcriAs=
|
|
||||||
github.com/sagernet/cronet-go/all v0.0.0-20260309100020-c128886ff3fc/go.mod h1:8aty0RW96DrJSMWXO6bRPMBJEjuqq5JWiOIi4bCRzFA=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9 h1:Qi0IKBpoPP3qZqIXuOKMsT2dv+l/MLWMyBHDMLRw2EA=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:p+wCMjOhj46SpSD/AJeTGgkCcbyA76FyH631XZatyU8=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260309101654-0cbdcfddded9 h1:Y7lWrZwEhC/HX8Pb5C92CrQihuaE7hrHmWB2ykst3iQ=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:3Ggy5wiyjA6t+aVVPnXlSEIVj9zkxd4ybH3NsvsNefs=
|
|
||||||
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
|
|
||||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:DuFTCnZloblY+7olXiZoRdueWfxi34EV5UheTFKM2rA=
|
|
||||||
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
|
|
||||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:x/6T2gjpLw9yNdCVR6xBlzMUzED9fxNFNt6U6A6SOh8=
|
|
||||||
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
|
|
||||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:Lx9PExM70rg8aNxPm0JPeSr5SWC3yFiCz4wIq86ugx8=
|
|
||||||
github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0=
|
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:BTEpw7/vKR9BNBsHebfpiGHDCPpjVJ3vLIbHNU3VUfM=
|
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
|
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:hdEph9nQXRnKwc/lIDwo15rmzbC6znXF5jJWHPN1Fiw=
|
|
||||||
github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260309101654-0cbdcfddded9 h1:Iq++oYV7dtRJHTpu8yclHJdn+1oj2t1e84/YpdXYWW8=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260309101654-0cbdcfddded9 h1:Y43fuLL8cgwRHpEKwxh0O3vYp7g/SZGvbkJj3cQ6USA=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:bX2GJmF0VCC+tBrVAa49YEsmJ4A9dLmwoA6DJUxRtCY=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:gQTR/2azUCInE0r3kmesZT9xu+x801+BmtDY0d0Tw9Y=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260309101654-0cbdcfddded9 h1:X4mP3jlYvxgrKpZLOKMmc/O8T5/zP83/23pgfQOc3tY=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:c6xj2nXr/65EDiRFddUKQIBQ/b/lAPoH8WFYlgadaPc=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:ahbl7yjOvGVVNUwk9TcQk+xejVfoYAYFRlhWnby0/YM=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260309101654-0cbdcfddded9 h1:JC5Zv5+J85da6g5G56VhdaK53fmo6Os2q/wWi5QlxOw=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260309101654-0cbdcfddded9 h1:4bt7Go588BoM4VjNYMxx0MrvbwlFQn3DdRDCM7BmkRo=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:E1z0BeLUh8EZfCjIyS9BrfCocZrt+0KPS0bzop3Sxf4=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260309101654-0cbdcfddded9 h1:d8ejxRHO7Vi9JqR/6DxR7RyI/swA2JfDWATR4T7otBw=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260309101654-0cbdcfddded9 h1:iUDVEVu3RxL5ArPIY72BesbuX5zQ1la/ZFwKpQcGc5c=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260309101654-0cbdcfddded9 h1:xB6ikOC/R3n3hjy68EJ0sbZhH4vwEhd6JM9jZ1U2SVY=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260309101654-0cbdcfddded9 h1:mBOuLCPOOMMq8N1+dUM5FqZclqga1+u6fAbPqQcbIhc=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260309101654-0cbdcfddded9 h1:cwPyDfj+ZNFE7kvcWbayQJyeC/KQA16HTXOxgHphL0w=
|
|
||||||
github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc=
|
|
||||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:Zk9zG8kt3mXAboclUXQlvvxKQuhnI8u5NdDEl8uotNY=
|
|
||||||
github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4=
|
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:Lu05srGqddQRMnl1MZtGAReln2yJljeGx9b1IadlMJ8=
|
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc=
|
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9 h1:Tk9bDywUmOtc0iMjjCVIwMlAQNsxCy+bK+bTNA0OaBE=
|
|
||||||
github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc=
|
|
||||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:tQqDQw3tEHdQpt7NTdAwF3UvZ3CjNIj/IJKMRFmm388=
|
|
||||||
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
|
|
||||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260309101654-0cbdcfddded9 h1:biUIbI2YxUrcQikEfS/bwPA8NsHp/WO+VZUG4morUmE=
|
|
||||||
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw=
|
|
||||||
github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs=
|
|
||||||
github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o=
|
|
||||||
github.com/sagernet/gvisor v0.0.0-20241123041152-536d05261cff h1:mlohw3360Wg1BNGook/UHnISXhUx4Gd/3tVLs5T0nSs=
|
|
||||||
github.com/sagernet/gvisor v0.0.0-20241123041152-536d05261cff/go.mod h1:ehZwnT2UpmOWAHFL48XdBhnd4Qu4hN2O3Ji0us3ZHMw=
|
|
||||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
|
|
||||||
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
|
|
||||||
github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I=
|
|
||||||
github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8=
|
|
||||||
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o=
|
|
||||||
github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4=
|
|
||||||
github.com/sagernet/sing v0.8.2 h1:kX1IH9SWJv4S0T9M8O+HNahWgbOuY1VauxbF7NU5lOg=
|
|
||||||
github.com/sagernet/sing v0.8.2/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
|
||||||
github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s=
|
|
||||||
github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk=
|
|
||||||
github.com/sagernet/sing-quic v0.6.0 h1:dhrFnP45wgVKEOT1EvtsToxdzRnHIDIAgj6WHV9pLyM=
|
|
||||||
github.com/sagernet/sing-quic v0.6.0/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8=
|
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
|
|
||||||
github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
|
|
||||||
github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
|
|
||||||
github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
|
|
||||||
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w=
|
|
||||||
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
|
|
||||||
github.com/sagernet/sing-tun v0.8.3-0.20260311132553-5485872f601f h1:uj3rzedphq1AiL0PpuVoob5RtKsPBcMRd8aqo+q0rqA=
|
|
||||||
github.com/sagernet/sing-tun v0.8.3-0.20260311132553-5485872f601f/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs=
|
|
||||||
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o=
|
|
||||||
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
|
|
||||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
|
|
||||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8=
|
|
||||||
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e h1:Sv1qUhJIidjSTc24XEknovDZnbmVSlAXj8wNVgIfgGo=
|
|
||||||
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
|
|
||||||
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c h1:f9cXNB+IOOPnR8DOLMTpr42jf7naxh5Un5Y09BBf5Cg=
|
|
||||||
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0=
|
|
||||||
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
|
|
||||||
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
|
|
||||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
|
||||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
|
|
||||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
|
|
||||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
|
|
||||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
|
|
||||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
|
||||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
|
||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
|
||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
|
||||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
|
|
||||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
|
||||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
|
||||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
|
||||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
|
||||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
|
||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
|
||||||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
|
||||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
|
|
||||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
|
||||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
|
||||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
|
||||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
|
||||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
|
||||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
|
||||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
|
||||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
|
||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
|
||||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
|
||||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
|
||||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
|
||||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
|
||||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
|
||||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk=
|
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
|
|
||||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
|
||||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
|
||||||
-852
@@ -1,852 +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="https://github.com/cedar2025/xboard-node/releases"
|
|
||||||
|
|
||||||
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"
|
|
||||||
if [ "$RELEASE_VERSION" = "latest" ]; then
|
|
||||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/latest/download/${artifact}"
|
|
||||||
else
|
|
||||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/download/${RELEASE_VERSION}/${artifact}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
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=https://github.com/cedar2025/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,490 +0,0 @@
|
|||||||
package cert
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/caddyserver/certmagic"
|
|
||||||
"github.com/libdns/alidns"
|
|
||||||
"github.com/libdns/cloudflare"
|
|
||||||
|
|
||||||
"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
|
|
||||||
|
|
||||||
// Cert material stored atomically so the ACME renewal goroutine can
|
|
||||||
// swap it without racing with TLSCert() readers.
|
|
||||||
mat atomic.Pointer[certMaterial]
|
|
||||||
|
|
||||||
magic *certmagic.Config
|
|
||||||
renewed atomic.Bool
|
|
||||||
acmeStarted bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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).
|
|
||||||
// For ACME modes that are already running this is a no-op — a process restart
|
|
||||||
// is required to change ACME settings.
|
|
||||||
// Returns true when the 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// Guard: certmagic's background goroutines must only be started once.
|
|
||||||
// A process restart is required to change ACME settings at runtime.
|
|
||||||
if m.acmeStarted {
|
|
||||||
nlog.Core().Info("cert: ACME already active, skipping re-init (restart to change ACME settings)")
|
|
||||||
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 {
|
|
||||||
if event == "cert_obtained" {
|
|
||||||
m.loadCertFromStorage(evtCtx, storage, data)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
|
|
||||||
if err := magic.ObtainCertSync(ctx, m.cfg.Domain); err != nil {
|
|
||||||
return fmt.Errorf("obtain certificate: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cert_obtained event callback should have loaded PEM. Verify.
|
|
||||||
if !m.HasCert() {
|
|
||||||
return fmt.Errorf("ACME obtained certificate but PEM was not loaded; check storage")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := magic.ManageAsync(ctx, []string{m.cfg.Domain}); err != nil {
|
|
||||||
return fmt.Errorf("start cert manager: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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.ToLower(strings.TrimSpace(m.cfg.DNSProvider))
|
|
||||||
env := m.cfg.DNSEnv
|
|
||||||
if env == nil {
|
|
||||||
env = map[string]string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "cloudflare", "cf":
|
|
||||||
token := firstOf(env, "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN")
|
|
||||||
if token == "" {
|
|
||||||
return nil, fmt.Errorf("cloudflare requires CF_API_TOKEN in dns_env")
|
|
||||||
}
|
|
||||||
return &cloudflare.Provider{APIToken: token}, nil
|
|
||||||
|
|
||||||
case "alidns", "aliyun":
|
|
||||||
keyID := firstOf(env, "ALICLOUD_ACCESS_KEY_ID", "ALI_ACCESS_KEY_ID")
|
|
||||||
keySecret := firstOf(env, "ALICLOUD_ACCESS_KEY_SECRET", "ALI_ACCESS_KEY_SECRET")
|
|
||||||
if keyID == "" || keySecret == "" {
|
|
||||||
return nil, fmt.Errorf("alidns requires ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET in dns_env")
|
|
||||||
}
|
|
||||||
return &alidns.Provider{
|
|
||||||
CredentialInfo: alidns.CredentialInfo{
|
|
||||||
AccessKeyID: keyID,
|
|
||||||
AccessKeySecret: keySecret,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported dns_provider: %q (supported: cloudflare, alidns)", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// loadCertFromStorage reads ACME cert/key from certmagic's storage into memory.
|
|
||||||
func (m *Manager) loadCertFromStorage(ctx context.Context, storage certmagic.Storage, data map[string]any) {
|
|
||||||
certKey, _ := data["certificate_path"].(string)
|
|
||||||
keyKey, _ := data["private_key_path"].(string)
|
|
||||||
if certKey == "" || keyKey == "" {
|
|
||||||
nlog.Core().Warn("cert_obtained event missing storage paths", "data", data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
certPEM, err := storage.Load(ctx, certKey)
|
|
||||||
if err != nil {
|
|
||||||
nlog.Core().Error("failed to load cert from ACME storage", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keyPEM, err := storage.Load(ctx, keyKey)
|
|
||||||
if err != nil {
|
|
||||||
nlog.Core().Error("failed to load key from ACME storage", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
m.storePEM(certPEM, keyPEM)
|
|
||||||
|
|
||||||
renewal, _ := data["renewal"].(bool)
|
|
||||||
nlog.Core().Info("TLS certificate loaded from ACME storage", "domain", m.cfg.Domain, "renewal", renewal)
|
|
||||||
if renewal {
|
|
||||||
m.renewed.Store(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstOf returns the first non-empty value for any of the given keys in the map.
|
|
||||||
func firstOf(m map[string]string, keys ...string) string {
|
|
||||||
for _, k := range keys {
|
|
||||||
if v := m[k]; v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -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,806 +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]net.Conn // connID → conn (only for force-close support)
|
|
||||||
|
|
||||||
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]net.Conn),
|
|
||||||
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
|
|
||||||
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,
|
|
||||||
sourceIP: sourceIP,
|
|
||||||
limiter: lim,
|
|
||||||
ctx: ctx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoutedPacketConnection wraps UDP with per-user counting (UDP not in connMap).
|
|
||||||
// Note: UDP connections are NOT stored in connMap because connMap is typed as
|
|
||||||
// map[string]net.Conn, but PacketConn is a different interface. Force-close
|
|
||||||
// for UDP connections is handled directly via trackedPacketConn.Close().
|
|
||||||
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()
|
|
||||||
|
|
||||||
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,
|
|
||||||
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 {
|
|
||||||
// This is a no-op for now — sing-box doesn't expose per-user connection
|
|
||||||
// kill easily. The kernel's RemoveUsers removes the inbound user which
|
|
||||||
// prevents new connections, and existing connections will fail on next I/O.
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 on close.
|
|
||||||
func (t *ConnTracker) removeConnRef(connID string) {
|
|
||||||
t.usersMu.Lock()
|
|
||||||
delete(t.connMap, connID)
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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.download)}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.upload)}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,840 +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 "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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,398 +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/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)
|
|
||||||
|
|
||||||
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},
|
|
||||||
); 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,71 +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)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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,94 +0,0 @@
|
|||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"runtime"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// GC metrics (process-wide)
|
|
||||||
NumGC uint32
|
|
||||||
LastPauseMS float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,468 +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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (c *Client) configPath() string {
|
|
||||||
if c.machineID > 0 {
|
|
||||||
return "/api/v2/server/config"
|
|
||||||
}
|
|
||||||
return "/api/v1/server/UniProxy/config"
|
|
||||||
}
|
|
||||||
|
|
||||||
// userPath returns the API path for user fetching.
|
|
||||||
func (c *Client) userPath() string {
|
|
||||||
if c.machineID > 0 {
|
|
||||||
return "/api/v2/server/user"
|
|
||||||
}
|
|
||||||
return "/api/v1/server/UniProxy/user"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfig fetches node configuration. Returns nil if not modified (304).
|
|
||||||
func (c *Client) GetConfig() (*NodeConfig, error) {
|
|
||||||
resp, err := c.doRequest("GET", 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("GET", 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.
|
|
||||||
func (c *Client) ReportMachineStatus(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/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
@@ -1,297 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/cert"
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
|
||||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
|
||||||
"github.com/cedar2025/xboard-node/internal/limiter"
|
|
||||||
"github.com/cedar2025/xboard-node/internal/model"
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeKernel struct {
|
|
||||||
running bool
|
|
||||||
|
|
||||||
startErr error
|
|
||||||
updateErr error
|
|
||||||
addErr error
|
|
||||||
|
|
||||||
startCalls int
|
|
||||||
updateCalls int
|
|
||||||
addCalls int
|
|
||||||
removeCalls int
|
|
||||||
|
|
||||||
onUpdateUsers func([]model.UserSpec)
|
|
||||||
onAddUsers func([]model.UserSpec)
|
|
||||||
onRemoveUsers func([]model.UserSpec)
|
|
||||||
|
|
||||||
speedLimitFunc func(string) *rate.Limiter
|
|
||||||
deviceLimitFunc func(string) (int, bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeKernel) Name() string { return "fake" }
|
|
||||||
func (f *fakeKernel) Protocols() []string { return []string{"vless"} }
|
|
||||||
func (f *fakeKernel) Capabilities() kernel.Capabilities { return kernel.Capabilities{} }
|
|
||||||
func (f *fakeKernel) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
|
||||||
_, _, _ = nodeConfig, users, tls
|
|
||||||
f.startCalls++
|
|
||||||
if f.startErr != nil {
|
|
||||||
return f.startErr
|
|
||||||
}
|
|
||||||
f.running = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) Stop() { f.running = false }
|
|
||||||
func (f *fakeKernel) IsRunning() bool { return f.running }
|
|
||||||
func (f *fakeKernel) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
|
||||||
_, _, _ = nodeConfig, users, tls
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) AddUsers(users []model.UserSpec) (int, error) {
|
|
||||||
f.addCalls++
|
|
||||||
if f.onAddUsers != nil {
|
|
||||||
f.onAddUsers(users)
|
|
||||||
}
|
|
||||||
if f.addErr != nil {
|
|
||||||
return 0, f.addErr
|
|
||||||
}
|
|
||||||
return len(users), nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) RemoveUsers(users []model.UserSpec) (int, error) {
|
|
||||||
f.removeCalls++
|
|
||||||
if f.onRemoveUsers != nil {
|
|
||||||
f.onRemoveUsers(users)
|
|
||||||
}
|
|
||||||
return len(users), nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) UpdateUsers(users []model.UserSpec) (int, int, error) {
|
|
||||||
f.updateCalls++
|
|
||||||
if f.onUpdateUsers != nil {
|
|
||||||
f.onUpdateUsers(users)
|
|
||||||
}
|
|
||||||
if f.updateErr != nil {
|
|
||||||
return 0, 0, f.updateErr
|
|
||||||
}
|
|
||||||
return len(users), 0, nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) GetUserTraffic(ctx context.Context) (map[int][2]int64, map[int]map[string]bool, int, error) {
|
|
||||||
_ = ctx
|
|
||||||
return nil, nil, 0, nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) CloseConnection(ctx context.Context, connID string) error {
|
|
||||||
_, _ = ctx, connID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) CloseUserConnections(ctx context.Context, uuid string) error {
|
|
||||||
_, _ = ctx, uuid
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (f *fakeKernel) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) { f.speedLimitFunc = fn }
|
|
||||||
func (f *fakeKernel) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) { f.deviceLimitFunc = fn }
|
|
||||||
func (f *fakeKernel) UpdateGlobalDevices(users map[int][]string) { _ = users }
|
|
||||||
func (f *fakeKernel) ClearGlobalDevices() {}
|
|
||||||
|
|
||||||
func newTestService(k *fakeKernel) *Service {
|
|
||||||
sharedLimiter := limiter.New()
|
|
||||||
s := &Service{
|
|
||||||
kernel: k,
|
|
||||||
limiter: sharedLimiter,
|
|
||||||
speedTracker: limiter.NewSpeedTracker(sharedLimiter),
|
|
||||||
cert: cert.NewManager(config.CertConfig{}),
|
|
||||||
}
|
|
||||||
k.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
|
|
||||||
k.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyUserUpdatePreparesLimiterBeforeKernelUpdate(t *testing.T) {
|
|
||||||
k := &fakeKernel{running: true}
|
|
||||||
s := newTestService(k)
|
|
||||||
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
|
|
||||||
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
|
|
||||||
s.updateUserState(oldUsers)
|
|
||||||
|
|
||||||
newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}}
|
|
||||||
k.onUpdateUsers = func(users []model.UserSpec) {
|
|
||||||
if len(users) != 1 || users[0].UUID != "uuid-new" {
|
|
||||||
t.Fatalf("unexpected users passed to UpdateUsers: %#v", users)
|
|
||||||
}
|
|
||||||
if got := k.speedLimitFunc("uuid-new"); got == nil {
|
|
||||||
t.Fatal("expected new user's limiter to be visible before kernel UpdateUsers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers))
|
|
||||||
|
|
||||||
if got := k.updateCalls; got != 1 {
|
|
||||||
t.Fatalf("UpdateUsers call count = %d, want 1", got)
|
|
||||||
}
|
|
||||||
if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" {
|
|
||||||
t.Fatalf("lastUsers = %#v, want new users", s.lastUsers)
|
|
||||||
}
|
|
||||||
if s.speedTracker.GetLimiter("uuid-new") == nil {
|
|
||||||
t.Fatal("expected limiter for new user after successful update")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyUserUpdateRestoresStateWhenKernelAndRestartFail(t *testing.T) {
|
|
||||||
k := &fakeKernel{
|
|
||||||
running: true,
|
|
||||||
updateErr: errors.New("update failed"),
|
|
||||||
startErr: errors.New("restart failed"),
|
|
||||||
}
|
|
||||||
s := newTestService(k)
|
|
||||||
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
|
|
||||||
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
|
|
||||||
s.updateUserState(oldUsers)
|
|
||||||
oldHash := s.lastUserHash
|
|
||||||
|
|
||||||
newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}}
|
|
||||||
s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers))
|
|
||||||
|
|
||||||
if got := k.startCalls; got != 1 {
|
|
||||||
t.Fatalf("Start call count = %d, want 1", got)
|
|
||||||
}
|
|
||||||
if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-old" {
|
|
||||||
t.Fatalf("lastUsers = %#v, want restored old users", s.lastUsers)
|
|
||||||
}
|
|
||||||
if s.lastUserHash != oldHash {
|
|
||||||
t.Fatalf("lastUserHash = %q, want %q", s.lastUserHash, oldHash)
|
|
||||||
}
|
|
||||||
if s.speedTracker.GetLimiter("uuid-old") == nil {
|
|
||||||
t.Fatal("expected old limiter to be restored after rollback")
|
|
||||||
}
|
|
||||||
if s.speedTracker.GetLimiter("uuid-new") != nil {
|
|
||||||
t.Fatal("expected new limiter to be removed after rollback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyUserDeltaAddPreparesLimiterBeforeKernelUpdate(t *testing.T) {
|
|
||||||
k := &fakeKernel{running: true}
|
|
||||||
s := newTestService(k)
|
|
||||||
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
|
|
||||||
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
|
|
||||||
s.updateUserState(oldUsers)
|
|
||||||
|
|
||||||
delta := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}}
|
|
||||||
k.onAddUsers = func(users []model.UserSpec) {
|
|
||||||
if len(users) != 1 || users[0].UUID != "uuid-new" {
|
|
||||||
t.Fatalf("unexpected users passed to AddUsers: %#v", users)
|
|
||||||
}
|
|
||||||
if got := k.speedLimitFunc("uuid-new"); got == nil {
|
|
||||||
t.Fatal("expected delta user's limiter to be visible before kernel AddUsers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.applyUserDelta(context.Background(), "add", delta)
|
|
||||||
|
|
||||||
if got := k.addCalls; got != 1 {
|
|
||||||
t.Fatalf("AddUsers call count = %d, want 1", got)
|
|
||||||
}
|
|
||||||
if s.speedTracker.GetLimiter("uuid-new") == nil {
|
|
||||||
t.Fatal("expected limiter for delta-added user after successful update")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func TestValidateNodeRuntimeRejectsUnsupportedDNSProvider(t *testing.T) {
|
|
||||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
|
||||||
err := validateNodeRuntime(cfg, []string{"http"}, &model.NodeSpec{
|
|
||||||
Protocol: "http",
|
|
||||||
CertConfig: &config.CertConfig{
|
|
||||||
CertMode: "dns",
|
|
||||||
DNSProvider: "3123123",
|
|
||||||
Domain: "example.com",
|
|
||||||
},
|
|
||||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if err.Error() == "" {
|
|
||||||
t.Fatal("expected non-empty error")
|
|
||||||
}
|
|
||||||
if got := err.Error(); got != "unsupported cert_config.dns_provider \"3123123\" (supported: cloudflare, alidns)" {
|
|
||||||
t.Fatalf("unexpected error: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateNodeRuntimeAllowsSelfManagedTLSBeforeFilesExist(t *testing.T) {
|
|
||||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
|
||||||
err := validateNodeRuntime(cfg, []string{"anytls", "hysteria"}, &model.NodeSpec{
|
|
||||||
Protocol: "anytls",
|
|
||||||
CertConfig: &config.CertConfig{
|
|
||||||
CertMode: "self",
|
|
||||||
Domain: "example.com",
|
|
||||||
},
|
|
||||||
}, kernel.TLSCert{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected self-managed TLS config to pass validation, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateNodeRuntimeAllowsSingboxRealityWithRequiredFields(t *testing.T) {
|
|
||||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
|
||||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
|
||||||
Protocol: "vless",
|
|
||||||
TLS: 2,
|
|
||||||
TLSSettings: map[string]any{
|
|
||||||
"private_key": "test-key",
|
|
||||||
"server_name": "example.com",
|
|
||||||
},
|
|
||||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected sing-box reality validation to pass, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateNodeRuntimeRejectsRealityWithoutTLSSettings(t *testing.T) {
|
|
||||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
|
||||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
|
||||||
Protocol: "vless",
|
|
||||||
TLS: 2,
|
|
||||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if got := err.Error(); got != "reality tls requires tls_settings" {
|
|
||||||
t.Fatalf("unexpected error: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateNodeRuntimeRejectsRealityWithoutPrivateKey(t *testing.T) {
|
|
||||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
|
||||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
|
||||||
Protocol: "vless",
|
|
||||||
TLS: 2,
|
|
||||||
TLSSettings: map[string]any{
|
|
||||||
"server_name": "example.com",
|
|
||||||
},
|
|
||||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if got := err.Error(); got != "reality tls requires tls_settings.private_key" {
|
|
||||||
t.Fatalf("unexpected error: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateNodeRuntimeRejectsRealityWithoutServerNameOrDest(t *testing.T) {
|
|
||||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
|
||||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
|
||||||
Protocol: "vless",
|
|
||||||
TLS: 2,
|
|
||||||
TLSSettings: map[string]any{
|
|
||||||
"private_key": "test-key",
|
|
||||||
},
|
|
||||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if got := err.Error(); got != "reality tls requires tls_settings.server_name or tls_settings.dest" {
|
|
||||||
t.Fatalf("unexpected error: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
package tracker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// snapshot is an immutable point-in-time view of tracker state.
|
|
||||||
// It is swapped atomically so readers never block writers.
|
|
||||||
type snapshot struct {
|
|
||||||
traffic map[int][2]int64 // userID → [upload, download] delta
|
|
||||||
aliveIPs map[int]map[string]bool // userID → set of source IPs
|
|
||||||
online map[int]int // userID → distinct IP count
|
|
||||||
connCount int
|
|
||||||
inSpeed int64
|
|
||||||
outSpeed int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracker computes per-user traffic deltas from cumulative counters
|
|
||||||
// provided by the kernel, and accumulates totals for panel reporting.
|
|
||||||
//
|
|
||||||
// Architecture: the kernel maintains per-user atomic counters and IP sets.
|
|
||||||
// Each tick, the service calls Process() with cumulative per-user traffic.
|
|
||||||
// Tracker computes deltas against the previous cycle's values — O(users),
|
|
||||||
// not O(connections).
|
|
||||||
//
|
|
||||||
// Thread safety: Process() acquires mu to update internal state, then
|
|
||||||
// atomically publishes a new snapshot. All read methods (Flush*,
|
|
||||||
// CurrentOnline, LogStats, *Speed) read the snapshot lock-free.
|
|
||||||
// This eliminates contention between the 10s Process tick and the 60s
|
|
||||||
// flush/push tick.
|
|
||||||
type Tracker struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
|
|
||||||
// lastSeen stores the cumulative traffic from the previous Process() call.
|
|
||||||
// Protected by mu — only written by Process.
|
|
||||||
lastSeen map[int][2]int64 // userID → [upload, download] cumulative
|
|
||||||
|
|
||||||
// pending accumulates deltas between flushes.
|
|
||||||
// Protected by mu — written by Process, drained by FlushTraffic.
|
|
||||||
pendingTraffic map[int][2]int64
|
|
||||||
|
|
||||||
// live holds the current snapshot, swapped atomically.
|
|
||||||
// Readers load this pointer without any lock.
|
|
||||||
live atomic.Pointer[snapshot]
|
|
||||||
|
|
||||||
// aliveIPsBuf is a reusable buffer for FlushAliveIPs output.
|
|
||||||
// Avoids allocating a new map+slice every 60s.
|
|
||||||
aliveIPsBuf map[int][]string
|
|
||||||
|
|
||||||
// lastAliveIPsHash detects changes to avoid duplicate reports.
|
|
||||||
lastAliveIPsHash string
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() *Tracker {
|
|
||||||
t := &Tracker{
|
|
||||||
lastSeen: make(map[int][2]int64),
|
|
||||||
pendingTraffic: make(map[int][2]int64),
|
|
||||||
aliveIPsBuf: make(map[int][]string),
|
|
||||||
}
|
|
||||||
// Publish initial empty snapshot.
|
|
||||||
t.live.Store(&snapshot{
|
|
||||||
traffic: make(map[int][2]int64),
|
|
||||||
aliveIPs: make(map[int]map[string]bool),
|
|
||||||
online: make(map[int]int),
|
|
||||||
})
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process computes per-user traffic deltas from cumulative kernel counters.
|
|
||||||
// Also stores alive IPs and connection count. O(users).
|
|
||||||
//
|
|
||||||
// This is the only writer to lastSeen and pendingTraffic.
|
|
||||||
// After computing deltas, it publishes a new snapshot for lock-free reads.
|
|
||||||
func (t *Tracker) Process(
|
|
||||||
cumTraffic map[int][2]int64,
|
|
||||||
kernelAliveIPs map[int]map[string]bool,
|
|
||||||
connCount int,
|
|
||||||
) {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
var cycleIn, cycleOut int64
|
|
||||||
|
|
||||||
for uid, cum := range cumTraffic {
|
|
||||||
prev := t.lastSeen[uid]
|
|
||||||
deltaUp := cum[0] - prev[0]
|
|
||||||
deltaDown := cum[1] - prev[1]
|
|
||||||
|
|
||||||
// Guard against counter reset (kernel restart).
|
|
||||||
if deltaUp < 0 {
|
|
||||||
deltaUp = cum[0]
|
|
||||||
}
|
|
||||||
if deltaDown < 0 {
|
|
||||||
deltaDown = cum[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
t.lastSeen[uid] = cum
|
|
||||||
|
|
||||||
if deltaUp > 0 || deltaDown > 0 {
|
|
||||||
cur := t.pendingTraffic[uid]
|
|
||||||
cur[0] += deltaUp
|
|
||||||
cur[1] += deltaDown
|
|
||||||
t.pendingTraffic[uid] = cur
|
|
||||||
|
|
||||||
cycleOut += deltaUp
|
|
||||||
cycleIn += deltaDown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute online from alive IPs.
|
|
||||||
online := make(map[int]int, len(kernelAliveIPs))
|
|
||||||
for uid, ips := range kernelAliveIPs {
|
|
||||||
online[uid] = len(ips)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish new snapshot (readers will see this atomically).
|
|
||||||
t.live.Store(&snapshot{
|
|
||||||
traffic: copyTrafficMap(t.pendingTraffic),
|
|
||||||
aliveIPs: kernelAliveIPs, // kernel provides fresh copy each tick
|
|
||||||
online: online,
|
|
||||||
connCount: connCount,
|
|
||||||
inSpeed: cycleIn,
|
|
||||||
outSpeed: cycleOut,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlushTraffic returns accumulated per-user traffic and resets the pending buffer.
|
|
||||||
// Lock-free: reads from live snapshot, then acquires mu only to drain.
|
|
||||||
func (t *Tracker) FlushTraffic() map[int][2]int64 {
|
|
||||||
t.mu.Lock()
|
|
||||||
data := t.pendingTraffic
|
|
||||||
t.pendingTraffic = make(map[int][2]int64, len(data))
|
|
||||||
t.mu.Unlock()
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreTraffic adds traffic back (used when push to panel fails).
|
|
||||||
func (t *Tracker) RestoreTraffic(data map[int][2]int64) {
|
|
||||||
t.mu.Lock()
|
|
||||||
for uid, d := range data {
|
|
||||||
cur := t.pendingTraffic[uid]
|
|
||||||
cur[0] += d[0]
|
|
||||||
cur[1] += d[1]
|
|
||||||
t.pendingTraffic[uid] = cur
|
|
||||||
}
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasTraffic returns true if there is accumulated traffic to report.
|
|
||||||
func (t *Tracker) HasTraffic() bool {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
return len(t.pendingTraffic) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlushAliveIPs returns per-user alive IPs.
|
|
||||||
// Reuses internal buffer. Returns nil if unchanged.
|
|
||||||
func (t *Tracker) FlushAliveIPs() map[int][]string {
|
|
||||||
s := t.live.Load()
|
|
||||||
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
// Calculate hash of current aliveIPs
|
|
||||||
currentHash := calcAliveIPsHash(s.aliveIPs)
|
|
||||||
|
|
||||||
// If no changes, return nil to avoid duplicate reporting
|
|
||||||
if currentHash == t.lastAliveIPsHash {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
t.lastAliveIPsHash = currentHash
|
|
||||||
|
|
||||||
// Clear old buffer entries.
|
|
||||||
for k := range t.aliveIPsBuf {
|
|
||||||
delete(t.aliveIPsBuf, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill buffer from snapshot.
|
|
||||||
for uid, ips := range s.aliveIPs {
|
|
||||||
buf := t.aliveIPsBuf[uid]
|
|
||||||
if buf == nil {
|
|
||||||
buf = make([]string, 0, len(ips))
|
|
||||||
}
|
|
||||||
buf = buf[:0]
|
|
||||||
for ip := range ips {
|
|
||||||
buf = append(buf, ip)
|
|
||||||
}
|
|
||||||
t.aliveIPsBuf[uid] = buf
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.aliveIPsBuf
|
|
||||||
}
|
|
||||||
|
|
||||||
// calcAliveIPsHash computes a deterministic hash for change detection.
|
|
||||||
func calcAliveIPsHash(aliveIPs map[int]map[string]bool) string {
|
|
||||||
if len(aliveIPs) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
h := sha256.New()
|
|
||||||
|
|
||||||
// Sort user IDs for consistent hashing
|
|
||||||
userIDs := make([]int, 0, len(aliveIPs))
|
|
||||||
for uid := range aliveIPs {
|
|
||||||
userIDs = append(userIDs, uid)
|
|
||||||
}
|
|
||||||
sort.Ints(userIDs)
|
|
||||||
|
|
||||||
for _, uid := range userIDs {
|
|
||||||
ips := aliveIPs[uid]
|
|
||||||
// Sort IPs for consistent hashing
|
|
||||||
ipList := make([]string, 0, len(ips))
|
|
||||||
for ip := range ips {
|
|
||||||
ipList = append(ipList, ip)
|
|
||||||
}
|
|
||||||
sort.Strings(ipList)
|
|
||||||
|
|
||||||
// Write user ID and IPs to hash
|
|
||||||
h.Write([]byte{byte(uid >> 24), byte(uid >> 16), byte(uid >> 8), byte(uid)})
|
|
||||||
for _, ip := range ipList {
|
|
||||||
h.Write([]byte(ip))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CurrentOnline returns a snapshot copy of user_id → device count (distinct IPs).
|
|
||||||
// Lock-free: reads from live snapshot.
|
|
||||||
func (t *Tracker) CurrentOnline() map[int]int {
|
|
||||||
s := t.live.Load()
|
|
||||||
cp := make(map[int]int, len(s.online))
|
|
||||||
for k, v := range s.online {
|
|
||||||
cp[k] = v
|
|
||||||
}
|
|
||||||
return cp
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreAliveIPs merges alive IPs back in (used when push to panel fails).
|
|
||||||
// Note: this operates on the buffer, which will be overwritten next Process().
|
|
||||||
func (t *Tracker) RestoreAliveIPs(data map[int][]string) {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
for uid, ipList := range data {
|
|
||||||
ips := t.aliveIPsBuf[uid]
|
|
||||||
if ips == nil {
|
|
||||||
ips = make([]string, 0, len(ipList))
|
|
||||||
}
|
|
||||||
// Use map for O(n) dedup instead of O(n²) linear search
|
|
||||||
existMap := make(map[string]struct{}, len(ips)+len(ipList))
|
|
||||||
for _, existing := range ips {
|
|
||||||
existMap[existing] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, ip := range ipList {
|
|
||||||
if _, exists := existMap[ip]; !exists {
|
|
||||||
ips = append(ips, ip)
|
|
||||||
existMap[ip] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.aliveIPsBuf[uid] = ips
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogStats logs current tracking statistics.
|
|
||||||
// Lock-free: reads from live snapshot.
|
|
||||||
func (t *Tracker) LogStats() {
|
|
||||||
s := t.live.Load()
|
|
||||||
nlog.TrackerStats(s.connCount, len(s.online))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActiveConnections returns the last observed active connection count.
|
|
||||||
// Lock-free: reads from live snapshot.
|
|
||||||
func (t *Tracker) ActiveConnections() int {
|
|
||||||
return t.live.Load().connCount
|
|
||||||
}
|
|
||||||
|
|
||||||
// TotalConnections is deprecated — no longer tracked per-connection.
|
|
||||||
// Returns 0 for backward compatibility.
|
|
||||||
func (t *Tracker) TotalConnections() int64 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// InboundSpeed returns the last observed inbound (download) speed in bytes/second.
|
|
||||||
// Lock-free: reads from live snapshot.
|
|
||||||
func (t *Tracker) InboundSpeed() int64 {
|
|
||||||
return t.live.Load().inSpeed / 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// OutboundSpeed returns the last observed outbound (upload) speed in bytes/second.
|
|
||||||
// Lock-free: reads from live snapshot.
|
|
||||||
func (t *Tracker) OutboundSpeed() int64 {
|
|
||||||
return t.live.Load().outSpeed / 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyTrafficMap creates a shallow copy of the traffic map.
|
|
||||||
func copyTrafficMap(src map[int][2]int64) map[int][2]int64 {
|
|
||||||
dst := make(map[int][2]int64, len(src))
|
|
||||||
for k, v := range src {
|
|
||||||
dst[k] = v
|
|
||||||
}
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
package tracker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestProcess_InitialTraffic(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
cumTraffic := map[int][2]int64{
|
|
||||||
1: {100, 200},
|
|
||||||
2: {50, 80},
|
|
||||||
}
|
|
||||||
aliveIPs := map[int]map[string]bool{
|
|
||||||
1: {"1.1.1.1": true},
|
|
||||||
2: {"2.2.2.2": true},
|
|
||||||
}
|
|
||||||
tr.Process(cumTraffic, aliveIPs, 2)
|
|
||||||
|
|
||||||
flushed := tr.FlushTraffic()
|
|
||||||
if flushed[1] != [2]int64{100, 200} {
|
|
||||||
t.Errorf("user 1 traffic: got %v", flushed[1])
|
|
||||||
}
|
|
||||||
if flushed[2] != [2]int64{50, 80} {
|
|
||||||
t.Errorf("user 2 traffic: got %v", flushed[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcess_DeltaCalculation(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
|
|
||||||
// First tick
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
|
|
||||||
// Second tick — counters advanced
|
|
||||||
tr.Process(map[int][2]int64{1: {300, 500}}, nil, 1)
|
|
||||||
|
|
||||||
flushed := tr.FlushTraffic()
|
|
||||||
// Total: first tick 100/200 + delta 200/300 = 300/500
|
|
||||||
if flushed[1] != [2]int64{300, 500} {
|
|
||||||
t.Errorf("accumulated: got %v, want [300,500]", flushed[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcess_CounterReset(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
|
|
||||||
// First tick with high counters
|
|
||||||
tr.Process(map[int][2]int64{1: {1000, 2000}}, nil, 1)
|
|
||||||
tr.FlushTraffic() // clear accumulator
|
|
||||||
|
|
||||||
// Counter reset (new value < old value) — treat as fresh
|
|
||||||
tr.Process(map[int][2]int64{1: {50, 80}}, nil, 1)
|
|
||||||
|
|
||||||
flushed := tr.FlushTraffic()
|
|
||||||
if flushed[1] != [2]int64{50, 80} {
|
|
||||||
t.Errorf("counter reset: got %v, want [50,80]", flushed[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcess_UserDisappears(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
|
|
||||||
// Tick 1: user present
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
|
|
||||||
// Tick 2: user gone
|
|
||||||
tr.Process(map[int][2]int64{}, nil, 0)
|
|
||||||
|
|
||||||
// Tick 3: user reappears — should count full bytes (treated as new)
|
|
||||||
tr.Process(map[int][2]int64{1: {50, 30}}, nil, 1)
|
|
||||||
|
|
||||||
flushed := tr.FlushTraffic()
|
|
||||||
// First tick: 100/200, tick 2: 0, tick 3: 50/30 = total 150/230
|
|
||||||
if flushed[1] != [2]int64{150, 230} {
|
|
||||||
t.Errorf("after disappear: got %v, want [150,230]", flushed[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcess_ZeroTraffic(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
tr.Process(map[int][2]int64{}, nil, 0)
|
|
||||||
|
|
||||||
if tr.HasTraffic() {
|
|
||||||
t.Error("expected no traffic for empty input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFlushTraffic(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
tr.Process(map[int][2]int64{
|
|
||||||
1: {100, 200},
|
|
||||||
2: {50, 80},
|
|
||||||
}, nil, 2)
|
|
||||||
|
|
||||||
flushed := tr.FlushTraffic()
|
|
||||||
if flushed[1] != [2]int64{100, 200} {
|
|
||||||
t.Errorf("flushed user 1: got %v", flushed[1])
|
|
||||||
}
|
|
||||||
if flushed[2] != [2]int64{50, 80} {
|
|
||||||
t.Errorf("flushed user 2: got %v", flushed[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// After flush, should be empty
|
|
||||||
if tr.HasTraffic() {
|
|
||||||
t.Error("expected no traffic after flush")
|
|
||||||
}
|
|
||||||
flushed2 := tr.FlushTraffic()
|
|
||||||
if len(flushed2) != 0 {
|
|
||||||
t.Errorf("expected empty after second flush, got %v", flushed2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestoreTraffic(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
|
|
||||||
flushed := tr.FlushTraffic()
|
|
||||||
tr.RestoreTraffic(flushed)
|
|
||||||
|
|
||||||
if !tr.HasTraffic() {
|
|
||||||
t.Error("expected traffic after restore")
|
|
||||||
}
|
|
||||||
|
|
||||||
restored := tr.FlushTraffic()
|
|
||||||
if restored[1] != [2]int64{100, 200} {
|
|
||||||
t.Errorf("restored: got %v", restored[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestoreTraffic_Additive(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
|
|
||||||
// Two ticks accumulate
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
tr.Process(map[int][2]int64{1: {200, 400}}, nil, 1)
|
|
||||||
|
|
||||||
// Flush and restore
|
|
||||||
first := tr.FlushTraffic()
|
|
||||||
tr.RestoreTraffic(first)
|
|
||||||
|
|
||||||
// Generate additional traffic
|
|
||||||
tr.Process(map[int][2]int64{1: {250, 500}}, nil, 1)
|
|
||||||
|
|
||||||
final := tr.FlushTraffic()
|
|
||||||
// Restored: 200/400 (initial 100/200 + delta 100/200) + new delta 50/100 = 250/500
|
|
||||||
if final[1][0] != 250 || final[1][1] != 500 {
|
|
||||||
t.Errorf("additive restore: got %v", final[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFlushAliveIPs(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
aliveIPs := map[int]map[string]bool{
|
|
||||||
1: {"1.1.1.1": true, "2.2.2.2": true},
|
|
||||||
2: {"3.3.3.3": true},
|
|
||||||
}
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}, 2: {30, 40}}, aliveIPs, 3)
|
|
||||||
|
|
||||||
flushed := tr.FlushAliveIPs()
|
|
||||||
if len(flushed[1]) != 2 {
|
|
||||||
t.Errorf("user 1 IPs: got %d, want 2", len(flushed[1]))
|
|
||||||
}
|
|
||||||
if len(flushed[2]) != 1 {
|
|
||||||
t.Errorf("user 2 IPs: got %d, want 1", len(flushed[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without another Process call, hash is same → returns nil (skip duplicate)
|
|
||||||
flushed2 := tr.FlushAliveIPs()
|
|
||||||
if flushed2 != nil {
|
|
||||||
t.Errorf("expected nil on duplicate flush, got %v", flushed2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFlushAliveIPs_DedupSameIP(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
aliveIPs := map[int]map[string]bool{
|
|
||||||
1: {"1.1.1.1": true},
|
|
||||||
}
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, aliveIPs, 2)
|
|
||||||
|
|
||||||
flushed := tr.FlushAliveIPs()
|
|
||||||
if len(flushed[1]) != 1 {
|
|
||||||
t.Errorf("expected 1 IP, got %d", len(flushed[1]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasTraffic(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
if tr.HasTraffic() {
|
|
||||||
t.Error("new tracker should not have traffic")
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
if !tr.HasTraffic() {
|
|
||||||
t.Error("should have traffic after process")
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.FlushTraffic()
|
|
||||||
if tr.HasTraffic() {
|
|
||||||
t.Error("should not have traffic after flush")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcess_NoTrafficDelta(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
tr.FlushTraffic() // clear
|
|
||||||
|
|
||||||
// Same values — no delta
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
if tr.HasTraffic() {
|
|
||||||
t.Error("expected no traffic for zero delta")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTrafficAccumulation(t *testing.T) {
|
|
||||||
tr := New()
|
|
||||||
|
|
||||||
// Tick 1
|
|
||||||
tr.Process(map[int][2]int64{1: {100, 200}}, nil, 1)
|
|
||||||
// Tick 2 (cumulative 300, 500 → delta 200, 300)
|
|
||||||
tr.Process(map[int][2]int64{1: {300, 500}}, nil, 1)
|
|
||||||
// Tick 3 (cumulative 350, 600 → delta 50, 100)
|
|
||||||
tr.Process(map[int][2]int64{1: {350, 600}}, nil, 1)
|
|
||||||
|
|
||||||
flushed := tr.FlushTraffic()
|
|
||||||
// Total: 100+200+50 = 350 upload, 200+300+100 = 600 download
|
|
||||||
if flushed[1] != [2]int64{350, 600} {
|
|
||||||
t.Errorf("accumulated: got %v, want [350,600]", flushed[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user