commit 37e6c016cb67592b21d39eaf82122c78c8a0ca40 Author: xboard Date: Sun Mar 15 09:27:24 2026 +0800 init xboard node diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b39366c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main, master, dev] + pull_request: + branches: [main, master] + +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.24' + + - name: Run tests + run: go test -v -race -count=1 ./internal/... + + 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.24' + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") + COMMIT=$(git rev-parse --short HEAD) + BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${BUILD_DATE}" + go build -ldflags "${LDFLAGS}" -tags "with_clash_api,with_quic" -o xboard-node-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/xboard-node diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84ac9c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f2dacf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Build stage +FROM golang:1.25-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 \ + -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..791629e --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +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) + +.PHONY: build clean test docker install + +# Build for current platform +build: + go build -ldflags "$(LDFLAGS)" -tags with_quic -o xboard-node ./cmd/xboard-node + +# Build for Linux amd64 +build-linux: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -tags with_quic -o xboard-node-linux-amd64 ./cmd/xboard-node + +# Build for Linux arm64 +build-linux-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -tags with_quic -o xboard-node-linux-arm64 ./cmd/xboard-node + +# Build all platforms +build-all: build-linux build-linux-arm64 + +# Run tests +test: + go test -tags with_quic -v ./... + +# Clean build artifacts +clean: + rm -f xboard-node xboard-node-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 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c8226d --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# xboard-node + +Dedicated node backend for [Xboard](https://github.com/cedar2025/Xboard). Fully compatible with Xboard API. + +> **Disclaimer**: This project is for educational and learning purposes only. + +## Install + +### Script +```bash +bash <(curl -Ls https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh) \ + -a https://panel.com -t TOKEN -n 1 +``` + +### Docker +```bash +docker run -d --restart=always --network=host \ + -e apiHost=https://panel.com \ + -e apiKey=TOKEN \ + -e nodeID=1 \ + ghcr.io/cedar2025/xboard-node:latest +``` + +## Features + +- **Kernels**: sing-box (default) / Xray-core. +- **Protocols**: Full coverage (V2Ray, Trojan, SS, Hysteria2, TUIC, Naive). +- **Speed**: Kernel-level rate limiting. +- **Sync**: Real-time WebSocket + REST fallback. +- **Dev**: Single Go binary. + +## Configuration + +`config.yml`: +```yaml +panel: + url: "https://panel.com" + token: "token" + node_id: 1 +``` + +## License + +MPL-2.0. + diff --git a/cmd/xboard-node/main.go b/cmd/xboard-node/main.go new file mode 100644 index 0000000..af901b8 --- /dev/null +++ b/cmd/xboard-node/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "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/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) + } + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) + os.Exit(1) + } + + config.InitLogger(cfg.Log) + + // Apply runtime memory tuning before anything else allocates. + applyRuntimeConfig(cfg.Runtime) + + nodes := cfg.ExpandNodes() + slog.Info("xboard-node starting", + "version", version, + "build_time", buildTime, + "nodes", len(nodes), + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigCh + slog.Info("received signal, shutting down gracefully", "signal", sig) + cancel() + + // Force-exit on second signal or after timeout. + select { + case sig = <-sigCh: + slog.Warn("received second signal, forcing exit", "signal", sig) + os.Exit(1) + case <-time.After(15 * time.Second): + slog.Error("shutdown timed out after 15s, forcing exit") + os.Exit(2) + } + }() + + // Optional health-check endpoint for container orchestrators. + if cfg.HealthPort > 0 { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.HealthPort)) + if err != nil { + slog.Error("failed to start health check listener", "port", cfg.HealthPort, "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"}`)) + }) + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + go func() { + slog.Info("health check listening", "addr", ln.Addr()) + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + slog.Warn("health check server stopped", "error", err) + } + }() + go func() { + <-ctx.Done() + srv.Close() + }() + } + + // Run one service per node. If any node fails, cancel all others. + errCh := make(chan error, len(nodes)) + var wg sync.WaitGroup + for _, nodeCfg := range nodes { + nodeCfg := nodeCfg // capture + wg.Add(1) + go func() { + defer wg.Done() + svc := service.New(nodeCfg) + if err := svc.Run(ctx); err != nil { + slog.Error("node service exited with error", + "node_id", nodeCfg.Panel.NodeID, "error", err) + errCh <- err + cancel() // bring down all other nodes + } else { + slog.Info("node service stopped", "node_id", nodeCfg.Panel.NodeID) + } + }() + } + + wg.Wait() + close(errCh) + + if err := <-errCh; err != nil { + os.Exit(1) + } + slog.Info("xboard-node stopped") +} + +// 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) + slog.Info("runtime: GOGC set", "gogc", rt.GoGCPercent, "prev", prev) + } + + // GOMEMLIMIT — parse human-readable size string (e.g. "30MiB") + if rt.GoMemLimit != "" { + limit, err := parseMemLimit(rt.GoMemLimit) + if err != nil { + slog.Warn("runtime: invalid gomemlimit, ignoring", "value", rt.GoMemLimit, "error", err) + } else { + prev := debug.SetMemoryLimit(limit) + slog.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 +} diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..abfd4b7 --- /dev/null +++ b/config.yml.example @@ -0,0 +1,92 @@ +# 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" + +# Runtime memory tuning (optional) +# runtime: +# # Soft memory limit (e.g. "30MiB", "512MiB") +# gomemlimit: "" +# # GOGC target percentage (default: 100) +# gogc: 0 + +# Health check endpoint for Docker/K8s (optional, 0 = disabled) +# health_port: 65530 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b5bb15b --- /dev/null +++ b/go.mod @@ -0,0 +1,176 @@ +module github.com/cedar2025/xboard-node + +go 1.25.7 + +require ( + github.com/caddyserver/certmagic v0.25.2 + 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.260206.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.57.2-0.20260111184307-eec823306178 // indirect + github.com/caddyserver/zerossl v0.1.5 // 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/florianl/go-nfqueue/v2 v2.0.2 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gaissmai/bart v0.18.0 // indirect + github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-chi/render v1.0.3 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/go-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.9.2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + github.com/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-20251014195629-e4eec4520535 // 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.48.0 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.1 // 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.20260315011338-3ac22acd89fc diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..738af69 --- /dev/null +++ b/go.sum @@ -0,0 +1,429 @@ +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.57.2-0.20260111184307-eec823306178 h1:bSq8n+gX4oO/qnM3MKf4kroW75n+phO9Qp6nigJKZ1E= +github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU= +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/sing-box v1.14.0-alpha.2.0.20260315011338-3ac22acd89fc h1:nhAy1qYcrU1e/HqzIQfVHCACMRtXkyEFkBCE80ztaE8= +github.com/cedar2025/sing-box v1.14.0-alpha.2.0.20260315011338-3ac22acd89fc/go.mod h1:IRlOGudMpKt7MqlRQWsL2cJhYBQzT/Bxe7dVB4QktCY= +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.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= +github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +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/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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +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/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.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U= +github.com/pires/go-proxyproto v0.9.2/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.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/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-20251014195629-e4eec4520535 h1:nwobseOLLRtdbP6z7Z2aVI97u8ZptTgD1ofovhAKmeU= +github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ= +github.com/xtls/xray-core v1.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAhdHqI= +github.com/xtls/xray-core v1.260206.0/go.mod h1:GyFIgVGRJkt3eyV/NMcdxOKXcJPqGGpyupHzy16uJhU= +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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +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-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= +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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/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= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..7d4cd83 --- /dev/null +++ b/install.sh @@ -0,0 +1,735 @@ +#!/bin/bash +set -e + +# xboard-node Multi-Node Deploy Script +# Supports: Ubuntu 20+, Debian 11+, CentOS 8+, Alpine 3.18+ +# +# One-command deploy (non-interactive): +# bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 1 +# bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 2 -k xray +# bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 3 --docker +# +# Interactive: +# bash install.sh +# +# Management: +# bash install.sh list +# bash install.sh remove +# bash install.sh update +# bash install.sh uninstall + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="/etc/xboard-node" +SERVICE_TEMPLATE="xboard-node@.service" +DOCKER_COMPOSE_FILE="${CONFIG_DIR}/docker-compose.yml" +DOCKER_IMAGE="ghcr.io/cedar2025/xboard-node:latest" + +# Parsed parameters (populated by parse_args) +PANEL_URL="" +PANEL_TOKEN="" +NODE_ID="" +NODE_TYPE="" +KERNEL_TYPE="singbox" +DOCKER_MODE=0 +SUBCOMMAND="" + +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}"; } + +# ─── Argument Parsing ───────────────────────────────────────────────── + +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + -a|--api) PANEL_URL="$2"; shift 2 ;; + -t|--token) PANEL_TOKEN="$2"; shift 2 ;; + -n|--node-id) NODE_ID="$2"; shift 2 ;; + -T|--node-type) NODE_TYPE="$2"; shift 2 ;; + -k|--kernel) KERNEL_TYPE="$2"; shift 2 ;; + --docker) DOCKER_MODE=1; shift ;; + add|remove|list|update|uninstall|help|--help|-h) + if [ -z "$SUBCOMMAND" ]; then + SUBCOMMAND="$1" + fi + shift + ;; + *) + if [ "$SUBCOMMAND" = "remove" ] && [ -z "$NODE_ID" ]; then + NODE_ID="$1" + fi + shift + ;; + esac + done + + # Normalize kernel type + case "$KERNEL_TYPE" in + xray|Xray|XRAY) KERNEL_TYPE="xray" ;; + *) KERNEL_TYPE="singbox" ;; + esac +} + +has_all_params() { + [ -n "$PANEL_URL" ] && [ -n "$PANEL_TOKEN" ] && [ -n "$NODE_ID" ] +} + +validate_params() { + if [ -z "$PANEL_URL" ]; then + log_error "Panel URL is required (-a/--api)" + exit 1 + fi + if [ -z "$PANEL_TOKEN" ]; then + log_error "Server Token is required (-t/--token)" + exit 1 + fi + if [ -z "$NODE_ID" ]; then + log_error "Node ID is required (-n/--node-id)" + exit 1 + fi + if ! [[ "$NODE_ID" =~ ^[0-9]+$ ]]; then + log_error "Node ID must be a positive integer, got: $NODE_ID" + exit 1 + fi +} + +# ─── System Detection ──────────────────────────────────────────────── + +check_root() { + if [ "$(id -u)" != "0" ]; then + log_error "Please run as root (or with sudo)" + exit 1 + fi +} + +detect_arch() { + ARCH=$(uname -m) + case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + armv7l) ARCH="armv7" ;; + *) + log_error "Unsupported architecture: $ARCH" + exit 1 + ;; + esac +} + +detect_os() { + if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID + elif [ -f /etc/alpine-release ]; then + OS="alpine" + else + OS="unknown" + fi +} + +install_deps() { + case "$OS" in + ubuntu|debian) + apt-get update -qq + apt-get install -y -qq wget curl tar >/dev/null 2>&1 + ;; + centos|rhel|rocky|almalinux|fedora) + yum install -y -q wget curl tar >/dev/null 2>&1 + ;; + alpine) + apk add --no-cache wget curl tar >/dev/null 2>&1 + ;; + esac +} + +# ─── Binary Install ────────────────────────────────────────────────── + +is_binary_installed() { + [ -x "${INSTALL_DIR}/xboard-node" ] +} + +install_binary() { + if is_binary_installed; then + log_info "xboard-node binary already installed" + return + fi + + log_step "Installing xboard-node binary..." + + local src="" + + if [ -f "./xboard-node" ]; then + src="./xboard-node" + elif [ -f "./xboard-node-linux-${ARCH}" ]; then + src="./xboard-node-linux-${ARCH}" + fi + + if [ -n "$src" ]; then + cp "$src" "${INSTALL_DIR}/xboard-node" + log_info "Installed from local file: $src" + else + local url="https://github.com/cedar2025/xboard-node/releases/latest/download/xboard-node-linux-${ARCH}" + log_info "Downloading from GitHub releases..." + if wget -q "$url" -O "${INSTALL_DIR}/xboard-node" 2>/dev/null; then + log_info "Downloaded successfully" + elif curl -fsSL "$url" -o "${INSTALL_DIR}/xboard-node" 2>/dev/null; then + log_info "Downloaded successfully" + else + log_error "Failed to download. Place xboard-node binary in current directory and retry." + exit 1 + fi + fi + + chmod +x "${INSTALL_DIR}/xboard-node" + log_info "xboard-node installed to ${INSTALL_DIR}/xboard-node" +} + +# ─── Systemd Template ──────────────────────────────────────────────── + +install_systemd_template() { + if ! command -v systemctl >/dev/null 2>&1; then + return + fi + + if [ -f "/etc/systemd/system/${SERVICE_TEMPLATE}" ]; then + return + fi + + log_step "Installing systemd service template..." + + cat > "/etc/systemd/system/${SERVICE_TEMPLATE}" << 'UNIT' +[Unit] +Description=Xboard Node Backend (node %i) +Documentation=https://github.com/cedar2025/xboard-node +After=network.target nss-lookup.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/xboard-node -c /etc/xboard-node/%i/config.yml +Restart=always +RestartSec=5 +LimitNOFILE=1048576 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +UNIT + + systemctl daemon-reload + log_info "Systemd template installed: ${SERVICE_TEMPLATE}" +} + +# ─── Migrate Legacy Config ─────────────────────────────────────────── + +migrate_legacy_config() { + if [ -f "${CONFIG_DIR}/config.yml" ] && [ ! -d "${CONFIG_DIR}/config.yml" ]; then + local legacy_id + legacy_id=$(grep -E '^\s*node_id:\s*' "${CONFIG_DIR}/config.yml" 2>/dev/null | head -1 | sed 's/[^0-9]*//g') + + if [ -z "$legacy_id" ]; then + legacy_id="default" + fi + + if [ ! -d "${CONFIG_DIR}/${legacy_id}" ]; then + log_warn "Migrating legacy config to multi-node layout: node ${legacy_id}" + mkdir -p "${CONFIG_DIR}/${legacy_id}" + mv "${CONFIG_DIR}/config.yml" "${CONFIG_DIR}/${legacy_id}/config.yml" + + if command -v systemctl >/dev/null 2>&1; then + systemctl stop xboard-node 2>/dev/null || true + systemctl disable xboard-node 2>/dev/null || true + rm -f /etc/systemd/system/xboard-node.service + + install_systemd_template + systemctl enable "xboard-node@${legacy_id}" 2>/dev/null || true + systemctl start "xboard-node@${legacy_id}" 2>/dev/null || true + log_info "Migrated service: xboard-node → xboard-node@${legacy_id}" + fi + fi + fi +} + +# ─── Interactive Prompts ────────────────────────────────────────────── + +prompt_missing_params() { + echo "" + log_step "=== Node Configuration ===" + echo "" + + if [ -z "$PANEL_URL" ]; then + read -rp " Panel URL (e.g. https://panel.example.com): " PANEL_URL + else + echo -e " Panel URL: ${CYAN}${PANEL_URL}${NC}" + fi + + if [ -z "$PANEL_TOKEN" ]; then + read -rp " Server Token: " PANEL_TOKEN + else + echo -e " Server Token: ${CYAN}${PANEL_TOKEN:0:8}***${NC}" + fi + + if [ -z "$NODE_ID" ]; then + read -rp " Node ID: " NODE_ID + else + echo -e " Node ID: ${CYAN}${NODE_ID}${NC}" + fi + + if [ -n "$NODE_TYPE" ]; then + echo -e " Node Type: ${CYAN}${NODE_TYPE}${NC}" + fi + + if [ -n "$KERNEL_TYPE" ] && [ "$KERNEL_TYPE" != "singbox" ]; then + echo -e " Kernel: ${CYAN}${KERNEL_TYPE}${NC}" + else + echo "" + echo " Kernel type:" + echo " 1) singbox (default, recommended)" + echo " 2) xray" + read -rp " Choose [1/2]: " KERNEL_CHOICE + case "$KERNEL_CHOICE" in + 2) KERNEL_TYPE="xray" ;; + *) KERNEL_TYPE="singbox" ;; + esac + fi + + echo "" + validate_params +} + +# ─── Node Operations ───────────────────────────────────────────────── + +write_node_config() { + local node_id="$1" + local node_dir="${CONFIG_DIR}/${node_id}" + + mkdir -p "$node_dir" + + local node_type_line="" + if [ -n "$NODE_TYPE" ]; then + node_type_line=" node_type: \"${NODE_TYPE}\"" + fi + + cat > "${node_dir}/config.yml" << EOF +panel: + url: "${PANEL_URL}" + token: "${PANEL_TOKEN}" + node_id: ${node_id} +${node_type_line} + +node: + push_interval: 0 + pull_interval: 0 + +kernel: + type: "${KERNEL_TYPE}" + config_dir: "${node_dir}" + log_level: "warn" + +log: + level: "info" + output: "stdout" +EOF + + log_info "Config written: ${node_dir}/config.yml" +} + +add_node_native() { + local node_id="$1" + + if [ -d "${CONFIG_DIR}/${node_id}" ]; then + log_error "Node ${node_id} already exists at ${CONFIG_DIR}/${node_id}/" + log_info "To reconfigure, first remove it: $0 remove ${node_id}" + exit 1 + fi + + write_node_config "$node_id" + + if command -v systemctl >/dev/null 2>&1; then + install_systemd_template + systemctl enable "xboard-node@${node_id}" + systemctl start "xboard-node@${node_id}" + log_info "Service started: xboard-node@${node_id}" + else + log_warn "No systemd found. Start manually:" + echo " xboard-node -c ${CONFIG_DIR}/${node_id}/config.yml" + fi +} + +add_node_docker() { + local node_id="$1" + + if [ -d "${CONFIG_DIR}/${node_id}" ]; then + log_error "Node ${node_id} already exists at ${CONFIG_DIR}/${node_id}/" + log_info "To reconfigure, first remove it: $0 remove ${node_id}" + exit 1 + fi + + write_node_config "$node_id" + regenerate_docker_compose + log_info "Docker compose updated: ${DOCKER_COMPOSE_FILE}" + + if command -v docker >/dev/null 2>&1; then + if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" + else + log_warn "docker compose not found. Start manually:" + echo " cd ${CONFIG_DIR} && docker compose up -d" + return + fi + cd "${CONFIG_DIR}" + ${COMPOSE_CMD} up -d "node-${node_id}" + log_info "Container started: xboard-node-${node_id}" + else + log_warn "Docker not installed. Install Docker first, then run:" + echo " cd ${CONFIG_DIR} && docker compose up -d" + fi +} + +regenerate_docker_compose() { + local nodes=() + for dir in "${CONFIG_DIR}"/*/; do + [ -f "${dir}config.yml" ] || continue + local nid + nid=$(basename "$dir") + nodes+=("$nid") + done + + if [ ${#nodes[@]} -eq 0 ]; then + rm -f "${DOCKER_COMPOSE_FILE}" + return + fi + + cat > "${DOCKER_COMPOSE_FILE}" << 'HEADER' +# Auto-generated by install.sh — do not edit manually. +# Regenerated each time a node is added or removed. + +services: +HEADER + + for nid in "${nodes[@]}"; do + cat >> "${DOCKER_COMPOSE_FILE}" << EOF + node-${nid}: + image: ${DOCKER_IMAGE} + container_name: xboard-node-${nid} + restart: always + network_mode: host + volumes: + - ./${nid}/config.yml:/etc/xboard-node/config.yml:ro + - ./${nid}:/etc/xboard-node/data + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +EOF + done +} + +# ─── Deploy Node (unified entry) ───────────────────────────────────── + +deploy_node() { + if has_all_params; then + validate_params + log_info "Deploying node ${NODE_ID} (${KERNEL_TYPE}) → ${PANEL_URL}" + else + prompt_missing_params + fi + + if [ "$DOCKER_MODE" -eq 1 ]; then + add_node_docker "$NODE_ID" + else + add_node_native "$NODE_ID" + fi + + echo "" + echo -e "${GREEN}=== Node ${NODE_ID} Deployed ===${NC}" + echo "" + + if [ "$DOCKER_MODE" -eq 1 ]; then + echo " Manage:" + echo " Logs: docker logs -f xboard-node-${NODE_ID}" + echo " Stop: cd ${CONFIG_DIR} && docker compose stop node-${NODE_ID}" + echo " Restart: cd ${CONFIG_DIR} && docker compose restart node-${NODE_ID}" + else + echo " Manage:" + echo " Status: systemctl status xboard-node@${NODE_ID}" + echo " Logs: journalctl -u xboard-node@${NODE_ID} -f" + echo " Stop: systemctl stop xboard-node@${NODE_ID}" + echo " Restart: systemctl restart xboard-node@${NODE_ID}" + fi + + echo "" + echo " Config: ${CONFIG_DIR}/${NODE_ID}/config.yml" + echo "" +} + +# ─── Remove Node ────────────────────────────────────────────────────── + +remove_node() { + local node_id="$1" + + if [ -z "$node_id" ]; then + log_error "Usage: $0 remove " + exit 1 + fi + + if [ ! -d "${CONFIG_DIR}/${node_id}" ]; then + log_error "Node ${node_id} not found" + exit 1 + fi + + log_step "Removing node ${node_id}..." + + if command -v systemctl >/dev/null 2>&1; then + systemctl stop "xboard-node@${node_id}" 2>/dev/null || true + systemctl disable "xboard-node@${node_id}" 2>/dev/null || true + log_info "Systemd service stopped and disabled" + fi + + if command -v docker >/dev/null 2>&1; then + docker rm -f "xboard-node-${node_id}" 2>/dev/null || true + fi + + rm -rf "${CONFIG_DIR}/${node_id}" + log_info "Config removed: ${CONFIG_DIR}/${node_id}/" + + regenerate_docker_compose + log_info "Node ${node_id} removed" +} + +# ─── List Nodes ─────────────────────────────────────────────────────── + +list_nodes() { + echo "" + echo -e "${BOLD} Deployed Nodes${NC}" + echo -e " ────────────────────────────────────────────" + + local found=0 + for dir in "${CONFIG_DIR}"/*/; do + [ -f "${dir}config.yml" ] || continue + found=1 + + local nid + nid=$(basename "$dir") + + local panel_url kernel_type + panel_url=$(grep -E '^\s*url:' "${dir}config.yml" 2>/dev/null | head -1 | sed 's/.*url:\s*"\?\([^"]*\)"\?.*/\1/') + kernel_type=$(grep -E '^\s*type:' "${dir}config.yml" 2>/dev/null | head -1 | sed 's/.*type:\s*"\?\([^"]*\)"\?.*/\1/') + + local status="${RED}stopped${NC}" + if command -v systemctl >/dev/null 2>&1; then + if systemctl is-active "xboard-node@${nid}" >/dev/null 2>&1; then + status="${GREEN}running (systemd)${NC}" + fi + fi + if command -v docker >/dev/null 2>&1; then + if docker inspect -f '{{.State.Running}}' "xboard-node-${nid}" 2>/dev/null | grep -q true; then + status="${GREEN}running (docker)${NC}" + fi + fi + + printf " ${BOLD}Node %-6s${NC} kernel=%-8s panel=%s\n" "$nid" "${kernel_type:-singbox}" "${panel_url:-unknown}" + echo -e " status=${status}" + echo "" + done + + if [ "$found" -eq 0 ]; then + echo " No nodes deployed yet." + echo "" + echo " Deploy your first node:" + echo " $0 -a https://panel.example.com -t TOKEN -n 1" + echo " $0 -a https://panel.example.com -t TOKEN -n 1 --docker" + fi + echo "" +} + +# ─── Update / Uninstall ────────────────────────────────────────────── + +update_binary() { + log_step "Updating xboard-node binary..." + + detect_arch + + local url="https://github.com/cedar2025/xboard-node/releases/latest/download/xboard-node-linux-${ARCH}" + local tmp="/tmp/xboard-node-update" + + if wget -q "$url" -O "$tmp" 2>/dev/null || curl -fsSL "$url" -o "$tmp" 2>/dev/null; then + chmod +x "$tmp" + mv "$tmp" "${INSTALL_DIR}/xboard-node" + log_info "Binary updated" + else + log_error "Failed to download update" + rm -f "$tmp" + exit 1 + fi + + if command -v systemctl >/dev/null 2>&1; then + for dir in "${CONFIG_DIR}"/*/; do + [ -f "${dir}config.yml" ] || continue + local nid + nid=$(basename "$dir") + if systemctl is-active "xboard-node@${nid}" >/dev/null 2>&1; then + systemctl restart "xboard-node@${nid}" + log_info "Restarted: xboard-node@${nid}" + fi + done + fi + + if [ -f "${DOCKER_COMPOSE_FILE}" ] && command -v docker >/dev/null 2>&1; then + log_info "For Docker nodes, pull the latest image and restart:" + echo " cd ${CONFIG_DIR} && docker compose pull && docker compose up -d" + fi +} + +do_uninstall() { + log_step "Uninstalling xboard-node..." + + if command -v systemctl >/dev/null 2>&1; then + for dir in "${CONFIG_DIR}"/*/; do + [ -f "${dir}config.yml" ] || continue + local nid + nid=$(basename "$dir") + systemctl stop "xboard-node@${nid}" 2>/dev/null || true + systemctl disable "xboard-node@${nid}" 2>/dev/null || true + done + systemctl stop xboard-node 2>/dev/null || true + systemctl disable xboard-node 2>/dev/null || true + rm -f /etc/systemd/system/xboard-node.service + rm -f "/etc/systemd/system/${SERVICE_TEMPLATE}" + systemctl daemon-reload + fi + + if command -v docker >/dev/null 2>&1; then + for dir in "${CONFIG_DIR}"/*/; do + [ -f "${dir}config.yml" ] || continue + local nid + nid=$(basename "$dir") + docker rm -f "xboard-node-${nid}" 2>/dev/null || true + done + fi + + rm -f "${INSTALL_DIR}/xboard-node" + log_info "Binary removed" + + echo "" + read -rp " Delete all configs? (${CONFIG_DIR}) [y/N]: " DELETE_ALL + if [[ "$DELETE_ALL" =~ ^[Yy]$ ]]; then + rm -rf "${CONFIG_DIR}" + log_info "All configs removed" + else + log_info "Configs preserved at ${CONFIG_DIR}/" + fi + + log_info "xboard-node uninstalled" +} + +# ─── Print Help ─────────────────────────────────────────────────────── + +print_help() { + cat << 'HELP' + + xboard-node Deploy Script + + DEPLOY A NODE (one command, repeat for each node): + + install.sh -a -t -n [-T ] [-k singbox|xray] [--docker] + + OPTIONS: + -a, --api Panel URL (e.g. https://panel.example.com) + -t, --token Server Token (from panel settings) + -n, --node-id Node ID (positive integer) + -T, --node-type Node type (optional, auto-detected from panel) + -k, --kernel Kernel type (singbox or xray, default: singbox) + --docker Use Docker instead of systemd + + EXAMPLES: + + # Deploy node 1 (sing-box, systemd): + bash install.sh -a https://panel.example.com -t mytoken123 -n 1 + + # Deploy node 2 (xray, systemd): + bash install.sh -a https://panel.example.com -t mytoken123 -n 2 -k xray + + # Deploy node 3 with explicit type (Docker): + bash install.sh -a https://panel.example.com -t mytoken123 -n 3 -T shadowsocks --docker + + # Interactive mode (will prompt for all params): + bash install.sh + + MANAGEMENT: + + bash install.sh list List all deployed nodes + bash install.sh remove Remove a node + bash install.sh update Update binary + restart all nodes + bash install.sh uninstall Remove everything + + DOCKER (env-var mode, no config file needed): + + docker run -d --restart=always --network=host \ + -e apiHost=https://panel.example.com \ + -e apiKey=YOUR_TOKEN \ + -e nodeID=1 \ + ghcr.io/cedar2025/xboard-node:latest + +HELP +} + +# ─── Main ───────────────────────────────────────────────────────────── + +main() { + parse_args "$@" + + case "$SUBCOMMAND" in + remove) + check_root + remove_node "$NODE_ID" + ;; + list) + list_nodes + ;; + update) + check_root + update_binary + ;; + uninstall) + check_root + do_uninstall + ;; + help|--help|-h) + print_help + ;; + add|"") + check_root + detect_arch + detect_os + install_deps + mkdir -p "$CONFIG_DIR" + migrate_legacy_config + + if [ "$DOCKER_MODE" -eq 0 ]; then + install_binary + install_systemd_template + fi + + deploy_node + ;; + *) + log_error "Unknown command: $SUBCOMMAND" + print_help + exit 1 + ;; + esac +} + +main "$@" diff --git a/internal/cert/cert.go b/internal/cert/cert.go new file mode 100644 index 0000000..5b53e7a --- /dev/null +++ b/internal/cert/cert.go @@ -0,0 +1,461 @@ +package cert + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "log/slog" + "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" +) + +// 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. Automatically generated and stored in CertDir. +// Recommended for nodes behind a CDN or reverse proxy. +// - "file" — User-provided certificate and key file paths (CertFile/KeyFile). +// - "content" — Certificate and key PEM content pushed from the panel. +// Written to disk in CertDir for the kernel to consume. +// - "none" — No TLS. The node will handle plain connections. +// +// Priority Logic: +// +// If CertMode is empty, the manager infers the mode in this order: +// 1. "http" (if auto_tls is true) +// 2. "content" (if both CertContent and KeyContent are provided) +// 3. "file" (if both CertFile and KeyFile paths are provided) +// 4. "none" (default) +type Manager struct { + cfg config.CertConfig + certFile string + keyFile string + + magic *certmagic.Config + renewed atomic.Bool + acmeStarted bool // prevents double-init of certmagic background goroutines +} + +// NewManager creates a certificate manager. +func NewManager(cfg config.CertConfig) *Manager { + m := &Manager{cfg: cfg} + m.setCertPaths() + return m +} + +// setCertPaths derives certFile / keyFile from the current m.cfg. +func (m *Manager) setCertPaths() { + switch m.resolveMode() { + case "file": + m.certFile = m.cfg.CertFile + m.keyFile = m.cfg.KeyFile + case "http", "dns", "self", "content": + dir := m.cfg.CertDir + if dir == "" { + dir = "/etc/xboard-node/certs" + } + domain := m.cfg.Domain + if domain == "" { + domain = "localhost" + } + m.certFile = filepath.Join(dir, domain+".crt") + m.keyFile = filepath.Join(dir, domain+".key") + default: + m.certFile = "" + m.keyFile = "" + } +} + +// Reconfigure applies a new cert configuration at runtime (e.g. from panel push). +// It is safe to call after Start(). For ACME modes that are already running this +// is a no-op — a process restart is required to change ACME settings. +// Returns true when the on-disk cert paths changed (caller should restart kernel). +func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) { + // Preserve the storage directory so panel-pushed configs don't change + // where certs live on disk (only the operator controls this path). + if newCfg.CertDir == "" { + newCfg.CertDir = m.cfg.CertDir + } + + oldCertFile, oldKeyFile := m.certFile, m.keyFile + m.cfg = newCfg + m.setCertPaths() + + if err := m.Start(ctx); err != nil { + return false, fmt.Errorf("cert reconfigure: %w", err) + } + + return m.certFile != oldCertFile || m.keyFile != oldKeyFile, nil +} + +// 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) CertFile() string { return m.certFile } +func (m *Manager) KeyFile() string { return m.keyFile } +func (m *Manager) HasCert() bool { return m.certFile != "" && m.keyFile != "" } +func (m *Manager) CertRenewed() bool { return m.renewed.Swap(false) } + +// Start initializes the cert manager based on the resolved mode. +func (m *Manager) Start(ctx context.Context) error { + mode := m.resolveMode() + slog.Info("cert manager starting", "mode", mode, "domain", m.cfg.Domain) + + switch mode { + case "none", "": + 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 { + if _, err := os.Stat(m.cfg.CertFile); err != nil { + return fmt.Errorf("cert file: %w", err) + } + if _, err := os.Stat(m.cfg.KeyFile); err != nil { + return fmt.Errorf("key file: %w", err) + } + slog.Info("using manual TLS certificates", "cert", m.certFile, "key", m.keyFile) + return nil +} + +// ─── Mode: self ──────────────────────────────────────────────────────────── + +func (m *Manager) startSelfSigned() error { + // If files already exist and are valid, skip regeneration. + if fileExists(m.certFile) && fileExists(m.keyFile) { + slog.Info("self-signed cert already exists, reusing", "cert", m.certFile) + return nil + } + + if err := os.MkdirAll(filepath.Dir(m.certFile), 0o755); err != nil { + return fmt.Errorf("create cert dir: %w", err) + } + + domain := m.cfg.Domain + if domain == "" { + domain = "localhost" + } + + 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}) + + if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil { + return fmt.Errorf("write cert: %w", err) + } + if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil { + return fmt.Errorf("write key: %w", err) + } + + slog.Info("self-signed certificate generated", + "domain", domain, "cert", m.certFile, "valid_years", 10) + return nil +} + +// ─── Mode: content ──────────────────────────────────────────────────────── + +// startContent writes panel-supplied PEM strings to disk so the kernel can +// reference them as ordinary cert/key files. +func (m *Manager) startContent() error { + if m.cfg.CertContent == "" || m.cfg.KeyContent == "" { + return fmt.Errorf("cert_mode 'content' requires both cert_content and key_content") + } + if err := os.MkdirAll(filepath.Dir(m.certFile), 0o755); err != nil { + return fmt.Errorf("create cert dir: %w", err) + } + if err := atomicWriteFile(m.certFile, []byte(m.cfg.CertContent), 0o644); err != nil { + return fmt.Errorf("write cert content: %w", err) + } + if err := atomicWriteFile(m.keyFile, []byte(m.cfg.KeyContent), 0o600); err != nil { + return fmt.Errorf("write key content: %w", err) + } + slog.Info("TLS certificate written from panel content", "cert", m.certFile) + 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 { + slog.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.syncCertFiles(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) + } + + if err := magic.ManageAsync(ctx, []string{m.cfg.Domain}); err != nil { + return fmt.Errorf("start cert manager: %w", err) + } + + m.acmeStarted = true + 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 ─────────────────────────────────────────────────────────────── + +// syncCertFiles copies the cert and key from certmagic's storage to stable paths. +func (m *Manager) syncCertFiles(ctx context.Context, storage certmagic.Storage, data map[string]any) { + certKey, _ := data["certificate_path"].(string) + keyKey, _ := data["private_key_path"].(string) + if certKey == "" || keyKey == "" { + slog.Warn("cert_obtained event missing storage paths", "data", data) + return + } + + certPEM, err := storage.Load(ctx, certKey) + if err != nil { + slog.Error("failed to load cert from storage", "error", err) + return + } + keyPEM, err := storage.Load(ctx, keyKey) + if err != nil { + slog.Error("failed to load key from storage", "error", err) + return + } + + if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil { + slog.Error("failed to write cert file", "path", m.certFile, "error", err) + return + } + if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil { + slog.Error("failed to write key file", "path", m.keyFile, "error", err) + return + } + + renewal, _ := data["renewal"].(bool) + slog.Info("TLS certificate synced", "domain", m.cfg.Domain, "renewal", renewal, + "cert", m.certFile) + m.renewed.Store(true) +} + +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + return os.Rename(tmpName, path) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// firstOf returns the first non-empty value for any of the given keys in the map. +func firstOf(m map[string]string, keys ...string) string { + for _, k := range keys { + if v := m[k]; v != "" { + return v + } + } + return "" +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1859a50 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,370 @@ +package config + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strconv" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Panel PanelConfig `yaml:"panel"` + Node NodeConfig `yaml:"node"` + Kernel KernelConfig `yaml:"kernel"` + Cert CertConfig `yaml:"cert"` + Log LogConfig `yaml:"log"` + Runtime RuntimeConfig `yaml:"runtime"` + // 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"` +} + +// 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"` + NodeID int `yaml:"node_id"` + NodeType string `yaml:"node_type"` +} + +type NodeConfig struct { + PushInterval int `yaml:"push_interval"` + PullInterval int `yaml:"pull_interval"` +} + +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"` +} + +// 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) + } + + cfg.applyEnvOverrides() + cfg.setDefaults() + + 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 + } +} + +func (c *Config) setDefaults() { + if c.Kernel.Type == "" { + c.Kernel.Type = "singbox" + } + if c.Kernel.ConfigDir == "" { + c.Kernel.ConfigDir = "/etc/xboard-node" + } + 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 + } +} + +func (c *Config) validate() error { + if c.Panel.URL == "" { + return fmt.Errorf("panel.url is required") + } + if c.Panel.Token == "" { + return fmt.Errorf("panel.token is required") + } + // In multi-node mode panel.node_id is optional; validate each NodeEntry instead. + if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 { + return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)") + } + for i, n := range c.Nodes { + if n.NodeID <= 0 { + return fmt.Errorf("nodes[%d].node_id must be positive", i) + } + } + 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 InitLogger(cfg LogConfig) { + var level slog.Level + switch cfg.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + var w io.Writer + switch cfg.Output { + case "stdout", "": + w = os.Stdout + case "stderr": + w = os.Stderr + default: + dir := filepath.Dir(cfg.Output) + if err := os.MkdirAll(dir, 0o755); err != nil { + slog.Error("failed to create log dir, falling back to stdout", "error", err) + w = os.Stdout + } else { + f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + slog.Error("failed to open log file, falling back to stdout", "error", err) + w = os.Stdout + } else { + w = f + } + } + } + + handler := slog.NewTextHandler(w, &slog.HandlerOptions{Level: level}) + slog.SetDefault(slog.New(handler)) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5d44110 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,269 @@ +package config + +import ( + "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) + } + if cfg.Kernel.ConfigDir != "/etc/xboard-node" { + t.Errorf("default config_dir: got %q", cfg.Kernel.ConfigDir) + } + 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("/etc/xboard-node", "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) + } +} diff --git a/internal/kernel/customcfg.go b/internal/kernel/customcfg.go new file mode 100644 index 0000000..8826060 --- /dev/null +++ b/internal/kernel/customcfg.go @@ -0,0 +1,110 @@ +package kernel + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + + "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) { + slog.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) + } + } + + slog.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 +} diff --git a/internal/kernel/geo.go b/internal/kernel/geo.go new file mode 100644 index 0000000..4684242 --- /dev/null +++ b/internal/kernel/geo.go @@ -0,0 +1,31 @@ +package kernel + +import ( + "strings" + + "github.com/cedar2025/xboard-node/internal/panel" +) + +// NeedsGeoIP returns true when any panel route rule contains a "geoip:" match entry. +func NeedsGeoIP(routes []panel.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 []panel.RouteRule) bool { + for _, r := range routes { + for _, m := range r.Match { + if strings.HasPrefix(m, "geosite:") { + return true + } + } + } + return false +} diff --git a/internal/kernel/geodata/geodata.go b/internal/kernel/geodata/geodata.go new file mode 100644 index 0000000..31130d5 --- /dev/null +++ b/internal/kernel/geodata/geodata.go @@ -0,0 +1,105 @@ +package geodata + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "time" +) + +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 { + slog.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 + } + slog.Info("geo database missing, downloading automatically", "file", name, "url", url) + if err := atomicDownload(dst, url); err != nil { + return err + } + if fi, err := os.Stat(dst); err == nil { + slog.Info("geo database ready", "file", name, "size_kb", fi.Size()/1024) + } + 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 +} diff --git a/internal/kernel/kernel.go b/internal/kernel/kernel.go new file mode 100644 index 0000000..1522478 --- /dev/null +++ b/internal/kernel/kernel.go @@ -0,0 +1,42 @@ +package kernel + +import ( + "context" + + "github.com/cedar2025/xboard-node/internal/panel" + "golang.org/x/time/rate" +) + +// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.) +type Kernel interface { + // Name returns the kernel identifier (e.g. "sing-box") + Name() string + // ApplyConfig generates kernel config from panel data and (re)starts the process + ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error + // Stop gracefully stops the kernel process + Stop() + // IsRunning returns whether the kernel is currently active + IsRunning() bool + // GetConnections returns all active proxy connections + GetConnections(ctx context.Context) ([]Connection, error) + // CloseConnection terminates a specific connection by ID + CloseConnection(ctx context.Context, connID string) error + // SetSpeedLimitFunc configures optional per-user bandwidth throttling. + // The function resolves a user UUID to a *rate.Limiter (nil = unlimited). + // For sing-box, the limiter is embedded in the connection tracker wrapper + // so both byte counting and rate limiting happen in a single CountFunc + // callback, allowing splice/zero-copy paths. + SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) + // Reload hot-swaps the inbound users and routing rules without full kernel restart. + // Existing connections may be briefly interrupted, but routes/outbounds stay alive. + Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error +} + +// Connection represents an active proxy connection reported by the kernel +type Connection struct { + ID string + UserID int // extracted user ID (0 if unknown) + Upload int64 // cumulative upload bytes + Download int64 // cumulative download bytes + SourceIP string // client source IP (cleaned, no port/brackets) +} diff --git a/internal/kernel/singbox/config.go b/internal/kernel/singbox/config.go new file mode 100644 index 0000000..241ade9 --- /dev/null +++ b/internal/kernel/singbox/config.go @@ -0,0 +1,710 @@ +package singbox + +import ( + "fmt" + "log/slog" + "net" + "path/filepath" + "strconv" + "strings" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/panel" +) + +// M is a shorthand for building JSON-like maps +type M = map[string]interface{} + +func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + outbounds := []M{ + {"type": "direct", "tag": "direct"}, + {"type": "block", "tag": "block"}, + } + + // Panel-defined custom outbounds (structured, converted to sing-box native) + for _, co := range nc.CustomOutbounds { + outbounds = append(outbounds, outboundConfigToSingbox(co)) + } + // Static outbounds from local config file (already kernel-native format) + for _, co := range kcfg.CustomOutbound { + outbounds = append(outbounds, M(co)) + } + + cfg := M{ + "log": M{ + "level": kcfg.LogLevel, + "timestamp": true, + }, + "outbounds": outbounds, + } + + inbound := buildInbound(nc, users, certFile, keyFile) + if inbound != nil { + cfg["inbounds"] = []M{inbound} + } + + // Merge panel routes and static config routes + cfg["route"] = buildRoutes(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute)) + + // Automatically enable rule_set caching (cache_file) when panel routes + // reference geoip:/geosite: entries so that the downloaded .srs rule_set + // files survive across process restarts. + if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) { + cfg["experimental"] = M{ + "cache_file": M{ + "enabled": true, + "path": filepath.Join(kcfg.ConfigDir, "cache.db"), + }, + } + } + + mergeCustomSingbox(cfg, kcfg) + return cfg +} + +// outboundConfigToSingbox converts a structured OutboundConfig (from the panel) +// into a sing-box outbound object. sing-box uses a flat layout where all +// protocol-specific fields sit at the top level alongside "type" and "tag". +func outboundConfigToSingbox(oc panel.OutboundConfig) M { + m := M{ + "type": oc.Protocol, + "tag": oc.Tag, + } + + // Transform common protocol keys to sing-box native format + // WireGuard: secret_key -> private_key; peers: endpoint -> address+port + if oc.Protocol == "wireguard" { + if sk, ok := oc.Settings["secret_key"]; ok { + m["private_key"] = sk + } + if peers, ok := oc.Settings["peers"].([]any); ok { + var wgPeers []M + for _, p := range peers { + if peerMap, ok := p.(map[string]any); ok { + newPeer := M{} + for k, v := range peerMap { + if k == "endpoint" { + if ep, ok := v.(string); ok { + host, portStr, err := net.SplitHostPort(ep) + if err == nil { + newPeer["address"] = host + port, _ := strconv.Atoi(portStr) + newPeer["port"] = port + } else { + newPeer["address"] = ep + } + } + } else { + newPeer[k] = v + } + } + wgPeers = append(wgPeers, newPeer) + } + } + m["peers"] = wgPeers + } + + // Copy any other top-level settings not handled above + for k, v := range oc.Settings { + if k != "secret_key" && k != "peers" { + m[k] = v + } + } + } else { + for k, v := range oc.Settings { + m[k] = v + } + } + + if oc.ProxyTag != "" { + m["proxy_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 +} + +func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M { + var rules []M + + // Custom Routes (Panel-pushed or Local) go FIRST to have highest priority + for _, cr := range custom { + rules = append(rules, M(cr)) + } + + // Standard blocks for private/loopback + rules = append(rules, M{ + "outbound": "block", + "ip_cidr": []string{ + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "fc00::/7", + "fe80::/10", + "::1/128", + }, + }, M{ + "outbound": "block", + "domain": []string{ + "geoip:private", + }, + }) + + // Panel-defined routes (usually specific blocks/proxies) + for _, pr := range panelRoutes { + if len(pr.Match) == 0 { + continue + } + + var domains, cidrs []string + for _, m := range pr.Match { + m = strings.TrimSpace(m) + if m == "" { + continue + } + // Strip leading wildcard for domain matching + if strings.HasPrefix(m, "*.") { + m = strings.TrimPrefix(m, "*.") + } + // Check if it's a CIDR block (contains /) + if strings.Contains(m, "/") { + cidrs = append(cidrs, m) + } else { + // Otherwise treat as domain + domains = append(domains, m) + } + } + + // Determine outbound tag based on action + outbound := "block" + if pr.Action == "direct" { + outbound = "direct" + } else if pr.Action == "dns" { + // Special case for sing-box DNS routing: + // If action is "dns" and action_value is provided, we use it as the server name. + // Otherwise it defaults to "dns-out". + server := "dns-out" + if pr.ActionValue != "" { + server = pr.ActionValue + } + outbound = server + } else if pr.Action == "proxy" { + // If panel provides a specific outbound tag in action_value, use it. + // This allows routing to WARP_JP etc. via normal routes. + if pr.ActionValue != "" { + outbound = pr.ActionValue + } + } + + // Create separate rule for domains + if len(domains) > 0 { + rule := M{ + "domain_suffix": domains, + "outbound": outbound, + } + rules = append(rules, rule) + } + + // Create separate rule for CIDRs + if len(cidrs) > 0 { + rule := M{ + "ip_cidr": cidrs, + "outbound": outbound, + } + rules = append(rules, rule) + } + } + + return M{ + "final": "direct", + "rules": rules, + } +} + +func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) { + custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig) + if err != nil { + slog.Error("failed to load custom sing-box config", "error", err) + return + } + if custom == nil { + return + } + + // dns — custom replaces + if v, ok := custom["dns"]; ok { + cfg["dns"] = v + } + + // experimental — custom replaces + if v, ok := custom["experimental"]; ok { + cfg["experimental"] = v + } + + // outbounds — append custom entries + if v, ok := custom["outbounds"]; ok { + if existing, ok := cfg["outbounds"].([]M); ok { + cfg["outbounds"] = kernel.MergeAppendList(existing, v) + } + } + + // endpoints (sing-box 1.11+ wireguard etc.) — append + if v, ok := custom["endpoints"]; ok { + if existing, ok := cfg["endpoints"].([]M); ok { + cfg["endpoints"] = kernel.MergeAppendList(existing, v) + } else { + if items := kernel.MergeAppendList(nil, v); len(items) > 0 { + cfg["endpoints"] = items + } + } + } + + // route — merge sub-fields + if customRoute, ok := custom["route"]; ok { + if customRouteMap, ok := customRoute.(map[string]any); ok { + mergeCustomSingboxRoute(cfg, customRouteMap) + } + } +} + +func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) { + route, ok := cfg["route"].(M) + if !ok { + route = M{} + cfg["route"] = route + } + + // rules — custom rules prepended (so they match before panel rules) + if v, ok := customRoute["rules"]; ok { + if existing, ok := route["rules"].([]M); ok { + route["rules"] = kernel.MergePrependList(existing, v) + } + } + + // rule_set — custom rule_sets appended + if v, ok := customRoute["rule_set"]; ok { + if existing, ok := route["rule_set"].([]M); ok { + route["rule_set"] = kernel.MergeAppendList(existing, v) + } else { + route["rule_set"] = kernel.MergeAppendList(nil, v) + } + } + + // final, auto_detect_interface, default_interface, etc. — custom overrides + for k, v := range customRoute { + if k == "rules" || k == "rule_set" { + continue + } + route[k] = v + } +} + +func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base := M{ + "tag": nc.Protocol + "-in", + "listen": "::", + "listen_port": nc.ServerPort, + } + + switch nc.Protocol { + case "shadowsocks": + return buildShadowsocks(base, nc, users) + case "vmess": + return buildVMess(base, nc, users, certFile, keyFile) + case "vless": + return buildVLESS(base, nc, users, certFile, keyFile) + case "trojan": + return buildTrojan(base, nc, users, certFile, keyFile) + case "hysteria": + return buildHysteria(base, nc, users, certFile, keyFile) + case "tuic": + return buildTUIC(base, nc, users, certFile, keyFile) + case "anytls": + return buildAnyTLS(base, nc, users, certFile, keyFile) + case "naive": + return buildNaive(base, nc, users, certFile, keyFile) + case "socks": + return buildSocks(base, users) + case "http": + return buildHTTP(base, nc, users, certFile, keyFile) + default: + return nil + } +} + +func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M { + base["type"] = "shadowsocks" + base["method"] = nc.Cipher + + if strings.HasPrefix(nc.Cipher, "2022-blake3-") { + base["password"] = nc.ServerKey + } + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "name": u.UUID, + "password": u.UUID, + }) + } + base["users"] = userList + + if nc.Plugin != "" { + base["plugin"] = nc.Plugin + base["plugin_opts"] = nc.PluginOpt + } + + return base +} + +func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["type"] = "vmess" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "name": u.UUID, + "uuid": u.UUID, + "alterId": 0, + }) + } + base["users"] = userList + + applyTransport(base, nc) + if nc.TLS == 1 { + base["tls"] = buildTLSConfig(nc, certFile, keyFile) + } + + return base +} + +func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["type"] = "vless" + + userList := make([]M, 0, len(users)) + for _, u := range users { + user := M{ + "name": u.UUID, + "uuid": u.UUID, + } + if nc.Flow != "" { + user["flow"] = nc.Flow + } + userList = append(userList, user) + } + base["users"] = userList + + applyTransport(base, nc) + + if nc.TLS == 1 { + base["tls"] = buildTLSConfig(nc, certFile, keyFile) + } else if nc.TLS == 2 { + base["tls"] = buildRealityConfig(nc) + } + + return base +} + +func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["type"] = "trojan" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "name": u.UUID, + "password": u.UUID, + }) + } + base["users"] = userList + + applyTransport(base, nc) + if nc.TLS == 1 { + base["tls"] = buildTLSConfig(nc, certFile, keyFile) + } else if nc.TLS == 2 { + base["tls"] = buildRealityConfig(nc) + } + return base +} + +func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + if nc.Version == 2 { + base["type"] = "hysteria2" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "name": u.UUID, + "password": u.UUID, + }) + } + base["users"] = userList + + if nc.Obfs != "" { + base["obfs"] = M{ + "type": nc.Obfs, + "password": nc.ObfsPassword, + } + } + } else { + base["type"] = "hysteria" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "name": u.UUID, + "auth_str": u.UUID, + }) + } + base["users"] = userList + base["up_mbps"] = nc.UpMbps + base["down_mbps"] = nc.DownMbps + + if nc.Obfs != "" { + base["obfs"] = nc.Obfs + } + } + + tls := buildTLSConfig(nc, certFile, keyFile) + // Hysteria/Hysteria2 uses QUIC and requires ALPN; default to h3 if not set. + if _, ok := tls["alpn"]; !ok { + tls["alpn"] = []string{"h3"} + } + base["tls"] = tls + return base +} + +func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["type"] = "tuic" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "name": u.UUID, + "uuid": u.UUID, + "password": u.UUID, + }) + } + base["users"] = userList + + if nc.CongestionControl != "" { + base["congestion_control"] = nc.CongestionControl + } + + tls := buildTLSConfig(nc, certFile, keyFile) + // TUIC requires ALPN for QUIC negotiation; default to h3 if not set by panel. + if _, ok := tls["alpn"]; !ok { + tls["alpn"] = []string{"h3"} + } + base["tls"] = tls + return base +} + +func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["type"] = "anytls" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "name": u.UUID, + "password": u.UUID, + }) + } + base["users"] = userList + + if nc.PaddingScheme != "" { + base["padding_scheme"] = string(nc.PaddingScheme) + } + + base["tls"] = buildTLSConfig(nc, certFile, keyFile) + return base +} + +func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["type"] = "naive" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "username": strconv.Itoa(u.ID), + "password": u.UUID, + }) + } + base["users"] = userList + + if nc.TLS == 1 { + base["tls"] = buildTLSConfig(nc, certFile, keyFile) + } + return base +} + +func buildSocks(base M, users []panel.User) M { + base["type"] = "socks" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "username": u.UUID, + "password": u.UUID, + }) + } + base["users"] = userList + return base +} + +func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["type"] = "http" + + userList := make([]M, 0, len(users)) + for _, u := range users { + userList = append(userList, M{ + "username": u.UUID, + "password": u.UUID, + }) + } + base["users"] = userList + + if nc.TLS == 1 { + base["tls"] = buildTLSConfig(nc, certFile, keyFile) + } + return base +} + +func applyTransport(base M, nc *panel.NodeConfig) { + if nc.Network == "" || nc.Network == "tcp" { + return + } + + transport := M{"type": nc.Network} + + if nc.NetworkSettings != nil { + switch nc.Network { + case "ws": + if v, ok := nc.NetworkSettings["path"]; ok { + transport["path"] = v + } + if v, ok := nc.NetworkSettings["headers"]; ok { + transport["headers"] = v + } + if v, ok := nc.NetworkSettings["host"]; ok { + if transport["headers"] == nil { + transport["headers"] = M{"Host": v} + } + } + if v, ok := nc.NetworkSettings["max_early_data"]; ok { + transport["max_early_data"] = v + } + if v, ok := nc.NetworkSettings["early_data_header_name"]; ok { + transport["early_data_header_name"] = v + } + case "grpc": + if v, ok := nc.NetworkSettings["service_name"]; ok { + transport["service_name"] = v + } + case "httpupgrade": + if v, ok := nc.NetworkSettings["path"]; ok { + transport["path"] = v + } + if v, ok := nc.NetworkSettings["host"]; ok { + transport["host"] = v + } + case "h2", "http": + transport["type"] = "http" + if v, ok := nc.NetworkSettings["path"]; ok { + transport["path"] = v + } + if v, ok := nc.NetworkSettings["host"]; ok { + transport["host"] = v + } + } + } + + base["transport"] = transport +} + +func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M { + tls := M{"enabled": true} + + serverName := nc.ServerName + if serverName == "" && nc.Host != "" { + serverName = nc.Host + } + if serverName != "" { + tls["server_name"] = serverName + } + + if nc.TLSSettings != nil { + if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" { + tls["server_name"] = sn + } + if alpn, ok := nc.TLSSettings["alpn"]; ok { + tls["alpn"] = alpn + } + } + + if certFile != "" && keyFile != "" { + tls["certificate_path"] = certFile + tls["key_path"] = keyFile + } + + return tls +} + +func buildRealityConfig(nc *panel.NodeConfig) M { + tls := M{"enabled": true} + + if nc.TLSSettings == nil { + return tls + } + + reality := M{"enabled": true} + + if pk, ok := nc.TLSSettings["private_key"]; ok { + reality["private_key"] = pk + } + if sid, ok := nc.TLSSettings["short_id"]; ok { + switch v := sid.(type) { + case string: + reality["short_id"] = []string{v} + case []interface{}: + reality["short_id"] = v + } + } + + handshake := M{} + if dest, ok := nc.TLSSettings["dest"]; ok { + destStr := fmt.Sprintf("%v", dest) + parts := strings.SplitN(destStr, ":", 2) + handshake["server"] = parts[0] + if len(parts) == 2 { + if port, err := strconv.Atoi(parts[1]); err == nil { + handshake["server_port"] = port + } + } else { + handshake["server_port"] = 443 + } + } + if sn, ok := nc.TLSSettings["server_name"]; ok { + handshake["server"] = sn + if _, exists := handshake["server_port"]; !exists { + handshake["server_port"] = 443 + } + } + + if len(handshake) > 0 { + reality["handshake"] = handshake + } + + tls["reality"] = reality + return tls +} diff --git a/internal/kernel/singbox/config_test.go b/internal/kernel/singbox/config_test.go new file mode 100644 index 0000000..333edc2 --- /dev/null +++ b/internal/kernel/singbox/config_test.go @@ -0,0 +1,694 @@ +package singbox + +import ( + "encoding/json" + "testing" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/panel" +) + +var testUsers = []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 --- + +func TestBuildInbound_Shadowsocks(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "shadowsocks", + ServerPort: 111, + Cipher: "aes-128-gcm", + } + inbound := buildInbound(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm") + assertMapValue(t, inbound, "password", "base64serverkey==") +} + +func TestBuildInbound_ShadowsocksWithPlugin(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "shadowsocks", + ServerPort: 333, + Cipher: "aes-128-gcm", + Plugin: "obfs-local", + PluginOpt: "obfs=http;obfs-host=example.com", + } + inbound := buildInbound(nc, testUsers, "", "") + assertMapValue(t, inbound, "plugin", "obfs-local") + assertMapValue(t, inbound, "plugin_opts", "obfs=http;obfs-host=example.com") +} + +// --- VMess --- + +func TestBuildInbound_VMess(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "vmess", + ServerPort: 443, + } + inbound := buildInbound(nc, testUsers, "", "") + 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(nc, testUsers, "/path/cert.pem", "/path/key.pem") + tls := inbound["tls"].(M) + assertMapValue(t, tls, "enabled", true) + assertMapValue(t, tls, "server_name", "example.com") + assertMapValue(t, tls, "certificate_path", "/path/cert.pem") + assertMapValue(t, tls, "key_path", "/path/key.pem") +} + +func TestBuildInbound_VMess_NoTLS(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "vmess", + ServerPort: 80, + TLS: 0, + } + inbound := buildInbound(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + 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{}{ + "service_name": "mygrpc", + }, + } + inbound := buildInbound(nc, testUsers, "", "") + transport := inbound["transport"].(M) + assertMapValue(t, transport, "type", "grpc") + assertMapValue(t, transport, "service_name", "mygrpc") +} + +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(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + tls := inbound["tls"].(M) + reality := tls["reality"].(M) + ids := reality["short_id"].([]interface{}) + if len(ids) != 2 { + t.Errorf("short_id: got %d entries, want 2", len(ids)) + } +} + +// --- Trojan --- + +func TestBuildInbound_Trojan(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "trojan", + ServerPort: 443, + } + inbound := buildInbound(nc, testUsers, "", "") + assertMapValue(t, inbound, "type", "trojan") + + users := inbound["users"].([]M) + assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444") +} + +func TestBuildInbound_Trojan_NoTLS(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "trojan", + ServerPort: 80, + TLS: 0, + } + inbound := buildInbound(nc, testUsers, "", "") + if _, exists := inbound["tls"]; exists { + t.Error("trojan with tls=0 should not have TLS config") + } +} + +func TestBuildInbound_Trojan_WithTLS(t *testing.T) { + nc := &panel.NodeConfig{ + Protocol: "trojan", + ServerPort: 443, + TLS: 1, + ServerName: "example.com", + } + inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "", "") + 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(nc, testUsers, "", "") + 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(nc, testUsers, "/c.pem", "/k.pem") + 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(nc, testUsers, "", "") + 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, nc, testUsers, "", "") + + 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_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"}, tc.nc, testUsers, "/c.pem", "/k.pem") + 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) + 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(rules, 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(rules, 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") +} + +// --- TLS Config --- + +func TestBuildTLSConfig_WithCert(t *testing.T) { + nc := &panel.NodeConfig{ServerName: "example.com"} + tls := buildTLSConfig(nc, "/cert.pem", "/key.pem") + assertMapValue(t, tls, "enabled", true) + assertMapValue(t, tls, "server_name", "example.com") + assertMapValue(t, tls, "certificate_path", "/cert.pem") + assertMapValue(t, tls, "key_path", "/key.pem") +} + +func TestBuildTLSConfig_NoCert(t *testing.T) { + nc := &panel.NodeConfig{ServerName: "example.com"} + tls := buildTLSConfig(nc, "", "") + assertMapValue(t, tls, "enabled", true) + if _, exists := tls["certificate_path"]; exists { + t.Error("should not have certificate_path when empty") + } +} + +func TestBuildTLSConfig_FallbackToHost(t *testing.T) { + nc := &panel.NodeConfig{Host: "fallback.com"} + tls := buildTLSConfig(nc, "", "") + 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(nc, "", "") + assertMapValue(t, tls, "server_name", "override.com") +} + +// --- Transport --- + +func TestApplyTransport_TCP(t *testing.T) { + base := M{} + nc := &panel.NodeConfig{Network: "tcp"} + applyTransport(base, 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, 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, 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 val != expected { + t.Errorf("%s: got %v, want %v", key, val, expected) + } + } +} diff --git a/internal/kernel/singbox/conntracker.go b/internal/kernel/singbox/conntracker.go new file mode 100644 index 0000000..5e281b5 --- /dev/null +++ b/internal/kernel/singbox/conntracker.go @@ -0,0 +1,402 @@ +package singbox + +import ( + "context" + "io" + "log/slog" + "net" + "strconv" + "sync" + "sync/atomic" + + "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/kernel" +) + +// connRecord holds the minimal per-connection state we need. +// Each field is accessed by multiple goroutines; upload/download use atomics. +type connRecord struct { + id string + userID int + sourceIP string + upload atomic.Int64 + download atomic.Int64 + conn net.Conn // kept for force-close via CloseByID +} + +func (r *connRecord) toConnection() kernel.Connection { + return kernel.Connection{ + ID: r.id, + UserID: r.userID, + SourceIP: r.sourceIP, + Upload: r.upload.Load(), + Download: r.download.Load(), + } +} + +// ConnTracker is a lightweight in-process connection tracker that replaces +// the Clash API TrafficManager. It handles: +// - per-connection byte counting (upload / download) +// - optional per-user speed limiting (when SetSpeedLimitFunc is configured) +// - source IP tracking for device-limit enforcement +// - force-close a connection by ID +// +// Both byte counting and speed limiting are implemented via sing's CountFunc +// callback mechanism (ReadCounter / WriteCounter interfaces). This allows +// sing's copy pipeline to use splice / ReadWaiter zero-copy paths while +// still counting bytes and enforcing bandwidth limits through post-transfer +// callbacks. +// +// Memory model: +// - active: live connections, looked up by ID for snapshots and CloseByID +// - pending: closed connections accumulated since the last Snapshot call, +// drained atomically so the Tracker sees every connection's final bytes +// +// Thread safety: active is guarded by mu; pending by pendingMu. +type ConnTracker struct { + mu sync.RWMutex + active map[string]*connRecord + + // idCounter generates unique connection IDs without crypto/rand. + idCounter atomic.Int64 + + pendingMu sync.Mutex + pending []*connRecord + maxPending int + + userMapMu sync.RWMutex + userMap map[string]int // UUID → userID + + // speedLimitFunc resolves a user UUID to a *rate.Limiter. When set, + // new connections for users with a speed limit are automatically + // rate-limited without needing a second ConnectionTracker wrapper. + // The function must be safe for concurrent calls. + speedLimitFunc func(uuid string) *rate.Limiter +} + +// NewConnTracker creates a tracker. +func NewConnTracker(maxPending int) *ConnTracker { + if maxPending <= 0 { + maxPending = 50000 + } + return &ConnTracker{ + active: make(map[string]*connRecord), + maxPending: maxPending, + userMap: make(map[string]int), + } +} + +// SetSpeedLimitFunc configures the per-user speed limit lookup. +// fn is called for every new connection; it must return nil for unlimited users. +// Thread-safe: fn itself must be safe for concurrent calls. +func (t *ConnTracker) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) { + t.speedLimitFunc = fn +} + +// SetUserMap replaces the UUID→userID mapping. Must be called whenever the +// user list changes so that new connections are attributed to the correct user. +func (t *ConnTracker) SetUserMap(m map[string]int) { + t.userMapMu.Lock() + t.userMap = m + t.userMapMu.Unlock() +} + +// ─── adapter.ConnectionTracker ─────────────────────────────────────────────── + +// RoutedConnection wraps a TCP conn to count bytes, track lifecycle, +// and optionally rate-limit per-user bandwidth. +func (t *ConnTracker) RoutedConnection( + ctx context.Context, conn net.Conn, + metadata adapter.InboundContext, + _ adapter.Rule, _ adapter.Outbound, +) net.Conn { + rec := t.allocRecord(metadata, conn) + var lim *rate.Limiter + if t.speedLimitFunc != nil { + lim = t.speedLimitFunc(metadata.User) + } + return &trackedConn{Conn: conn, rec: rec, tracker: t, limiter: lim, ctx: ctx} +} + +// RoutedPacketConnection wraps a UDP PacketConn to count bytes, track lifecycle, +// and optionally rate-limit per-user bandwidth. +func (t *ConnTracker) RoutedPacketConnection( + ctx context.Context, conn N.PacketConn, + metadata adapter.InboundContext, + _ adapter.Rule, _ adapter.Outbound, +) N.PacketConn { + rec := t.allocRecord(metadata, nil) + var lim *rate.Limiter + if t.speedLimitFunc != nil { + lim = t.speedLimitFunc(metadata.User) + } + return &trackedPacketConn{PacketConn: conn, rec: rec, tracker: t, limiter: lim, ctx: ctx} +} + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +func (t *ConnTracker) allocRecord(metadata adapter.InboundContext, conn net.Conn) *connRecord { + // Use a fast atomic counter instead of crypto/rand UUID generation. + // Under connection floods (thousands of concurrent handshakes), uuid.NewV4() + // becomes a syscall bottleneck; a counter is allocation-free and lock-free. + id := strconv.FormatInt(t.idCounter.Add(1), 36) + t.userMapMu.RLock() + uid := t.userMap[metadata.User] + t.userMapMu.RUnlock() + rec := &connRecord{ + id: id, + userID: uid, + sourceIP: metadata.Source.Addr.String(), + conn: conn, + } + t.mu.Lock() + t.active[rec.id] = rec + t.mu.Unlock() + return rec +} + +// closeRecord is idempotent: if the record has already been moved to pending, +// the second call is a no-op. +func (t *ConnTracker) closeRecord(rec *connRecord) { + t.mu.Lock() + _, present := t.active[rec.id] + if present { + delete(t.active, rec.id) + } + t.mu.Unlock() + + if !present { + return + } + + t.pendingMu.Lock() + if len(t.pending) < t.maxPending { + t.pending = append(t.pending, rec) + } else { + slog.Warn("conntracker: pending buffer full, dropping closed connection", + "max_pending", t.maxPending, "user_id", rec.userID) + } + t.pendingMu.Unlock() +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +// Snapshot returns a point-in-time view of all active connections plus all +// connections that closed since the previous Snapshot call. The pending +// (closed) list is drained atomically so callers never miss a connection. +func (t *ConnTracker) Snapshot() []kernel.Connection { + // Drain pending under its own lock (hot path for closed connections). + t.pendingMu.Lock() + pending := t.pending + t.pending = nil + t.pendingMu.Unlock() + + // Two-phase read to minimise RLock hold time at high connection counts: + // Phase 1 (under RLock): collect record *pointers* — a pointer copy is + // ~8 bytes vs ~80 bytes for a full Connection struct, and no atomic loads. + // Phase 2 (lock-free): call toConnection() which atomically reads the + // upload/download counters. Atomics are safe without any mutex. + // This shrinks the RLock window proportionally as connection count grows, + // reducing serialisation against concurrent allocRecord/closeRecord calls. + t.mu.RLock() + ptrs := make([]*connRecord, 0, len(t.active)) + for _, rec := range t.active { + ptrs = append(ptrs, rec) + } + t.mu.RUnlock() + + result := make([]kernel.Connection, 0, len(ptrs)+len(pending)) + for _, rec := range ptrs { + result = append(result, rec.toConnection()) + } + for _, rec := range pending { + result = append(result, rec.toConnection()) + } + return result +} + +// CloseByID force-closes a live connection by its ID. +// Returns true if the connection was found and closed. +func (t *ConnTracker) CloseByID(id string) bool { + t.mu.RLock() + rec, ok := t.active[id] + t.mu.RUnlock() + if !ok { + return false + } + if rec.conn != nil { + rec.conn.Close() + } + return true +} + +// ActiveCount returns the number of currently tracked live connections. +func (t *ConnTracker) ActiveCount() int { + t.mu.RLock() + defer t.mu.RUnlock() + return len(t.active) +} + +// ─── trackedConn (TCP) ─────────────────────────────────────────────────────── + +type trackedConn struct { + net.Conn + rec *connRecord + tracker *ConnTracker + limiter *rate.Limiter // nil = unlimited + ctx context.Context // for rate limiter WaitN cancellation +} + +// Read is the fallback path used when the copy pipeline cannot unwrap this +// wrapper (should not happen with current code, but kept for robustness). +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 { + c.rec.download.Add(int64(n)) + if c.limiter != nil { + waitN := min(n, c.limiter.Burst()) + _ = c.limiter.WaitN(c.ctx, waitN) + } + } + return n, err +} + +// Write is the fallback path. +func (c *trackedConn) Write(b []byte) (int, error) { + if c.limiter != nil { + burst := c.limiter.Burst() + var n int + for n < len(b) { + chunk := min(len(b)-n, burst) + _ = c.limiter.WaitN(c.ctx, chunk) + written, writeErr := c.Conn.Write(b[n : n+chunk]) + n += written + c.rec.upload.Add(int64(written)) + if writeErr != nil { + return n, writeErr + } + } + return n, nil + } + n, err := c.Conn.Write(b) + if n > 0 { + c.rec.upload.Add(int64(n)) + } + return n, err +} + +func (c *trackedConn) Close() error { + c.tracker.closeRecord(c.rec) + return c.Conn.Close() +} + +// makeCountFunc builds a CountFunc that does byte accounting and optional +// rate limiting in a single callback. The direction is selected by the +// counter pointer (download or upload). +func (c *trackedConn) makeCountFunc(counter *atomic.Int64) N.CountFunc { + if c.limiter == nil { + // Fast path: no rate limiting, just atomic add. + return func(n int64) { counter.Add(n) } + } + return func(n int64) { + counter.Add(n) + waitN := int(n) + if burst := c.limiter.Burst(); waitN > burst { + waitN = burst + } + _ = c.limiter.WaitN(c.ctx, waitN) + } +} + +// UnwrapReader implements N.ReadCounter — lets the copy pipeline strip this +// wrapper, collect a counter callback, and reach the inner conn for +// splice / ReadWaiter zero-copy transfer. +func (c *trackedConn) UnwrapReader() (io.Reader, []N.CountFunc) { + return c.Conn, []N.CountFunc{c.makeCountFunc(&c.rec.download)} +} + +// UnwrapWriter implements N.WriteCounter. +func (c *trackedConn) UnwrapWriter() (io.Writer, []N.CountFunc) { + return c.Conn, []N.CountFunc{c.makeCountFunc(&c.rec.upload)} +} + +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 + rec *connRecord + tracker *ConnTracker + limiter *rate.Limiter + ctx context.Context +} + +func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, error) { + dest, err := c.PacketConn.ReadPacket(buffer) + if err == nil { + n := int64(buffer.Len()) + c.rec.download.Add(n) + if c.limiter != nil { + waitN := int(min(n, int64(c.limiter.Burst()))) + _ = c.limiter.WaitN(c.ctx, waitN) + } + } + return dest, err +} + +func (c *trackedPacketConn) WritePacket(buffer *buf.Buffer, dest singM.Socksaddr) error { + n := int64(buffer.Len()) + err := c.PacketConn.WritePacket(buffer, dest) + if err == nil { + c.rec.upload.Add(n) + if c.limiter != nil { + waitN := int(min(n, int64(c.limiter.Burst()))) + _ = c.limiter.WaitN(c.ctx, waitN) + } + } + return err +} + +func (c *trackedPacketConn) Close() error { + c.tracker.closeRecord(c.rec) + 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) + waitN := int(n) + if burst := c.limiter.Burst(); waitN > burst { + waitN = burst + } + _ = c.limiter.WaitN(c.ctx, waitN) + } +} + +func (c *trackedPacketConn) UnwrapPacketReader() (N.PacketReader, []N.CountFunc) { + return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.rec.download)} +} + +func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) { + return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.rec.upload)} +} + +func (c *trackedPacketConn) Upstream() any { return c.PacketConn } +func (c *trackedPacketConn) ReaderReplaceable() bool { return true } +func (c *trackedPacketConn) WriterReplaceable() bool { return true } diff --git a/internal/kernel/singbox/singbox.go b/internal/kernel/singbox/singbox.go new file mode 100644 index 0000000..71733ff --- /dev/null +++ b/internal/kernel/singbox/singbox.go @@ -0,0 +1,348 @@ +package singbox + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "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/panel" +) + +// 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. +// +// Connection tracking is done entirely in-process via ConnTracker, which is +// registered as an adapter.ConnectionTracker with sing-box's router. This +// avoids the HTTP/JSON overhead of the Clash API (saves 5-15 MB at 500+ +// concurrent connections). +type SingBox struct { + cfg config.KernelConfig + + mu sync.Mutex + box *box.Box + ctx context.Context + cancel context.CancelFunc + + // connTracker is our lightweight in-process byte/IP tracker. + // Created fresh on every ApplyConfig (full restart). + // Survives ReloadUsers (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 + + // trackerRegistered prevents duplicate AppendTracker calls on the same + // Router instance during ReloadUsers. Reset to false on full restart. + trackerRegistered bool +} + +func New(cfg config.KernelConfig) *SingBox { + return &SingBox{cfg: cfg} +} + +func (s *SingBox) Name() string { return "sing-box" } + +func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { + s.mu.Lock() + defer s.mu.Unlock() + + cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile) + data, err := json.Marshal(cfgMap) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + slog.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) + } + + s.stop() + + 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) + } + + s.box = instance + s.ctx = ctx + s.cancel = cancel + + // Fresh tracker on full restart (all connections were terminated by s.stop). + s.connTracker = NewConnTracker(0) + s.connTracker.SetUserMap(buildUserMap(users)) + if s.speedLimitFunc != nil { + s.connTracker.SetSpeedLimitFunc(s.speedLimitFunc) + } + + s.trackerRegistered = false + s.registerTracker(ctx) + + slog.Info("sing-box started (in-process)", "users", len(users)) + return nil +} + +// Reload hot-swaps the inbound users and routing rules without restarting the box. +// Routes, outbounds, and the connTracker stay alive so in-flight connections +// continue to be tracked correctly. +func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.box == nil { + return fmt.Errorf("not running") + } + + cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile) + 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 { + slog.Warn("routing reload failed", "error", err) + } else { + slog.Info("sing-box routing reloaded") + } + + 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[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 + } + slog.Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err) + } + + // 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)) + } + + slog.Info("sing-box reloaded (users and routes hot-swapped)", "users", len(users)) + 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. +// The function is forwarded to the ConnTracker, which embeds the rate limiter +// in each tracked connection wrapper. This merges byte counting and rate limiting +// into a single CountFunc callback, allowing splice/zero-copy paths. +func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) { + s.mu.Lock() + defer s.mu.Unlock() + s.speedLimitFunc = fn + if s.connTracker != nil { + s.connTracker.SetSpeedLimitFunc(fn) + } +} + +// GetConnections returns a snapshot of all active and recently-closed +// connections directly from the in-process ConnTracker. +func (s *SingBox) GetConnections(_ context.Context) ([]kernel.Connection, error) { + ct := s.connTrackerSafe() + if ct == nil { + return nil, nil + } + conns := ct.Snapshot() + slog.Debug("GetConnections snapshot", "count", len(conns)) + return conns, 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) { + slog.Debug("CloseConnection: connection not found (already closed?)", "id", connID) + } + return nil +} + +// buildUserMap creates a UUID→userID mapping used by ConnTracker to attribute +// connections to the correct user. All sing-box protocols use the user's UUID +// as the inbound name/username, so this covers every protocol. +func buildUserMap(users []panel.User) map[string]int { + m := make(map[string]int, len(users)) + for _, u := range users { + m[u.UUID] = u.ID + } + return m +} diff --git a/internal/kernel/xray/config.go b/internal/kernel/xray/config.go new file mode 100644 index 0000000..45bc6e4 --- /dev/null +++ b/internal/kernel/xray/config.go @@ -0,0 +1,553 @@ +package xray + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/panel" +) + +// M is a shorthand for building JSON-like maps +type M = map[string]interface{} + +func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + outbounds := []M{ + {"protocol": "freedom", "tag": "direct"}, + {"protocol": "blackhole", "tag": "block"}, + } + + // Panel-defined custom outbounds (structured, converted to Xray native) + for _, co := range nc.CustomOutbounds { + outbounds = append(outbounds, outboundConfigToXray(co)) + } + // Static outbounds from local config file (already kernel-native format) + for _, co := range kcfg.CustomOutbound { + outbounds = append(outbounds, M(co)) + } + + cfg := M{ + "log": M{ + "loglevel": xrayLogLevel(kcfg.LogLevel), + }, + "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, certFile, keyFile) + if inbound != nil { + cfg["inbounds"] = []M{inbound} + } else { + slog.Warn("xray: unsupported protocol, no inbound configured — node will not accept connections", + "protocol", nc.Protocol, + "supported", "vmess, vless, trojan, shadowsocks, socks, http") + } + + // Merge panel routes and static config routes + cfg["routing"] = buildRouting(nc.Routes, 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 panel.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 { + slog.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 *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + listenAddr := "::" + if nc.ListenIP != "" { + listenAddr = nc.ListenIP + } + base := M{ + "tag": nc.Protocol + "-in", + "listen": listenAddr, + "port": nc.ServerPort, + "protocol": nc.Protocol, + } + + switch nc.Protocol { + case "vmess": + return buildVMess(base, nc, users, certFile, keyFile) + case "vless": + return buildVLESS(base, nc, users, certFile, keyFile) + case "trojan": + return buildTrojan(base, nc, users, certFile, keyFile) + case "shadowsocks": + return buildShadowsocks(base, nc, users) + case "socks": + return buildSocks(base, users) + case "http": + return buildHTTP(base, nc, users, certFile, keyFile) + default: + return nil + } +} + +// userEmail returns the stats-tracking email for a user. +// Format: "user@" 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 *panel.NodeConfig, users []panel.User, certFile, keyFile string) 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, certFile, keyFile) + return base +} + +func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) 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) + } + base["settings"] = M{ + "clients": clients, + "decryption": "none", + } + + applyStreamSettings(base, nc, certFile, keyFile) + return base +} + +func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + clients := make([]M, 0, len(users)) + for _, u := range users { + clients = append(clients, M{ + "password": u.UUID, + "email": userEmail(u.ID), + }) + } + base["settings"] = M{"clients": clients} + + applyStreamSettings(base, nc, certFile, keyFile) + return base +} + +func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M { + // Single-user mode for traditional ciphers + if !strings.HasPrefix(nc.Cipher, "2022-blake3-") { + if len(users) > 0 { + base["settings"] = M{ + "method": nc.Cipher, + "password": users[0].UUID, + "email": userEmail(users[0].ID), + "network": "tcp,udp", + } + } + return base + } + + // Multi-user mode for 2022-blake3 ciphers + clients := make([]M, 0, len(users)) + for _, u := range users { + clients = append(clients, M{ + "password": u.UUID, + "email": userEmail(u.ID), + }) + } + base["settings"] = M{ + "method": nc.Cipher, + "password": nc.ServerKey, + "clients": clients, + "network": "tcp,udp", + } + return base +} + +func buildSocks(base M, users []panel.User) M { + base["protocol"] = "socks" + accounts := make([]M, 0, len(users)) + for _, u := range users { + accounts = append(accounts, M{ + "user": u.UUID, + "pass": u.UUID, + }) + } + base["settings"] = M{ + "auth": "password", + "accounts": accounts, + "udp": true, + } + return base +} + +func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M { + base["protocol"] = "http" + accounts := make([]M, 0, len(users)) + for _, u := range users { + accounts = append(accounts, M{ + "user": u.UUID, + "pass": u.UUID, + }) + } + base["settings"] = M{ + "accounts": accounts, + } + + if nc.TLS == 1 { + applyStreamSettings(base, nc, certFile, keyFile) + } + return base +} + +func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) { + 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["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 + } + } + if certFile != "" && keyFile != "" { + tlsSettings["certificates"] = []M{ + { + "certificateFile": certFile, + "keyFile": keyFile, + }, + } + } + ss["security"] = "tls" + ss["tlsSettings"] = tlsSettings + } else if nc.TLS == 2 { + ss["security"] = "reality" + ss["realitySettings"] = buildRealitySettings(nc) + } + + base["streamSettings"] = ss +} + +func buildRealitySettings(nc *panel.NodeConfig) 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 []panel.RouteRule, customRules []map[string]any) M { + var xrayRules []M + + // Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority + 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 { + if len(rule.Match) == 0 { + continue + } + + var ips, domains []string + for _, m := range rule.Match { + m = strings.TrimSpace(m) + if m == "" { + continue + } + if strings.HasPrefix(m, "geoip:") { + ips = append(ips, m) // "geoip:cn" → ip field, passed as-is + } else if strings.HasPrefix(m, "geosite:") { + domains = append(domains, m) // "geosite:google" → domain field, passed as-is + } else if strings.Contains(m, "/") { + ips = append(ips, m) + } else { + m = strings.TrimPrefix(m, "*.") + domains = append(domains, "domain:"+m) + } + } + + outbound := "block" + if rule.Action == "direct" { + outbound = "direct" + } else if rule.Action == "proxy" && rule.ActionValue != "" { + outbound = rule.ActionValue + } + + if len(domains) > 0 { + xrayRules = append(xrayRules, M{ + "type": "field", + "domain": domains, + "outboundTag": outbound, + }) + } + if len(ips) > 0 { + xrayRules = append(xrayRules, M{ + "type": "field", + "ip": ips, + "outboundTag": outbound, + }) + } + } + + return M{ + "domainStrategy": "AsIs", + "rules": xrayRules, + } +} diff --git a/internal/kernel/xray/config_test.go b/internal/kernel/xray/config_test.go new file mode 100644 index 0000000..e27b966 --- /dev/null +++ b/internal/kernel/xray/config_test.go @@ -0,0 +1,364 @@ +package xray + +import ( + "encoding/json" + "testing" + + "github.com/cedar2025/xboard-node/internal/config" + "github.com/cedar2025/xboard-node/internal/panel" +) + +var testKernelCfg = config.KernelConfig{ + Type: "xray", + LogLevel: "warn", +} + +var testUsers = []panel.User{ + {ID: 1, UUID: "279d4f89-3a2c-488d-a67c-2d39a72acdde"}, + {ID: 5, UUID: "4d5965c8-a60c-452a-a943-af83ec0bb0db"}, +} + +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, &tc.nc, testUsers, "/cert.pem", "/key.pem") + + 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, &nc, testUsers, "", "") + 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, &nc, testUsers, "", "") + 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) + 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(rules, 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 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, &nc, testUsers, "", "") + 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_SingleUser(t *testing.T) { + nc := panel.NodeConfig{ + Protocol: "shadowsocks", + ServerPort: 8388, + Cipher: "aes-128-gcm", + } + cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "") + 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{}) + + // Single-user mode: password directly, no clients array + if settings["method"] != "aes-128-gcm" { + t.Errorf("expected method aes-128-gcm, got %v", settings["method"]) + } + if settings["password"] != testUsers[0].UUID { + t.Errorf("expected password %s, got %v", testUsers[0].UUID, settings["password"]) + } +} + +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, &nc, testUsers, "", "") + 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)) + } +} diff --git a/internal/kernel/xray/dispatcher.go b/internal/kernel/xray/dispatcher.go new file mode 100644 index 0000000..54c71cf --- /dev/null +++ b/internal/kernel/xray/dispatcher.go @@ -0,0 +1,428 @@ +package xray + +import ( + "context" + "errors" + "log/slog" + "reflect" + "sort" + "strconv" + "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" + "golang.org/x/time/rate" + + "github.com/cedar2025/xboard-node/internal/kernel" +) + +// 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, + conns: make(map[string]*dispatchedConn), + userIPs: make(map[string]map[string]int), + } + globalLimitDispatcher.Store(ld) + slog.Debug("xray: limit dispatcher installed") + return ld, nil +} + +// LimitDispatcher wraps xray's DefaultDispatcher to add per-user device +// limit enforcement, speed limiting, and per-connection source IP tracking. +// It hooks into Dispatch/DispatchLink which are called AFTER the protocol +// handler has identified the user, so it works for ALL protocols. +type LimitDispatcher struct { + inner interface{} // original DefaultDispatcher (Feature + Dispatcher) + innerDisp routing.Dispatcher // same object, typed as Dispatcher + + mu sync.RWMutex + conns map[string]*dispatchedConn // connID → conn + userIPs map[string]map[string]int // email → sourceIP → count + deviceLimits map[string]int // email → max devices + speedLimits map[string]int // email → Mbps + speedBuckets sync.Map // email → *rate.Limiter + emailToUID map[string]int // email → panel user ID + + connIDSeq atomic.Uint64 +} + +type dispatchedConn struct { + id string + email string + sourceIP string + userID int + upload atomic.Int64 + download atomic.Int64 + closed atomic.Bool +} + +// ─── 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.wrapLink(ctx, 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.wrapLink(ctx, 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) { + slog.Info("xray: device limit exceeded", "email", email, "ip", sourceIP) + return "", "", false, errors.New("device limit exceeded for " + email) + } + return email, sourceIP, isTCP, nil +} + +// wrapLink instruments a transport.Link with per-connection byte counting, +// rate limiting, and lifecycle tracking. Must only be called when email != "". +func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, email, sourceIP string, isTCP bool) { + connID := "xd-" + strconv.FormatUint(d.connIDSeq.Add(1), 36) + dc := &dispatchedConn{ + id: connID, + email: email, + sourceIP: sourceIP, + userID: d.getUID(email), + } + + d.mu.Lock() + d.conns[connID] = dc + d.mu.Unlock() + + onClose := func() { + if dc.closed.CompareAndSwap(false, true) { + if isTCP { + d.delConn(email, sourceIP) + } + d.mu.Lock() + delete(d.conns, connID) + d.mu.Unlock() + } + } + + limiter := d.getBucket(email) + + link.Reader = &statsCloseReader{ + Reader: link.Reader, + counter: &dc.upload, + limiter: limiter, + ctx: ctx, + } + link.Writer = &statsCloseWriter{ + Writer: link.Writer, + counter: &dc.download, + limiter: limiter, + ctx: ctx, + 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, speedLimits map[string]int) { + d.mu.Lock() + d.emailToUID = emailToUID + d.deviceLimits = deviceLimits + d.speedLimits = speedLimits + d.mu.Unlock() + + d.speedBuckets.Range(func(key, _ interface{}) bool { + if _, ok := speedLimits[key.(string)]; !ok { + d.speedBuckets.Delete(key) + } + return true + }) +} + +func (d *LimitDispatcher) ResetConns() { + d.mu.Lock() + d.conns = make(map[string]*dispatchedConn) + d.userIPs = make(map[string]map[string]int) + d.mu.Unlock() + + d.speedBuckets.Range(func(key, _ interface{}) bool { + d.speedBuckets.Delete(key) + return true + }) +} + +func (d *LimitDispatcher) Snapshot() []kernel.Connection { + d.mu.RLock() + defer d.mu.RUnlock() + + result := make([]kernel.Connection, 0, len(d.conns)) + for _, c := range d.conns { + result = append(result, kernel.Connection{ + ID: c.id, + UserID: c.userID, + SourceIP: c.sourceIP, + Upload: c.upload.Load(), + Download: c.download.Load(), + }) + } + return result +} + +func (d *LimitDispatcher) CloseConn(id string) bool { + d.mu.RLock() + c, ok := d.conns[id] + d.mu.RUnlock() + if !ok { + return false + } + c.closed.Store(true) + d.delConn(c.email, c.sourceIP) + d.mu.Lock() + delete(d.conns, id) + d.mu.Unlock() + return true +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) bool { + d.mu.Lock() + defer d.mu.Unlock() + + limit, hasLimit := d.deviceLimits[email] + if !hasLimit || limit <= 0 { + if isTCP { + if d.userIPs[email] == nil { + d.userIPs[email] = make(map[string]int) + } + d.userIPs[email][sourceIP]++ + } + return false + } + + ips := d.userIPs[email] + if ips == nil { + ips = make(map[string]int) + d.userIPs[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 +} + +func (d *LimitDispatcher) delConn(email, sourceIP string) { + d.mu.Lock() + defer d.mu.Unlock() + if ips, ok := d.userIPs[email]; ok { + ips[sourceIP]-- + if ips[sourceIP] <= 0 { + delete(ips, sourceIP) + } + if len(ips) == 0 { + delete(d.userIPs, email) + } + } +} + +func (d *LimitDispatcher) getUID(email string) int { + d.mu.RLock() + defer d.mu.RUnlock() + return d.emailToUID[email] +} + +func (d *LimitDispatcher) getBucket(email string) *rate.Limiter { + d.mu.RLock() + mbps, ok := d.speedLimits[email] + d.mu.RUnlock() + if !ok || mbps <= 0 { + return nil + } + bytesPerSec := int(mbps) * 1_000_000 / 8 + burst := bytesPerSec + if burst < 64*1024 { + burst = 64 * 1024 + } + v, loaded := d.speedBuckets.LoadOrStore(email, rate.NewLimiter(rate.Limit(bytesPerSec), burst)) + lim := v.(*rate.Limiter) + if loaded { + // Update existing limiter in case speed limit changed. + lim.SetLimit(rate.Limit(bytesPerSec)) + lim.SetBurst(burst) + } + return lim +} + +// ─── I/O wrappers ─────────────────────────────────────────────────────────── + +type statsCloseReader struct { + buf.Reader + counter *atomic.Int64 + limiter *rate.Limiter + ctx context.Context +} + +func (r *statsCloseReader) ReadMultiBuffer() (buf.MultiBuffer, error) { + mb, err := r.Reader.ReadMultiBuffer() + if n := int64(mb.Len()); n > 0 { + r.counter.Add(n) + if r.limiter != nil { + waitN := int(n) + if burst := r.limiter.Burst(); waitN > burst { + waitN = burst + } + _ = r.limiter.WaitN(r.ctx, waitN) + } + } + return mb, err +} + +func (r *statsCloseReader) Close() error { return common.Close(r.Reader) } +func (r *statsCloseReader) Interrupt() { common.Interrupt(r.Reader) } + +type statsCloseWriter struct { + buf.Writer + counter *atomic.Int64 + limiter *rate.Limiter + ctx context.Context + onClose func() + closed atomic.Bool +} + +func (w *statsCloseWriter) WriteMultiBuffer(mb buf.MultiBuffer) error { + n := int64(mb.Len()) + if w.limiter != nil { + waitN := int(n) + if burst := w.limiter.Burst(); waitN > burst { + waitN = burst + } + _ = w.limiter.WaitN(w.ctx, waitN) + } + w.counter.Add(n) + return w.Writer.WriteMultiBuffer(mb) +} + +func (w *statsCloseWriter) Close() error { + if w.closed.CompareAndSwap(false, true) { + w.onClose() + } + return common.Close(w.Writer) +} + +func (w *statsCloseWriter) Interrupt() { + if w.closed.CompareAndSwap(false, true) { + w.onClose() + } + common.Interrupt(w.Writer) +} diff --git a/internal/kernel/xray/dispatcher_test.go b/internal/kernel/xray/dispatcher_test.go new file mode 100644 index 0000000..91c9d3a --- /dev/null +++ b/internal/kernel/xray/dispatcher_test.go @@ -0,0 +1,204 @@ +package xray + +import ( + "testing" + + "github.com/cedar2025/xboard-node/internal/panel" +) + +func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) { + ld := &LimitDispatcher{ + conns: make(map[string]*dispatchedConn), + userIPs: make(map[string]map[string]int), + } + + users := []panel.User{ + {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 := &LimitDispatcher{ + conns: make(map[string]*dispatchedConn), + userIPs: make(map[string]map[string]int), + } + + 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_SpeedBucket(t *testing.T) { + ld := &LimitDispatcher{ + conns: make(map[string]*dispatchedConn), + userIPs: make(map[string]map[string]int), + } + + email1 := userEmail(1) + email2 := userEmail(2) + + speedLimits := map[string]int{email1: 10} // 10 Mbps + ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, speedLimits) + + // User 1 has speed limit — should get a limiter + limiter := ld.getBucket(email1) + if limiter == nil { + t.Error("user with speed limit should get a limiter") + } + + // Same user should get the same limiter (cached) + limiter2 := ld.getBucket(email1) + if limiter != limiter2 { + t.Error("same user should get cached limiter") + } + + // User 2 has no speed limit — should get nil + limiter3 := ld.getBucket(email2) + if limiter3 != nil { + t.Error("user without speed limit should get nil") + } +} + +func TestLimitDispatcher_Snapshot(t *testing.T) { + ld := &LimitDispatcher{ + conns: make(map[string]*dispatchedConn), + userIPs: make(map[string]map[string]int), + } + + // Add some connections + ld.conns["conn-1"] = &dispatchedConn{ + id: "conn-1", email: "user@1", sourceIP: "1.1.1.1", userID: 1, + } + ld.conns["conn-2"] = &dispatchedConn{ + id: "conn-2", email: "user@2", sourceIP: "2.2.2.2", userID: 2, + } + ld.conns["conn-1"].upload.Store(1000) + ld.conns["conn-1"].download.Store(2000) + + snapshot := ld.Snapshot() + if len(snapshot) != 2 { + t.Fatalf("expected 2 connections, got %d", len(snapshot)) + } + + found := false + for _, c := range snapshot { + if c.ID == "conn-1" { + found = true + if c.UserID != 1 || c.SourceIP != "1.1.1.1" || c.Upload != 1000 || c.Download != 2000 { + t.Errorf("unexpected connection data: %+v", c) + } + } + } + if !found { + t.Error("conn-1 not found in snapshot") + } +} + +func TestLimitDispatcher_CloseConn(t *testing.T) { + ld := &LimitDispatcher{ + conns: make(map[string]*dispatchedConn), + userIPs: make(map[string]map[string]int), + } + + email := userEmail(1) + ld.userIPs[email] = map[string]int{"1.1.1.1": 1} + ld.conns["conn-1"] = &dispatchedConn{ + id: "conn-1", email: email, sourceIP: "1.1.1.1", userID: 1, + } + + ok := ld.CloseConn("conn-1") + if !ok { + t.Error("CloseConn should return true for existing connection") + } + + if len(ld.conns) != 0 { + t.Error("connection should be removed after close") + } + + ok = ld.CloseConn("nonexistent") + if ok { + t.Error("CloseConn should return false for nonexistent connection") + } +} + +func TestLimitDispatcher_ResetConns(t *testing.T) { + ld := &LimitDispatcher{ + conns: make(map[string]*dispatchedConn), + userIPs: make(map[string]map[string]int), + } + ld.conns["c1"] = &dispatchedConn{id: "c1"} + ld.userIPs["user@1"] = map[string]int{"1.1.1.1": 1} + + ld.ResetConns() + + if len(ld.conns) != 0 { + t.Error("conns should be empty after reset") + } + if len(ld.userIPs) != 0 { + t.Error("userIPs should be empty after reset") + } +} diff --git a/internal/kernel/xray/xray.go b/internal/kernel/xray/xray.go new file mode 100644 index 0000000..030de9f --- /dev/null +++ b/internal/kernel/xray/xray.go @@ -0,0 +1,331 @@ +package xray + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "log/slog" + "os" + "sort" + "sync" + "time" + + xrayCore "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/features/stats" + "github.com/xtls/xray-core/infra/conf/serial" + "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/panel" +) + +// drainTimeout is how long stop() waits for in-flight connections to finish +// naturally before hard-killing the xray instance. +const drainTimeout = 5 * time.Second + +// Xray implements kernel.Kernel by embedding xray-core as a Go library. +// +// Device limits and speed limits are enforced through a custom dispatcher +// wrapper (LimitDispatcher) that hooks into session.InboundFromContext(ctx) +// — the same approach used by V2bX and XrayR. This works for ALL protocols +// because the dispatcher is called AFTER the protocol handler has identified +// the user and recorded the source IP in the session context. +type Xray struct { + cfg config.KernelConfig + + mu sync.Mutex + instance *xrayCore.Instance + users []panel.User + protocol string + + inboundTag string + + // limitDispatcher is the per-instance LimitDispatcher created during + // xrayCore.New(). Stored here (instead of using the global pointer) + // so that multi-node mode with multiple Xray instances works correctly. + limitDispatcher *LimitDispatcher + + // lastKernelHash caches a hash of the kernel-affecting config + user + // identities. Used by Reload to skip unnecessary full restarts. + lastKernelHash string + + // cumTraffic keeps running totals for the aggregate stats fallback. + cumTraffic map[int][2]int64 +} + +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) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { + x.mu.Lock() + defer x.mu.Unlock() + + // Auto-download geo databases and set XRAY_LOCATION_ASSET when panel + // routes reference geoip:/geosite: entries. + if kernel.NeedsGeoIP(nodeConfig.Routes) || kernel.NeedsGeoSite(nodeConfig.Routes) { + geoDataDir := x.cfg.GeoDataDir + if err := geodata.Ensure(geoDataDir, + kernel.NeedsGeoIP(nodeConfig.Routes), + kernel.NeedsGeoSite(nodeConfig.Routes), + "xray", + ); err != nil { + slog.Warn("geo database unavailable; geoip/geosite rules may not match", "error", err) + } + os.Setenv("XRAY_LOCATION_ASSET", geoDataDir) + } + + cfgMap := buildConfig(x.cfg, nodeConfig, users, certFile, keyFile) + data, err := json.Marshal(cfgMap) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + slog.Debug("xray config generated", "len", len(data)) + + pbConfig, err := serial.LoadJSONConfig(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("parse xray config: %w", err) + } + + x.stop() + + // Hold the global creation mutex around xrayCore.New() + capture so that + // concurrent Xray instances (multi-node) don't clobber each other's + // LimitDispatcher reference. + xrayCreationMu.Lock() + instance, err := xrayCore.New(pbConfig) + if err != nil { + xrayCreationMu.Unlock() + return fmt.Errorf("create xray instance: %w", err) + } + x.limitDispatcher = globalLimitDispatcher.Load() + xrayCreationMu.Unlock() + + if err := instance.Start(); err != nil { + instance.Close() + return fmt.Errorf("start xray: %w", err) + } + + x.instance = instance + x.users = users + x.protocol = nodeConfig.Protocol + x.inboundTag = nodeConfig.Protocol + "-in" + x.cumTraffic = make(map[int][2]int64) + x.lastKernelHash = computeKernelHash(nodeConfig, users) + + // Configure the limit dispatcher with user limits + x.updateDispatcherLimits(users) + + slog.Info("xray started (in-process)", + "users", len(users), + "protocol", nodeConfig.Protocol, + "device_limits", "enabled (all protocols via dispatcher hook)", + "speed_limits", "enabled (all protocols via dispatcher hook)", + ) + + return nil +} + +func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { + // Always update dispatcher limits (instant, no restart). + x.updateDispatcherLimits(users) + + // Skip full restart if kernel-affecting fields are unchanged. + // This covers user-only limit changes where identities (ID, UUID) and + // config (routes, protocol settings, port) haven't changed. + newHash := computeKernelHash(nodeConfig, users) + x.mu.Lock() + same := x.lastKernelHash == newHash + x.mu.Unlock() + if same { + x.mu.Lock() + x.users = users + x.mu.Unlock() + slog.Info("xray: config/users unchanged, limits updated without restart") + return nil + } + + return x.ApplyConfig(nodeConfig, users, certFile, keyFile) +} + +func (x *Xray) Stop() { + x.mu.Lock() + defer x.mu.Unlock() + x.stop() +} + +func (x *Xray) stop() { + if x.instance != nil { + // Drain: wait for in-flight connections to finish naturally. + if x.limitDispatcher != nil { + deadline := time.Now().Add(drainTimeout) + for time.Now().Before(deadline) { + x.limitDispatcher.mu.RLock() + n := len(x.limitDispatcher.conns) + x.limitDispatcher.mu.RUnlock() + if n == 0 { + break + } + time.Sleep(100 * time.Millisecond) + } + } + x.instance.Close() + x.instance = nil + } + if x.limitDispatcher != nil { + x.limitDispatcher.ResetConns() + x.limitDispatcher = nil + } +} + +func (x *Xray) IsRunning() bool { + x.mu.Lock() + defer x.mu.Unlock() + return x.instance != nil +} + +// GetConnections returns per-connection data from the dispatcher. +// Each connection has a real source IP and user ID, enabling device limit +// enforcement by the upper-layer limiter. +func (x *Xray) GetConnections(ctx context.Context) ([]kernel.Connection, error) { + x.mu.Lock() + defer x.mu.Unlock() + + if x.instance == nil { + return nil, nil + } + + // Primary: use LimitDispatcher's per-connection tracking + if x.limitDispatcher != nil { + conns := x.limitDispatcher.Snapshot() + if len(conns) > 0 { + return conns, nil + } + } + + // Fallback: aggregate stats (for edge cases) + return x.getAggregateStats() +} + +func (x *Xray) getAggregateStats() ([]kernel.Connection, error) { + sm := x.instance.GetFeature(stats.ManagerType()) + if sm == nil { + return nil, nil + } + statsManager, ok := sm.(stats.Manager) + if !ok { + return nil, nil + } + + var conns []kernel.Connection + for _, u := range x.users { + email := userEmail(u.ID) + + var deltaUp, deltaDown int64 + if c := statsManager.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>uplink", email)); c != nil { + deltaUp = c.Set(0) + } + if c := statsManager.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>downlink", email)); c != nil { + deltaDown = c.Set(0) + } + + if deltaUp > 0 || deltaDown > 0 { + cum := x.cumTraffic[u.ID] + cum[0] += deltaUp + cum[1] += deltaDown + x.cumTraffic[u.ID] = cum + } + + cum := x.cumTraffic[u.ID] + if cum[0] > 0 || cum[1] > 0 { + conns = append(conns, kernel.Connection{ + ID: fmt.Sprintf("xray-%d", u.ID), + UserID: u.ID, + Upload: cum[0], + Download: cum[1], + }) + } + } + return conns, nil +} + +func (x *Xray) CloseConnection(_ context.Context, connID string) error { + x.mu.Lock() + ld := x.limitDispatcher + x.mu.Unlock() + if ld != nil { + ld.CloseConn(connID) + } + return nil +} + +func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {} + +var _ kernel.Kernel = (*Xray)(nil) + +// updateDispatcherLimits configures the global LimitDispatcher with +// per-user device limits and speed limits. +func (x *Xray) updateDispatcherLimits(users []panel.User) { + x.mu.Lock() + ld := x.limitDispatcher + x.mu.Unlock() + if ld == nil { + return + } + + emailToUID := make(map[string]int, len(users)) + deviceLimits := make(map[string]int) + speedLimits := make(map[string]int) + + for _, u := range users { + email := userEmail(u.ID) + emailToUID[email] = u.ID + // SOCKS/HTTP protocols use the raw UUID as User.Email in xray's session, + // while VMess/VLESS/Trojan use the "user@" format. Map both so that + // limits work for all protocols. + emailToUID[u.UUID] = u.ID + if u.DeviceLimit > 0 { + deviceLimits[email] = u.DeviceLimit + deviceLimits[u.UUID] = u.DeviceLimit + } + if u.SpeedLimit > 0 { + speedLimits[email] = u.SpeedLimit + speedLimits[u.UUID] = u.SpeedLimit + } + } + + ld.UpdateLimits(emailToUID, deviceLimits, speedLimits) +} + +// xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture +// so that concurrent Xray instances in multi-node mode each capture their +// own LimitDispatcher. +var xrayCreationMu sync.Mutex + +// computeKernelHash returns a hash of the config + user identities (ID, UUID) +// that would require a kernel restart if changed. Limit-only changes are +// excluded so they can be applied without restart. +func computeKernelHash(nc *panel.NodeConfig, users []panel.User) string { + h := sha256.New() + configData, _ := json.Marshal(nc) + h.Write(configData) + sorted := make([]panel.User, 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)) +} diff --git a/internal/limiter/limiter.go b/internal/limiter/limiter.go new file mode 100644 index 0000000..d4f8527 --- /dev/null +++ b/internal/limiter/limiter.go @@ -0,0 +1,177 @@ +package limiter + +import ( + "fmt" + "log/slog" + "net/netip" + "sort" + "sync" + "sync/atomic" + + "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/panel" +) + +// KickAction represents a connection that should be closed. +type KickAction struct { + ConnID string + UserID int + Reason string +} + +// Limiter enforces per-user device limits and detects removed users. +type Limiter struct { + mu sync.RWMutex + users map[int]panel.User + + deviceLimitEvents atomic.Uint64 +} + +func New() *Limiter { + return &Limiter{ + users: make(map[int]panel.User), + } +} + +// UpdateUsers refreshes the user limit configuration and returns +// the IDs of users that were present before but are now removed. +func (l *Limiter) UpdateUsers(users []panel.User) []int { + l.mu.Lock() + defer l.mu.Unlock() + + newUsers := make(map[int]panel.User, 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 + return removed +} + +// Check inspects connections against device limits. +// Returns a list of connections that should be closed. +func (l *Limiter) Check(conns []kernel.Connection) []KickAction { + l.mu.RLock() + defer l.mu.RUnlock() + + var kicks []KickAction + + // Device limit check + userIPs := make(map[int]map[string]bool) + userConnsByIP := make(map[int]map[string][]string) // user_id -> ip -> []conn_ids + for _, conn := range conns { + if conn.UserID == 0 || conn.SourceIP == "" { + continue + } + if userIPs[conn.UserID] == nil { + userIPs[conn.UserID] = make(map[string]bool) + userConnsByIP[conn.UserID] = make(map[string][]string) + } + userIPs[conn.UserID][conn.SourceIP] = true + userConnsByIP[conn.UserID][conn.SourceIP] = append( + userConnsByIP[conn.UserID][conn.SourceIP], conn.ID, + ) + } + + for uid, ips := range userIPs { + user, ok := l.users[uid] + if !ok || user.DeviceLimit == 0 { + continue + } + if len(ips) <= user.DeviceLimit { + continue + } + + // Count a device-limit violation event for metrics reporting. + l.deviceLimitEvents.Add(1) + + slog.Warn("user exceeded device limit", + "user_id", uid, + "devices", len(ips), + "limit", user.DeviceLimit, + ) + + // Sort IPs by numeric address so the "allowed" set is deterministic + // across calls. netip.Addr comparison avoids the lexicographic ordering + // quirks of string sort (e.g. "10.x" < "192.x", IPv6 always last). + // Parse all addresses up front to avoid O(n log n) repeated parsing. + type parsedIP struct { + raw string + addr netip.Addr + } + ipList := make([]parsedIP, 0, len(ips)) + for ip := range ips { + a, _ := netip.ParseAddr(ip) + ipList = append(ipList, parsedIP{raw: ip, addr: a}) + } + sort.Slice(ipList, func(i, j int) bool { + return ipList[i].addr.Compare(ipList[j].addr) < 0 + }) + + allowed := make(map[string]bool, user.DeviceLimit) + for i := 0; i < user.DeviceLimit && i < len(ipList); i++ { + allowed[ipList[i].raw] = true + } + + for ip, connIDs := range userConnsByIP[uid] { + if allowed[ip] { + continue + } + for _, connID := range connIDs { + kicks = append(kicks, KickAction{ + ConnID: connID, + UserID: uid, + Reason: fmt.Sprintf("devices %d > limit %d", len(ips), user.DeviceLimit), + }) + } + } + } + + return kicks +} + +// LimiterMetrics holds aggregated limiter statistics. +type LimiterMetrics struct { + // DeviceLimitEvents is the total number of times a user exceeded + // 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(), + } +} + +// KickUsers returns KickActions for all connections belonging to the given user IDs. +func (l *Limiter) KickUsers(conns []kernel.Connection, userIDs []int) []KickAction { + if len(userIDs) == 0 { + return nil + } + + remove := make(map[int]bool, len(userIDs)) + for _, id := range userIDs { + remove[id] = true + } + + var kicks []KickAction + for _, conn := range conns { + if remove[conn.UserID] { + kicks = append(kicks, KickAction{ + ConnID: conn.ID, + UserID: conn.UserID, + Reason: "user removed from panel", + }) + } + } + return kicks +} diff --git a/internal/limiter/limiter_test.go b/internal/limiter/limiter_test.go new file mode 100644 index 0000000..ca8c114 --- /dev/null +++ b/internal/limiter/limiter_test.go @@ -0,0 +1,339 @@ +package limiter + +import ( + "sort" + "testing" + + "github.com/cedar2025/xboard-node/internal/kernel" + "github.com/cedar2025/xboard-node/internal/panel" +) + +func TestNew(t *testing.T) { + l := New() + if l == nil { + t.Fatal("New returned nil") + } +} + +func TestUpdateUsers(t *testing.T) { + l := New() + users := []panel.User{ + {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) + } + + // Verify by running a check + conns := []kernel.Connection{{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}} + kicks := l.Check(conns) + if len(kicks) != 0 { + t.Errorf("expected no kicks, got %d", len(kicks)) + } +} + +func TestUpdateUsers_DetectsRemoved(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, UUID: "u1"}, + {ID: 2, UUID: "u2"}, + {ID: 3, UUID: "u3"}, + }) + + // Remove user 2 + removed := l.UpdateUsers([]panel.User{ + {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([]panel.User{ + {ID: 1, UUID: "u1"}, + {ID: 2, UUID: "u2"}, + {ID: 3, UUID: "u3"}, + }) + + // Remove users 1 and 3 + removed := l.UpdateUsers([]panel.User{ + {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([]panel.User{ + {ID: 1, UUID: "u1"}, + }) + + // Add user, keep existing + removed := l.UpdateUsers([]panel.User{ + {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([]panel.User{{ID: 1, DeviceLimit: 1}}) + + // Replace with different users + removed := l.UpdateUsers([]panel.User{{ID: 2, DeviceLimit: 1}}) + + if len(removed) != 1 || removed[0] != 1 { + t.Errorf("expected removed=[1], got %v", removed) + } +} + +func TestDeviceLimit_NoKick_UnderLimit(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, DeviceLimit: 3}, + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 1, SourceIP: "2.2.2.2"}, + } + kicks := l.Check(conns) + + if len(kicks) != 0 { + t.Errorf("2 devices under limit 3, got %d kicks", len(kicks)) + } +} + +func TestDeviceLimit_Kick_OverLimit(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, DeviceLimit: 2}, + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 1, SourceIP: "2.2.2.2"}, + {ID: "c3", UserID: 1, SourceIP: "3.3.3.3"}, + } + kicks := l.Check(conns) + + if len(kicks) == 0 { + t.Fatal("expected kicks for exceeding device limit") + } + if len(kicks) < 1 { + t.Errorf("expected at least 1 kick, got %d", len(kicks)) + } +} + +func TestDeviceLimit_ExactlyAtLimit(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, DeviceLimit: 2}, + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 1, SourceIP: "2.2.2.2"}, + } + kicks := l.Check(conns) + + if len(kicks) != 0 { + t.Errorf("exactly at limit should not kick, got %d", len(kicks)) + } +} + +func TestDeviceLimit_UnlimitedDevices(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, DeviceLimit: 0}, // 0 = unlimited + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 1, SourceIP: "2.2.2.2"}, + {ID: "c3", UserID: 1, SourceIP: "3.3.3.3"}, + {ID: "c4", UserID: 1, SourceIP: "4.4.4.4"}, + {ID: "c5", UserID: 1, SourceIP: "5.5.5.5"}, + } + kicks := l.Check(conns) + + if len(kicks) != 0 { + t.Errorf("unlimited devices should not be kicked, got %d", len(kicks)) + } +} + +func TestDeviceLimit_SameIPMultipleConnections(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, DeviceLimit: 1}, + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c3", UserID: 1, SourceIP: "1.1.1.1"}, + } + kicks := l.Check(conns) + + if len(kicks) != 0 { + t.Errorf("same IP multiple conns should count as 1 device, got %d kicks", len(kicks)) + } +} + +func TestDeviceLimit_EmptySourceIP(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, DeviceLimit: 1}, + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: ""}, + {ID: "c2", UserID: 1, SourceIP: ""}, + } + kicks := l.Check(conns) + + if len(kicks) != 0 { + t.Errorf("empty source IP should be ignored, got %d kicks", len(kicks)) + } +} + +func TestMultipleUsers_DeviceLimits(t *testing.T) { + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, DeviceLimit: 1}, + {ID: 2, DeviceLimit: 1}, + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 2, SourceIP: "2.2.2.2"}, + {ID: "c3", UserID: 2, SourceIP: "3.3.3.3"}, + } + kicks := l.Check(conns) + + deviceKicks := 0 + for _, k := range kicks { + if k.UserID == 2 { + deviceKicks++ + } + } + if deviceKicks == 0 { + t.Error("expected device kick for user 2") + } + + for _, k := range kicks { + if k.UserID == 1 { + t.Error("user 1 should not be kicked") + } + } +} + +func TestNoSpeedKick(t *testing.T) { + // Speed limits should NOT cause kicks — only device limits and user removal + l := New() + l.UpdateUsers([]panel.User{ + {ID: 1, SpeedLimit: 1, DeviceLimit: 0}, + }) + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + } + kicks := l.Check(conns) + if len(kicks) != 0 { + t.Errorf("speed limits should not cause kicks, got %d", len(kicks)) + } +} + +func TestKickUsers_Basic(t *testing.T) { + l := New() + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 2, SourceIP: "2.2.2.2"}, + {ID: "c3", UserID: 1, SourceIP: "1.1.1.2"}, + {ID: "c4", UserID: 3, SourceIP: "3.3.3.3"}, + } + + kicks := l.KickUsers(conns, []int{1}) + + if len(kicks) != 2 { + t.Fatalf("expected 2 kicks for user 1, got %d", len(kicks)) + } + for _, k := range kicks { + if k.UserID != 1 { + t.Errorf("expected kick for user 1, got user %d", k.UserID) + } + if k.Reason != "user removed from panel" { + t.Errorf("unexpected reason: %s", k.Reason) + } + } +} + +func TestKickUsers_MultipleUsers(t *testing.T) { + l := New() + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 2, SourceIP: "2.2.2.2"}, + {ID: "c3", UserID: 3, SourceIP: "3.3.3.3"}, + } + + kicks := l.KickUsers(conns, []int{1, 3}) + + if len(kicks) != 2 { + t.Fatalf("expected 2 kicks, got %d", len(kicks)) + } + kickedUsers := map[int]bool{} + for _, k := range kicks { + kickedUsers[k.UserID] = true + } + if !kickedUsers[1] || !kickedUsers[3] { + t.Errorf("expected users 1 and 3 kicked, got %v", kickedUsers) + } +} + +func TestKickUsers_NoUsers(t *testing.T) { + l := New() + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + } + + kicks := l.KickUsers(conns, nil) + if len(kicks) != 0 { + t.Errorf("expected no kicks for nil user list, got %d", len(kicks)) + } + + kicks = l.KickUsers(conns, []int{}) + if len(kicks) != 0 { + t.Errorf("expected no kicks for empty user list, got %d", len(kicks)) + } +} + +func TestKickUsers_NoMatchingConnections(t *testing.T) { + l := New() + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}, + } + + kicks := l.KickUsers(conns, []int{99}) + if len(kicks) != 0 { + t.Errorf("expected no kicks for non-existent user, got %d", len(kicks)) + } +} diff --git a/internal/limiter/speedtracker.go b/internal/limiter/speedtracker.go new file mode 100644 index 0000000..5d03eca --- /dev/null +++ b/internal/limiter/speedtracker.go @@ -0,0 +1,99 @@ +package limiter + +import ( + "sync" + "sync/atomic" + + "golang.org/x/time/rate" +) + +// 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 +} + +// 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), + } +} + +// UpdateBuckets rebuilds rate limiter buckets based on current user config. +// Call this whenever the user list changes. +func (t *SpeedTracker) UpdateBuckets() { + t.mu.Lock() + defer t.mu.Unlock() + + t.limiter.mu.RLock() + defer t.limiter.mu.RUnlock() + + newBuckets := make(map[int]*rate.Limiter, len(t.limiter.users)) + newUUIDMap := make(map[string]int, len(t.limiter.users)) + for uid, user := range t.limiter.users { + if user.UUID != "" { + newUUIDMap[user.UUID] = uid + } + if user.SpeedLimit <= 0 { + continue + } + bytesPerSec := int(user.SpeedLimit) * 1_000_000 / 8 // Mbps → bytes/s + // Burst = 1 second of data, floored at 64 KiB, capped at 4 s. + // A generous burst ensures downloads ramp up quickly (good UX) while + // the token bucket still converges to the target rate within seconds. + // The 4 s cap prevents excessive burst at very high limits. + burst := bytesPerSec + if burst < 64*1024 { + burst = 64 * 1024 + } + if cap4s := bytesPerSec * 4; cap4s > 64*1024 && burst > cap4s { + burst = cap4s + } + if existing, ok := t.buckets[uid]; ok { + existing.SetLimit(rate.Limit(bytesPerSec)) + existing.SetBurst(burst) + newBuckets[uid] = existing + } else { + newBuckets[uid] = rate.NewLimiter(rate.Limit(bytesPerSec), burst) + } + } + t.buckets = newBuckets + t.uuidMap = newUUIDMap + t.hasLimits.Store(len(newBuckets) > 0) +} + +// GetLimiter returns the rate limiter for the given user UUID, or nil if +// no limit applies. This is called by ConnTracker for every new connection. +// Thread-safe. +func (t *SpeedTracker) GetLimiter(user string) *rate.Limiter { + if !t.hasLimits.Load() { + return nil + } + t.mu.RLock() + defer t.mu.RUnlock() + return t.buckets[t.uuidMap[user]] +} + +// 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) +} diff --git a/internal/limiter/speedtracker_test.go b/internal/limiter/speedtracker_test.go new file mode 100644 index 0000000..df00421 --- /dev/null +++ b/internal/limiter/speedtracker_test.go @@ -0,0 +1,123 @@ +package limiter + +import ( + "testing" + + "github.com/cedar2025/xboard-node/internal/panel" +) + +func TestSpeedTracker_UpdateBuckets(t *testing.T) { + l := New() + st := NewSpeedTracker(l) + + users := []panel.User{ + {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 := []panel.User{ + {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 := []panel.User{ + {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 := []panel.User{ + {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 := []panel.User{ + {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") + } +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..8adac2c --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,82 @@ +package monitor + +import ( + "log/slog" + "runtime" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "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 + MemTotal uint64 + MemUsed uint64 + SwapTotal uint64 + SwapUsed uint64 + DiskTotal uint64 + DiskUsed uint64 + + // 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 { + slog.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 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.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 +} diff --git a/internal/panel/client.go b/internal/panel/client.go new file mode 100644 index 0000000..000bea3 --- /dev/null +++ b/internal/panel/client.go @@ -0,0 +1,318 @@ +package panel + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/cedar2025/xboard-node/internal/config" +) + +// Client communicates with the Xboard panel API +type Client struct { + baseURL string + token string + nodeID int + nodeType string + 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, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + }, + }, + } +} + +// 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 := map[string]interface{}{} + + if len(traffic) > 0 { + t := make(map[string][2]int64, len(traffic)) + for uid, d := range traffic { + t[strconv.Itoa(uid)] = d + } + payload["traffic"] = t + } + + if len(alive) > 0 { + a := make(map[string][]string, len(alive)) + for uid, ips := range alive { + a[strconv.Itoa(uid)] = ips + } + payload["alive"] = a + } + + if len(online) > 0 { + o := make(map[string]int, len(online)) + for uid, count := range online { + o[strconv.Itoa(uid)] = count + } + payload["online"] = 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) +} + +// GetConfig fetches node configuration. Returns nil if not modified (304). +func (c *Client) GetConfig() (*NodeConfig, error) { + resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/config", nil, c.configETag) + 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 cfg NodeConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + 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", "/api/v1/server/UniProxy/user", 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 = "" +} + +// postJSON marshals a map payload (with auth fields injected) and POSTs it. +// Auth fields are injected before marshalling — single marshal pass. +func (c *Client) postJSON(path string, payload map[string]interface{}) error { + payload["token"] = c.token + payload["node_id"] = c.nodeID + if c.nodeType != "" { + payload["node_type"] = c.nodeType + } + + 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" { + q := url.Values{} + q.Set("token", c.token) + q.Set("node_id", strconv.Itoa(c.nodeID)) + if c.nodeType != "" { + q.Set("node_type", c.nodeType) + } + fullURL += "?" + q.Encode() + } else if body != nil { + bodyReader = bytes.NewReader(body) + } else { + authOnly := map[string]interface{}{ + "token": c.token, + "node_id": c.nodeID, + } + if c.nodeType != "" { + authOnly["node_type"] = c.nodeType + } + 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) + } + + slog.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) + } + + slog.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(), + } +} diff --git a/internal/panel/client_test.go b/internal/panel/client_test.go new file mode 100644 index 0000000..7893773 --- /dev/null +++ b/internal/panel/client_test.go @@ -0,0 +1,295 @@ +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() +} diff --git a/internal/panel/types.go b/internal/panel/types.go new file mode 100644 index 0000000..223edba --- /dev/null +++ b/internal/panel/types.go @@ -0,0 +1,159 @@ +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"` +} + +// NodeConfig is the response from GET /api/v1/server/UniProxy/config +type NodeConfig struct { + 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"` + + // 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"` + 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"` +} + +// CertConfig holds certificate automation settings from the panel +type CertConfig struct { + CertMode string `json:"cert_mode"` // none, dns, http, self, file + 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 +} + +// 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"` +} + +// 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"` +} diff --git a/internal/panel/types_test.go b/internal/panel/types_test.go new file mode 100644 index 0000000..933bac2 --- /dev/null +++ b/internal/panel/types_test.go @@ -0,0 +1,140 @@ +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) + } +} diff --git a/internal/panel/ws.go b/internal/panel/ws.go new file mode 100644 index 0000000..69a049c --- /dev/null +++ b/internal/panel/ws.go @@ -0,0 +1,302 @@ +package panel + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "strconv" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +// WSEvent types +const ( + WSEventSyncConfig = "sync.config" + WSEventSyncUsers = "sync.users" + WSEventSyncUserDelta = "sync.user.delta" +) + +// 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 +} + +// 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"` +} + +type syncUsersPayload struct { + Users []User `json:"users"` + Timestamp int64 `json:"timestamp"` +} + +type syncUserDeltaPayload struct { + Action string `json:"action"` + Users []User `json:"users"` + Timestamp int64 `json:"timestamp"` +} + +// 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) + + connected atomic.Bool +} + +// NewWSClient creates a new WebSocket client. +// wsURL is the base WebSocket URL (e.g. "ws://panel.example.com:8076"). +// token and nodeID are used for authentication via query parameters. +func NewWSClient(wsURL string, token string, nodeID int, onEvent func(WSEvent), onStatus func(WSStatusChange)) *WSClient { + return &WSClient{ + wsURL: wsURL, + token: token, + nodeID: nodeID, + onEvent: onEvent, + onStatus: onStatus, + } +} + +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 := time.Second + for { + start := time.Now() + err := w.connect(ctx) + + wasConnected := w.connected.Swap(false) + if wasConnected { + w.notifyStatus(false) + } + + if err != nil { + slog.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 = time.Second + } + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if backoff < 30*time.Second { + backoff = min(backoff*2, 30*time.Second) + } + } +} + +func (w *WSClient) connect(ctx context.Context) error { + // Build WS URL with auth query params + u, err := url.Parse(w.wsURL) + if err != nil { + return fmt.Errorf("parse ws url: %w", err) + } + q := u.Query() + q.Set("token", w.token) + q.Set("node_id", strconv.Itoa(w.nodeID)) + u.RawQuery = q.Encode() + + slog.Debug("ws connecting", "url", u.Redacted()) + + dialer := websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + } + 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) + } + + if firstMsg.Event == "error" { + var errData struct { + Message string `json:"message"` + } + json.Unmarshal(firstMsg.Data, &errData) + 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) + slog.Info("ws connected (implicit auth)") + w.handleMessage(firstMsg) + } else { + w.connected.Store(true) + w.notifyStatus(true) + slog.Info("ws connected and authenticated") + } + + // Ping interval: send pong responses to server pings + const pingInterval = 50 * time.Second + + msgCh := make(chan wsMessage, 16) + 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 + } + select { + case msgCh <- msg: + case <-ctx.Done(): + return + } + } + }() + + pingTimer := time.NewTimer(pingInterval) + defer pingTimer.Stop() + + 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 msg := <-msgCh: + pingTimer.Reset(pingInterval) + w.handleMessage(msg) + + case <-pingTimer.C: + // Send pong to keep connection alive + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteJSON(wsMessage{Event: "pong"}); err != nil { + return fmt.Errorf("pong: %w", err) + } + pingTimer.Reset(pingInterval) + } + } +} + +func (w *WSClient) handleMessage(msg wsMessage) { + switch msg.Event { + case "ping": + // Server ping — handled by the pong timer reset above + slog.Debug("ws received ping") + + case "auth.success": + slog.Debug("ws auth confirmed") + + case WSEventSyncConfig: + w.handleDataEvent(msg) + + case WSEventSyncUsers: + w.handleDataEvent(msg) + + case WSEventSyncUserDelta: + w.handleDataEvent(msg) + + default: + slog.Debug("ws unknown event", "event", msg.Event) + } +} + +func (w *WSClient) handleDataEvent(msg wsMessage) { + var event WSEvent + event.Type = msg.Event + + switch msg.Event { + case WSEventSyncConfig: + slog.Info("ws sync config event received") + var p syncConfigPayload + if err := json.Unmarshal(msg.Data, &p); err != nil { + slog.Warn("ws: cannot decode config payload", "error", err) + return + } + event.Config = &p.Config + + case WSEventSyncUsers: + slog.Info("ws sync users event received") + var p syncUsersPayload + if err := json.Unmarshal(msg.Data, &p); err != nil { + slog.Warn("ws: cannot decode users payload", "error", err) + return + } + event.Users = p.Users + + case WSEventSyncUserDelta: + slog.Info("ws sync user delta event received") + var p syncUserDeltaPayload + if err := json.Unmarshal(msg.Data, &p); err != nil { + slog.Warn("ws: cannot decode user delta payload", "error", err) + return + } + event.DeltaAction = p.Action + event.DeltaUsers = p.Users + } + + w.onEvent(event) +} diff --git a/internal/panel/ws_test.go b/internal/panel/ws_test.go new file mode 100644 index 0000000..324df14 --- /dev/null +++ b/internal/panel/ws_test.go @@ -0,0 +1,291 @@ +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, func(event WSEvent) { + mu.Lock() + received = append(received, event) + mu.Unlock() + }, 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, func(WSEvent) {}, 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, func(WSEvent) {}, 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, func(event WSEvent) { + mu.Lock() + received = append(received, event) + mu.Unlock() + }, 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) + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..99d8968 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,874 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "log/slog" + "math" + "sort" + "time" + + "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/kernel/singbox" + "github.com/cedar2025/xboard-node/internal/kernel/xray" + "github.com/cedar2025/xboard-node/internal/limiter" + "github.com/cedar2025/xboard-node/internal/monitor" + "github.com/cedar2025/xboard-node/internal/panel" + "github.com/cedar2025/xboard-node/internal/tracker" +) + +type Service struct { + cfg *config.Config + panel *panel.Client + kernel kernel.Kernel + tracker *tracker.Tracker + limiter *limiter.Limiter + speedTracker *limiter.SpeedTracker + cert *cert.Manager + + lastConfig *panel.NodeConfig + lastUsers []panel.User + + lastAppliedPort int // the port currently bound by the kernel + lastAppliedIP string // the listen IP currently bound by the kernel + + pushInterval int // seconds + pullInterval int // seconds + + lastUserHash string // hash of user list for change detection + lastConfigHash string // hash of full config for change detection + pullBackoff apiBackoff // backoff for panel pull failures + pushBackoff apiBackoff // backoff for panel push failures + + wsClient *panel.WSClient // WebSocket client (nil if WS not enabled) + wsEvents chan panel.WSEvent // receives data events from WS client + wsStatusCh chan panel.WSStatusChange // receives WS connect/disconnect notifications + wsCancel context.CancelFunc // cancels the WS client goroutine + wsDisconnectAt time.Time // when WS last disconnected (zero if connected) +} + +// apiBackoff implements simple exponential backoff for API failures. +type apiBackoff struct { + skipRemaining int +} + +func (b *apiBackoff) shouldSkip() bool { + if b.skipRemaining > 0 { + b.skipRemaining-- + return true + } + return false +} + +func (b *apiBackoff) onSuccess() { b.skipRemaining = 0 } +func (b *apiBackoff) onFailure() { + if b.skipRemaining <= 0 { + b.skipRemaining = 1 + } else if b.skipRemaining < 8 { + b.skipRemaining *= 2 + } +} + +func New(cfg *config.Config) *Service { + panelClient := panel.NewClient(cfg.Panel) + certMgr := cert.NewManager(cfg.Cert) + + var k kernel.Kernel + switch cfg.Kernel.Type { + case "singbox": + k = singbox.New(cfg.Kernel) + case "xray": + k = xray.New(cfg.Kernel) + default: + slog.Error("unsupported kernel type, defaulting to sing-box", "type", cfg.Kernel.Type) + k = singbox.New(cfg.Kernel) + } + + l := limiter.New() + st := limiter.NewSpeedTracker(l) + + return &Service{ + cfg: cfg, + panel: panelClient, + kernel: k, + tracker: tracker.New(), + limiter: l, + speedTracker: st, + cert: certMgr, + wsEvents: make(chan panel.WSEvent, 16), + wsStatusCh: make(chan panel.WSStatusChange, 4), + } +} + +func (s *Service) Run(ctx context.Context) error { + // Start cert manager (handles auto-TLS or manual cert verification) + if err := s.cert.Start(ctx); err != nil { + return fmt.Errorf("cert manager: %w", err) + } + defer s.cert.Stop() + + // Handshake: get WS config + initial data in one call + if err := s.initialSetup(ctx); err != nil { + return fmt.Errorf("initial setup: %w", err) + } + defer s.kernel.Stop() + + // Set up tickers + trackTicker := time.NewTicker(10 * time.Second) + pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second + pullInterval := time.Duration(s.pullInterval) * time.Second + reportTicker := time.NewTicker(pushInterval) + pullTicker := time.NewTicker(pullInterval) + + // WS discovery: when in REST-only mode, periodically re-handshake to check + // if WS has been enabled. When WS is disconnected for too long, re-check + // if it's still available. + wsDiscoveryTicker := time.NewTicker(5 * time.Minute) + + defer trackTicker.Stop() + defer reportTicker.Stop() + defer pullTicker.Stop() + defer wsDiscoveryTicker.Stop() + + s.startWSClient(ctx) + + sMode := "disabled" + if s.wsClient != nil { + sMode = "connecting" + } + slog.Info("service started", + "kernel", s.kernel.Name(), + "push_interval", s.pushInterval, + "pull_interval", s.pullInterval, + "websocket", sMode, + ) + + for { + select { + case <-ctx.Done(): + slog.Info("service shutting down") + s.pushReport() + return nil + + case <-trackTicker.C: + s.trackAndEnforce(ctx) + + case <-reportTicker.C: + s.pushReport() + + case <-pullTicker.C: + // When WebSocket is connected, skip REST polling entirely. + // Config/user updates arrive via WS push. + if s.wsClient != nil && s.wsClient.IsConnected() { + slog.Debug("ws connected, skipping REST pull") + continue + } + slog.Info("ws not connected or disabled, polling from API") + s.pullViaAPI(ctx) + + case <-wsDiscoveryTicker.C: + s.wsDiscovery(ctx) + + case status := <-s.wsStatusCh: + s.handleWSStatus(ctx, status) + + case event := <-s.wsEvents: + s.handleWSEvent(ctx, event) + } + } +} + +func (s *Service) initialSetup(ctx context.Context) error { + // Register speed limit lookup with kernel unconditionally (before WS/V1 branch). + // This ensures speedLimitFunc is set regardless of which code path applies config. + s.kernel.SetSpeedLimitFunc(s.speedTracker.GetLimiter) + + slog.Info("performing handshake with panel") + + var hs *panel.HandshakeResponse + var err error + + // Retry loop for initial handshake + for attempt := 1; ; attempt++ { + hs, err = s.panel.Handshake() + if err != nil { + slog.Error("handshake failed", "attempt", attempt, "error", err) + if attempt >= 5 { + return fmt.Errorf("handshake failed after %d attempts: %w", attempt, err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt*5) * time.Second): + continue + } + } + break + } + + // Apply settings from panel handshake (Handshake provides default intervals) + if s.cfg.Node.PushInterval == 0 && hs.Settings.PushInterval > 0 { + s.pushInterval = hs.Settings.PushInterval + } else { + s.pushInterval = s.cfg.Node.PushInterval + } + if s.pushInterval == 0 { + s.pushInterval = 60 + } + + if s.cfg.Node.PullInterval == 0 && hs.Settings.PullInterval > 0 { + s.pullInterval = hs.Settings.PullInterval + } else { + s.pullInterval = s.cfg.Node.PullInterval + } + if s.pullInterval == 0 { + s.pullInterval = 60 + } + + // If WebSocket is enabled, skip initial V1 APIs fetch and wait for推送 + if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" { + s.wsClient = s.newWSClient(hs.WebSocket.WSURL) + slog.Info("handshake complete (websocket enabled), waiting for data push...", + "ws_url", hs.WebSocket.WSURL, + ) + return nil + } + + // Falls back to V1 APIs only if WebSocket is disabled in panel + slog.Info("websocket disabled or missing, falling back to V1 APIs") + + // Fetch initial config and users via V1 APIs + nodeConfig, err := s.panel.GetConfig() + if err != nil { + return fmt.Errorf("initial config fetch: %w", err) + } + if nodeConfig == nil { + return fmt.Errorf("initial config is nil") + } + + users, err := s.panel.GetUsers() + if err != nil { + return fmt.Errorf("initial user fetch: %w", err) + } + + // Apply initial data + s.lastConfig = nodeConfig + s.lastUsers = users + s.lastUserHash = computeUserHash(users) + s.lastConfigHash = computeConfigHash(nodeConfig) + s.limiter.UpdateUsers(users) + s.speedTracker.UpdateBuckets() + + slog.Info("handshake complete (V1 fallback)", + "protocol", nodeConfig.Protocol, + "port", nodeConfig.ServerPort, + "users", len(users), + ) + + if len(users) == 0 { + slog.Warn("no users, kernel will not start until users are available") + return nil + } + + // Update service-level config overrides from remote NodeConfig if present + s.applyRemoteOverrides(ctx, nodeConfig) + + s.lastAppliedPort = nodeConfig.ServerPort + s.lastAppliedIP = nodeConfig.ListenIP + + certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile() + if err := s.kernel.ApplyConfig(nodeConfig, users, certFile, keyFile); err != nil { + return fmt.Errorf("apply config: %w", err) + } + + return nil +} + +// applyRemoteOverrides updates service-level settings (log level, cert config) +// from the panel's NodeConfig. Returns true if cert paths changed (kernel restart needed). +func (s *Service) applyRemoteOverrides(ctx context.Context, nc *panel.NodeConfig) bool { + if nc == nil { + return false + } + + // Dynamic Log Level (Kernel) + if nc.KernelLogLevel != "" && nc.KernelLogLevel != s.cfg.Kernel.LogLevel { + slog.Info("cert: kernel log level override", "old", s.cfg.Kernel.LogLevel, "new", nc.KernelLogLevel) + s.cfg.Kernel.LogLevel = nc.KernelLogLevel + } + + // Certificate configuration from panel (panel-first: takes precedence over local config) + if nc.CertConfig != nil { + return s.applyPanelCert(ctx, nc.CertConfig) + } + + // Legacy fields (deprecated: prefer cert_config) + if nc.AutoTLS != s.cfg.Cert.AutoTLS { + slog.Info("cert: auto_tls policy changed (deprecated field)", "new", nc.AutoTLS) + s.cfg.Cert.AutoTLS = nc.AutoTLS + } + if nc.Domain != "" && nc.Domain != s.cfg.Cert.Domain { + s.cfg.Cert.Domain = nc.Domain + } + + return false +} + +// applyPanelCert converts a panel CertConfig into the local config format and +// reconfigures the cert manager. Reports whether cert paths changed. +func (s *Service) applyPanelCert(ctx context.Context, pc *panel.CertConfig) bool { + newCfg := config.CertConfig{ + CertMode: pc.CertMode, + Domain: pc.Domain, + Email: pc.Email, + DNSProvider: pc.DNSProvider, + DNSEnv: pc.DNSEnv, + HTTPPort: pc.HTTPPort, + CertFile: pc.CertFile, + KeyFile: pc.KeyFile, + CertContent: pc.CertContent, + KeyContent: pc.KeyContent, + // Preserve local storage dir — only the operator controls where certs live. + CertDir: s.cfg.Cert.CertDir, + } + + changed, err := s.cert.Reconfigure(ctx, newCfg) + if err != nil { + slog.Error("failed to apply panel cert config", "mode", pc.CertMode, "error", err) + return false + } + s.cfg.Cert = newCfg + if changed { + slog.Info("cert: paths updated from panel", "cert", s.cert.CertFile(), "key", s.cert.KeyFile()) + } + return changed +} + +// startWSClient starts the WS client goroutine if a client is configured. +func (s *Service) startWSClient(ctx context.Context) { + if s.wsClient == nil { + return + } + wsCtx, wsCancel := context.WithCancel(ctx) + s.wsCancel = wsCancel + go s.wsClient.Run(wsCtx) +} + +// newWSClient creates a WSClient with standard event/status callbacks. +func (s *Service) newWSClient(wsURL string) *panel.WSClient { + return panel.NewWSClient( + wsURL, + s.cfg.Panel.Token, + s.cfg.Panel.NodeID, + func(event panel.WSEvent) { + select { + case s.wsEvents <- event: + default: + slog.Warn("ws event channel full, dropping event", "type", event.Type) + } + }, + func(status panel.WSStatusChange) { + select { + case s.wsStatusCh <- status: + default: + } + }, + ) +} + +// handleWSStatus reacts to WS connectivity changes. +// +// - On disconnect: record timestamp, immediately REST poll. +// - On reconnect: clear disconnect timestamp, REST poll to catch missed events. +func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChange) { + if status.Connected { + s.wsDisconnectAt = time.Time{} + slog.Info("ws connected, waiting for server-side full sync push") + // No REST pull needed — server detects new connection and pushes + // full config + users automatically on auth success. + } else { + if s.wsDisconnectAt.IsZero() { + s.wsDisconnectAt = time.Now() + } + slog.Info("ws disconnected, falling back to REST polling") + s.pullViaAPI(ctx) + } +} + +// wsDiscovery periodically checks WS availability: +// +// 1. REST-only mode (wsClient == nil): Re-handshake to check if panel now has +// WS enabled. If so, create and start a WS client. This handles the case +// where WS was not enabled at startup but enabled later. +// +// 2. WS disconnected for >10 min: Re-handshake to check if WS config changed. +// If WS is now disabled, stop the WS client and switch to REST-only. +// If WS config changed (different URL/channel), restart with new config. +func (s *Service) wsDiscovery(ctx context.Context) { + needsCheck := false + + if s.wsClient == nil { + needsCheck = true + slog.Debug("ws discovery: no WS client, checking if panel enabled WS") + } else if !s.wsDisconnectAt.IsZero() && time.Since(s.wsDisconnectAt) > 10*time.Minute { + needsCheck = true + slog.Debug("ws discovery: WS disconnected for >10min, re-checking WS config") + } + + if !needsCheck { + return + } + + hs, err := s.panel.Handshake() + if err != nil { + slog.Debug("ws discovery: handshake failed", "error", err) + return + } + + // Apply any latest config/user changes via dedicated APIs + s.pullViaAPI(ctx) + + if hs.WebSocket.Enabled && hs.WebSocket.WSURL != "" { + if s.wsClient == nil { + slog.Info("ws discovery: panel has WS enabled, creating WS client") + s.wsClient = s.newWSClient(hs.WebSocket.WSURL) + s.wsDisconnectAt = time.Time{} + s.startWSClient(ctx) + } + } else if s.wsClient != nil { + slog.Info("ws discovery: panel disabled WS, switching to REST-only") + if s.wsCancel != nil { + s.wsCancel() + } + s.wsClient = nil + s.wsCancel = nil + s.wsDisconnectAt = time.Time{} + } +} + +// handleWSEvent processes data events received via WebSocket +func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) { + switch event.Type { + case panel.WSEventSyncConfig: + if event.Config == nil { + slog.Warn("ws: sync.config event with nil config, ignoring") + return + } + newConfigHash := computeConfigHash(event.Config) + if newConfigHash == s.lastConfigHash { + slog.Debug("ws: config unchanged (same hash), skipping") + return + } + slog.Info("ws: applying config update", "protocol", event.Config.Protocol) + s.lastConfig = event.Config + s.lastConfigHash = newConfigHash + s.applyRemoteOverrides(ctx, event.Config) + s.applyChanges(ctx, true, false) + + case panel.WSEventSyncUsers: + if event.Users == nil { + slog.Warn("ws: sync.users event with nil users, ignoring") + return + } + newHash := computeUserHash(event.Users) + if newHash == s.lastUserHash { + slog.Debug("ws: users unchanged (same hash), skipping") + return + } + slog.Info("ws: applying user update", "count", len(event.Users)) + s.applyUserUpdate(ctx, event.Users, newHash) + + case panel.WSEventSyncUserDelta: + if len(event.DeltaUsers) == 0 { + slog.Warn("ws: sync.user.delta event with empty users, ignoring") + return + } + slog.Info("ws: applying user delta", "action", event.DeltaAction, "count", len(event.DeltaUsers)) + s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers) + + default: + slog.Debug("ws: unknown event type", "type", event.Type) + } +} + +// pullViaAPI re-calls the config and user APIs for polling fallback. +// Also opportunistically detects WS enablement when in REST-only mode. +func (s *Service) pullViaAPI(ctx context.Context) { + if s.pullBackoff.shouldSkip() { + slog.Debug("skipping pull due to backoff") + return + } + + config, err := s.panel.GetConfig() + if err != nil { + slog.Error("poll config failed", "error", err) + s.pullBackoff.onFailure() + return + } + + users, err := s.panel.GetUsers() + if err != nil { + slog.Error("poll users failed", "error", err) + s.pullBackoff.onFailure() + return + } + + s.pullBackoff.onSuccess() + + configChanged := false + + if s.cert.CertRenewed() { + slog.Info("certificate renewed, kernel restart needed") + configChanged = true + } + + if config != nil { + newConfigHash := computeConfigHash(config) + if s.lastConfigHash != newConfigHash { + configChanged = true + slog.Info("config updated from panel", + "protocol", config.Protocol, "port", config.ServerPort) + s.lastConfig = config + s.lastConfigHash = newConfigHash + // Apply kernel overrides and cert updates from REST pull + if s.applyRemoteOverrides(ctx, config) { + configChanged = true // Restart kernel if cert paths changed + } + } + } + + if users != nil { + newHash := computeUserHash(users) + usersChanged := newHash != s.lastUserHash + + if usersChanged && !configChanged { + s.applyUserUpdate(ctx, users, newHash) + } else if usersChanged { + // Config is also changing — ApplyConfig will restart the kernel with the + // updated user list, so skip the intermediate ReloadUsers. + s.syncUserState(ctx, users, newHash, false) + } + } + + if configChanged { + s.applyChanges(ctx, true, false) + } +} + +// syncUserState updates user tracking state (limiter, speedTracker, lastUsers) +// without touching the kernel. Set kickRemoved=false when a full kernel restart +// is about to happen anyway — removed users' connections will be killed then. +func (s *Service) syncUserState(ctx context.Context, users []panel.User, newHash string, kickRemoved bool) { + removedIDs := s.limiter.UpdateUsers(users) + s.speedTracker.UpdateBuckets() + + if kickRemoved && len(removedIDs) > 0 && s.kernel.IsRunning() { + conns, err := s.kernel.GetConnections(ctx) + if err == nil { + kicks := s.limiter.KickUsers(conns, removedIDs) + s.executeKicks(ctx, kicks) + slog.Info("kicked removed users", "removed_ids", removedIDs, "kicked_conns", len(kicks)) + } + } + + s.lastUsers = users + s.lastUserHash = newHash + slog.Info("users updated", "count", len(users)) +} + +// applyUserUpdate updates user state and hot-reloads the kernel inbounds. +func (s *Service) applyUserUpdate(ctx context.Context, users []panel.User, newHash string) { + s.syncUserState(ctx, users, newHash, true) + s.applyChanges(ctx, false, true) +} + +// applyUserDelta applies an incremental user change (add or remove). +func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers []panel.User) { + switch action { + case "add": + // Build a map of current users for fast lookup + userMap := make(map[int]panel.User, len(s.lastUsers)) + for _, u := range s.lastUsers { + userMap[u.ID] = u + } + for _, u := range deltaUsers { + userMap[u.ID] = u // add or update + } + merged := make([]panel.User, 0, len(userMap)) + for _, u := range userMap { + merged = append(merged, u) + } + newHash := computeUserHash(merged) + s.applyUserUpdate(ctx, merged, newHash) + + case "remove": + removeSet := make(map[int]struct{}, len(deltaUsers)) + for _, u := range deltaUsers { + removeSet[u.ID] = struct{}{} + } + filtered := make([]panel.User, 0, len(s.lastUsers)) + for _, u := range s.lastUsers { + if _, ok := removeSet[u.ID]; !ok { + filtered = append(filtered, u) + } + } + newHash := computeUserHash(filtered) + s.applyUserUpdate(ctx, filtered, newHash) + default: + slog.Warn("ws: unknown user delta action", "action", action) + } +} + +// applyChanges applies config and/or user changes to the kernel +func (s *Service) applyChanges(ctx context.Context, configChanged, usersChanged bool) { + certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile() + + if configChanged { + if s.lastConfig != nil && len(s.lastUsers) > 0 { + // If the port or listen IP has changed, we MUST perform a full restart + // because hot-reload (s.kernel.Reload) typically only updates users/rules + // and doesn't re-bind existing listeners in most kernels. + needsRestart := s.kernel.IsRunning() && (s.lastConfig.ServerPort != s.lastAppliedPort || s.lastConfig.ListenIP != s.lastAppliedIP) + + if !needsRestart { + // Try hot-reload for other config changes (could be route/user changes). + if err := s.kernel.Reload(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil { + slog.Warn("reload failed, falling back to full restart", "error", err) + needsRestart = true + } + } + + if needsRestart || !s.kernel.IsRunning() { + if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil { + slog.Error("failed to apply config", "error", err) + } else { + // Update tracking state for next change + s.lastAppliedPort = s.lastConfig.ServerPort + s.lastAppliedIP = s.lastConfig.ListenIP + } + } + } else if len(s.lastUsers) == 0 { + slog.Warn("no users, stopping kernel") + s.kernel.Stop() + } + } else if usersChanged && s.kernel.IsRunning() { + // Hot-reload for user-only changes + if err := s.kernel.Reload(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil { + slog.Warn("inbound reload failed, falling back to full restart", "error", err) + if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil { + slog.Error("failed to apply config (fallback)", "error", err) + } else { + s.lastAppliedPort = s.lastConfig.ServerPort + s.lastAppliedIP = s.lastConfig.ListenIP + } + } + } else if usersChanged && !s.kernel.IsRunning() && len(s.lastUsers) > 0 { + // Kernel was stopped (no users), now we have users — start it + if err := s.kernel.ApplyConfig(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil { + slog.Error("failed to start kernel with new users", "error", err) + } + } +} + +func (s *Service) trackAndEnforce(ctx context.Context) { + if !s.kernel.IsRunning() { + return + } + + conns, err := s.kernel.GetConnections(ctx) + if err != nil { + slog.Debug("get connections failed", "error", err) + return + } + + s.tracker.Process(conns) + s.tracker.LogStats() + + kicks := s.limiter.Check(conns) + s.executeKicks(ctx, kicks) +} + +func (s *Service) executeKicks(ctx context.Context, kicks []limiter.KickAction) { + for _, kick := range kicks { + slog.Info("kicking connection", + "user_id", kick.UserID, + "conn_id", kick.ConnID, + "reason", kick.Reason, + ) + if err := s.kernel.CloseConnection(ctx, kick.ConnID); err != nil { + slog.Error("failed to close connection", "conn_id", kick.ConnID, "error", err) + } + } +} + +// pushReport sends consolidated traffic + alive + status to the panel +func (s *Service) pushReport() { + if s.pushBackoff.shouldSkip() { + slog.Debug("skipping report due to backoff") + return + } + + traffic := s.tracker.FlushTraffic() + aliveIPs := s.tracker.FlushAliveIPs() + online := s.tracker.CurrentOnline() + status := monitor.Collect() + + metrics := s.buildMetrics(status) + + if len(traffic) == 0 && len(aliveIPs) == 0 && len(online) == 0 { + // Still send status periodically + if err := s.panel.Report( + nil, nil, nil, + status.CPU, + [2]uint64{status.MemTotal, status.MemUsed}, + [2]uint64{status.SwapTotal, status.SwapUsed}, + [2]uint64{status.DiskTotal, status.DiskUsed}, + metrics, + ); err != nil { + slog.Error("failed to push status report", "error", err) + s.pushBackoff.onFailure() + return + } + s.pushBackoff.onSuccess() + return + } + + if err := s.panel.Report( + traffic, aliveIPs, online, + status.CPU, + [2]uint64{status.MemTotal, status.MemUsed}, + [2]uint64{status.SwapTotal, status.SwapUsed}, + [2]uint64{status.DiskTotal, status.DiskUsed}, + metrics, + ); err != nil { + slog.Error("failed to push report", "error", err) + s.tracker.RestoreTraffic(traffic) + s.tracker.RestoreAliveIPs(aliveIPs) + s.pushBackoff.onFailure() + return + } + + s.pushBackoff.onSuccess() + slog.Info("report pushed", "users", len(traffic)) +} + +// buildMetrics aggregates node-level metrics to be reported to the panel. +// This includes active connections, per-core CPU, GC stats, API call stats, +// WebSocket status, and limiter hit counts. +func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} { + m := make(map[string]interface{}) + + m["uptime"] = status.Uptime + + // Active connections (last measured during tracker.Process()). + m["active_connections"] = s.tracker.ActiveConnections() + m["total_connections"] = s.tracker.TotalConnections() + + // Speed + m["inbound_speed"] = s.tracker.InboundSpeed() + m["outbound_speed"] = s.tracker.OutboundSpeed() + + // Per-core CPU usage (if available). + if len(status.CPUPerCore) > 0 { + m["cpu_per_core"] = status.CPUPerCore + } + + // Speed Limiter metrics + m["speed_limiter"] = map[string]interface{}{ + "has_limits": s.speedTracker.HasLimits(), + "limited_users": s.speedTracker.LimitedUserCount(), + } + + // GC metrics. + m["gc"] = map[string]interface{}{ + "num_gc": status.NumGC, + "last_pause_ms": status.LastPauseMS, + } + + // API metrics. + api := s.panel.SnapshotMetrics() + m["api"] = map[string]interface{}{ + "success": api.Success, + "failure": api.Failure, + } + + // WebSocket status. + wsEnabled := s.wsClient != nil + wsConnected := wsEnabled && s.wsClient.IsConnected() + m["ws"] = map[string]interface{}{ + "enabled": wsEnabled, + "connected": wsConnected, + } + + // Limiter metrics. + lm := s.limiter.SnapshotMetrics() + m["limits"] = map[string]interface{}{ + "device_limit_events": lm.DeviceLimitEvents, + "speed_limited_users": s.speedTracker.LimitedUserCount(), + } + + return m +} + +// computeConfigHash returns a SHA-256 hash of the kernel-relevant node config +// fields. BaseConfig (PushInterval/PullInterval) is excluded because it only +// affects service-layer polling intervals and must not trigger a kernel restart. +func computeConfigHash(cfg *panel.NodeConfig) string { + if cfg == nil { + return "" + } + tmp := *cfg + tmp.BaseConfig = panel.BaseConfig{} + data, err := json.Marshal(&tmp) + if err != nil { + return "" + } + h := sha256.Sum256(data) + return fmt.Sprintf("%x", h) +} + +// computeUserHash returns a deterministic hash of the user list for change detection. +// Uses direct byte encoding instead of binary.Write to avoid reflection overhead. +func computeUserHash(users []panel.User) string { + sorted := make([]panel.User, len(users)) + copy(sorted, users) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + + h := sha256.New() + var buf [8]byte + for _, u := range sorted { + buf[0] = byte(u.ID) + buf[1] = byte(u.ID >> 8) + buf[2] = byte(u.ID >> 16) + buf[3] = byte(u.ID >> 24) + buf[4] = byte(u.ID >> 32) + buf[5] = byte(u.ID >> 40) + buf[6] = byte(u.ID >> 48) + buf[7] = byte(u.ID >> 56) + h.Write(buf[:]) + h.Write([]byte(u.UUID)) + buf[0] = byte(u.SpeedLimit) + buf[1] = byte(u.SpeedLimit >> 8) + buf[2] = byte(u.SpeedLimit >> 16) + buf[3] = byte(u.SpeedLimit >> 24) + buf[4] = byte(u.SpeedLimit >> 32) + buf[5] = byte(u.SpeedLimit >> 40) + buf[6] = byte(u.SpeedLimit >> 48) + buf[7] = byte(u.SpeedLimit >> 56) + h.Write(buf[:]) + buf[0] = byte(u.DeviceLimit) + buf[1] = byte(u.DeviceLimit >> 8) + buf[2] = byte(u.DeviceLimit >> 16) + buf[3] = byte(u.DeviceLimit >> 24) + buf[4] = byte(u.DeviceLimit >> 32) + buf[5] = byte(u.DeviceLimit >> 40) + buf[6] = byte(u.DeviceLimit >> 48) + buf[7] = byte(u.DeviceLimit >> 56) + h.Write(buf[:]) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go new file mode 100644 index 0000000..0309c09 --- /dev/null +++ b/internal/tracker/tracker.go @@ -0,0 +1,275 @@ +package tracker + +import ( + "log/slog" + + "github.com/cedar2025/xboard-node/internal/kernel" +) + +const ( + // DefaultMaxConnState caps how many connection-state entries we maintain. + // Prevents unbounded memory growth from rogue / DDoS connection floods. + DefaultMaxConnState = 100_000 + + // DefaultMaxAliveIPsPerUser caps the per-user IP set tracked per push interval. + // 200 IPs per user is generous for any legitimate use case. + DefaultMaxAliveIPsPerUser = 200 +) + +// Tracker tracks per-connection traffic deltas and accumulates per-user totals. +// +// Thread safety model: Tracker is designed for SINGLE-GOROUTINE use only. +// All methods (Process, Flush*, Restore*, CurrentOnline, speed accessors) are +// called exclusively from the service goroutine's select loop. No internal +// locking is provided. Do NOT call Tracker methods from multiple goroutines. +// +// Hot-path optimisation (②): activeConns is allocated once and cleared in-place +// every Process() call instead of being reallocated, saving GC pressure at high +// connection counts. +// +// OOM protection (④): connState and aliveIPs are capped at configurable maximums. +type Tracker struct { + connState map[string][2]int64 // conn_id → last [upload, download] + traffic map[int][2]int64 // user_id → accumulated [upload, download] + aliveIPs map[int]map[string]bool // user_id → set of source IPs + online map[int]int // user_id → current active connection count + + // ② Reused buffer: cleared each Process() call, not reallocated. + activeConns map[string]struct{} + + // ④ Limits (set at construction, never mutated). + maxConnState int + maxAliveIPsPerUser int + + // lastActiveConns stores the last observed number of active connections. + // This is updated on each Process() call and read by the service when + // building node metrics for panel reporting. + lastActiveConns int + totalConns int64 // cumulative number of connections seen since startup + + // lastUserCount is used to pre-size intervalTraffic each cycle, avoiding + // repeated map rehashing under heavy user counts. + lastUserCount int + + inSpeed int64 + outSpeed int64 +} + +func New() *Tracker { + return NewWithLimits(DefaultMaxConnState, DefaultMaxAliveIPsPerUser) +} + +func NewWithLimits(maxConnState, maxAliveIPsPerUser int) *Tracker { + return &Tracker{ + connState: make(map[string][2]int64), + traffic: make(map[int][2]int64), + aliveIPs: make(map[int]map[string]bool), + online: make(map[int]int), + activeConns: make(map[string]struct{}), + maxConnState: maxConnState, + maxAliveIPsPerUser: maxAliveIPsPerUser, + } +} + +// Process calculates per-user traffic deltas for this interval. +// Internally accumulates totals for panel push and tracks alive IPs. +// Returns per-user interval traffic [upload, download] for the limiter. +func (t *Tracker) Process(conns []kernel.Connection) map[int][2]int64 { + // Pre-size using last cycle's user count to avoid rehashing under heavy load. + intervalTraffic := make(map[int][2]int64, t.lastUserCount) + + // Clear activeConns in-place (no alloc, backing array retained). + for k := range t.activeConns { + delete(t.activeConns, k) + } + + // Rebuild aliveIPs each cycle so only currently-active IPs are reported. + // Previously, IPs accumulated across cycles leading to stale entries. + for uid := range t.aliveIPs { + for ip := range t.aliveIPs[uid] { + delete(t.aliveIPs[uid], ip) + } + } + + // Clear online counts + for uid := range t.online { + delete(t.online, uid) + } + + for _, conn := range conns { + t.activeConns[conn.ID] = struct{}{} + + if conn.UserID == 0 { + continue + } + + t.online[conn.UserID]++ + + var deltaUp, deltaDown int64 + prev, exists := t.connState[conn.ID] + if exists { + deltaUp = conn.Upload - prev[0] + deltaDown = conn.Download - prev[1] + if deltaUp < 0 { + deltaUp = conn.Upload + } + if deltaDown < 0 { + deltaDown = conn.Download + } + } else { + // ④ New connection: only track if below the state cap. + if len(t.connState) >= t.maxConnState { + slog.Debug("connState cap reached, skipping new connection", + "cap", t.maxConnState, "conn_id", conn.ID) + continue + } + deltaUp = conn.Upload + deltaDown = conn.Download + t.totalConns++ + } + t.connState[conn.ID] = [2]int64{conn.Upload, conn.Download} + + if deltaUp > 0 || deltaDown > 0 { + cur := t.traffic[conn.UserID] + cur[0] += deltaUp + cur[1] += deltaDown + t.traffic[conn.UserID] = cur + + it := intervalTraffic[conn.UserID] + it[0] += deltaUp + it[1] += deltaDown + intervalTraffic[conn.UserID] = it + } + + // ④ Track alive IPs, capped per user. + if conn.SourceIP != "" { + ips := t.aliveIPs[conn.UserID] + if ips == nil { + ips = make(map[string]bool) + t.aliveIPs[conn.UserID] = ips + } + if len(ips) < t.maxAliveIPsPerUser { + ips[conn.SourceIP] = true + } + } + } + + // Cleanup stale connState entries for connections that are no longer active. + for id := range t.connState { + if _, active := t.activeConns[id]; !active { + delete(t.connState, id) + } + } + + // Snapshot current active connection count for metrics reporting. + t.lastActiveConns = len(t.connState) + + // Derive speed directly from intervalTraffic deltas accumulated this cycle. + // This avoids the negative-diff bug that occurs when connState entries for + // closed connections are removed, which made cumulative totals drop and + // clamped speed to 0 for one cycle after a mass disconnect. + var cycleIn, cycleOut int64 + for _, it := range intervalTraffic { + cycleOut += it[0] + cycleIn += it[1] + } + t.inSpeed = cycleIn + t.outSpeed = cycleOut + + // Update hint for next cycle's map pre-sizing. + if n := len(intervalTraffic); n > 0 { + t.lastUserCount = n + } + + return intervalTraffic +} + +// FlushTraffic returns accumulated per-user traffic and resets the counter. +func (t *Tracker) FlushTraffic() map[int][2]int64 { + data := t.traffic + t.traffic = make(map[int][2]int64, len(data)) + return data +} + +// RestoreTraffic adds traffic back (used when push to panel fails). +func (t *Tracker) RestoreTraffic(data map[int][2]int64) { + for uid, d := range data { + cur := t.traffic[uid] + cur[0] += d[0] + cur[1] += d[1] + t.traffic[uid] = cur + } +} + +// HasTraffic returns true if there is accumulated traffic to report. +func (t *Tracker) HasTraffic() bool { + return len(t.traffic) > 0 +} + +// FlushAliveIPs returns per-user alive IPs and resets the tracker. +func (t *Tracker) FlushAliveIPs() map[int][]string { + data := make(map[int][]string, len(t.aliveIPs)) + for uid, ips := range t.aliveIPs { + ipList := make([]string, 0, len(ips)) + for ip := range ips { + ipList = append(ipList, ip) + } + data[uid] = ipList + } + t.aliveIPs = make(map[int]map[string]bool, len(t.aliveIPs)) + return data +} + +// CurrentOnline returns a map of user_id to active connection count. +func (t *Tracker) CurrentOnline() map[int]int { + return t.online +} + +// RestoreAliveIPs merges alive IPs back in (used when push to panel fails). +func (t *Tracker) RestoreAliveIPs(data map[int][]string) { + for uid, ipList := range data { + ips := t.aliveIPs[uid] + if ips == nil { + ips = make(map[string]bool, len(ipList)) + t.aliveIPs[uid] = ips + } + for _, ip := range ipList { + if len(ips) < t.maxAliveIPsPerUser { + ips[ip] = true + } + } + } +} + +// LogStats logs current tracking statistics. +func (t *Tracker) LogStats() { + slog.Debug("tracker stats", + "active_connections", len(t.connState), + "users_with_traffic", len(t.traffic), + "users_with_ips", len(t.aliveIPs), + ) +} + +// ActiveConnections returns the last observed active connection count. +// This is cheap and safe because Tracker is only mutated from the service +// goroutine; reads happen from the same goroutine when pushing reports. +func (t *Tracker) ActiveConnections() int { + return t.lastActiveConns +} + +// TotalConnections returns the cumulative connection count since startup. +func (t *Tracker) TotalConnections() int64 { + return t.totalConns +} + +// InboundSpeed returns the last observed inbound (download) speed in bytes/second. +// Process() is called every 10 seconds, so we divide the 10-second total by 10. +func (t *Tracker) InboundSpeed() int64 { + return t.inSpeed / 10 +} + +// OutboundSpeed returns the last observed outbound (upload) speed in bytes/second. +// Process() is called every 10 seconds, so we divide the 10-second total by 10. +func (t *Tracker) OutboundSpeed() int64 { + return t.outSpeed / 10 +} diff --git a/internal/tracker/tracker_test.go b/internal/tracker/tracker_test.go new file mode 100644 index 0000000..d5fc2cf --- /dev/null +++ b/internal/tracker/tracker_test.go @@ -0,0 +1,278 @@ +package tracker + +import ( + "testing" + + "github.com/cedar2025/xboard-node/internal/kernel" +) + +func TestProcess_InitialTraffic(t *testing.T) { + tr := New() + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 2, Upload: 50, Download: 80, SourceIP: "2.2.2.2"}, + } + + interval := tr.Process(conns) + + if interval[1] != [2]int64{100, 200} { + t.Errorf("user 1 interval: got %v", interval[1]) + } + if interval[2] != [2]int64{50, 80} { + t.Errorf("user 2 interval: got %v", interval[2]) + } +} + +func TestProcess_DeltaCalculation(t *testing.T) { + tr := New() + + // First tick + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + } + tr.Process(conns) + + // Second tick — counters advanced + conns[0].Upload = 300 + conns[0].Download = 500 + interval := tr.Process(conns) + + if interval[1] != [2]int64{200, 300} { + t.Errorf("delta: got %v, want [200,300]", interval[1]) + } +} + +func TestProcess_CounterReset(t *testing.T) { + tr := New() + + // First tick with high counters + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 1000, Download: 2000}, + } + tr.Process(conns) + + // Counter reset (new value < old value) — treat as fresh + conns[0].Upload = 50 + conns[0].Download = 80 + interval := tr.Process(conns) + + if interval[1] != [2]int64{50, 80} { + t.Errorf("counter reset: got %v, want [50,80]", interval[1]) + } +} + +func TestProcess_ConnectionCleanup(t *testing.T) { + tr := New() + + // Tick 1: connection exists + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + }) + + // Tick 2: connection gone — should clean up state + tr.Process([]kernel.Connection{}) + + // Tick 3: same conn ID reappears — should count full bytes (not delta) + interval := tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 50, Download: 30}, + }) + + if interval[1] != [2]int64{50, 30} { + t.Errorf("after cleanup: got %v, want [50,30]", interval[1]) + } +} + +func TestProcess_ZeroUserID(t *testing.T) { + tr := New() + interval := tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 0, Upload: 100, Download: 200}, + }) + + if len(interval) != 0 { + t.Errorf("expected no interval traffic for userID=0, got %v", interval) + } +} + +func TestProcess_MultipleConnectionsSameUser(t *testing.T) { + tr := New() + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + {ID: "c2", UserID: 1, Upload: 50, Download: 80}, + } + + interval := tr.Process(conns) + + if interval[1] != [2]int64{150, 280} { + t.Errorf("aggregated: got %v, want [150,280]", interval[1]) + } +} + +func TestFlushTraffic(t *testing.T) { + tr := New() + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + {ID: "c2", UserID: 2, Upload: 50, Download: 80}, + }) + + 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() + + // Generate some traffic + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + }) + + flushed := tr.FlushTraffic() + + // Restore (simulating failed push) + 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() + + // Generate traffic + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + }) + + // Generate more traffic + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 200, Download: 400}, + }) + + // Flush and restore + first := tr.FlushTraffic() + tr.RestoreTraffic(first) + + // Generate additional traffic + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 250, Download: 500}, + }) + + final := tr.FlushTraffic() + // Should be restored(200,400) + 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() + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 1, Upload: 50, Download: 80, SourceIP: "2.2.2.2"}, + {ID: "c3", UserID: 2, Upload: 30, Download: 40, SourceIP: "3.3.3.3"}, + }) + + aliveIPs := tr.FlushAliveIPs() + if len(aliveIPs[1]) != 2 { + t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1])) + } + if len(aliveIPs[2]) != 1 { + t.Errorf("user 2 IPs: got %d, want 1", len(aliveIPs[2])) + } + + // After flush, should be empty + aliveIPs2 := tr.FlushAliveIPs() + if len(aliveIPs2) != 0 { + t.Errorf("expected empty after second flush, got %v", aliveIPs2) + } +} + +func TestFlushAliveIPs_DedupSameIP(t *testing.T) { + tr := New() + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200, SourceIP: "1.1.1.1"}, + {ID: "c2", UserID: 1, Upload: 50, Download: 80, SourceIP: "1.1.1.1"}, + }) + + aliveIPs := tr.FlushAliveIPs() + if len(aliveIPs[1]) != 1 { + t.Errorf("expected dedup to 1 IP, got %d", len(aliveIPs[1])) + } +} + +func TestHasTraffic(t *testing.T) { + tr := New() + if tr.HasTraffic() { + t.Error("new tracker should not have traffic") + } + + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + }) + 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() + + conns := []kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + } + tr.Process(conns) + + // Same values — no delta + interval := tr.Process(conns) + if len(interval) != 0 { + t.Errorf("expected no interval traffic for zero delta, got %v", interval) + } +} + +func TestTrafficAccumulation(t *testing.T) { + tr := New() + + // Tick 1 + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 100, Download: 200}, + }) + // Tick 2 + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 300, Download: 500}, + }) + // Tick 3 + tr.Process([]kernel.Connection{ + {ID: "c1", UserID: 1, Upload: 350, Download: 600}, + }) + + 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]) + } +}