init xboard node
This commit is contained in:
@@ -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
|
||||||
+24
@@ -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
|
||||||
+50
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
+735
@@ -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 <node_id>
|
||||||
|
# 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 <node_id>"
|
||||||
|
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 <url> -t <token> -n <node_id> [-T <node_type>] [-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 <node_id> 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 "$@"
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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@<id>" so we can parse back the user ID from stats counters.
|
||||||
|
func userEmail(userID int) string {
|
||||||
|
return fmt.Sprintf("user@%d", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVMess(base M, nc *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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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@<id>" 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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user