feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl
This commit is contained in:
@@ -56,12 +56,18 @@ jobs:
|
||||
make build-linux-arm64
|
||||
fi
|
||||
|
||||
- name: Upload Artifact
|
||||
- name: Upload xboard-node artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xboard-node-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: xboard-node-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
|
||||
- name: Upload xbctl artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xbctl-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: xbctl-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
|
||||
build-docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
@@ -118,3 +124,5 @@ jobs:
|
||||
files: |
|
||||
dist/xboard-node-linux-amd64
|
||||
dist/xboard-node-linux-arm64
|
||||
dist/xbctl-linux-amd64
|
||||
dist/xbctl-linux-arm64
|
||||
|
||||
@@ -25,3 +25,10 @@ config.json
|
||||
benchmark/
|
||||
third_party
|
||||
bench-results
|
||||
internal/kernel/singbox/config_bench_test.go
|
||||
internal/kernel/xray/config_bench_test.go
|
||||
internal/service/service_bench_test.go
|
||||
internal/tracker/tracker_bench_test.go
|
||||
cmd/xboard-node-bench-compare
|
||||
cmd/xboard-node-bench
|
||||
.env
|
||||
@@ -2,19 +2,22 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
|
||||
.PHONY: build clean test docker install
|
||||
.PHONY: build clean test docker install build-linux build-linux-arm64 build-all
|
||||
|
||||
# Build for current platform
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -tags "with_quic with_utls with_wireguard with_clash_api" -o xboard-node ./cmd/xboard-node
|
||||
go build -ldflags "$(LDFLAGS)" -o xbctl ./cmd/xbctl
|
||||
|
||||
# Build for Linux amd64
|
||||
build-linux:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -tags "with_quic with_utls with_wireguard with_acme with_clash_api" -o xboard-node-linux-amd64 ./cmd/xboard-node
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o xbctl-linux-amd64 ./cmd/xbctl
|
||||
|
||||
# Build for Linux arm64
|
||||
build-linux-arm64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -tags "with_quic with_utls with_wireguard with_acme with_clash_api" -o xboard-node-linux-arm64 ./cmd/xboard-node
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o xbctl-linux-arm64 ./cmd/xbctl
|
||||
|
||||
# Build all platforms
|
||||
build-all: build-linux build-linux-arm64
|
||||
@@ -25,7 +28,7 @@ test:
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -f xboard-node xboard-node-linux-*
|
||||
rm -f xboard-node xbctl xboard-node-linux-* xbctl-linux-*
|
||||
|
||||
# Build Docker image
|
||||
docker:
|
||||
@@ -34,6 +37,7 @@ docker:
|
||||
# Install to system (single node, legacy compat)
|
||||
install: build
|
||||
sudo cp xboard-node /usr/local/bin/
|
||||
sudo cp xbctl /usr/local/bin/
|
||||
sudo mkdir -p /etc/xboard-node
|
||||
@if [ ! -f /etc/xboard-node/config.yml ]; then \
|
||||
sudo cp config.yml.example /etc/xboard-node/config.yml; \
|
||||
|
||||
@@ -1,65 +1,75 @@
|
||||
# xboard-node
|
||||
|
||||
Dedicated node backend for [Xboard](https://github.com/cedar2025/Xboard). Fully compatible with Xboard API.
|
||||
Node backend for [Xboard](https://github.com/cedar2025/Xboard). Supports `sing-box` / `xray-core` dual kernels.
|
||||
|
||||
> **Disclaimer**: This project is for educational and learning purposes only.
|
||||
|
||||
## Overview
|
||||
## Features
|
||||
|
||||
| Item | Description |
|
||||
| --- | --- |
|
||||
| Role | Xboard-compatible node backend |
|
||||
| Kernels | `sing-box` (default), `xray-core` |
|
||||
| Protocols | V2Ray family, Trojan, Shadowsocks, Hysteria2, TUIC, Naive |
|
||||
| Modes | Panel-managed mode, `standalone` mode |
|
||||
| Sync | WebSocket push, REST polling/report fallback |
|
||||
| User controls | Per-user speed limit, device limit, alive IP tracking |
|
||||
| Runtime ops | Hot user add/remove/update |
|
||||
| Reporting | Traffic, online/alive-IP state, CPU, memory, swap, disk, connection count |
|
||||
| Deployment | Single Go service, Docker, Docker Compose |
|
||||
- Protocols: V2Ray family, Trojan, Shadowsocks, Hysteria2, TUIC, AnyTLS
|
||||
- Sync: WebSocket push + REST polling dual channel
|
||||
- User controls: speed limit, device limit, alive-IP tracking, hot update
|
||||
- Deploy modes: node mode, machine mode, standalone mode
|
||||
- Multi-instance: single process binding multiple panels / nodes
|
||||
|
||||
## Install
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d --restart=always --network=host \
|
||||
-e apiHost=https://panel.com \
|
||||
-e apiKey=TOKEN \
|
||||
-e nodeID=1 \
|
||||
-e apiHost=https://panel.com -e apiKey=TOKEN -e nodeID=1 \
|
||||
ghcr.io/cedar2025/xboard-node:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
**1. Get the `compose/` directory**
|
||||
|
||||
```bash
|
||||
git clone -b compose --depth 1 https://github.com/cedar2025/xboard-node.git
|
||||
cd xboard-node
|
||||
```
|
||||
|
||||
**2. Edit local config**
|
||||
|
||||
```bash
|
||||
vim config/config.yml
|
||||
# edit config/config.yml — set panel.url, panel.token, panel.node_id
|
||||
```
|
||||
|
||||
**3. Start**
|
||||
|
||||
```bash
|
||||
vim config/config.yml # set panel.url / token / node_id
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Installer (Linux systemd)
|
||||
|
||||
```bash
|
||||
# Node mode
|
||||
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
|
||||
sudo bash -s -- --mode node --panel https://panel.example.com --token TOKEN --node-id 1
|
||||
|
||||
# Machine mode
|
||||
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
|
||||
sudo bash -s -- --mode machine --panel https://panel.example.com --token TOKEN --machine-id 1
|
||||
|
||||
# Run again with another --panel to append a binding. No manual instance management needed.
|
||||
# Upgrade
|
||||
curl -fsSL .../install.sh | sudo bash -s -- upgrade
|
||||
# Uninstall
|
||||
curl -fsSL .../install.sh | sudo bash -s -- uninstall --purge --yes
|
||||
```
|
||||
|
||||
## xbctl
|
||||
|
||||
Run `xbctl` after installation for help. Common commands:
|
||||
|
||||
```bash
|
||||
xbctl list # list all instances
|
||||
xbctl status # running status
|
||||
xbctl bind add-node --panel URL --token TOKEN --node-id 1
|
||||
xbctl bind add-machine --panel URL --token TOKEN --machine-id 1
|
||||
xbctl bind remove-node --panel URL --node-id 1
|
||||
xbctl service restart
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`config.yml`:
|
||||
```yaml
|
||||
panel:
|
||||
url: "https://panel.com"
|
||||
token: "token"
|
||||
node_id: 1
|
||||
```
|
||||
Legacy single-panel config is fully compatible. Appending bindings auto-migrates to `instances` format. See `config.yml.example`.
|
||||
|
||||
## Extensions
|
||||
|
||||
- Custom routes: [docs-custom-routes.md](docs-custom-routes.md)
|
||||
- Custom outbounds: [docs-custom-outbounds.md](docs-custom-outbounds.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigPath = "/etc/xboard-node/config.yml"
|
||||
defaultMetaPath = "/etc/xboard-node/install-meta.json"
|
||||
defaultCredentialsPath = "/etc/xboard-node/credentials.env"
|
||||
defaultInstallerPath = "/etc/xboard-node/install.sh"
|
||||
defaultBinaryPath = "/usr/local/bin/xboard-node"
|
||||
defaultCLIPath = "/usr/local/bin/xbctl"
|
||||
defaultHealthURL = "http://127.0.0.1:65530/healthz"
|
||||
serviceName = "xboard-node.service"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
type instanceRow struct {
|
||||
ID string `json:"id"`
|
||||
Mode string `json:"mode"`
|
||||
Panel string `json:"panel"`
|
||||
Target string `json:"target"`
|
||||
Service string `json:"service"`
|
||||
Health string `json:"health"`
|
||||
}
|
||||
|
||||
type fileRootConfig struct {
|
||||
Log *fileLogConfig `yaml:"log,omitempty"`
|
||||
Kernel *fileKernelConfig `yaml:"kernel,omitempty"`
|
||||
Node *fileNodeConfig `yaml:"node,omitempty"`
|
||||
WS *config.WSConfig `yaml:"ws,omitempty"`
|
||||
Runtime *fileRuntimeConfig `yaml:"runtime,omitempty"`
|
||||
Cert *config.CertConfig `yaml:"cert,omitempty"`
|
||||
Instances []fileInstance `yaml:"instances,omitempty"`
|
||||
}
|
||||
|
||||
type fileInstance struct {
|
||||
ID string `yaml:"id,omitempty"`
|
||||
Panel filePanelConfig `yaml:"panel"`
|
||||
Node *fileNodeConfig `yaml:"node,omitempty"`
|
||||
Kernel fileKernelConfig `yaml:"kernel"`
|
||||
Log fileLogConfig `yaml:"log"`
|
||||
Runtime *fileRuntimeConfig `yaml:"runtime,omitempty"`
|
||||
HealthPort int `yaml:"health_port,omitempty"`
|
||||
Machine *fileMachineConfig `yaml:"machine,omitempty"`
|
||||
Standalone map[string]any `yaml:"standalone,omitempty"`
|
||||
Cert *config.CertConfig `yaml:"cert,omitempty"`
|
||||
WS *config.WSConfig `yaml:"ws,omitempty"`
|
||||
Nodes []config.NodeEntry `yaml:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
type filePanelConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
TokenEnv string `yaml:"token_env,omitempty"`
|
||||
NodeID int `yaml:"node_id,omitempty"`
|
||||
NodeType string `yaml:"node_type,omitempty"`
|
||||
}
|
||||
|
||||
type fileMachineConfig struct {
|
||||
MachineID int `yaml:"machine_id"`
|
||||
TokenEnv string `yaml:"token_env,omitempty"`
|
||||
}
|
||||
|
||||
type fileNodeConfig struct {
|
||||
PushInterval int `yaml:"push_interval,omitempty"`
|
||||
PullInterval int `yaml:"pull_interval,omitempty"`
|
||||
TrackInterval int `yaml:"track_interval,omitempty"`
|
||||
DeviceReportInterval int `yaml:"device_report_interval,omitempty"`
|
||||
}
|
||||
|
||||
type fileKernelConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
LogLevel string `yaml:"log_level,omitempty"`
|
||||
GeoDataDir string `yaml:"geo_data_dir,omitempty"`
|
||||
CustomConfig string `yaml:"custom_config,omitempty"`
|
||||
CustomRoute []map[string]any `yaml:"custom_route,omitempty"`
|
||||
CustomOut []map[string]any `yaml:"custom_outbound,omitempty"`
|
||||
}
|
||||
|
||||
type fileLogConfig struct {
|
||||
Level string `yaml:"level,omitempty"`
|
||||
Output string `yaml:"output,omitempty"`
|
||||
}
|
||||
|
||||
type fileRuntimeConfig struct {
|
||||
GoMemLimit string `yaml:"gomemlimit,omitempty"`
|
||||
GoGCPercent int `yaml:"gogc,omitempty"`
|
||||
}
|
||||
|
||||
type instanceSummary struct {
|
||||
ID string `json:"id"`
|
||||
PanelURL string `json:"panel_url"`
|
||||
Mode string `json:"mode"`
|
||||
NodeID *int `json:"node_id"`
|
||||
MachineID *int `json:"machine_id"`
|
||||
HealthPort int `json:"health_port"`
|
||||
}
|
||||
|
||||
type installMeta struct {
|
||||
ConfigMode string `json:"config_mode"`
|
||||
Version string `json:"version"`
|
||||
LatestInstanceID string `json:"latest_instance_id"`
|
||||
InstanceCount int `json:"instance_count"`
|
||||
Instances []instanceSummary `json:"instances"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func loadInstallMeta(path string) (*installMeta, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := &installMeta{}
|
||||
if err := json.Unmarshal(data, meta); err != nil {
|
||||
return nil, fmt.Errorf("parse install meta: %w", err)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
printUsage()
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
return runStatus()
|
||||
case "list":
|
||||
return runList(args[1:])
|
||||
case "instance":
|
||||
return runInstance(args[1:])
|
||||
case "service":
|
||||
return runService(args[1:])
|
||||
case "logs":
|
||||
return runService([]string{"logs"})
|
||||
case "health":
|
||||
return runHealth()
|
||||
case "bind":
|
||||
return runBind(args[1:])
|
||||
case "bind-node":
|
||||
return runBind(append([]string{"add-node"}, args[1:]...))
|
||||
case "bind-machine":
|
||||
return runBind(append([]string{"add-machine"}, args[1:]...))
|
||||
case "unbind-node":
|
||||
return runBind(append([]string{"remove-node"}, args[1:]...))
|
||||
case "unbind-machine":
|
||||
return runBind(append([]string{"remove-machine"}, args[1:]...))
|
||||
case "start", "stop", "restart":
|
||||
return runService(args)
|
||||
case "upgrade":
|
||||
return runInstaller(append([]string{"upgrade"}, args[1:]...))
|
||||
case "uninstall":
|
||||
return runInstaller(append([]string{"uninstall"}, args[1:]...))
|
||||
case "version", "-v", "--version":
|
||||
fmt.Printf("xbctl %s (built %s)\n", version, buildTime)
|
||||
return nil
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown command: %s", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`xbctl commands:
|
||||
xbctl help
|
||||
xbctl status
|
||||
xbctl list [--output text|json]
|
||||
xbctl instance list [--output text|json]
|
||||
xbctl instance get <id> [--output text|json]
|
||||
xbctl service status|start|stop|restart|logs
|
||||
xbctl health
|
||||
xbctl bind add-node --panel URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray]
|
||||
xbctl bind add-machine --panel URL --token TOKEN --machine-id ID [--kernel singbox|xray]
|
||||
xbctl bind remove-node --panel URL --node-id ID
|
||||
xbctl bind remove-machine --panel URL --machine-id ID
|
||||
xbctl bind-node ...
|
||||
xbctl bind-machine ...
|
||||
xbctl unbind-node --panel URL --node-id ID
|
||||
xbctl unbind-machine --panel URL --machine-id ID
|
||||
xbctl upgrade [...]
|
||||
xbctl uninstall [...]`)
|
||||
}
|
||||
|
||||
func runStatus() error {
|
||||
return runInstaller([]string{"status"})
|
||||
}
|
||||
|
||||
func runList(args []string) error {
|
||||
output := parseOutput(args)
|
||||
rows, err := collectInstanceRows()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printRows(rows, output)
|
||||
}
|
||||
|
||||
func runInstance(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return runList(nil)
|
||||
}
|
||||
if args[0] == "list" {
|
||||
return runList(args[1:])
|
||||
}
|
||||
if args[0] != "get" {
|
||||
return fmt.Errorf("unknown instance command: %s", args[0])
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return errors.New("usage: xbctl instance get <id> [--output text|json]")
|
||||
}
|
||||
id := args[1]
|
||||
output := parseOutput(args[2:])
|
||||
rows, err := collectInstanceRows()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.ID == id {
|
||||
return printRows([]instanceRow{row}, output)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("instance not found: %s", id)
|
||||
}
|
||||
|
||||
func runService(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: xbctl service <status|start|stop|restart|logs>")
|
||||
}
|
||||
sub := args[0]
|
||||
rest := args[1:]
|
||||
switch sub {
|
||||
case "status":
|
||||
return runCommand("sudo", append([]string{"systemctl", "status", serviceName, "--no-pager"}, rest...)...)
|
||||
case "start", "stop", "restart":
|
||||
return runCommand("sudo", append([]string{"systemctl", sub, serviceName}, rest...)...)
|
||||
case "logs":
|
||||
if len(rest) == 0 {
|
||||
rest = []string{"-f"}
|
||||
}
|
||||
return runCommand("sudo", append([]string{"journalctl", "-u", serviceName}, rest...)...)
|
||||
default:
|
||||
return fmt.Errorf("unknown service command: %s", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func runHealth() error {
|
||||
return runCommand("curl", "-fsS", defaultHealthURL)
|
||||
}
|
||||
|
||||
func runBind(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: xbctl bind <add-node|add-machine|remove-node|remove-machine> ...")
|
||||
}
|
||||
sub := args[0]
|
||||
rest := args[1:]
|
||||
switch sub {
|
||||
case "add-node":
|
||||
return runInstaller(append([]string{"--mode", "node"}, rest...))
|
||||
case "add-machine":
|
||||
return runInstaller(append([]string{"--mode", "machine"}, rest...))
|
||||
case "remove-node":
|
||||
panel, nodeID, err := parseRemoveNodeArgs(rest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeBinding(panel, nodeID, 0)
|
||||
case "remove-machine":
|
||||
panel, machineID, err := parseRemoveMachineArgs(rest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeBinding(panel, 0, machineID)
|
||||
default:
|
||||
return fmt.Errorf("unknown bind command: %s", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstaller(args []string) error {
|
||||
installerPath := defaultInstallerPath
|
||||
usingSystemInstaller := false
|
||||
if _, err := os.Stat(installerPath); err == nil {
|
||||
usingSystemInstaller = true
|
||||
} else {
|
||||
installerPath = "./install.sh"
|
||||
}
|
||||
all := append([]string{"bash", installerPath}, args...)
|
||||
if usingSystemInstaller {
|
||||
all = append(all, "--binary", defaultBinaryPath, "--xbctl-binary", defaultCLIPath)
|
||||
}
|
||||
return runCommand("sudo", all...)
|
||||
}
|
||||
|
||||
func runCommand(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func parseOutput(args []string) string {
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--output" && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
if strings.HasPrefix(args[i], "--output=") {
|
||||
return strings.TrimPrefix(args[i], "--output=")
|
||||
}
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
func parseRemoveNodeArgs(args []string) (string, int, error) {
|
||||
var panel string
|
||||
var nodeID int
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--panel", "-a", "--api":
|
||||
if i+1 >= len(args) {
|
||||
return "", 0, errors.New("missing value for --panel")
|
||||
}
|
||||
panel = args[i+1]
|
||||
i++
|
||||
case "--node-id", "-n":
|
||||
if i+1 >= len(args) {
|
||||
return "", 0, errors.New("missing value for --node-id")
|
||||
}
|
||||
var err error
|
||||
nodeID, err = parsePositiveInt(args[i+1], "node-id")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
i++
|
||||
default:
|
||||
return "", 0, fmt.Errorf("unknown remove-node arg: %s", args[i])
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(panel) == "" || nodeID <= 0 {
|
||||
return "", 0, errors.New("usage: xbctl bind remove-node --panel URL --node-id ID")
|
||||
}
|
||||
return strings.TrimSpace(panel), nodeID, nil
|
||||
}
|
||||
|
||||
func parseRemoveMachineArgs(args []string) (string, int, error) {
|
||||
var panel string
|
||||
var machineID int
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--panel", "-a", "--api":
|
||||
if i+1 >= len(args) {
|
||||
return "", 0, errors.New("missing value for --panel")
|
||||
}
|
||||
panel = args[i+1]
|
||||
i++
|
||||
case "--machine-id":
|
||||
if i+1 >= len(args) {
|
||||
return "", 0, errors.New("missing value for --machine-id")
|
||||
}
|
||||
var err error
|
||||
machineID, err = parsePositiveInt(args[i+1], "machine-id")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
i++
|
||||
default:
|
||||
return "", 0, fmt.Errorf("unknown remove-machine arg: %s", args[i])
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(panel) == "" || machineID <= 0 {
|
||||
return "", 0, errors.New("usage: xbctl bind remove-machine --panel URL --machine-id ID")
|
||||
}
|
||||
return strings.TrimSpace(panel), machineID, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, field string) (int, error) {
|
||||
var value int
|
||||
_, err := fmt.Sscanf(raw, "%d", &value)
|
||||
if err != nil || value <= 0 {
|
||||
return 0, fmt.Errorf("invalid %s: %s", field, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func removeBinding(panelURL string, nodeID int, machineID int) error {
|
||||
root, err := loadWritableRootConfig(defaultConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instances := normalizeRootInstances(root)
|
||||
if len(instances) == 0 {
|
||||
return errors.New("no instances configured")
|
||||
}
|
||||
|
||||
kept := make([]config.Config, 0, len(instances))
|
||||
removed := make([]config.Config, 0, 1)
|
||||
for _, inst := range instances {
|
||||
matched := strings.TrimSpace(inst.Panel.URL) == strings.TrimSpace(panelURL)
|
||||
if nodeID > 0 {
|
||||
matched = matched && !inst.IsMachineMode() && inst.Panel.NodeID == nodeID
|
||||
}
|
||||
if machineID > 0 {
|
||||
matched = matched && inst.IsMachineMode() && inst.Machine != nil && inst.Machine.MachineID == machineID
|
||||
}
|
||||
if matched {
|
||||
removed = append(removed, inst)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, inst)
|
||||
}
|
||||
if len(removed) == 0 {
|
||||
if nodeID > 0 {
|
||||
return fmt.Errorf("binding not found: panel=%s node_id=%d", panelURL, nodeID)
|
||||
}
|
||||
return fmt.Errorf("binding not found: panel=%s machine_id=%d", panelURL, machineID)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return errors.New("refusing to remove the last binding; use xbctl uninstall if you want to remove everything")
|
||||
}
|
||||
|
||||
root.Instances = kept
|
||||
// Preserve top-level shared settings (log, kernel, etc.) — instances inherit from these.
|
||||
if err := writeRootConfig(defaultConfigPath, root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pruneCredentialKeys(defaultCredentialsPath, removed); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeInstallMeta(defaultMetaPath, root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runCommand("sudo", "systemctl", "restart", serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %d binding(s)\n", len(removed))
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadWritableRootConfig(path string) (*config.RootConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
rc := &config.RootConfig{}
|
||||
if err := yaml.Unmarshal(data, rc); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if len(rc.Instances) == 0 {
|
||||
legacy := &config.Config{}
|
||||
if err := yaml.Unmarshal(data, legacy); err != nil {
|
||||
return nil, fmt.Errorf("parse legacy config: %w", err)
|
||||
}
|
||||
rc.Config = *legacy
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func normalizeRootInstances(root *config.RootConfig) []config.Config {
|
||||
if len(root.Instances) > 0 {
|
||||
return append([]config.Config(nil), root.Instances...)
|
||||
}
|
||||
return []config.Config{root.Config}
|
||||
}
|
||||
|
||||
func writeRootConfig(path string, root *config.RootConfig) error {
|
||||
instances := normalizeRootInstances(root)
|
||||
out := fileRootConfig{Instances: make([]fileInstance, 0, len(instances))}
|
||||
|
||||
// Preserve top-level shared settings so instances can inherit them.
|
||||
p := &root.Config
|
||||
if p.Log.Level != "" || p.Log.Output != "" {
|
||||
out.Log = &fileLogConfig{Level: p.Log.Level, Output: p.Log.Output}
|
||||
}
|
||||
if p.Kernel.Type != "" || p.Kernel.LogLevel != "" {
|
||||
out.Kernel = &fileKernelConfig{Type: p.Kernel.Type, LogLevel: p.Kernel.LogLevel}
|
||||
}
|
||||
if p.Node.PushInterval != 0 || p.Node.PullInterval != 0 || p.Node.TrackInterval != 0 || p.Node.DeviceReportInterval != 0 {
|
||||
out.Node = &fileNodeConfig{
|
||||
PushInterval: p.Node.PushInterval,
|
||||
PullInterval: p.Node.PullInterval,
|
||||
TrackInterval: p.Node.TrackInterval,
|
||||
DeviceReportInterval: p.Node.DeviceReportInterval,
|
||||
}
|
||||
}
|
||||
if p.Runtime.GoMemLimit != "" || p.Runtime.GoGCPercent != 0 {
|
||||
out.Runtime = &fileRuntimeConfig{GoMemLimit: p.Runtime.GoMemLimit, GoGCPercent: p.Runtime.GoGCPercent}
|
||||
}
|
||||
if p.WS.StatusInterval != 0 || p.WS.HandshakeTimeout != 0 || p.WS.BackoffInitial != 0 {
|
||||
out.WS = &p.WS
|
||||
}
|
||||
if p.Cert.CertMode != "" || p.Cert.Domain != "" || p.Cert.CertFile != "" || p.Cert.AutoTLS {
|
||||
out.Cert = &p.Cert
|
||||
}
|
||||
|
||||
for _, inst := range instances {
|
||||
fi := fileInstance{
|
||||
ID: inst.InstanceID,
|
||||
Panel: filePanelConfig{
|
||||
URL: inst.Panel.URL,
|
||||
TokenEnv: inst.Panel.TokenEnv,
|
||||
NodeID: inst.Panel.NodeID,
|
||||
NodeType: inst.Panel.NodeType,
|
||||
},
|
||||
Kernel: fileKernelConfig{
|
||||
Type: inst.Kernel.Type,
|
||||
ConfigDir: inst.Kernel.ConfigDir,
|
||||
LogLevel: inst.Kernel.LogLevel,
|
||||
GeoDataDir: inst.Kernel.GeoDataDir,
|
||||
CustomConfig: inst.Kernel.CustomConfig,
|
||||
CustomRoute: inst.Kernel.CustomRoute,
|
||||
CustomOut: inst.Kernel.CustomOutbound,
|
||||
},
|
||||
Log: fileLogConfig{
|
||||
Level: inst.Log.Level,
|
||||
Output: inst.Log.Output,
|
||||
},
|
||||
HealthPort: inst.HealthPort,
|
||||
}
|
||||
if !inst.IsMachineMode() && (inst.Node.PushInterval != 0 || inst.Node.PullInterval != 0 || inst.Node.TrackInterval != 0 || inst.Node.DeviceReportInterval != 0) {
|
||||
fi.Node = &fileNodeConfig{
|
||||
PushInterval: inst.Node.PushInterval,
|
||||
PullInterval: inst.Node.PullInterval,
|
||||
TrackInterval: inst.Node.TrackInterval,
|
||||
DeviceReportInterval: inst.Node.DeviceReportInterval,
|
||||
}
|
||||
}
|
||||
if inst.Runtime.GoMemLimit != "" || inst.Runtime.GoGCPercent != 0 {
|
||||
fi.Runtime = &fileRuntimeConfig{
|
||||
GoMemLimit: inst.Runtime.GoMemLimit,
|
||||
GoGCPercent: inst.Runtime.GoGCPercent,
|
||||
}
|
||||
}
|
||||
if inst.IsMachineMode() && inst.Machine != nil {
|
||||
fi.Machine = &fileMachineConfig{
|
||||
MachineID: inst.Machine.MachineID,
|
||||
TokenEnv: inst.Machine.TokenEnv,
|
||||
}
|
||||
fi.Panel.NodeID = 0
|
||||
fi.Panel.NodeType = ""
|
||||
}
|
||||
if inst.Cert.CertMode != "" || inst.Cert.Domain != "" || inst.Cert.CertFile != "" || inst.Cert.AutoTLS {
|
||||
fi.Cert = &inst.Cert
|
||||
}
|
||||
if inst.WS.StatusInterval != 0 || inst.WS.HandshakeTimeout != 0 || inst.WS.BackoffInitial != 0 {
|
||||
fi.WS = &inst.WS
|
||||
}
|
||||
if len(inst.Nodes) > 0 {
|
||||
fi.Nodes = inst.Nodes
|
||||
}
|
||||
out.Instances = append(out.Instances, fi)
|
||||
}
|
||||
data, err := yaml.Marshal(&out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func pruneCredentialKeys(path string, removed []config.Config) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read credentials: %w", err)
|
||||
}
|
||||
removeKeys := map[string]struct{}{}
|
||||
for _, inst := range removed {
|
||||
if env := strings.TrimSpace(inst.Panel.TokenEnv); env != "" {
|
||||
removeKeys[env] = struct{}{}
|
||||
}
|
||||
if inst.Machine != nil {
|
||||
if env := strings.TrimSpace(inst.Machine.TokenEnv); env != "" {
|
||||
removeKeys[env] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
kept := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := trimmed
|
||||
if idx := strings.Index(trimmed, "="); idx >= 0 {
|
||||
key = trimmed[:idx]
|
||||
}
|
||||
if _, ok := removeKeys[key]; ok {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
output := strings.Join(kept, "\n")
|
||||
if output != "" {
|
||||
output += "\n"
|
||||
}
|
||||
return os.WriteFile(path, []byte(output), 0o600)
|
||||
}
|
||||
|
||||
func writeInstallMeta(path string, root *config.RootConfig) error {
|
||||
versionValue := "unknown"
|
||||
if meta, err := loadInstallMeta(path); err == nil && strings.TrimSpace(meta.Version) != "" {
|
||||
versionValue = meta.Version
|
||||
}
|
||||
instances := normalizeRootInstances(root)
|
||||
items := make([]instanceSummary, 0, len(instances))
|
||||
latestID := ""
|
||||
for _, inst := range instances {
|
||||
id, err := inst.AutoInstanceID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latestID = id
|
||||
item := instanceSummary{
|
||||
ID: id,
|
||||
PanelURL: inst.Panel.URL,
|
||||
Mode: instanceMode(inst),
|
||||
NodeID: intPtr(inst.Panel.NodeID),
|
||||
MachineID: machineIDPtr(&inst),
|
||||
HealthPort: inst.HealthPort,
|
||||
}
|
||||
if item.Mode == "machine" {
|
||||
item.NodeID = nil
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
meta := installMeta{
|
||||
ConfigMode: "instances",
|
||||
Version: versionValue,
|
||||
LatestInstanceID: latestID,
|
||||
InstanceCount: len(items),
|
||||
Instances: items,
|
||||
UpdatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal install meta: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func instanceMode(inst config.Config) string {
|
||||
if inst.IsMachineMode() {
|
||||
return "machine"
|
||||
}
|
||||
return "node"
|
||||
}
|
||||
|
||||
func collectInstanceRows() ([]instanceRow, error) {
|
||||
rows, err := collectRowsFromMeta()
|
||||
if err == nil && len(rows) > 0 {
|
||||
return rows, nil
|
||||
}
|
||||
return collectRowsFromConfig()
|
||||
}
|
||||
|
||||
func collectRowsFromMeta() ([]instanceRow, error) {
|
||||
meta, err := loadInstallMeta(defaultMetaPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serviceStatus := systemctlState()
|
||||
healthStatus := healthStatus()
|
||||
rows := make([]instanceRow, 0, len(meta.Instances))
|
||||
for _, inst := range meta.Instances {
|
||||
rows = append(rows, instanceRow{
|
||||
ID: inst.ID,
|
||||
Mode: inst.Mode,
|
||||
Panel: inst.PanelURL,
|
||||
Target: formatTarget(inst.NodeID, inst.MachineID),
|
||||
Service: serviceStatus,
|
||||
Health: healthStatus,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func collectRowsFromConfig() ([]instanceRow, error) {
|
||||
root, err := config.LoadRoot(defaultConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances, err := root.NormalizeInstances()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serviceStatus := systemctlState()
|
||||
healthStatus := healthStatus()
|
||||
rows := make([]instanceRow, 0, len(instances))
|
||||
for _, inst := range instances {
|
||||
mode := "node"
|
||||
if inst.IsMachineMode() {
|
||||
mode = "machine"
|
||||
}
|
||||
rows = append(rows, instanceRow{
|
||||
ID: inst.InstanceID,
|
||||
Mode: mode,
|
||||
Panel: inst.Panel.URL,
|
||||
Target: formatTarget(intPtr(inst.Panel.NodeID), machineIDPtr(inst)),
|
||||
Service: serviceStatus,
|
||||
Health: healthStatus,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func printRows(rows []instanceRow, output string) error {
|
||||
if output == "json" {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(rows)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
tw := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tMODE\tPANEL\tTARGET\tSERVICE\tHEALTH")
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", row.ID, row.Mode, row.Panel, row.Target, row.Service, row.Health)
|
||||
}
|
||||
tw.Flush()
|
||||
_, err := fmt.Print(buf.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func systemctlState() string {
|
||||
cmd := exec.Command("systemctl", "is-active", serviceName)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func healthStatus() string {
|
||||
cmd := exec.Command("curl", "-fsS", defaultHealthURL)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "down"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func formatTarget(nodeID *int, machineID *int) string {
|
||||
if nodeID != nil && *nodeID > 0 {
|
||||
return fmt.Sprintf("node_id=%d", *nodeID)
|
||||
}
|
||||
if machineID != nil && *machineID > 0 {
|
||||
return fmt.Sprintf("machine_id=%d", *machineID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
vv := v
|
||||
return &vv
|
||||
}
|
||||
|
||||
func machineIDPtr(cfg *config.Config) *int {
|
||||
if cfg.Machine == nil || cfg.Machine.MachineID <= 0 {
|
||||
return nil
|
||||
}
|
||||
vv := cfg.Machine.MachineID
|
||||
return &vv
|
||||
}
|
||||
+82
-29
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/machine"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/cedar2025/xboard-node/internal/service"
|
||||
)
|
||||
@@ -34,22 +35,27 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
rootCfg, err := config.LoadRoot(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
config.InitLogger(cfg.Log)
|
||||
instances, err := rootCfg.NormalizeInstances()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to normalize config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
config.InitLogger(instances[0].Log)
|
||||
|
||||
// Apply runtime memory tuning before anything else allocates.
|
||||
applyRuntimeConfig(cfg.Runtime)
|
||||
applyRuntimeConfig(instances[0].Runtime)
|
||||
|
||||
runWithReload(cfg, *configPath)
|
||||
runWithReload(rootCfg, *configPath)
|
||||
}
|
||||
|
||||
// runWithReload restarts all node services when the config file changes.
|
||||
func runWithReload(initialCfg *config.Config, configPath string) {
|
||||
func runWithReload(initialRoot *config.RootConfig, configPath string) {
|
||||
var healthSrv *http.Server
|
||||
var healthPort int
|
||||
startHealth := func(port int) {
|
||||
@@ -77,22 +83,27 @@ func runWithReload(initialCfg *config.Config, configPath string) {
|
||||
}()
|
||||
}
|
||||
|
||||
startHealth(initialCfg.HealthPort)
|
||||
initialInstances, err := initialRoot.NormalizeInstances()
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to normalize initial config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
startHealth(initialInstances[0].HealthPort)
|
||||
defer func() {
|
||||
if healthSrv != nil {
|
||||
healthSrv.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for cfg := initialCfg; ; {
|
||||
for root := initialRoot; ; {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
reloadCh := make(chan *config.Config, 1)
|
||||
reloadCh := make(chan *config.RootConfig, 1)
|
||||
|
||||
watcher, err := config.WatchConfig(ctx, configPath, func(newCfg *config.Config) {
|
||||
watcher, err := config.WatchConfigRoot(ctx, configPath, func(newCfg *config.RootConfig) {
|
||||
select {
|
||||
case reloadCh <- newCfg:
|
||||
default:
|
||||
@@ -120,40 +131,77 @@ func runWithReload(initialCfg *config.Config, configPath string) {
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.HealthPort != healthPort {
|
||||
instances, err := root.NormalizeInstances()
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to normalize config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := config.ValidateStartupLayout(instances); err != nil {
|
||||
nlog.Core().Error("startup layout validation failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if instances[0].HealthPort != healthPort {
|
||||
if healthSrv != nil {
|
||||
healthSrv.Close()
|
||||
healthSrv = nil
|
||||
}
|
||||
startHealth(cfg.HealthPort)
|
||||
startHealth(instances[0].HealthPort)
|
||||
}
|
||||
|
||||
nodes := cfg.ExpandNodes()
|
||||
nlog.Core().Info(fmt.Sprintf("xboard-node %s starting, %d nodes", version, len(nodes)))
|
||||
|
||||
errCh := make(chan error, len(nodes))
|
||||
errCh := make(chan error, len(instances))
|
||||
doneCh := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for _, nodeCfg := range nodes {
|
||||
nodeCfg := nodeCfg
|
||||
for _, instanceCfg := range instances {
|
||||
instanceCfg := instanceCfg
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
svc := service.New(nodeCfg)
|
||||
if err := svc.Run(ctx); err != nil {
|
||||
nlog.Core().Error("node service exited with error",
|
||||
"node_id", nodeCfg.Panel.NodeID, "error", err)
|
||||
if instanceCfg.IsMachineMode() {
|
||||
nlog.Core().Info("starting machine instance", "instance", instanceCfg.InstanceID, "machine_id", instanceCfg.Machine.MachineID, "panel_url", instanceCfg.Panel.URL)
|
||||
orch := machine.New(instanceCfg)
|
||||
if err := orch.Run(ctx); err != nil {
|
||||
nlog.Core().Error("machine instance exited with error", "instance", instanceCfg.InstanceID, "error", err)
|
||||
errCh <- err
|
||||
cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
nodes := instanceCfg.ExpandNodes()
|
||||
nlog.Core().Info("starting node instance", "instance", instanceCfg.InstanceID, "nodes", len(nodes), "panel_url", instanceCfg.Panel.URL)
|
||||
var instanceWG sync.WaitGroup
|
||||
for idx, nodeCfg := range nodes {
|
||||
nodeCfg := nodeCfg
|
||||
instanceWG.Add(1)
|
||||
go func(idx int) {
|
||||
defer instanceWG.Done()
|
||||
if idx > 0 {
|
||||
delay := time.Duration(idx) * 250 * time.Millisecond
|
||||
if delay > 2*time.Second {
|
||||
delay = 2 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
svc := service.New(nodeCfg)
|
||||
if err := svc.Run(ctx); err != nil {
|
||||
nlog.Core().Error("node service exited with error", "instance", nodeCfg.InstanceID, "node_id", nodeCfg.Panel.NodeID, "error", err)
|
||||
errCh <- err
|
||||
cancel()
|
||||
}
|
||||
}(idx)
|
||||
}
|
||||
instanceWG.Wait()
|
||||
}()
|
||||
}
|
||||
|
||||
doneCh := make(chan struct{})
|
||||
go func() { wg.Wait(); close(doneCh) }()
|
||||
|
||||
var newCfg *config.Config
|
||||
var newRoot *config.RootConfig
|
||||
select {
|
||||
case newCfg = <-reloadCh:
|
||||
case newRoot = <-reloadCh:
|
||||
nlog.Core().Info("config changed, restarting all services...")
|
||||
cancel()
|
||||
<-doneCh
|
||||
@@ -165,7 +213,7 @@ func runWithReload(initialCfg *config.Config, configPath string) {
|
||||
watcher.Stop()
|
||||
}
|
||||
|
||||
if newCfg == nil {
|
||||
if newRoot == nil {
|
||||
close(errCh)
|
||||
if err := <-errCh; err != nil {
|
||||
os.Exit(1)
|
||||
@@ -174,9 +222,14 @@ func runWithReload(initialCfg *config.Config, configPath string) {
|
||||
return
|
||||
}
|
||||
|
||||
config.InitLogger(newCfg.Log)
|
||||
applyRuntimeConfig(newCfg.Runtime)
|
||||
cfg = newCfg
|
||||
newInstances, err := newRoot.NormalizeInstances()
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to normalize reloaded config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
config.InitLogger(newInstances[0].Log)
|
||||
applyRuntimeConfig(newInstances[0].Runtime)
|
||||
root = newRoot
|
||||
nlog.Core().Info("reload complete, services restarting with new config")
|
||||
}
|
||||
}
|
||||
|
||||
+22
-84
@@ -1,88 +1,26 @@
|
||||
# Xboard Node Configuration
|
||||
# Copy this file to config.yml and modify as needed
|
||||
# Top-level settings are inherited by all instances. Instance overrides take precedence.
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# PANEL MODE (Default)
|
||||
# Connect to an Xboard panel for config / users / reporting.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ── Single node (simplest) ──
|
||||
# panel:
|
||||
# url: "https://panel.example.com"
|
||||
# token: "your-api-key"
|
||||
# node_id: 1
|
||||
|
||||
panel:
|
||||
# Xboard panel URL (required in panel mode)
|
||||
url: "https://panel.example.com"
|
||||
# Server communication token (required in panel mode)
|
||||
token: "your-server-token"
|
||||
# Node ID as configured in the panel
|
||||
node_id: 1
|
||||
|
||||
# Kernel settings (used by both panel mode and standalone mode)
|
||||
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)
|
||||
log_level: "warn"
|
||||
|
||||
# TLS certificate settings
|
||||
# cert_mode: "http" | "dns" | "self" | "file" | "none"
|
||||
cert:
|
||||
cert_mode: "none"
|
||||
# domain: "node.example.com"
|
||||
# email: "admin@example.com"
|
||||
# http_port: 80
|
||||
# dns_provider: "cloudflare"
|
||||
# dns_env:
|
||||
# CF_API_TOKEN: "your-cloudflare-api-token"
|
||||
# cert_file: "/etc/ssl/certs/cert.pem"
|
||||
# key_file: "/etc/ssl/private/key.pem"
|
||||
# cert_dir: "/etc/xboard-node/certs"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
output: "stdout"
|
||||
|
||||
# Standalone mode runs the node entirely from local config and never contacts
|
||||
# the panel. When enabled, panel.* is ignored.
|
||||
#
|
||||
# standalone:
|
||||
# enabled: true
|
||||
# node:
|
||||
# protocol: "vless"
|
||||
# listen_ip: "::"
|
||||
# server_port: 8443
|
||||
# network: "ws"
|
||||
# tls: 1
|
||||
# host: "node.example.com"
|
||||
# server_name: "node.example.com"
|
||||
# network_settings:
|
||||
# path: "/app"
|
||||
# host: "node.example.com"
|
||||
# routes:
|
||||
# - match: ["geoip:private"]
|
||||
# action: "direct"
|
||||
# users:
|
||||
# - id: 1
|
||||
# uuid: "11111111-1111-1111-1111-111111111111"
|
||||
# speed_limit: 0
|
||||
# device_limit: 0
|
||||
|
||||
# Advanced / multi-node panel mode
|
||||
# When `nodes:` is present, the top-level `panel.node_id` is ignored.
|
||||
#
|
||||
# nodes:
|
||||
# - node_id: 1
|
||||
# - node_id: 2
|
||||
# cert:
|
||||
# auto_tls: true
|
||||
# domain: "node2.example.com"
|
||||
# - node_id: 3
|
||||
# ── Multiple instances ──
|
||||
# kernel:
|
||||
# config_dir: "/etc/xboard-node/custom-node-3"
|
||||
|
||||
# Runtime memory tuning (optional)
|
||||
# runtime:
|
||||
# gomemlimit: ""
|
||||
# gogc: 0
|
||||
|
||||
# Health check endpoint for Docker/K8s (optional, 0 = disabled)
|
||||
# health_port: 65530
|
||||
# type: "singbox"
|
||||
# log:
|
||||
# level: "info"
|
||||
# instances:
|
||||
# - panel:
|
||||
# url: "https://panel.example.com"
|
||||
# token: "api-key-1"
|
||||
# node_id: 1
|
||||
# - panel:
|
||||
# url: "https://panel.example.com"
|
||||
# machine:
|
||||
# machine_id: 2
|
||||
# token: "machine-token-2"
|
||||
# log:
|
||||
# level: "debug" # override for this instance only
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Custom Outbounds
|
||||
|
||||
## Quick Example
|
||||
|
||||
```json
|
||||
{
|
||||
"custom_outbounds": [
|
||||
{
|
||||
"tag": "warp",
|
||||
"protocol": "wireguard",
|
||||
"settings": {
|
||||
"peers": [{"address": "162.159.195.1"}],
|
||||
"private_key": "aMzA..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "us-proxy",
|
||||
"protocol": "socks",
|
||||
"settings": {"servers": [{"address": "1.2.3.4", "port": 1080}]},
|
||||
"proxy_tag": "warp"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Protocols
|
||||
|
||||
| Protocol | Xray | Sing-box |
|
||||
|----------|------|----------|
|
||||
| vmess | ✅ | ✅ |
|
||||
| vless | ✅ | ✅ |
|
||||
| trojan | ✅ | ✅ |
|
||||
| shadowsocks | ✅ | ✅ |
|
||||
| socks | ✅ | ✅ |
|
||||
| http | ✅ | ✅ |
|
||||
| wireguard | ✅ | ✅ |
|
||||
| tuic | ❌ | ✅ |
|
||||
| hysteria2 | ❌ | ✅ |
|
||||
| anytls | ❌ | ✅ |
|
||||
| naive | ❌ | ✅ |
|
||||
| mieru | ❌ | ✅ |
|
||||
|
||||
## Core Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `tag` | ✅ | Outbound tag, referenced in routes |
|
||||
| `protocol` | ✅ | Protocol type |
|
||||
| `settings` | ✅ | Protocol-specific configuration |
|
||||
| `proxy_tag` | ❌ | Chain to another outbound (references its tag) |
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Prefer** `custom_outbounds`: panel-managed, cross-kernel compatible
|
||||
- **Use** `kernel.custom_outbound` only: when native fields are needed
|
||||
@@ -0,0 +1,64 @@
|
||||
# Custom Routes
|
||||
|
||||
## Quick Example
|
||||
|
||||
```json
|
||||
{
|
||||
"custom_route_rules": [
|
||||
{
|
||||
"name": "direct-example",
|
||||
"match": {"domain_suffixes": ["example.com"]},
|
||||
"action": {"type": "direct"}
|
||||
},
|
||||
{
|
||||
"name": "block-ads",
|
||||
"match": {"domains": ["ads.example.com"]},
|
||||
"action": {"type": "block"}
|
||||
},
|
||||
{
|
||||
"name": "route-warp",
|
||||
"match": {"ip_cidrs": ["1.1.1.0/24"], "ports": ["80", "443"]},
|
||||
"action": {"type": "route", "target": "warp-out"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Match Conditions
|
||||
|
||||
| Condition | Description | Example |
|
||||
|-----------|-------------|---------|
|
||||
| `domains` | Exact domain match | `["api.example.com"]` |
|
||||
| `domain_suffixes` | Suffix match | `["example.com"]` |
|
||||
| `ip_cidrs` | IP CIDR ranges | `["10.0.0.0/8"]` |
|
||||
| `ports` | Port (single or range) | `["443", "8000-9000"]` |
|
||||
| `networks` | Protocol | `["tcp"]` or `["udp"]` |
|
||||
| `source_cidrs` | Source IP CIDR | `["192.168.1.0/24"]` |
|
||||
| `source_ports` | Source port | `["1024-65535"]` |
|
||||
|
||||
## Action Types
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `{"type": "direct"}` | Direct connection, bypass proxy |
|
||||
| `{"type": "block"}` | Block connection |
|
||||
| `{"type": "route", "target": "tag"}` | Route to specified outbound (by tag) |
|
||||
|
||||
## Application Order
|
||||
|
||||
1. Structured `custom_route_rules` (highest priority)
|
||||
2. Raw `custom_routes`
|
||||
3. Built-in blocklist rules
|
||||
4. Panel routes
|
||||
|
||||
## Kernel Compatibility
|
||||
|
||||
| Feature | Xray | Sing-box |
|
||||
|---------|------|----------|
|
||||
| All match conditions | ✅ | ✅ |
|
||||
| direct / block / route | ✅ | ✅ |
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Prefer** `custom_route_rules`: cross-kernel compatible, panel-managed
|
||||
- **Use** `custom_routes` only: when native features are needed (e.g., load balancing)
|
||||
Regular → Executable
+912
-588
File diff suppressed because it is too large
Load Diff
+142
-112
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/libdns/cloudflare"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
)
|
||||
|
||||
@@ -30,80 +32,123 @@ import (
|
||||
// Supported modes (CertConfig.CertMode):
|
||||
// - "http" — ACME HTTP-01 challenge via certmagic (needs port 80).
|
||||
// - "dns" — ACME DNS-01 challenge via certmagic + libdns provider.
|
||||
// - "self" — Self-signed certificate. Automatically generated and stored in CertDir.
|
||||
// Recommended for nodes behind a CDN or reverse proxy.
|
||||
// - "self" — Self-signed certificate generated in memory.
|
||||
// - "file" — User-provided certificate and key file paths (CertFile/KeyFile).
|
||||
// - "content" — Certificate and key PEM content pushed from the panel.
|
||||
// Written to disk in CertDir for the kernel to consume.
|
||||
// - "none" — No TLS. The node will handle plain connections.
|
||||
//
|
||||
// Priority Logic:
|
||||
// All modes normalise to in-memory PEM. Kernels never see file paths —
|
||||
// they always receive PEM bytes via TLSCert().
|
||||
//
|
||||
// Priority Logic (when CertMode is empty):
|
||||
//
|
||||
// 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
|
||||
|
||||
// Cert material stored atomically so the ACME renewal goroutine can
|
||||
// swap it without racing with TLSCert() readers.
|
||||
mat atomic.Pointer[certMaterial]
|
||||
|
||||
magic *certmagic.Config
|
||||
renewed atomic.Bool
|
||||
acmeStarted bool // prevents double-init of certmagic background goroutines
|
||||
acmeStarted bool
|
||||
}
|
||||
|
||||
// certMaterial is an immutable snapshot of PEM-encoded cert + key.
|
||||
type certMaterial struct {
|
||||
certPEM []byte
|
||||
keyPEM []byte
|
||||
}
|
||||
|
||||
// NewManager creates a certificate manager.
|
||||
func NewManager(cfg config.CertConfig) *Manager {
|
||||
m := &Manager{cfg: cfg}
|
||||
m.setCertPaths()
|
||||
return m
|
||||
return &Manager{cfg: cfg}
|
||||
}
|
||||
|
||||
// setCertPaths derives certFile / keyFile from the current m.cfg.
|
||||
func (m *Manager) setCertPaths() {
|
||||
switch m.resolveMode() {
|
||||
case "file":
|
||||
m.certFile = m.cfg.CertFile
|
||||
m.keyFile = m.cfg.KeyFile
|
||||
case "http", "dns", "self", "content":
|
||||
// storePEM atomically swaps the in-memory cert material and persists to disk
|
||||
// so that restarts can reload without re-generating or re-requesting.
|
||||
func (m *Manager) storePEM(cert, key []byte) {
|
||||
m.mat.Store(&certMaterial{certPEM: cert, keyPEM: key})
|
||||
m.persistPEM(cert, key)
|
||||
}
|
||||
|
||||
// persistPEM writes cert/key to cert_dir for restart recovery.
|
||||
// Errors are logged but not returned — persistence is best-effort.
|
||||
func (m *Manager) persistPEM(cert, key []byte) {
|
||||
dir := m.cfg.CertDir
|
||||
if dir == "" {
|
||||
dir = "/etc/xboard-node/certs"
|
||||
return
|
||||
}
|
||||
domain := m.cfg.Domain
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
nlog.Core().Warn("cert: failed to create cert_dir", "dir", dir, "error", err)
|
||||
return
|
||||
}
|
||||
m.certFile = filepath.Join(dir, domain+".crt")
|
||||
m.keyFile = filepath.Join(dir, domain+".key")
|
||||
default:
|
||||
m.certFile = ""
|
||||
m.keyFile = ""
|
||||
certPath := filepath.Join(dir, "cert.pem")
|
||||
keyPath := filepath.Join(dir, "key.pem")
|
||||
if err := atomicWriteFile(certPath, cert, 0o644); err != nil {
|
||||
nlog.Core().Warn("cert: failed to persist cert", "path", certPath, "error", err)
|
||||
}
|
||||
if err := atomicWriteFile(keyPath, key, 0o600); err != nil {
|
||||
nlog.Core().Warn("cert: failed to persist key", "path", keyPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to a temp file and renames, preventing partial reads.
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// loadPersistedPEM loads previously persisted cert material from cert_dir.
|
||||
// Returns true if valid material was loaded.
|
||||
func (m *Manager) loadPersistedPEM() bool {
|
||||
dir := m.cfg.CertDir
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
certPEM, err := os.ReadFile(filepath.Join(dir, "cert.pem"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
keyPEM, err := os.ReadFile(filepath.Join(dir, "key.pem"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := validateKeyPair(certPEM, keyPEM); err != nil {
|
||||
nlog.Core().Warn("cert: persisted cert invalid, will regenerate", "error", err)
|
||||
return false
|
||||
}
|
||||
// Store in memory only (don't re-persist what we just read).
|
||||
m.mat.Store(&certMaterial{certPEM: certPEM, keyPEM: keyPEM})
|
||||
nlog.Core().Info("cert: loaded persisted certificate from disk", "dir", dir)
|
||||
return true
|
||||
}
|
||||
|
||||
// Reconfigure applies a new cert configuration at runtime (e.g. from panel push).
|
||||
// It is safe to call after Start(). For ACME modes that are already running this
|
||||
// is a no-op — a process restart is required to change ACME settings.
|
||||
// Returns true when the on-disk cert paths changed (caller should restart kernel).
|
||||
// For ACME modes that are already running this is a no-op — a process restart
|
||||
// is required to change ACME settings.
|
||||
// Returns true when the PEM material changed (caller should restart kernel).
|
||||
func (m *Manager) Reconfigure(ctx context.Context, newCfg config.CertConfig) (bool, error) {
|
||||
// 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
|
||||
oldTLS := m.TLSCert()
|
||||
m.cfg = newCfg
|
||||
m.setCertPaths()
|
||||
|
||||
if err := m.Start(ctx); err != nil {
|
||||
return false, fmt.Errorf("cert reconfigure: %w", err)
|
||||
}
|
||||
|
||||
return m.certFile != oldCertFile || m.keyFile != oldKeyFile, nil
|
||||
newTLS := m.TLSCert()
|
||||
return !pemEqual(oldTLS, newTLS), nil
|
||||
}
|
||||
|
||||
// resolveMode returns the effective cert mode, handling backward compat for auto_tls.
|
||||
@@ -127,11 +172,26 @@ func (m *Manager) resolveMode() string {
|
||||
return "none"
|
||||
}
|
||||
|
||||
func (m *Manager) CertFile() string { return m.certFile }
|
||||
func (m *Manager) KeyFile() string { return m.keyFile }
|
||||
func (m *Manager) HasCert() bool { return m.certFile != "" && m.keyFile != "" }
|
||||
func (m *Manager) HasCert() bool {
|
||||
mat := m.mat.Load()
|
||||
return mat != nil && len(mat.certPEM) > 0 && len(mat.keyPEM) > 0
|
||||
}
|
||||
func (m *Manager) CertRenewed() bool { return m.renewed.Swap(false) }
|
||||
|
||||
// TLSCert returns the current PEM material as a kernel.TLSCert.
|
||||
func (m *Manager) TLSCert() kernel.TLSCert {
|
||||
mat := m.mat.Load()
|
||||
if mat == nil {
|
||||
return kernel.TLSCert{}
|
||||
}
|
||||
return kernel.TLSCert{CertPEM: mat.certPEM, KeyPEM: mat.keyPEM}
|
||||
}
|
||||
|
||||
// pemEqual reports whether two TLSCert values carry identical PEM content.
|
||||
func pemEqual(a, b kernel.TLSCert) bool {
|
||||
return string(a.CertPEM) == string(b.CertPEM) && string(a.KeyPEM) == string(b.KeyPEM)
|
||||
}
|
||||
|
||||
// Start initializes the cert manager based on the resolved mode.
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
mode := m.resolveMode()
|
||||
@@ -165,29 +225,30 @@ func (m *Manager) Stop() {}
|
||||
// ─── Mode: file ────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Manager) startFile() error {
|
||||
if _, err := os.Stat(m.cfg.CertFile); err != nil {
|
||||
certPEM, err := os.ReadFile(m.cfg.CertFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cert file: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(m.cfg.KeyFile); err != nil {
|
||||
keyPEM, err := os.ReadFile(m.cfg.KeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key file: %w", err)
|
||||
}
|
||||
nlog.Core().Debug("using manual TLS certificates", "cert", m.certFile, "key", m.keyFile)
|
||||
if err := validateKeyPair(certPEM, keyPEM); err != nil {
|
||||
return fmt.Errorf("invalid certificate pair: %w", err)
|
||||
}
|
||||
m.storePEM(certPEM, keyPEM)
|
||||
nlog.Core().Debug("TLS certificate loaded from files", "cert", m.cfg.CertFile, "key", m.cfg.KeyFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Mode: self ────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Manager) startSelfSigned() error {
|
||||
// If files already exist and are valid, skip regeneration.
|
||||
if fileExists(m.certFile) && fileExists(m.keyFile) {
|
||||
nlog.Core().Debug("self-signed cert exists, reusing", "cert", m.certFile)
|
||||
// Reuse persisted self-signed cert if available and valid.
|
||||
if m.loadPersistedPEM() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(m.certFile), 0o755); err != nil {
|
||||
return fmt.Errorf("create cert dir: %w", err)
|
||||
}
|
||||
|
||||
domain := m.cfg.Domain
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
@@ -231,36 +292,29 @@ func (m *Manager) startSelfSigned() error {
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil {
|
||||
return fmt.Errorf("write cert: %w", err)
|
||||
}
|
||||
if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil {
|
||||
return fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
|
||||
nlog.Core().Info("self-signed certificate generated",
|
||||
"domain", domain, "cert", m.certFile, "valid_years", 10)
|
||||
m.storePEM(certPEM, keyPEM)
|
||||
nlog.Core().Info("self-signed certificate generated (in-memory)", "domain", domain, "valid_years", 10)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Mode: content ────────────────────────────────────────────────────────
|
||||
|
||||
// startContent writes panel-supplied PEM strings to disk so the kernel can
|
||||
// reference them as ordinary cert/key files.
|
||||
// startContent stores panel-supplied PEM strings in memory for inline use.
|
||||
func (m *Manager) startContent() error {
|
||||
if m.cfg.CertContent == "" || m.cfg.KeyContent == "" {
|
||||
// No fresh content from panel — try previously persisted material.
|
||||
if m.loadPersistedPEM() {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cert_mode 'content' requires both cert_content and key_content")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.certFile), 0o755); err != nil {
|
||||
return fmt.Errorf("create cert dir: %w", err)
|
||||
certPEM := []byte(m.cfg.CertContent)
|
||||
keyPEM := []byte(m.cfg.KeyContent)
|
||||
if err := validateKeyPair(certPEM, keyPEM); err != nil {
|
||||
return fmt.Errorf("invalid certificate content: %w", err)
|
||||
}
|
||||
if err := atomicWriteFile(m.certFile, []byte(m.cfg.CertContent), 0o644); err != nil {
|
||||
return fmt.Errorf("write cert content: %w", err)
|
||||
}
|
||||
if err := atomicWriteFile(m.keyFile, []byte(m.cfg.KeyContent), 0o600); err != nil {
|
||||
return fmt.Errorf("write key content: %w", err)
|
||||
}
|
||||
nlog.Core().Info("TLS certificate written from panel content", "cert", m.certFile)
|
||||
m.storePEM(certPEM, keyPEM)
|
||||
nlog.Core().Info("TLS certificate loaded from panel content (in-memory)")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -295,7 +349,7 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
|
||||
Storage: storage,
|
||||
OnEvent: func(evtCtx context.Context, event string, data map[string]any) error {
|
||||
if event == "cert_obtained" {
|
||||
m.syncCertFiles(evtCtx, storage, data)
|
||||
m.loadCertFromStorage(evtCtx, storage, data)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -330,6 +384,11 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
|
||||
return fmt.Errorf("obtain certificate: %w", err)
|
||||
}
|
||||
|
||||
// The cert_obtained event callback should have loaded PEM. Verify.
|
||||
if !m.HasCert() {
|
||||
return fmt.Errorf("ACME obtained certificate but PEM was not loaded; check storage")
|
||||
}
|
||||
|
||||
if err := magic.ManageAsync(ctx, []string{m.cfg.Domain}); err != nil {
|
||||
return fmt.Errorf("start cert manager: %w", err)
|
||||
}
|
||||
@@ -338,6 +397,13 @@ func (m *Manager) startACME(ctx context.Context, dnsSolver *certmagic.DNS01Solve
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKeyPair(certPEM, keyPEM []byte) error {
|
||||
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── DNS Provider Factory ──────────────────────────────────────────────────
|
||||
|
||||
// buildDNSSolver builds a certmagic DNS01Solver from the configured provider.
|
||||
@@ -384,8 +450,8 @@ func (m *Manager) newDNSProvider() (certmagic.DNSProvider, error) {
|
||||
|
||||
// ─── 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) {
|
||||
// loadCertFromStorage reads ACME cert/key from certmagic's storage into memory.
|
||||
func (m *Manager) loadCertFromStorage(ctx context.Context, storage certmagic.Storage, data map[string]any) {
|
||||
certKey, _ := data["certificate_path"].(string)
|
||||
keyKey, _ := data["private_key_path"].(string)
|
||||
if certKey == "" || keyKey == "" {
|
||||
@@ -395,58 +461,22 @@ func (m *Manager) syncCertFiles(ctx context.Context, storage certmagic.Storage,
|
||||
|
||||
certPEM, err := storage.Load(ctx, certKey)
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to load cert from storage", "error", err)
|
||||
nlog.Core().Error("failed to load cert from ACME storage", "error", err)
|
||||
return
|
||||
}
|
||||
keyPEM, err := storage.Load(ctx, keyKey)
|
||||
if err != nil {
|
||||
nlog.Core().Error("failed to load key from storage", "error", err)
|
||||
nlog.Core().Error("failed to load key from ACME storage", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := atomicWriteFile(m.certFile, certPEM, 0o644); err != nil {
|
||||
nlog.Core().Error("failed to write cert file", "path", m.certFile, "error", err)
|
||||
return
|
||||
}
|
||||
if err := atomicWriteFile(m.keyFile, keyPEM, 0o600); err != nil {
|
||||
nlog.Core().Error("failed to write key file", "path", m.keyFile, "error", err)
|
||||
return
|
||||
}
|
||||
m.storePEM(certPEM, keyPEM)
|
||||
|
||||
renewal, _ := data["renewal"].(bool)
|
||||
nlog.Core().Info("TLS certificate synced", "domain", m.cfg.Domain, "renewal", renewal,
|
||||
"cert", m.certFile)
|
||||
nlog.Core().Info("TLS certificate loaded from ACME storage", "domain", m.cfg.Domain, "renewal", renewal)
|
||||
if renewal {
|
||||
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.
|
||||
|
||||
+487
-3
@@ -1,12 +1,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"golang.org/x/term"
|
||||
@@ -14,6 +19,7 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
InstanceID string `yaml:"-"`
|
||||
Panel PanelConfig `yaml:"panel"`
|
||||
Node NodeConfig `yaml:"node"`
|
||||
Kernel KernelConfig `yaml:"kernel"`
|
||||
@@ -30,6 +36,20 @@ type Config struct {
|
||||
// one service instance is started per entry. All entries share the same
|
||||
// panel URL/token, kernel type, log settings and runtime tuning.
|
||||
Nodes []NodeEntry `yaml:"nodes,omitempty"`
|
||||
|
||||
// Machine enables machine mode: a single process manages all nodes
|
||||
// bound to this machine on the panel. Nodes are discovered dynamically
|
||||
// via GET /api/v2/server/machine/nodes. When set, Panel.NodeID, Nodes
|
||||
// and Panel.Token are ignored; the machine token is used instead.
|
||||
Machine *MachineConfig `yaml:"machine,omitempty"`
|
||||
}
|
||||
|
||||
// MachineConfig identifies this process as a panel-managed machine that
|
||||
// dynamically discovers and runs all nodes bound to it.
|
||||
type MachineConfig struct {
|
||||
MachineID int `yaml:"machine_id"`
|
||||
Token string `yaml:"token"`
|
||||
TokenEnv string `yaml:"token_env,omitempty"`
|
||||
}
|
||||
|
||||
// NodeEntry describes a single node in multi-node mode.
|
||||
@@ -75,8 +95,10 @@ type RuntimeConfig struct {
|
||||
type PanelConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
TokenEnv string `yaml:"token_env,omitempty"`
|
||||
NodeID int `yaml:"node_id"`
|
||||
NodeType string `yaml:"node_type"`
|
||||
MachineID int `yaml:"-"`
|
||||
}
|
||||
|
||||
type NodeConfig struct {
|
||||
@@ -161,6 +183,174 @@ type LogConfig struct {
|
||||
Output string `yaml:"output"`
|
||||
}
|
||||
|
||||
type RootConfig struct {
|
||||
Instances []Config `yaml:"instances,omitempty"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
func LoadRoot(path string) (*RootConfig, error) {
|
||||
rc := &RootConfig{}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, rc); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if len(rc.Instances) == 0 {
|
||||
legacy := &Config{}
|
||||
if err := yaml.Unmarshal(data, legacy); err != nil {
|
||||
return nil, fmt.Errorf("parse legacy config: %w", err)
|
||||
}
|
||||
rc.Config = *legacy
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
// In multi-instance mode, top-level settings (log, kernel, node, ws, etc.)
|
||||
// serve as defaults that each instance inherits. Instance-level settings
|
||||
// take precedence over top-level ones.
|
||||
if len(rc.Instances) > 0 {
|
||||
for i := range rc.Instances {
|
||||
rc.Instances[i].inheritFrom(&rc.Config)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive default base directory from the config file's location so that
|
||||
// data (certs, sing-box cache, …) lives next to the config file when
|
||||
// config_dir is not explicitly set. This makes non-root / dev deployments
|
||||
// work without any extra configuration.
|
||||
baseDir := configBaseDir(path)
|
||||
|
||||
rc.applyEnvOverrides()
|
||||
rc.resolveEnvRefs()
|
||||
|
||||
// Compute stable instance IDs early — they're content-based (derived from
|
||||
// panel URL + machine/node ID), so reordering instances in the YAML doesn't
|
||||
// cause data-directory mix-ups. Must run before setDefaultsFrom which uses
|
||||
// InstanceID to derive per-instance config_dir.
|
||||
if err := rc.assignInstanceIDs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc.setDefaultsFrom(baseDir)
|
||||
|
||||
if err := rc.validateRoot(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (rc *RootConfig) applyEnvOverrides() {
|
||||
if len(rc.Instances) == 0 {
|
||||
rc.Config.applyEnvOverrides()
|
||||
return
|
||||
}
|
||||
for i := range rc.Instances {
|
||||
rc.Instances[i].applyEnvOverrides()
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RootConfig) resolveEnvRefs() {
|
||||
if len(rc.Instances) == 0 {
|
||||
rc.Config.resolveEnvRefs()
|
||||
return
|
||||
}
|
||||
for i := range rc.Instances {
|
||||
rc.Instances[i].resolveEnvRefs()
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RootConfig) setDefaultsFrom(baseDir string) {
|
||||
if len(rc.Instances) == 0 {
|
||||
rc.Config.setDefaultsFrom(baseDir)
|
||||
return
|
||||
}
|
||||
for i := range rc.Instances {
|
||||
instBase := baseDir
|
||||
// When multiple instances share the same base dir, derive a unique
|
||||
// sub-directory per instance to avoid config_dir collisions.
|
||||
// InstanceID is always populated by assignInstanceIDs() before this runs.
|
||||
if len(rc.Instances) > 1 && rc.Instances[i].Kernel.ConfigDir == "" {
|
||||
instBase = filepath.Join(baseDir, rc.Instances[i].InstanceID)
|
||||
}
|
||||
rc.Instances[i].setDefaultsFrom(instBase)
|
||||
}
|
||||
}
|
||||
|
||||
// assignInstanceIDs computes a stable, content-based InstanceID for each
|
||||
// instance that doesn't already have one. The ID is derived from the panel URL
|
||||
// and machine/node ID, so reordering instances in the YAML file doesn't cause
|
||||
// data directories to swap.
|
||||
func (rc *RootConfig) assignInstanceIDs() error {
|
||||
for i := range rc.Instances {
|
||||
if rc.Instances[i].InstanceID != "" {
|
||||
continue
|
||||
}
|
||||
id, err := rc.Instances[i].AutoInstanceID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("instances[%d]: %w", i, err)
|
||||
}
|
||||
rc.Instances[i].InstanceID = id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// configBaseDir resolves the canonical base directory from the config file path.
|
||||
// This directory is used as the default for config_dir when not explicitly set,
|
||||
// so that data (certs, sing-box cache, …) lives next to the config file.
|
||||
func configBaseDir(configPath string) string {
|
||||
abs, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "/etc/xboard-node"
|
||||
}
|
||||
return filepath.Dir(abs)
|
||||
}
|
||||
|
||||
func (rc *RootConfig) validateRoot() error {
|
||||
if len(rc.Instances) == 0 {
|
||||
return rc.Config.validate()
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for i := range rc.Instances {
|
||||
// InstanceID is already computed by assignInstanceIDs().
|
||||
id := rc.Instances[i].InstanceID
|
||||
if err := rc.Instances[i].validate(); err != nil {
|
||||
return fmt.Errorf("instances[%d]: %w", i, err)
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return fmt.Errorf("instances[%d]: duplicate instance id %q", i, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RootConfig) NormalizeInstances() ([]*Config, error) {
|
||||
if len(rc.Instances) == 0 {
|
||||
id, err := rc.Config.AutoInstanceID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc.Config.InstanceID = id
|
||||
return []*Config{&rc.Config}, nil
|
||||
}
|
||||
out := make([]*Config, 0, len(rc.Instances))
|
||||
for i := range rc.Instances {
|
||||
cfg := rc.Instances[i]
|
||||
if cfg.InstanceID == "" {
|
||||
id, err := cfg.AutoInstanceID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.InstanceID = id
|
||||
}
|
||||
out = append(out, &cfg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Load reads configuration from a YAML file, then applies environment variable
|
||||
// overrides. If the config file does not exist, a config is built entirely from
|
||||
// environment variables (useful for Docker deployment with -e flags).
|
||||
@@ -176,8 +366,11 @@ func Load(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
baseDir := configBaseDir(path)
|
||||
|
||||
cfg.applyEnvOverrides()
|
||||
cfg.setDefaults()
|
||||
cfg.resolveEnvRefs()
|
||||
cfg.setDefaultsFrom(baseDir)
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
@@ -227,14 +420,145 @@ func (c *Config) applyEnvOverrides() {
|
||||
if v := envFirst("logLevel", "LOG_LEVEL"); v != "" {
|
||||
c.Log.Level = v
|
||||
}
|
||||
if v := envFirst("MACHINE_ID"); v != "" {
|
||||
if id, err := strconv.Atoi(v); err == nil {
|
||||
if c.Machine == nil {
|
||||
c.Machine = &MachineConfig{}
|
||||
}
|
||||
c.Machine.MachineID = id
|
||||
}
|
||||
}
|
||||
if v := envFirst("MACHINE_TOKEN"); v != "" {
|
||||
if c.Machine == nil {
|
||||
c.Machine = &MachineConfig{}
|
||||
}
|
||||
c.Machine.Token = v
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
func (c *Config) resolveEnvRefs() {
|
||||
if c.Panel.Token == "" && c.Panel.TokenEnv != "" {
|
||||
c.Panel.Token = os.Getenv(c.Panel.TokenEnv)
|
||||
}
|
||||
if c.Machine != nil && c.Machine.Token == "" && c.Machine.TokenEnv != "" {
|
||||
c.Machine.Token = os.Getenv(c.Machine.TokenEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// inheritFrom copies non-zero fields from the parent config into c when c's
|
||||
// corresponding field is zero-valued. This lets top-level settings serve as
|
||||
// defaults for each instance in multi-instance configs.
|
||||
// Fields that must be unique per instance (config_dir, cert_dir, instance_id)
|
||||
// are intentionally excluded.
|
||||
func (c *Config) inheritFrom(parent *Config) {
|
||||
// Log
|
||||
if c.Log.Level == "" {
|
||||
c.Log.Level = parent.Log.Level
|
||||
}
|
||||
if c.Log.Output == "" {
|
||||
c.Log.Output = parent.Log.Output
|
||||
}
|
||||
// Node intervals
|
||||
if c.Node.PushInterval == 0 {
|
||||
c.Node.PushInterval = parent.Node.PushInterval
|
||||
}
|
||||
if c.Node.PullInterval == 0 {
|
||||
c.Node.PullInterval = parent.Node.PullInterval
|
||||
}
|
||||
if c.Node.TrackInterval == 0 {
|
||||
c.Node.TrackInterval = parent.Node.TrackInterval
|
||||
}
|
||||
if c.Node.DeviceReportInterval == 0 {
|
||||
c.Node.DeviceReportInterval = parent.Node.DeviceReportInterval
|
||||
}
|
||||
// WS
|
||||
if c.WS.StatusInterval == 0 {
|
||||
c.WS.StatusInterval = parent.WS.StatusInterval
|
||||
}
|
||||
if c.WS.HandshakeTimeout == 0 {
|
||||
c.WS.HandshakeTimeout = parent.WS.HandshakeTimeout
|
||||
}
|
||||
if c.WS.BackoffInitial == 0 {
|
||||
c.WS.BackoffInitial = parent.WS.BackoffInitial
|
||||
}
|
||||
if c.WS.BackoffMax == 0 {
|
||||
c.WS.BackoffMax = parent.WS.BackoffMax
|
||||
}
|
||||
if c.WS.DiscoveryInterval == 0 {
|
||||
c.WS.DiscoveryInterval = parent.WS.DiscoveryInterval
|
||||
}
|
||||
// Runtime
|
||||
if c.Runtime.GoGCPercent == 0 {
|
||||
c.Runtime.GoGCPercent = parent.Runtime.GoGCPercent
|
||||
}
|
||||
if c.Runtime.GoMemLimit == "" {
|
||||
c.Runtime.GoMemLimit = parent.Runtime.GoMemLimit
|
||||
}
|
||||
// Kernel (NOT config_dir — each instance needs unique dir)
|
||||
if c.Kernel.Type == "" {
|
||||
c.Kernel.Type = parent.Kernel.Type
|
||||
}
|
||||
if c.Kernel.LogLevel == "" {
|
||||
c.Kernel.LogLevel = parent.Kernel.LogLevel
|
||||
}
|
||||
if c.Kernel.GeoDataDir == "" {
|
||||
c.Kernel.GeoDataDir = parent.Kernel.GeoDataDir
|
||||
}
|
||||
if c.Kernel.CustomConfig == "" {
|
||||
c.Kernel.CustomConfig = parent.Kernel.CustomConfig
|
||||
}
|
||||
if len(c.Kernel.CustomOutbound) == 0 {
|
||||
c.Kernel.CustomOutbound = parent.Kernel.CustomOutbound
|
||||
}
|
||||
if len(c.Kernel.CustomRoute) == 0 {
|
||||
c.Kernel.CustomRoute = parent.Kernel.CustomRoute
|
||||
}
|
||||
// Cert (NOT cert_dir — derived from config_dir later)
|
||||
if c.Cert.CertMode == "" {
|
||||
c.Cert.CertMode = parent.Cert.CertMode
|
||||
}
|
||||
if c.Cert.Domain == "" {
|
||||
c.Cert.Domain = parent.Cert.Domain
|
||||
}
|
||||
if c.Cert.Email == "" {
|
||||
c.Cert.Email = parent.Cert.Email
|
||||
}
|
||||
if c.Cert.CertFile == "" {
|
||||
c.Cert.CertFile = parent.Cert.CertFile
|
||||
}
|
||||
if c.Cert.KeyFile == "" {
|
||||
c.Cert.KeyFile = parent.Cert.KeyFile
|
||||
}
|
||||
if c.Cert.DNSProvider == "" {
|
||||
c.Cert.DNSProvider = parent.Cert.DNSProvider
|
||||
}
|
||||
if len(c.Cert.DNSEnv) == 0 {
|
||||
c.Cert.DNSEnv = parent.Cert.DNSEnv
|
||||
}
|
||||
if c.Cert.HTTPPort == 0 {
|
||||
c.Cert.HTTPPort = parent.Cert.HTTPPort
|
||||
}
|
||||
if c.Cert.CertContent == "" {
|
||||
c.Cert.CertContent = parent.Cert.CertContent
|
||||
}
|
||||
if c.Cert.KeyContent == "" {
|
||||
c.Cert.KeyContent = parent.Cert.KeyContent
|
||||
}
|
||||
// Only inherit AutoTLS when the child has no explicit cert configuration.
|
||||
// A child with cert_mode, cert_file, or cert_content explicitly chooses
|
||||
// its own cert strategy — inheriting auto_tls would override that choice.
|
||||
childHasCertConfig := c.Cert.CertMode != "" || c.Cert.CertFile != "" || c.Cert.CertContent != ""
|
||||
if !childHasCertConfig && !c.Cert.AutoTLS && parent.Cert.AutoTLS {
|
||||
c.Cert.AutoTLS = parent.Cert.AutoTLS
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) setDefaultsFrom(baseDir string) {
|
||||
if c.Kernel.Type == "" {
|
||||
c.Kernel.Type = "singbox"
|
||||
}
|
||||
if c.Kernel.ConfigDir == "" {
|
||||
c.Kernel.ConfigDir = "/etc/xboard-node"
|
||||
c.Kernel.ConfigDir = baseDir
|
||||
}
|
||||
if c.Kernel.GeoDataDir == "" {
|
||||
c.Kernel.GeoDataDir = c.Kernel.ConfigDir
|
||||
@@ -279,11 +603,105 @@ func (c *Config) setDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) IsMachineMode() bool {
|
||||
return c.Machine != nil && c.Machine.MachineID > 0
|
||||
}
|
||||
|
||||
func (c *Config) AutoInstanceID() (string, error) {
|
||||
mode := "node"
|
||||
target := "0"
|
||||
baseURL := strings.TrimSpace(c.Panel.URL)
|
||||
if c.IsStandalone() {
|
||||
mode = "standalone"
|
||||
target = "local"
|
||||
baseURL = "standalone"
|
||||
} else if c.IsMachineMode() {
|
||||
mode = "machine"
|
||||
target = strconv.Itoa(c.Machine.MachineID)
|
||||
} else if len(c.Nodes) > 0 {
|
||||
// Multi-node mode: include sorted node IDs in the hash to avoid collisions.
|
||||
ids := make([]string, len(c.Nodes))
|
||||
for i, n := range c.Nodes {
|
||||
ids[i] = strconv.Itoa(n.NodeID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
target = strings.Join(ids, ",")
|
||||
} else {
|
||||
target = strconv.Itoa(c.Panel.NodeID)
|
||||
}
|
||||
normalized, slug, err := normalizeBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := normalized + "|" + mode + "|" + target
|
||||
sum := sha1.Sum([]byte(key))
|
||||
short := hex.EncodeToString(sum[:])[:6]
|
||||
return fmt.Sprintf("%s-%s-%s-%s", slug, mode, target, short), nil
|
||||
}
|
||||
|
||||
func normalizeBaseURL(raw string) (string, string, error) {
|
||||
if raw == "standalone" {
|
||||
return raw, raw, nil
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid panel.url %q: %w", raw, err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return "", "", fmt.Errorf("invalid panel.url %q", raw)
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
pathPart := strings.TrimSuffix(u.EscapedPath(), "/")
|
||||
normalized := u.Scheme + "://" + u.Host
|
||||
if pathPart != "" && pathPart != "/" {
|
||||
normalized += pathPart
|
||||
}
|
||||
slugBase := u.Host
|
||||
if pathPart != "" && pathPart != "/" {
|
||||
slugBase += "-" + strings.Trim(pathPart, "/")
|
||||
}
|
||||
slugBase = strings.ToLower(slugBase)
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range slugBase {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
slug := strings.Trim(b.String(), "-")
|
||||
if slug == "" {
|
||||
slug = "panel"
|
||||
}
|
||||
if len(slug) > 48 {
|
||||
slug = strings.Trim(slug[:48], "-")
|
||||
}
|
||||
return normalized, slug, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.IsStandalone() {
|
||||
if err := c.validateStandalone(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if c.IsMachineMode() {
|
||||
if c.Panel.URL == "" {
|
||||
return fmt.Errorf("panel.url is required")
|
||||
}
|
||||
if c.Machine.Token == "" {
|
||||
return fmt.Errorf("machine.token is required")
|
||||
}
|
||||
if len(c.Nodes) > 0 {
|
||||
return fmt.Errorf("machine mode and nodes: are mutually exclusive")
|
||||
}
|
||||
} else {
|
||||
if c.Panel.URL == "" {
|
||||
return fmt.Errorf("panel.url is required")
|
||||
@@ -375,6 +793,23 @@ func (c *Config) ExpandNodes() []*Config {
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Config) ExpandMachineNode(nodeID int, nodeType string) *Config {
|
||||
nodeCfg := *c
|
||||
nodeCfg.Nodes = nil
|
||||
nodeCfg.Panel.NodeID = nodeID
|
||||
nodeCfg.Panel.NodeType = nodeType
|
||||
nodeCfg.Panel.Token = c.Machine.Token
|
||||
nodeCfg.Panel.MachineID = c.Machine.MachineID
|
||||
|
||||
nodeCfg.Kernel.ConfigDir = fmt.Sprintf("%s/node-%d", c.Kernel.ConfigDir, nodeID)
|
||||
if nodeCfg.Kernel.GeoDataDir == c.Kernel.ConfigDir {
|
||||
nodeCfg.Kernel.GeoDataDir = c.Kernel.GeoDataDir
|
||||
}
|
||||
nodeCfg.Cert.CertDir = filepath.Join(nodeCfg.Kernel.ConfigDir, "certs")
|
||||
|
||||
return &nodeCfg
|
||||
}
|
||||
|
||||
func InitLogger(cfg LogConfig) {
|
||||
var minLevel slog.Level
|
||||
switch cfg.Level {
|
||||
@@ -420,3 +855,52 @@ func InitLogger(cfg LogConfig) {
|
||||
// Application logging goes through nlog; silence slog.Default for stray library use.
|
||||
slog.SetDefault(slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// ValidateStartupLayout checks that multiple instances do not conflict on
|
||||
// health ports, kernel config directories, or node bindings.
|
||||
func ValidateStartupLayout(instances []*Config) error {
|
||||
healthPorts := make(map[int]string)
|
||||
configDirs := make(map[string]string)
|
||||
nodeBindings := make(map[string]string)
|
||||
for _, instance := range instances {
|
||||
if instance == nil {
|
||||
continue
|
||||
}
|
||||
owner := instance.InstanceID
|
||||
if instance.HealthPort > 0 {
|
||||
if other, ok := healthPorts[instance.HealthPort]; ok {
|
||||
return fmt.Errorf("health_port %d is used by both %s and %s", instance.HealthPort, other, owner)
|
||||
}
|
||||
healthPorts[instance.HealthPort] = owner
|
||||
}
|
||||
if dir := strings.TrimSpace(instance.Kernel.ConfigDir); dir != "" {
|
||||
if other, ok := configDirs[dir]; ok && other != owner {
|
||||
return fmt.Errorf("kernel config_dir %q is shared by %s and %s", dir, other, owner)
|
||||
}
|
||||
configDirs[dir] = owner
|
||||
}
|
||||
// Machine mode discovers nodes dynamically — skip static binding check,
|
||||
// but still validate custom_config if set at the instance level.
|
||||
if instance.IsMachineMode() {
|
||||
if path := strings.TrimSpace(instance.Kernel.CustomConfig); path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("kernel.custom_config %q for %s: %w", path, owner, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, node := range instance.ExpandNodes() {
|
||||
binding := fmt.Sprintf("%s/%d", strings.TrimSpace(node.Panel.URL), node.Panel.NodeID)
|
||||
if other, ok := nodeBindings[binding]; ok {
|
||||
return fmt.Errorf("node binding %s is declared by both %s and %s", binding, other, owner)
|
||||
}
|
||||
nodeBindings[binding] = owner
|
||||
if path := strings.TrimSpace(node.Kernel.CustomConfig); path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("kernel.custom_config %q for %s: %w", path, owner, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -67,8 +68,10 @@ panel:
|
||||
if cfg.Kernel.Type != "singbox" {
|
||||
t.Errorf("default kernel.type: got %q, want singbox", cfg.Kernel.Type)
|
||||
}
|
||||
if cfg.Kernel.ConfigDir != "/etc/xboard-node" {
|
||||
t.Errorf("default config_dir: got %q", cfg.Kernel.ConfigDir)
|
||||
// config_dir should default to the directory containing the config file.
|
||||
expectedDir := filepath.Dir(path)
|
||||
if cfg.Kernel.ConfigDir != expectedDir {
|
||||
t.Errorf("default config_dir: got %q, want %q", cfg.Kernel.ConfigDir, expectedDir)
|
||||
}
|
||||
if cfg.Kernel.LogLevel != "warn" {
|
||||
t.Errorf("default kernel log_level: got %q, want warn", cfg.Kernel.LogLevel)
|
||||
@@ -82,7 +85,7 @@ panel:
|
||||
if cfg.Cert.HTTPPort != 80 {
|
||||
t.Errorf("default http_port: got %d, want 80", cfg.Cert.HTTPPort)
|
||||
}
|
||||
expectedCertDir := filepath.Join("/etc/xboard-node", "certs")
|
||||
expectedCertDir := filepath.Join(expectedDir, "certs")
|
||||
if cfg.Cert.CertDir != expectedCertDir {
|
||||
t.Errorf("default cert_dir: got %q, want %q", cfg.Cert.CertDir, expectedCertDir)
|
||||
}
|
||||
@@ -323,3 +326,220 @@ kernel:
|
||||
t.Fatal("expected error for standalone mode without users")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestLoadRoot_LegacyConfigNormalizesToSingleInstance(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok"
|
||||
node_id: 1
|
||||
`)
|
||||
root, err := LoadRoot(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot: %v", err)
|
||||
}
|
||||
instances, err := root.NormalizeInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeInstances: %v", err)
|
||||
}
|
||||
if len(instances) != 1 {
|
||||
t.Fatalf("instances: got %d, want 1", len(instances))
|
||||
}
|
||||
if instances[0].Panel.URL != "https://panel.example.com" {
|
||||
t.Errorf("instance url: got %q", instances[0].Panel.URL)
|
||||
}
|
||||
if instances[0].InstanceID == "" {
|
||||
t.Fatal("expected auto instance id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoot_InstancesConfig(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
instances:
|
||||
- panel:
|
||||
url: "https://panel-a.example.com"
|
||||
token_env: "PANEL_A_TOKEN"
|
||||
node_id: 1
|
||||
kernel:
|
||||
type: singbox
|
||||
- panel:
|
||||
url: "https://panel-b.example.com"
|
||||
machine:
|
||||
machine_id: 2
|
||||
token_env: "PANEL_B_MACHINE_TOKEN"
|
||||
kernel:
|
||||
type: singbox
|
||||
`)
|
||||
t.Setenv("PANEL_A_TOKEN", "token-a")
|
||||
t.Setenv("PANEL_B_MACHINE_TOKEN", "token-b")
|
||||
root, err := LoadRoot(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot: %v", err)
|
||||
}
|
||||
instances, err := root.NormalizeInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeInstances: %v", err)
|
||||
}
|
||||
if len(instances) != 2 {
|
||||
t.Fatalf("instances: got %d, want 2", len(instances))
|
||||
}
|
||||
if instances[0].Panel.Token != "token-a" {
|
||||
t.Errorf("panel a token: got %q", instances[0].Panel.Token)
|
||||
}
|
||||
if instances[1].Machine == nil || instances[1].Machine.Token != "token-b" {
|
||||
t.Fatalf("machine token: got %+v", instances[1].Machine)
|
||||
}
|
||||
if instances[0].InstanceID == instances[1].InstanceID {
|
||||
t.Fatal("expected unique instance ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_AutoInstanceIDStable(t *testing.T) {
|
||||
cfg := &Config{Panel: PanelConfig{URL: "https://Panel.Example.com/", NodeID: 1, Token: "tok"}}
|
||||
cfg.setDefaultsFrom("/etc/xboard-node")
|
||||
id1, err := cfg.AutoInstanceID()
|
||||
if err != nil {
|
||||
t.Fatalf("AutoInstanceID: %v", err)
|
||||
}
|
||||
id2, err := cfg.AutoInstanceID()
|
||||
if err != nil {
|
||||
t.Fatalf("AutoInstanceID second: %v", err)
|
||||
}
|
||||
if id1 != id2 {
|
||||
t.Fatalf("ids differ: %q vs %q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoot_InheritanceFromTopLevel(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
log:
|
||||
level: "debug"
|
||||
output: "stderr"
|
||||
kernel:
|
||||
type: xray
|
||||
log_level: "error"
|
||||
node:
|
||||
push_interval: 42
|
||||
pull_interval: 99
|
||||
instances:
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-a"
|
||||
node_id: 1
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-b"
|
||||
node_id: 2
|
||||
log:
|
||||
level: "warn"
|
||||
`)
|
||||
root, err := LoadRoot(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot: %v", err)
|
||||
}
|
||||
if len(root.Instances) != 2 {
|
||||
t.Fatalf("instances: got %d, want 2", len(root.Instances))
|
||||
}
|
||||
// Instance 0: inherits everything from top-level.
|
||||
inst0 := root.Instances[0]
|
||||
if inst0.Log.Level != "debug" {
|
||||
t.Errorf("inst0 log.level: got %q, want %q", inst0.Log.Level, "debug")
|
||||
}
|
||||
if inst0.Log.Output != "stderr" {
|
||||
t.Errorf("inst0 log.output: got %q, want %q", inst0.Log.Output, "stderr")
|
||||
}
|
||||
if inst0.Kernel.Type != "xray" {
|
||||
t.Errorf("inst0 kernel.type: got %q, want %q", inst0.Kernel.Type, "xray")
|
||||
}
|
||||
if inst0.Kernel.LogLevel != "error" {
|
||||
t.Errorf("inst0 kernel.log_level: got %q, want %q", inst0.Kernel.LogLevel, "error")
|
||||
}
|
||||
if inst0.Node.PushInterval != 42 {
|
||||
t.Errorf("inst0 push_interval: got %d, want 42", inst0.Node.PushInterval)
|
||||
}
|
||||
if inst0.Node.PullInterval != 99 {
|
||||
t.Errorf("inst0 pull_interval: got %d, want 99", inst0.Node.PullInterval)
|
||||
}
|
||||
// Instance 1: overrides log.level, inherits the rest.
|
||||
inst1 := root.Instances[1]
|
||||
if inst1.Log.Level != "warn" {
|
||||
t.Errorf("inst1 log.level: got %q, want %q", inst1.Log.Level, "warn")
|
||||
}
|
||||
if inst1.Log.Output != "stderr" {
|
||||
t.Errorf("inst1 log.output: got %q, want %q", inst1.Log.Output, "stderr")
|
||||
}
|
||||
if inst1.Kernel.Type != "xray" {
|
||||
t.Errorf("inst1 kernel.type: got %q, want %q", inst1.Kernel.Type, "xray")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoot_InstanceOrderDoesNotAffectConfigDir(t *testing.T) {
|
||||
yamlTmpl := func(first, second int) string {
|
||||
return fmt.Sprintf(`
|
||||
instances:
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-a"
|
||||
node_id: %d
|
||||
- panel:
|
||||
url: "https://panel.example.com"
|
||||
token: "tok-b"
|
||||
node_id: %d
|
||||
`, first, second)
|
||||
}
|
||||
|
||||
path1 := writeTemp(t, yamlTmpl(1, 2))
|
||||
path2 := writeTemp(t, yamlTmpl(2, 1))
|
||||
root1, err := LoadRoot(path1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot order1: %v", err)
|
||||
}
|
||||
root2, err := LoadRoot(path2)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoot order2: %v", err)
|
||||
}
|
||||
|
||||
// Collect the config_dir suffix (relative to base) by InstanceID.
|
||||
suffix := func(instances []Config) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, inst := range instances {
|
||||
// The config_dir ends with .../{instanceID}, extract last component.
|
||||
m[inst.InstanceID] = filepath.Base(inst.Kernel.ConfigDir)
|
||||
}
|
||||
return m
|
||||
}
|
||||
dirs1 := suffix(root1.Instances)
|
||||
dirs2 := suffix(root2.Instances)
|
||||
|
||||
for id, s1 := range dirs1 {
|
||||
s2, ok := dirs2[id]
|
||||
if !ok {
|
||||
t.Fatalf("instance %q missing in reversed order", id)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Errorf("instance %q config_dir suffix differs: %q vs %q", id, s1, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritFrom_AutoTLSNotForcedWhenChildHasCertMode(t *testing.T) {
|
||||
parent := &Config{Cert: CertConfig{AutoTLS: true, Domain: "example.com"}}
|
||||
child := &Config{Cert: CertConfig{CertMode: "file", CertFile: "/etc/cert.pem"}}
|
||||
child.inheritFrom(parent)
|
||||
if child.Cert.AutoTLS {
|
||||
t.Error("auto_tls should NOT be inherited when child has explicit cert_mode")
|
||||
}
|
||||
if child.Cert.Domain != "example.com" {
|
||||
t.Errorf("domain: got %q, want %q", child.Cert.Domain, "example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritFrom_AutoTLSInheritedWhenChildHasNoCertConfig(t *testing.T) {
|
||||
parent := &Config{Cert: CertConfig{AutoTLS: true, Domain: "example.com"}}
|
||||
child := &Config{}
|
||||
child.inheritFrom(parent)
|
||||
if !child.Cert.AutoTLS {
|
||||
t.Error("auto_tls should be inherited when child has no cert config")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type StandaloneNodeConfig struct {
|
||||
|
||||
NetworkSettings map[string]any `yaml:"network_settings,omitempty"`
|
||||
Routes []StandaloneRouteRule `yaml:"routes,omitempty"`
|
||||
CustomRouteRules []StandaloneCustomRouteRule `yaml:"custom_route_rules,omitempty"`
|
||||
|
||||
Cipher string `yaml:"cipher,omitempty"`
|
||||
Plugin string `yaml:"plugin,omitempty"`
|
||||
@@ -57,6 +58,28 @@ type StandaloneRouteRule struct {
|
||||
ActionValue string `yaml:"action_value,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneCustomRouteRule struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Disabled bool `yaml:"disabled,omitempty"`
|
||||
Match StandaloneRouteMatch `yaml:"match,omitempty"`
|
||||
Action StandaloneRouteAction `yaml:"action,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneRouteMatch struct {
|
||||
Domains []string `yaml:"domains,omitempty"`
|
||||
DomainSuffixes []string `yaml:"domain_suffixes,omitempty"`
|
||||
IPCIDRs []string `yaml:"ip_cidrs,omitempty"`
|
||||
Ports []string `yaml:"ports,omitempty"`
|
||||
Networks []string `yaml:"networks,omitempty"`
|
||||
SourceCIDRs []string `yaml:"source_cidrs,omitempty"`
|
||||
SourcePorts []string `yaml:"source_ports,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneRouteAction struct {
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Target string `yaml:"target,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneMultiplexConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Protocol string `yaml:"protocol,omitempty"`
|
||||
|
||||
@@ -16,6 +16,7 @@ type Watcher struct {
|
||||
path string
|
||||
debounce time.Duration
|
||||
onChange func(*Config)
|
||||
onChangeRoot func(*RootConfig)
|
||||
watcher *fsnotify.Watcher
|
||||
|
||||
stopOnce sync.Once
|
||||
@@ -56,6 +57,38 @@ func WatchConfig(ctx context.Context, path string, onChange func(*Config)) (*Wat
|
||||
return w, nil
|
||||
}
|
||||
|
||||
|
||||
// WatchConfigRoot watches path and reloads the root config model.
|
||||
func WatchConfigRoot(ctx context.Context, path string, onChange func(*RootConfig)) (*Watcher, error) {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
fsw.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
path: absPath,
|
||||
debounce: 1 * time.Second,
|
||||
onChangeRoot: onChange,
|
||||
watcher: fsw,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
go w.loop(ctx)
|
||||
nlog.Core().Info("config watcher started", "path", absPath)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) loop(ctx context.Context) {
|
||||
var timer *time.Timer
|
||||
defer func() {
|
||||
@@ -102,6 +135,16 @@ func (w *Watcher) reload() {
|
||||
if w.stopped.Load() {
|
||||
return
|
||||
}
|
||||
if w.onChangeRoot != nil {
|
||||
root, err := LoadRoot(w.path)
|
||||
if err != nil {
|
||||
nlog.Core().Error("config reload failed, keeping current config", "error", err)
|
||||
return
|
||||
}
|
||||
nlog.Core().Info("config reloaded successfully")
|
||||
w.onChangeRoot(root)
|
||||
return
|
||||
}
|
||||
cfg, err := Load(w.path)
|
||||
if err != nil {
|
||||
nlog.Core().Error("config reload failed, keeping current config", "error", err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package controlplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
@@ -23,10 +24,14 @@ func (l *LocalControlPlane) Initial(ctx context.Context, _ func() map[string]int
|
||||
return Bootstrap{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
nodeSpec, err := model.NodeSpecFromStandaloneValidated(l.cfg)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("load standalone node config: %w", err)
|
||||
}
|
||||
return Bootstrap{
|
||||
PushInterval: l.cfg.Node.PushInterval,
|
||||
PullInterval: l.cfg.Node.PullInterval,
|
||||
Config: model.NodeSpecFromStandalone(l.cfg),
|
||||
Config: nodeSpec,
|
||||
Users: model.UserSpecsFromStandalone(l.cfg),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// MachinePanelControlPlane implements ControlPlane for a single node running
|
||||
// under the machine orchestrator. REST calls go through a per-node panel.Client
|
||||
// with machine-level auth; WS events are delivered via an externally-managed
|
||||
// PushClient supplied by the machine WS mux.
|
||||
type MachinePanelControlPlane struct {
|
||||
client *panel.Client
|
||||
kcfg config.KernelConfig
|
||||
push PushClient // virtual push client from WS mux (may be nil)
|
||||
|
||||
registerFn func(statuses chan<- StatusChange) *NodeMailbox
|
||||
}
|
||||
|
||||
// NewMachinePanelControlPlane creates a per-node control plane for machine mode.
|
||||
// push may be nil if WS is unavailable.
|
||||
// registerFn is called during Initial() to wire machine mailbox access and status
|
||||
// updates into the service loop. It may be nil when WS is not used.
|
||||
func NewMachinePanelControlPlane(
|
||||
client *panel.Client,
|
||||
kcfg config.KernelConfig,
|
||||
push PushClient,
|
||||
registerFn func(statuses chan<- StatusChange) *NodeMailbox,
|
||||
) *MachinePanelControlPlane {
|
||||
return &MachinePanelControlPlane{
|
||||
client: client,
|
||||
kcfg: kcfg,
|
||||
push: push,
|
||||
registerFn: registerFn,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) SupportsPolling() bool { return true }
|
||||
func (p *MachinePanelControlPlane) SupportsDiscovery() bool { return false }
|
||||
func (p *MachinePanelControlPlane) SupportsReporting() bool { return true }
|
||||
func (p *MachinePanelControlPlane) SupportsDeviceReports() bool { return p.push != nil }
|
||||
|
||||
func (p *MachinePanelControlPlane) Initial(
|
||||
ctx context.Context,
|
||||
metricsFn func() map[string]interface{},
|
||||
events chan<- Event,
|
||||
statuses chan<- StatusChange,
|
||||
) (Bootstrap, error) {
|
||||
// In machine mode the orchestrator handles the shared WS handshake, but each
|
||||
// node service still needs one initial REST snapshot so newly discovered nodes
|
||||
// can start immediately instead of waiting for a subsequent WS full-sync.
|
||||
hs, err := p.client.Handshake()
|
||||
if err != nil {
|
||||
nlog.Core().Debug("machine node handshake skipped, using REST", "error", err)
|
||||
}
|
||||
|
||||
bootstrap := Bootstrap{}
|
||||
if hs != nil {
|
||||
bootstrap.PushInterval = hs.Settings.PushInterval
|
||||
bootstrap.PullInterval = hs.Settings.PullInterval
|
||||
}
|
||||
|
||||
configSnapshot, err := p.client.GetConfig()
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial config: %w", err)
|
||||
}
|
||||
if configSnapshot == nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial config is nil")
|
||||
}
|
||||
users, err := p.client.GetUsers()
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial users: %w", err)
|
||||
}
|
||||
bootstrap.Config, err = model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial config normalize: %w", err)
|
||||
}
|
||||
bootstrap.Users = model.UserSpecsFromPanel(users)
|
||||
|
||||
// Register mailbox access after the initial snapshot is prepared, so the
|
||||
// Service can start the kernel immediately and only then drain coalesced WS
|
||||
// state from the machine mailbox.
|
||||
if p.push != nil {
|
||||
if p.registerFn != nil {
|
||||
bootstrap.Mailbox = p.registerFn(statuses)
|
||||
}
|
||||
bootstrap.Push = p.push
|
||||
}
|
||||
|
||||
return bootstrap, nil
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Poll(ctx context.Context) (Snapshot, error) {
|
||||
configSnapshot, err := p.client.GetConfig()
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("machine poll config: %w", err)
|
||||
}
|
||||
users, err := p.client.GetUsers()
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("machine poll users: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Snapshot{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
nodeSpec, err := model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("machine poll normalize: %w", err)
|
||||
}
|
||||
return Snapshot{Config: nodeSpec, Users: model.UserSpecsFromPanel(users)}, nil
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Discover(
|
||||
ctx context.Context,
|
||||
metricsFn func() map[string]interface{},
|
||||
events chan<- Event,
|
||||
statuses chan<- StatusChange,
|
||||
) (PushClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Report(payload ReportPayload) error {
|
||||
return p.client.Report(
|
||||
payload.Traffic, payload.Alive, payload.Online,
|
||||
payload.CPU, payload.Mem, payload.Swap, payload.Disk,
|
||||
payload.Metrics,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) ReportDevices(push PushClient, devices map[int][]string) {
|
||||
if push != nil {
|
||||
push.SendDeviceReport(devices)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Metrics() APIMetrics {
|
||||
api := p.client.SnapshotMetrics()
|
||||
return APIMetrics{Success: api.Success, Failure: api.Failure}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
// MailboxState is the coalesced snapshot drained from a NodeMailbox.
|
||||
type MailboxState struct {
|
||||
Config *model.NodeSpec
|
||||
Users []model.UserSpec
|
||||
DeviceUsers map[int][]string
|
||||
HasConfig bool
|
||||
HasUsers bool
|
||||
HasDevices bool
|
||||
NeedsReconcile bool
|
||||
}
|
||||
|
||||
// NodeMailbox buffers WS events for a machine-mode node service that is not
|
||||
// yet ready to process them. Events are coalesced (latest-wins) and drained
|
||||
// as a single snapshot once the service marks the mailbox as ready.
|
||||
type NodeMailbox struct {
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
config *model.NodeSpec
|
||||
users []model.UserSpec
|
||||
deviceUsers map[int][]string
|
||||
dirtyConfig bool
|
||||
dirtyUsers bool
|
||||
dirtyDevices bool
|
||||
needsReconcile bool
|
||||
hasFullUserState bool
|
||||
notifyCh chan struct{}
|
||||
}
|
||||
|
||||
func NewNodeMailbox() *NodeMailbox {
|
||||
return &NodeMailbox{notifyCh: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) MarkReady() {
|
||||
m.mu.Lock()
|
||||
m.ready = true
|
||||
m.mu.Unlock()
|
||||
m.notify()
|
||||
}
|
||||
|
||||
// SeedBaseline initializes the mailbox with the REST-derived baseline so that
|
||||
// subsequent delta events can be applied incrementally instead of triggering
|
||||
// a full REST reconciliation.
|
||||
func (m *NodeMailbox) SeedBaseline(users []model.UserSpec, config *model.NodeSpec) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if users != nil && !m.hasFullUserState {
|
||||
m.users = cloneUsers(users)
|
||||
m.hasFullUserState = true
|
||||
}
|
||||
if config != nil && m.config == nil {
|
||||
m.config = cloneNodeSpec(config)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) Apply(event Event) {
|
||||
m.mu.Lock()
|
||||
changed := false
|
||||
|
||||
switch event.Type {
|
||||
case EventSyncConfig:
|
||||
if event.Config != nil {
|
||||
m.config = cloneNodeSpec(event.Config)
|
||||
m.dirtyConfig = true
|
||||
changed = true
|
||||
}
|
||||
case EventSyncUsers:
|
||||
if event.Users != nil {
|
||||
m.users = cloneUsers(event.Users)
|
||||
m.dirtyUsers = true
|
||||
m.hasFullUserState = true
|
||||
changed = true
|
||||
}
|
||||
case EventSyncDevices:
|
||||
if event.DeviceUsers != nil {
|
||||
m.deviceUsers = cloneDeviceUsers(event.DeviceUsers)
|
||||
m.dirtyDevices = true
|
||||
changed = true
|
||||
}
|
||||
case EventSyncUserDelta:
|
||||
if len(event.DeltaUsers) > 0 {
|
||||
if !m.hasFullUserState {
|
||||
m.needsReconcile = true
|
||||
changed = true
|
||||
} else if !applyUserDelta(&m.users, event.DeltaAction, event.DeltaUsers) {
|
||||
m.needsReconcile = true
|
||||
changed = true
|
||||
} else {
|
||||
m.dirtyUsers = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
m.notify()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) notify() {
|
||||
select {
|
||||
case m.notifyCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) NotifyCh() <-chan struct{} { return m.notifyCh }
|
||||
|
||||
func (m *NodeMailbox) DrainIfReady() MailboxState {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.ready {
|
||||
return MailboxState{}
|
||||
}
|
||||
state := MailboxState{NeedsReconcile: m.needsReconcile}
|
||||
if m.dirtyConfig && m.config != nil {
|
||||
state.Config = cloneNodeSpec(m.config)
|
||||
state.HasConfig = true
|
||||
m.dirtyConfig = false
|
||||
}
|
||||
if m.dirtyUsers {
|
||||
state.Users = cloneUsers(m.users)
|
||||
state.HasUsers = true
|
||||
m.dirtyUsers = false
|
||||
}
|
||||
if m.dirtyDevices && m.deviceUsers != nil {
|
||||
state.DeviceUsers = cloneDeviceUsers(m.deviceUsers)
|
||||
state.HasDevices = true
|
||||
m.dirtyDevices = false
|
||||
}
|
||||
m.needsReconcile = false
|
||||
return state
|
||||
}
|
||||
|
||||
func cloneNodeSpec(spec *model.NodeSpec) *model.NodeSpec {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *spec
|
||||
if spec.NetworkSettings != nil {
|
||||
clone.NetworkSettings = make(map[string]interface{}, len(spec.NetworkSettings))
|
||||
for k, v := range spec.NetworkSettings {
|
||||
clone.NetworkSettings[k] = v
|
||||
}
|
||||
}
|
||||
if spec.TLSSettings != nil {
|
||||
clone.TLSSettings = make(map[string]interface{}, len(spec.TLSSettings))
|
||||
for k, v := range spec.TLSSettings {
|
||||
clone.TLSSettings[k] = v
|
||||
}
|
||||
}
|
||||
if spec.Routes != nil {
|
||||
clone.Routes = append([]model.RouteRule(nil), spec.Routes...)
|
||||
}
|
||||
if spec.CustomOutbounds != nil {
|
||||
clone.CustomOutbounds = append([]model.OutboundConfig(nil), spec.CustomOutbounds...)
|
||||
}
|
||||
if spec.CustomRoutes != nil {
|
||||
clone.CustomRoutes = make([]map[string]any, len(spec.CustomRoutes))
|
||||
for i, route := range spec.CustomRoutes {
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
copied := make(map[string]any, len(route))
|
||||
for k, v := range route {
|
||||
copied[k] = v
|
||||
}
|
||||
clone.CustomRoutes[i] = copied
|
||||
}
|
||||
}
|
||||
if spec.CustomRouteRules != nil {
|
||||
clone.CustomRouteRules = append([]model.CustomRouteRule(nil), spec.CustomRouteRules...)
|
||||
}
|
||||
if spec.CertConfig != nil {
|
||||
certCopy := *spec.CertConfig
|
||||
if spec.CertConfig.DNSEnv != nil {
|
||||
certCopy.DNSEnv = make(map[string]string, len(spec.CertConfig.DNSEnv))
|
||||
for k, v := range spec.CertConfig.DNSEnv {
|
||||
certCopy.DNSEnv[k] = v
|
||||
}
|
||||
}
|
||||
clone.CertConfig = &certCopy
|
||||
}
|
||||
if spec.Multiplex != nil {
|
||||
muxCopy := *spec.Multiplex
|
||||
if spec.Multiplex.Brutal != nil {
|
||||
brutalCopy := *spec.Multiplex.Brutal
|
||||
muxCopy.Brutal = &brutalCopy
|
||||
}
|
||||
clone.Multiplex = &muxCopy
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneUsers(users []model.UserSpec) []model.UserSpec {
|
||||
if users == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]model.UserSpec, len(users))
|
||||
copy(cloned, users)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneDeviceUsers(devices map[int][]string) map[int][]string {
|
||||
if devices == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[int][]string, len(devices))
|
||||
for uid, ips := range devices {
|
||||
dup := make([]string, len(ips))
|
||||
copy(dup, ips)
|
||||
cloned[uid] = dup
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func applyUserDelta(users *[]model.UserSpec, action string, delta []model.UserSpec) bool {
|
||||
current := cloneUsers(*users)
|
||||
index := make(map[int]int, len(current))
|
||||
for i, user := range current {
|
||||
index[user.ID] = i
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
for _, user := range delta {
|
||||
if idx, ok := index[user.ID]; ok {
|
||||
current[idx] = user
|
||||
continue
|
||||
}
|
||||
index[user.ID] = len(current)
|
||||
current = append(current, user)
|
||||
}
|
||||
case "remove":
|
||||
removeIDs := make(map[int]struct{}, len(delta))
|
||||
for _, user := range delta {
|
||||
removeIDs[user.ID] = struct{}{}
|
||||
}
|
||||
filtered := current[:0]
|
||||
for _, user := range current {
|
||||
if _, ok := removeIDs[user.ID]; ok {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
current = append([]model.UserSpec(nil), filtered...)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
*users = current
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
func TestNodeMailboxLatestStateAndNotifyCollapse(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
cfg1 := &model.NodeSpec{Protocol: "vmess", ServerPort: 10010}
|
||||
cfg2 := &model.NodeSpec{Protocol: "vless", ServerPort: 10020}
|
||||
|
||||
mb.Apply(Event{Type: EventSyncConfig, Config: cfg1})
|
||||
mb.Apply(Event{Type: EventSyncConfig, Config: cfg2})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected collapsed notify signal")
|
||||
}
|
||||
|
||||
mb.MarkReady()
|
||||
state := mb.DrainIfReady()
|
||||
if !state.HasConfig || state.Config == nil {
|
||||
t.Fatal("expected config state")
|
||||
}
|
||||
if state.Config.Protocol != "vless" || state.Config.ServerPort != 10020 {
|
||||
t.Fatalf("unexpected config: %+v", state.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxFullUsersOverwriteAndDeltaBeforeBaseline(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "add",
|
||||
DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}},
|
||||
})
|
||||
mb.MarkReady()
|
||||
state := mb.DrainIfReady()
|
||||
if !state.NeedsReconcile {
|
||||
t.Fatal("expected reconcile when delta arrives before baseline")
|
||||
}
|
||||
|
||||
mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 1, UUID: "u1"}}})
|
||||
mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 2, UUID: "u2"}}})
|
||||
state = mb.DrainIfReady()
|
||||
if !state.HasUsers {
|
||||
t.Fatal("expected users snapshot")
|
||||
}
|
||||
if len(state.Users) != 1 || state.Users[0].ID != 2 {
|
||||
t.Fatalf("unexpected users: %+v", state.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxDevicesLatestWins(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"1.1.1.1"}}})
|
||||
mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"2.2.2.2"}}})
|
||||
mb.MarkReady()
|
||||
state := mb.DrainIfReady()
|
||||
if !state.HasDevices {
|
||||
t.Fatal("expected devices state")
|
||||
}
|
||||
if got := state.DeviceUsers[1][0]; got != "2.2.2.2" {
|
||||
t.Fatalf("unexpected device state: %v", state.DeviceUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxReconcileNotifies(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.MarkReady()
|
||||
|
||||
// Drain the MarkReady notification
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
}
|
||||
|
||||
// Delta without baseline should trigger reconcile AND notify
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "add",
|
||||
DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected notify on reconcile path")
|
||||
}
|
||||
|
||||
state := mb.DrainIfReady()
|
||||
if !state.NeedsReconcile {
|
||||
t.Fatal("expected NeedsReconcile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxSeedBaselineEnablesDelta(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}, {ID: 2, UUID: "u2"}}, nil)
|
||||
mb.MarkReady()
|
||||
|
||||
// Drain MarkReady notify
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
}
|
||||
|
||||
// Delta should now apply incrementally, not trigger reconcile
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "add",
|
||||
DeltaUsers: []model.UserSpec{{ID: 3, UUID: "u3"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected notify after delta")
|
||||
}
|
||||
|
||||
state := mb.DrainIfReady()
|
||||
if state.NeedsReconcile {
|
||||
t.Fatal("should not need reconcile after seeded baseline")
|
||||
}
|
||||
if !state.HasUsers {
|
||||
t.Fatal("expected users")
|
||||
}
|
||||
if len(state.Users) != 3 {
|
||||
t.Fatalf("expected 3 users, got %d: %+v", len(state.Users), state.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxDeltaFailedApplyNotifies(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}}, nil)
|
||||
mb.MarkReady()
|
||||
|
||||
// Drain MarkReady notify
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
}
|
||||
|
||||
// Invalid action should trigger reconcile and still notify
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "invalid_action",
|
||||
DeltaUsers: []model.UserSpec{{ID: 2, UUID: "u2"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected notify on failed delta apply")
|
||||
}
|
||||
|
||||
state := mb.DrainIfReady()
|
||||
if !state.NeedsReconcile {
|
||||
t.Fatal("expected NeedsReconcile for invalid delta action")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type PanelControlPlane struct {
|
||||
cfg config.PanelConfig
|
||||
wsCfg config.WSConfig
|
||||
kcfg config.KernelConfig
|
||||
client *panel.Client
|
||||
}
|
||||
|
||||
@@ -21,10 +22,11 @@ type panelPushClient struct {
|
||||
inner *panel.WSClient
|
||||
}
|
||||
|
||||
func NewPanelControlPlane(panelCfg config.PanelConfig, wsCfg config.WSConfig) *PanelControlPlane {
|
||||
func NewPanelControlPlane(panelCfg config.PanelConfig, wsCfg config.WSConfig, kcfg config.KernelConfig) *PanelControlPlane {
|
||||
return &PanelControlPlane{
|
||||
cfg: panelCfg,
|
||||
wsCfg: wsCfg,
|
||||
kcfg: kcfg,
|
||||
client: panel.NewClient(panelCfg),
|
||||
}
|
||||
}
|
||||
@@ -58,7 +60,10 @@ func (p *PanelControlPlane) Initial(ctx context.Context, metricsFn func() map[st
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("initial user fetch: %w", err)
|
||||
}
|
||||
bootstrap.Config = model.NodeSpecFromPanel(configSnapshot)
|
||||
bootstrap.Config, err = model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("initial config normalize: %w", err)
|
||||
}
|
||||
bootstrap.Users = model.UserSpecsFromPanel(users)
|
||||
return bootstrap, nil
|
||||
}
|
||||
@@ -77,7 +82,11 @@ func (p *PanelControlPlane) Poll(ctx context.Context) (Snapshot, error) {
|
||||
return Snapshot{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
return Snapshot{Config: model.NodeSpecFromPanel(configSnapshot), Users: model.UserSpecsFromPanel(users)}, nil
|
||||
nodeSpec, err := model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("poll config normalize: %w", err)
|
||||
}
|
||||
return Snapshot{Config: nodeSpec, Users: model.UserSpecsFromPanel(users)}, nil
|
||||
}
|
||||
|
||||
func (p *PanelControlPlane) Discover(ctx context.Context, metricsFn func() map[string]interface{}, events chan<- Event, statuses chan<- StatusChange) (PushClient, error) {
|
||||
@@ -124,11 +133,19 @@ func (p *PanelControlPlane) newPushClient(metricsFn func() map[string]interface{
|
||||
p.cfg.NodeID,
|
||||
cfg,
|
||||
func(event panel.WSEvent) {
|
||||
translated := translateWSEvent(event)
|
||||
translated, err := TranslateWSEvent(event, p.kcfg)
|
||||
if err != nil {
|
||||
nlog.Core().Warn("invalid ws event config, dropping event", "type", event.Type, "error", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case events <- translated:
|
||||
default:
|
||||
nlog.Core().Warn("ws event channel full, dropping event", "type", translated.Type)
|
||||
select {
|
||||
case statuses <- StatusChange{Connected: true, NeedsResync: true}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
func(status panel.WSStatusChange) {
|
||||
@@ -142,10 +159,14 @@ func (p *PanelControlPlane) newPushClient(metricsFn func() map[string]interface{
|
||||
return &panelPushClient{inner: inner}
|
||||
}
|
||||
|
||||
func translateWSEvent(event panel.WSEvent) Event {
|
||||
func TranslateWSEvent(event panel.WSEvent, kcfg config.KernelConfig) (Event, error) {
|
||||
translated := Event{Type: EventType(event.Type), DeltaAction: event.DeltaAction, DeviceUsers: event.DeviceUsers}
|
||||
if event.Config != nil {
|
||||
translated.Config = model.NodeSpecFromPanel(event.Config)
|
||||
var err error
|
||||
translated.Config, err = model.NodeSpecFromPanelValidated(event.Config, kcfg)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("translate node config: %w", err)
|
||||
}
|
||||
}
|
||||
if event.Users != nil {
|
||||
translated.Users = model.UserSpecsFromPanel(event.Users)
|
||||
@@ -153,9 +174,11 @@ func translateWSEvent(event panel.WSEvent) Event {
|
||||
if event.DeltaUsers != nil {
|
||||
translated.DeltaUsers = model.UserSpecsFromPanel(event.DeltaUsers)
|
||||
}
|
||||
return translated
|
||||
return translated, nil
|
||||
}
|
||||
|
||||
func (p *panelPushClient) Run(ctx context.Context) { p.inner.Run(ctx) }
|
||||
func (p *panelPushClient) IsConnected() bool { return p.inner.IsConnected() }
|
||||
func (p *panelPushClient) SendDeviceReport(devices map[int][]string) { p.inner.SendDeviceReport(devices) }
|
||||
func (p *panelPushClient) SendDeviceReport(devices map[int][]string) {
|
||||
p.inner.SendDeviceReport(devices)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
panelapi "github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestPanelControlPlaneInitialRejectsInvalidCustomOutbounds(t *testing.T) {
|
||||
server := newPanelTestServer(`{"protocol":"shadowsocks","server_port":8388,"custom_outbounds":[{"tag":"proxy","protocol":"socks","proxy_tag":"missing","settings":{"server":"2.2.2.2","server_port":1080}}]}`)
|
||||
defer server.Close()
|
||||
|
||||
cp := NewPanelControlPlane(config.PanelConfig{URL: server.URL, Token: "token", NodeID: 1}, config.WSConfig{}, config.KernelConfig{Type: "singbox"})
|
||||
_, err := cp.Initial(context.Background(), nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `initial config normalize: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelControlPlanePollRejectsInvalidCustomOutbounds(t *testing.T) {
|
||||
server := newPanelTestServer(`{"protocol":"shadowsocks","server_port":8388,"custom_outbounds":[{"tag":"proxy","protocol":"socks","proxy_tag":"missing","settings":{"server":"2.2.2.2","server_port":1080}}]}`)
|
||||
defer server.Close()
|
||||
|
||||
cp := NewPanelControlPlane(config.PanelConfig{URL: server.URL, Token: "token", NodeID: 1}, config.WSConfig{}, config.KernelConfig{Type: "singbox"})
|
||||
_, err := cp.Poll(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `poll config normalize: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWSEventRejectsInvalidCustomOutbounds(t *testing.T) {
|
||||
_, err := TranslateWSEvent(panelapi.WSEvent{
|
||||
Type: panelapi.WSEventSyncConfig,
|
||||
Config: &panelapi.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panelapi.OutboundConfig{
|
||||
{Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
},
|
||||
},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `translate node config: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPanelTestServer(configBody string) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/server/handshake", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"websocket":{"enabled":false},"settings":{"push_interval":60,"pull_interval":60}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/server/UniProxy/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(configBody))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/server/UniProxy/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"users":[{"id":1,"uuid":"11111111-1111-1111-1111-111111111111"}]}`))
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestTranslateWSEventRejectsUnsupportedProtocolForKernel(t *testing.T) {
|
||||
_, err := TranslateWSEvent(panelapi.WSEvent{
|
||||
Type: panelapi.WSEventSyncConfig,
|
||||
Config: &panelapi.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panelapi.OutboundConfig{
|
||||
{Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443}},
|
||||
},
|
||||
},
|
||||
}, config.KernelConfig{Type: "xray"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `translate node config: validate custom outbounds: custom_outbounds[0].protocol "hysteria2" is not supported by kernel "xray"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type Event struct {
|
||||
|
||||
type StatusChange struct {
|
||||
Connected bool
|
||||
NeedsResync bool
|
||||
}
|
||||
|
||||
type APIMetrics struct {
|
||||
@@ -39,6 +40,7 @@ type Bootstrap struct {
|
||||
Push PushClient
|
||||
Config *model.NodeSpec
|
||||
Users []model.UserSpec
|
||||
Mailbox *NodeMailbox
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
func NeedsGeoIPRules(rules []model.CustomRouteRule) bool {
|
||||
for _, r := range rules {
|
||||
for _, v := range r.Match.IPCIDRs {
|
||||
if strings.HasPrefix(v, "geoip:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, v := range r.Match.SourceCIDRs {
|
||||
if strings.HasPrefix(v, "geoip:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NeedsGeoSiteRules(rules []model.CustomRouteRule) bool {
|
||||
for _, r := range rules {
|
||||
for _, v := range r.Match.Domains {
|
||||
if strings.HasPrefix(v, "geosite:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, v := range r.Match.DomainSuffixes {
|
||||
if strings.HasPrefix(v, "geosite:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,6 +20,19 @@ type Capabilities struct {
|
||||
ForceCloseUser bool
|
||||
}
|
||||
|
||||
// TLSCert holds PEM-encoded TLS certificate material.
|
||||
// All cert modes (self-signed, file, ACME, panel-content) normalise to PEM
|
||||
// so kernels never need to deal with file paths.
|
||||
type TLSCert struct {
|
||||
CertPEM []byte
|
||||
KeyPEM []byte
|
||||
}
|
||||
|
||||
// HasCert reports whether a valid cert+key pair is available.
|
||||
func (t TLSCert) HasCert() bool {
|
||||
return len(t.CertPEM) > 0 && len(t.KeyPEM) > 0
|
||||
}
|
||||
|
||||
// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.).
|
||||
//
|
||||
// The interface is split into lifecycle, user management, and observability
|
||||
@@ -41,7 +54,7 @@ type Kernel interface {
|
||||
// Start initialises the kernel with the given node config and initial
|
||||
// user set, binds listeners, and begins accepting connections.
|
||||
// Calling Start on an already-running kernel stops the old instance first.
|
||||
Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error
|
||||
Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls TLSCert) error
|
||||
// Stop gracefully shuts down the kernel, draining active connections.
|
||||
Stop()
|
||||
// IsRunning returns whether the kernel is currently accepting connections.
|
||||
@@ -49,7 +62,7 @@ type Kernel interface {
|
||||
// Reload re-generates the full config and hot-swaps listeners/routes.
|
||||
// Use this when port, protocol, or TLS settings change.
|
||||
// Existing connections MAY be briefly interrupted.
|
||||
Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error
|
||||
Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls TLSCert) error
|
||||
|
||||
// ─── User management (non-disruptive) ───────────────────────────────
|
||||
// AddUsers registers new users with the running kernel.
|
||||
|
||||
+243
-107
@@ -18,7 +18,7 @@ import (
|
||||
// M is a shorthand for building JSON-like maps
|
||||
type M = map[string]interface{}
|
||||
|
||||
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
var outbounds []M
|
||||
tags := make(map[string]bool)
|
||||
|
||||
@@ -53,18 +53,18 @@ func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.Use
|
||||
"outbounds": outbounds,
|
||||
}
|
||||
|
||||
inbound := buildInbound(nc, users, certFile, keyFile)
|
||||
inbound := buildInbound(nc, users, tc)
|
||||
if inbound != nil {
|
||||
cfg["inbounds"] = []M{inbound}
|
||||
}
|
||||
|
||||
// Merge panel routes and static config routes
|
||||
cfg["route"] = buildRoutes(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
cfg["route"] = buildRoutes(nc.Routes, nc.CustomRouteRules, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
|
||||
// Automatically enable rule_set caching (cache_file) when panel routes
|
||||
// reference geoip:/geosite: entries so that the downloaded .srs rule_set
|
||||
// files survive across process restarts.
|
||||
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) {
|
||||
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) || kernel.NeedsGeoIPRules(nc.CustomRouteRules) || kernel.NeedsGeoSiteRules(nc.CustomRouteRules) {
|
||||
cfg["experimental"] = M{
|
||||
"cache_file": M{
|
||||
"enabled": true,
|
||||
@@ -144,10 +144,18 @@ func mergeRouteList(a, b []map[string]any) []map[string]any {
|
||||
return res
|
||||
}
|
||||
|
||||
func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M {
|
||||
func buildRoutes(panelRoutes []model.RouteRule, customRules []model.CustomRouteRule, custom []map[string]any) M {
|
||||
var rules []M
|
||||
|
||||
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
|
||||
// Structured custom routes now take the highest priority for panel-managed overrides.
|
||||
for _, rule := range customRules {
|
||||
if rule.Disabled {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, compileCustomRouteRule(rule)...)
|
||||
}
|
||||
|
||||
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
|
||||
for _, cr := range custom {
|
||||
rules = append(rules, M(cr))
|
||||
}
|
||||
@@ -177,69 +185,8 @@ func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M {
|
||||
},
|
||||
)
|
||||
|
||||
// Panel-defined routes (usually specific blocks/proxies)
|
||||
for _, pr := range panelRoutes {
|
||||
if len(pr.Match) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var domains, cidrs []string
|
||||
for _, m := range pr.Match {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
// Strip leading wildcard for domain matching
|
||||
if strings.HasPrefix(m, "*.") {
|
||||
m = strings.TrimPrefix(m, "*.")
|
||||
}
|
||||
// Check if it's a CIDR block (contains /)
|
||||
if strings.Contains(m, "/") {
|
||||
cidrs = append(cidrs, m)
|
||||
} else {
|
||||
// Otherwise treat as domain
|
||||
domains = append(domains, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine outbound tag based on action
|
||||
outbound := "block"
|
||||
if pr.Action == "direct" {
|
||||
outbound = "direct"
|
||||
} else if pr.Action == "dns" {
|
||||
// Special case for sing-box DNS routing:
|
||||
// If action is "dns" and action_value is provided, we use it as the server name.
|
||||
// Otherwise it defaults to "dns-out".
|
||||
server := "dns-out"
|
||||
if pr.ActionValue != "" {
|
||||
server = pr.ActionValue
|
||||
}
|
||||
outbound = server
|
||||
} else if pr.Action == "proxy" {
|
||||
// If panel provides a specific outbound tag in action_value, use it.
|
||||
// This allows routing to WARP_JP etc. via normal routes.
|
||||
if pr.ActionValue != "" {
|
||||
outbound = pr.ActionValue
|
||||
}
|
||||
}
|
||||
|
||||
// Create separate rule for domains
|
||||
if len(domains) > 0 {
|
||||
rule := M{
|
||||
"domain_suffix": domains,
|
||||
"outbound": outbound,
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
// Create separate rule for CIDRs
|
||||
if len(cidrs) > 0 {
|
||||
rule := M{
|
||||
"ip_cidr": cidrs,
|
||||
"outbound": outbound,
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
rules = append(rules, compilePanelRouteRule(pr)...)
|
||||
}
|
||||
|
||||
return M{
|
||||
@@ -248,6 +195,162 @@ func buildRoutes(panelRoutes []model.RouteRule, custom []map[string]any) M {
|
||||
}
|
||||
}
|
||||
|
||||
func compilePanelRouteRule(pr model.RouteRule) []M {
|
||||
if len(pr.Match) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var domains, cidrs []string
|
||||
for _, item := range pr.Match {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
item = strings.TrimPrefix(item, "*.")
|
||||
if strings.Contains(item, "/") {
|
||||
cidrs = append(cidrs, item)
|
||||
continue
|
||||
}
|
||||
domains = append(domains, item)
|
||||
}
|
||||
|
||||
outbound := "block"
|
||||
switch pr.Action {
|
||||
case "direct":
|
||||
outbound = "direct"
|
||||
case "dns":
|
||||
if pr.ActionValue != "" {
|
||||
outbound = pr.ActionValue
|
||||
} else {
|
||||
outbound = "dns-out"
|
||||
}
|
||||
case "proxy":
|
||||
if pr.ActionValue != "" {
|
||||
outbound = pr.ActionValue
|
||||
}
|
||||
}
|
||||
|
||||
var compiled []M
|
||||
if len(domains) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"domain_suffix": copyStrings(domains),
|
||||
"outbound": outbound,
|
||||
})
|
||||
}
|
||||
if len(cidrs) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"ip_cidr": copyStrings(cidrs),
|
||||
"outbound": outbound,
|
||||
})
|
||||
}
|
||||
return compiled
|
||||
}
|
||||
|
||||
func compileCustomRouteRule(rule model.CustomRouteRule) []M {
|
||||
if rule.Disabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
outbound := singboxOutboundForAction(rule.Action)
|
||||
var compiled []M
|
||||
|
||||
if len(rule.Match.Domains) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"domain": copyStrings(rule.Match.Domains),
|
||||
"outbound": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.DomainSuffixes) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"domain_suffix": copyStrings(rule.Match.DomainSuffixes),
|
||||
"outbound": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.IPCIDRs) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"ip_cidr": copyStrings(rule.Match.IPCIDRs),
|
||||
"outbound": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.Ports) > 0 {
|
||||
ports, portRanges := splitPorts(rule.Match.Ports)
|
||||
entry := M{"outbound": outbound}
|
||||
if len(ports) > 0 {
|
||||
entry["port"] = ports
|
||||
}
|
||||
if len(portRanges) > 0 {
|
||||
entry["port_range"] = portRanges
|
||||
}
|
||||
compiled = append(compiled, entry)
|
||||
}
|
||||
if len(rule.Match.Networks) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"network": copyStrings(rule.Match.Networks),
|
||||
"outbound": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.SourceCIDRs) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"source_ip_cidr": copyStrings(rule.Match.SourceCIDRs),
|
||||
"outbound": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.SourcePorts) > 0 {
|
||||
ports, portRanges := splitPorts(rule.Match.SourcePorts)
|
||||
entry := M{"outbound": outbound}
|
||||
if len(ports) > 0 {
|
||||
entry["source_port"] = ports
|
||||
}
|
||||
if len(portRanges) > 0 {
|
||||
entry["source_port_range"] = portRanges
|
||||
}
|
||||
compiled = append(compiled, entry)
|
||||
}
|
||||
return compiled
|
||||
}
|
||||
|
||||
func singboxOutboundForAction(action model.RouteAction) string {
|
||||
switch action.Type {
|
||||
case "direct":
|
||||
return "direct"
|
||||
case "route":
|
||||
if action.Target != "" {
|
||||
return action.Target
|
||||
}
|
||||
return "block"
|
||||
default:
|
||||
return "block"
|
||||
}
|
||||
}
|
||||
|
||||
func splitPorts(values []string) ([]int, []string) {
|
||||
var ports []int
|
||||
var ranges []string
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(value, ":-") {
|
||||
ranges = append(ranges, strings.ReplaceAll(value, "-", ":"))
|
||||
continue
|
||||
}
|
||||
if port, err := strconv.Atoi(value); err == nil {
|
||||
ports = append(ports, port)
|
||||
}
|
||||
}
|
||||
return ports, ranges
|
||||
}
|
||||
|
||||
func copyStrings(src []string) []string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
|
||||
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
||||
if err != nil {
|
||||
@@ -326,7 +429,7 @@ func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base := M{
|
||||
"tag": nc.Protocol + "-in",
|
||||
"listen": "::",
|
||||
@@ -337,23 +440,23 @@ func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile
|
||||
case "shadowsocks":
|
||||
return buildShadowsocks(base, nc, users)
|
||||
case "vmess":
|
||||
return buildVMess(base, nc, users, certFile, keyFile)
|
||||
return buildVMess(base, nc, users, tc)
|
||||
case "vless":
|
||||
return buildVLESS(base, nc, users, certFile, keyFile)
|
||||
return buildVLESS(base, nc, users, tc)
|
||||
case "trojan":
|
||||
return buildTrojan(base, nc, users, certFile, keyFile)
|
||||
return buildTrojan(base, nc, users, tc)
|
||||
case "hysteria":
|
||||
return buildHysteria(base, nc, users, certFile, keyFile)
|
||||
return buildHysteria(base, nc, users, tc)
|
||||
case "tuic":
|
||||
return buildTUIC(base, nc, users, certFile, keyFile)
|
||||
return buildTUIC(base, nc, users, tc)
|
||||
case "anytls":
|
||||
return buildAnyTLS(base, nc, users, certFile, keyFile)
|
||||
return buildAnyTLS(base, nc, users, tc)
|
||||
case "naive":
|
||||
return buildNaive(base, nc, users, certFile, keyFile)
|
||||
return buildNaive(base, nc, users, tc)
|
||||
case "socks":
|
||||
return buildSocks(base, users)
|
||||
case "http":
|
||||
return buildHTTP(base, nc, users, certFile, keyFile)
|
||||
return buildHTTP(base, nc, users, tc)
|
||||
case "mieru":
|
||||
return buildMieru(base, nc, users)
|
||||
default:
|
||||
@@ -433,7 +536,7 @@ func buildShadowsocks(base M, nc *model.NodeSpec, users []model.UserSpec) M {
|
||||
return base
|
||||
}
|
||||
|
||||
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base["type"] = "vmess"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
@@ -451,7 +554,7 @@ func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
applyMultiplex(base, nc)
|
||||
|
||||
if nc.TLS == 1 {
|
||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
||||
if tls := buildTLSConfig(nc, tc); tls != nil {
|
||||
base["tls"] = tls
|
||||
}
|
||||
}
|
||||
@@ -459,7 +562,7 @@ func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
return base
|
||||
}
|
||||
|
||||
func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
if nc.Decryption != "" && nc.Decryption != "none" {
|
||||
nlog.Core().Warn("sing-box does not support VLESS encryption (decryption), use xray kernel for this feature")
|
||||
}
|
||||
@@ -484,7 +587,7 @@ func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
applyMultiplex(base, nc)
|
||||
|
||||
if nc.TLS == 1 {
|
||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
||||
if tls := buildTLSConfig(nc, tc); tls != nil {
|
||||
base["tls"] = tls
|
||||
}
|
||||
} else if nc.TLS == 2 {
|
||||
@@ -494,7 +597,7 @@ func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
return base
|
||||
}
|
||||
|
||||
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base["type"] = "trojan"
|
||||
|
||||
userList := make([]M, len(users))
|
||||
@@ -512,7 +615,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
applyMultiplex(base, nc)
|
||||
|
||||
if nc.TLS == 1 {
|
||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
||||
if tls := buildTLSConfig(nc, tc); tls != nil {
|
||||
base["tls"] = tls
|
||||
}
|
||||
} else if nc.TLS == 2 {
|
||||
@@ -521,7 +624,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
|
||||
// If still no TLS/Reality but cert paths exist, enable TLS (panel may omit tls=1).
|
||||
if _, ok := base["tls"]; !ok {
|
||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
||||
if tls := buildTLSConfig(nc, tc); tls != nil {
|
||||
base["tls"] = tls
|
||||
}
|
||||
}
|
||||
@@ -533,7 +636,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
return base
|
||||
}
|
||||
|
||||
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
if nc.Version == 2 {
|
||||
base["type"] = "hysteria2"
|
||||
|
||||
@@ -571,7 +674,7 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
|
||||
}
|
||||
}
|
||||
|
||||
tls := buildTLSConfig(nc, certFile, keyFile)
|
||||
tls := buildTLSConfig(nc, tc)
|
||||
if tls == nil {
|
||||
nlog.Core().Warn("hysteria requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
|
||||
return base
|
||||
@@ -584,7 +687,7 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
|
||||
return base
|
||||
}
|
||||
|
||||
func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base["type"] = "tuic"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
@@ -601,7 +704,7 @@ func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
|
||||
base["congestion_control"] = nc.CongestionControl
|
||||
}
|
||||
|
||||
tls := buildTLSConfig(nc, certFile, keyFile)
|
||||
tls := buildTLSConfig(nc, tc)
|
||||
if tls == nil {
|
||||
nlog.Core().Warn("tuic requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
|
||||
return base
|
||||
@@ -614,7 +717,7 @@ func buildTUIC(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
|
||||
return base
|
||||
}
|
||||
|
||||
func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base["type"] = "anytls"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
@@ -630,7 +733,7 @@ func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
base["padding_scheme"] = nc.PaddingScheme
|
||||
}
|
||||
|
||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
||||
if tls := buildTLSConfig(nc, tc); tls != nil {
|
||||
base["tls"] = tls
|
||||
} else {
|
||||
nlog.Core().Warn("anytls requires TLS certificate files on disk; configure cert_mode (self, file, http, dns, or content). Sing-box will not start this inbound without tls.")
|
||||
@@ -638,7 +741,7 @@ func buildAnyTLS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
return base
|
||||
}
|
||||
|
||||
func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base["type"] = "naive"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
@@ -651,7 +754,7 @@ func buildNaive(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
base["users"] = userList
|
||||
|
||||
if nc.TLS == 1 {
|
||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
||||
if tls := buildTLSConfig(nc, tc); tls != nil {
|
||||
base["tls"] = tls
|
||||
}
|
||||
}
|
||||
@@ -672,7 +775,7 @@ func buildSocks(base M, users []model.UserSpec) M {
|
||||
return base
|
||||
}
|
||||
|
||||
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base["type"] = "http"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
@@ -686,7 +789,7 @@ func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
|
||||
|
||||
applyProxyProtocol(base, nc)
|
||||
if nc.TLS == 1 {
|
||||
if tls := buildTLSConfig(nc, certFile, keyFile); tls != nil {
|
||||
if tls := buildTLSConfig(nc, tc); tls != nil {
|
||||
base["tls"] = tls
|
||||
}
|
||||
}
|
||||
@@ -747,38 +850,40 @@ func applyTransport(base M, nc *model.NodeSpec) {
|
||||
base["transport"] = transport
|
||||
}
|
||||
|
||||
// buildTLSConfig returns sing-box inbound TLS options when certificate and key paths exist.
|
||||
// If either path is missing, it returns nil: the inbound should run without a TLS layer
|
||||
// (e.g. TLS terminated at nginx/CDN while the panel still shows tls=1).
|
||||
// Sing-box does not accept a magic "self-signed" path — it tries to open that name as a file.
|
||||
func buildTLSConfig(nc *model.NodeSpec, certFile, keyFile string) M {
|
||||
if certFile == "" || keyFile == "" {
|
||||
// buildTLSConfig returns sing-box inbound TLS options when certificate material
|
||||
// is available. Returns nil if no TLS material is provided.
|
||||
func buildTLSConfig(nc *model.NodeSpec, tc kernel.TLSCert) M {
|
||||
if !tc.HasCert() {
|
||||
return nil
|
||||
}
|
||||
|
||||
tls := M{"enabled": true}
|
||||
t := M{"enabled": true}
|
||||
|
||||
serverName := nc.ServerName
|
||||
if serverName == "" && nc.Host != "" {
|
||||
serverName = nc.Host
|
||||
}
|
||||
if serverName != "" {
|
||||
tls["server_name"] = serverName
|
||||
t["server_name"] = serverName
|
||||
}
|
||||
|
||||
if nc.TLSSettings != nil {
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
|
||||
tls["server_name"] = sn
|
||||
t["server_name"] = sn
|
||||
}
|
||||
if alpn, ok := nc.TLSSettings["alpn"]; ok {
|
||||
tls["alpn"] = alpn
|
||||
t["alpn"] = alpn
|
||||
}
|
||||
// ECH server-side: only key/key_path needed for inbound
|
||||
if ech := extractECHInbound(nc.TLSSettings); ech != nil {
|
||||
t["ech"] = ech
|
||||
}
|
||||
}
|
||||
|
||||
tls["certificate_path"] = certFile
|
||||
tls["key_path"] = keyFile
|
||||
t["certificate"] = []string{string(tc.CertPEM)}
|
||||
t["key"] = []string{string(tc.KeyPEM)}
|
||||
|
||||
return tls
|
||||
return t
|
||||
}
|
||||
|
||||
func buildRealityConfig(nc *model.NodeSpec) M {
|
||||
@@ -881,3 +986,34 @@ func applyProxyProtocol(base M, nc *model.NodeSpec) {
|
||||
// }
|
||||
// base["proxy_protocol"] = true
|
||||
}
|
||||
|
||||
// extractECHInbound extracts ECH config for sing-box server (inbound).
|
||||
// sing-box InboundECHOptions needs: enabled, key (PEM array), key_path.
|
||||
func extractECHInbound(tlsSettings map[string]interface{}) M {
|
||||
echRaw, ok := tlsSettings["ech"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
echMap, ok := echRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
enabled, _ := echMap["enabled"].(bool)
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
ech := M{"enabled": true}
|
||||
hasSource := false
|
||||
if key, _ := echMap["key"].(string); key != "" {
|
||||
ech["key"] = []string{key} // sing-box expects PEM as-is
|
||||
hasSource = true
|
||||
}
|
||||
if keyPath, _ := echMap["key_path"].(string); keyPath != "" {
|
||||
ech["key_path"] = keyPath
|
||||
hasSource = true
|
||||
}
|
||||
if !hasSource {
|
||||
nlog.Core().Warn("ECH enabled but no key or key_path provided")
|
||||
}
|
||||
return ech // let sing-box report a clear error if key is missing
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package singbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
@@ -33,7 +35,7 @@ func TestBuildInbound_Shadowsocks(t *testing.T) {
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "shadowsocks")
|
||||
assertMapValue(t, inbound, "method", "aes-128-gcm")
|
||||
assertMapValue(t, inbound, "listen_port", 111)
|
||||
@@ -53,7 +55,7 @@ func TestBuildInbound_Shadowsocks2022(t *testing.T) {
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "base64serverkey==",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
|
||||
assertMapValue(t, inbound, "password", "base64serverkey==")
|
||||
}
|
||||
@@ -65,7 +67,7 @@ func TestBuildInbound_VMess(t *testing.T) {
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "vmess")
|
||||
assertMapValue(t, inbound, "tag", "vmess-in")
|
||||
|
||||
@@ -81,12 +83,12 @@ func TestBuildInbound_VMess_WithTLS(t *testing.T) {
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/path/cert.pem", "/path/key.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
assertMapValue(t, tls, "server_name", "example.com")
|
||||
assertMapValue(t, tls, "certificate_path", "/path/cert.pem")
|
||||
assertMapValue(t, tls, "key_path", "/path/key.pem")
|
||||
assertMapValue(t, tls, "certificate", []string{"CERT"})
|
||||
assertMapValue(t, tls, "key", []string{"KEY"})
|
||||
}
|
||||
|
||||
// Panel tls=1 with cert_mode none (no paths): inbound must be plain — TLS terminated at nginx/CDN.
|
||||
@@ -98,7 +100,7 @@ func TestBuildInbound_VMess_TLS_PanelOn_NoCertFiles(t *testing.T) {
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Fatal("expected no tls block when cert/key paths are empty (nginx offload)")
|
||||
}
|
||||
@@ -110,7 +112,7 @@ func TestBuildInbound_VMess_NoTLS(t *testing.T) {
|
||||
ServerPort: 80,
|
||||
TLS: 0,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Error("should not have TLS when tls=0")
|
||||
}
|
||||
@@ -128,7 +130,7 @@ func TestBuildInbound_VMess_WithWebSocket(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "ws")
|
||||
assertMapValue(t, transport, "path", "/ws")
|
||||
@@ -143,7 +145,7 @@ func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
|
||||
"serviceName": "mygrpc",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "grpc")
|
||||
assertMapValue(t, transport, "service_name", "mygrpc")
|
||||
@@ -158,7 +160,7 @@ func TestBuildInbound_VMess_WithGRPCSnakeCaseServiceName(t *testing.T) {
|
||||
"service_name": "mygrpc-snake",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "grpc")
|
||||
assertMapValue(t, transport, "service_name", "mygrpc-snake")
|
||||
@@ -174,7 +176,7 @@ func TestBuildInbound_VMess_WithH2(t *testing.T) {
|
||||
"host": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "http")
|
||||
assertMapValue(t, transport, "path", "/h2path")
|
||||
@@ -190,7 +192,7 @@ func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
|
||||
"host": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "httpupgrade")
|
||||
assertMapValue(t, transport, "path", "/upgrade")
|
||||
@@ -204,7 +206,7 @@ func TestBuildInbound_VLESS(t *testing.T) {
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "vless")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
@@ -217,7 +219,7 @@ func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
|
||||
ServerPort: 443,
|
||||
Flow: "xtls-rprx-vision",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
|
||||
}
|
||||
@@ -234,7 +236,7 @@ func TestBuildInbound_VLESS_Reality(t *testing.T) {
|
||||
"server_name": "www.example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
|
||||
@@ -258,7 +260,7 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
|
||||
"dest": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
reality := inbound["tls"].(M)["reality"].(M)
|
||||
ids := reality["short_id"].([]string)
|
||||
if len(ids) != 2 {
|
||||
@@ -268,16 +270,29 @@ func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
|
||||
|
||||
// --- Trojan ---
|
||||
|
||||
func TestBuildInbound_Trojan(t *testing.T) {
|
||||
func TestBuildInbound_Trojan_Reality(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 443,
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "test-private-key",
|
||||
"dest": "gateway.example.com:8443",
|
||||
"server_name": "cdn.example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
assertMapValue(t, inbound, "type", "trojan")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
assertMapValue(t, tls, "server_name", "cdn.example.com")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
reality := tls["reality"].(M)
|
||||
assertMapValue(t, reality, "enabled", true)
|
||||
assertMapValue(t, reality, "private_key", "test-private-key")
|
||||
|
||||
handshake := reality["handshake"].(M)
|
||||
assertMapValue(t, handshake, "server", "gateway.example.com")
|
||||
assertMapValue(t, handshake, "server_port", 8443)
|
||||
}
|
||||
|
||||
func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
|
||||
@@ -286,7 +301,7 @@ func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
|
||||
ServerPort: 80,
|
||||
TLS: 0,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
// Without certificate paths there is no TLS layer; sing-box trojan needs certs or Reality.
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Fatal("expected no tls when cert paths are empty and tls=0")
|
||||
@@ -300,7 +315,7 @@ func TestBuildInbound_Trojan_WithTLS(t *testing.T) {
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
@@ -313,7 +328,7 @@ func TestBuildInbound_Hysteria2(t *testing.T) {
|
||||
ServerPort: 444,
|
||||
Version: 2,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "hysteria2")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
@@ -331,7 +346,7 @@ func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
|
||||
Obfs: "salamander",
|
||||
ObfsPassword: "secret",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
obfs := inbound["obfs"].(M)
|
||||
assertMapValue(t, obfs, "type", "salamander")
|
||||
assertMapValue(t, obfs, "password", "secret")
|
||||
@@ -345,7 +360,7 @@ func TestBuildInbound_Hysteria1(t *testing.T) {
|
||||
UpMbps: 100,
|
||||
DownMbps: 200,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "hysteria")
|
||||
assertMapValue(t, inbound, "up_mbps", 100)
|
||||
assertMapValue(t, inbound, "down_mbps", 200)
|
||||
@@ -362,7 +377,7 @@ func TestBuildInbound_TUIC(t *testing.T) {
|
||||
ServerPort: 555,
|
||||
CongestionControl: "bbr",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "tuic")
|
||||
assertMapValue(t, inbound, "congestion_control", "bbr")
|
||||
|
||||
@@ -379,7 +394,7 @@ func TestBuildInbound_AnyTLS(t *testing.T) {
|
||||
ServerPort: 443,
|
||||
PaddingScheme: "stop=8\n0=30-30",
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "anytls")
|
||||
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
|
||||
|
||||
@@ -392,7 +407,7 @@ func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
|
||||
Protocol: "anytls",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
if _, exists := inbound["padding_scheme"]; exists {
|
||||
t.Error("should not have padding_scheme when empty")
|
||||
}
|
||||
@@ -406,7 +421,7 @@ func TestBuildInbound_Naive(t *testing.T) {
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, inbound, "type", "naive")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
@@ -424,7 +439,7 @@ func TestBuildInbound_Socks(t *testing.T) {
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "socks")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
@@ -443,7 +458,7 @@ func TestBuildInbound_HTTP(t *testing.T) {
|
||||
Protocol: "http",
|
||||
ServerPort: 8080,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
assertMapValue(t, inbound, "type", "http")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
@@ -456,7 +471,7 @@ func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "/c.pem", "/k.pem")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
@@ -468,7 +483,7 @@ func TestBuildInbound_Unknown(t *testing.T) {
|
||||
Protocol: "unknown-proto",
|
||||
ServerPort: 999,
|
||||
}
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, "", "")
|
||||
inbound := buildInbound(testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
if inbound != nil {
|
||||
t.Errorf("unknown protocol should return nil, got %v", inbound)
|
||||
}
|
||||
@@ -483,7 +498,7 @@ func TestBuildConfig(t *testing.T) {
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
@@ -527,7 +542,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
outbounds := cfg["outbounds"].([]M)
|
||||
|
||||
// We have 2 overrides in input, so we should have exactly 2 outbounds total
|
||||
@@ -580,7 +595,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, testNodeSpec(tc.nc), testUsers, "/c.pem", "/k.pem")
|
||||
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, testNodeSpec(tc.nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", tc.name, err)
|
||||
@@ -596,7 +611,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
// --- Routes ---
|
||||
|
||||
func TestBuildRoutes_Default(t *testing.T) {
|
||||
route := buildRoutes(nil, nil)
|
||||
route := buildRoutes(nil, nil, nil)
|
||||
assertMapValue(t, route, "final", "direct")
|
||||
|
||||
rules := route["rules"].([]M)
|
||||
@@ -613,7 +628,7 @@ func TestBuildRoutes_WithCustomRules(t *testing.T) {
|
||||
{ID: 2, Match: []string{"10.0.0.0/8"}, Action: "block"},
|
||||
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
|
||||
}
|
||||
route := buildRoutes(testRouteRules(rules), nil)
|
||||
route := buildRoutes(testRouteRules(rules), nil, nil)
|
||||
allRules := route["rules"].([]M)
|
||||
|
||||
if len(allRules) != 5 {
|
||||
@@ -638,7 +653,7 @@ func TestBuildRoutes_MultiMatch(t *testing.T) {
|
||||
{ID: 1, Match: []string{"*.evil.com", "bad.org", "192.168.1.0/24"}, Action: "block"},
|
||||
{ID: 2, Match: []string{"*.bypass.com"}, Action: "direct"},
|
||||
}
|
||||
route := buildRoutes(testRouteRules(rules), nil)
|
||||
route := buildRoutes(testRouteRules(rules), nil, nil)
|
||||
allRules := route["rules"].([]M)
|
||||
|
||||
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
|
||||
@@ -668,20 +683,80 @@ func TestBuildRoutes_MultiMatch(t *testing.T) {
|
||||
assertMapValue(t, allRules[4], "outbound", "direct")
|
||||
}
|
||||
|
||||
func TestBuildRoutes_WithCustomRouteRules(t *testing.T) {
|
||||
customRules := []model.CustomRouteRule{
|
||||
{
|
||||
Name: "direct-mixed",
|
||||
Match: model.RouteMatch{
|
||||
Domains: []string{"full.example.com"},
|
||||
DomainSuffixes: []string{"example.org"},
|
||||
IPCIDRs: []string{"1.1.1.0/24"},
|
||||
Ports: []string{"53", "1000-1002"},
|
||||
Networks: []string{"tcp"},
|
||||
SourceCIDRs: []string{"10.10.0.0/16"},
|
||||
SourcePorts: []string{"2000-2001"},
|
||||
},
|
||||
Action: model.RouteAction{Type: "direct"},
|
||||
},
|
||||
}
|
||||
route := buildRoutes(nil, customRules, nil)
|
||||
allRules := route["rules"].([]M)
|
||||
if len(allRules) != 9 {
|
||||
t.Fatalf("rules count: got %d, want 9", len(allRules))
|
||||
}
|
||||
if allRules[0]["domain"].([]string)[0] != "full.example.com" {
|
||||
t.Fatalf("unexpected exact domain rule: %v", allRules[0])
|
||||
}
|
||||
if allRules[1]["domain_suffix"].([]string)[0] != "example.org" {
|
||||
t.Fatalf("unexpected domain suffix rule: %v", allRules[1])
|
||||
}
|
||||
if allRules[2]["ip_cidr"].([]string)[0] != "1.1.1.0/24" {
|
||||
t.Fatalf("unexpected ip cidr rule: %v", allRules[2])
|
||||
}
|
||||
if allRules[3]["port"].([]int)[0] != 53 {
|
||||
t.Fatalf("unexpected port rule: %v", allRules[3])
|
||||
}
|
||||
if allRules[4]["network"].([]string)[0] != "tcp" {
|
||||
t.Fatalf("unexpected network rule: %v", allRules[4])
|
||||
}
|
||||
if allRules[5]["source_ip_cidr"].([]string)[0] != "10.10.0.0/16" {
|
||||
t.Fatalf("unexpected source cidr rule: %v", allRules[5])
|
||||
}
|
||||
if allRules[6]["source_port_range"].([]string)[0] != "2000:2001" {
|
||||
t.Fatalf("unexpected source port rule: %v", allRules[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoutes_StructuredCustomRulesRemainFirst(t *testing.T) {
|
||||
raw := []map[string]any{{"domain": []string{"raw.example.com"}, "outbound": "raw-tag"}}
|
||||
custom := []model.CustomRouteRule{{
|
||||
Match: model.RouteMatch{DomainSuffixes: []string{"structured.example"}},
|
||||
Action: model.RouteAction{Type: "direct"},
|
||||
}}
|
||||
route := buildRoutes(nil, custom, raw)
|
||||
allRules := route["rules"].([]M)
|
||||
if allRules[0]["outbound"] != "direct" {
|
||||
t.Fatalf("expected structured route first, got %v", allRules[0]["outbound"])
|
||||
}
|
||||
if allRules[1]["outbound"] != "raw-tag" {
|
||||
t.Fatalf("expected raw custom route second, got %v", allRules[1]["outbound"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- TLS Config ---
|
||||
|
||||
func TestBuildTLSConfig_WithCert(t *testing.T) {
|
||||
nc := &panel.NodeConfig{ServerName: "example.com"}
|
||||
tls := buildTLSConfig(testNodeSpec(nc), "/cert.pem", "/key.pem")
|
||||
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
assertMapValue(t, tls, "server_name", "example.com")
|
||||
assertMapValue(t, tls, "certificate_path", "/cert.pem")
|
||||
assertMapValue(t, tls, "key_path", "/key.pem")
|
||||
assertMapValue(t, tls, "certificate", []string{"CERT"})
|
||||
assertMapValue(t, tls, "key", []string{"KEY"})
|
||||
}
|
||||
|
||||
func TestBuildTLSConfig_NoCert(t *testing.T) {
|
||||
nc := &panel.NodeConfig{ServerName: "example.com"}
|
||||
tls := buildTLSConfig(testNodeSpec(nc), "", "")
|
||||
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{})
|
||||
if tls != nil {
|
||||
t.Fatalf("expected nil tls config when cert paths are empty, got %+v", tls)
|
||||
}
|
||||
@@ -689,7 +764,7 @@ func TestBuildTLSConfig_NoCert(t *testing.T) {
|
||||
|
||||
func TestBuildTLSConfig_FallbackToHost(t *testing.T) {
|
||||
nc := &panel.NodeConfig{Host: "fallback.com"}
|
||||
tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem")
|
||||
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, tls, "server_name", "fallback.com")
|
||||
}
|
||||
|
||||
@@ -700,7 +775,7 @@ func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
|
||||
"server_name": "override.com",
|
||||
},
|
||||
}
|
||||
tls := buildTLSConfig(testNodeSpec(nc), "/c.pem", "/k.pem")
|
||||
tls := buildTLSConfig(testNodeSpec(nc), kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
assertMapValue(t, tls, "server_name", "override.com")
|
||||
}
|
||||
|
||||
@@ -764,8 +839,51 @@ func assertMapValue(t *testing.T, m M, key string, expected interface{}) {
|
||||
t.Errorf("%s: got %v (type %T), want %d", key, val, val, e)
|
||||
}
|
||||
default:
|
||||
if val != expected {
|
||||
if !reflect.DeepEqual(val, expected) {
|
||||
t.Errorf("%s: got %v, want %v", key, val, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractECHInbound(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tls map[string]interface{}
|
||||
expectNil bool
|
||||
expectKey []string
|
||||
}{
|
||||
{"no ech", map[string]interface{}{}, true, nil},
|
||||
{"disabled", map[string]interface{}{"ech": map[string]interface{}{"enabled": false}}, true, nil},
|
||||
{"enabled with key", map[string]interface{}{"ech": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"key": "-----BEGIN ECH KEYS-----\nABCD\n-----END ECH KEYS-----\n",
|
||||
}}, false, []string{"-----BEGIN ECH KEYS-----\nABCD\n-----END ECH KEYS-----\n"}},
|
||||
{"enabled with key_path", map[string]interface{}{"ech": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"key_path": "/etc/ech/keys.pem",
|
||||
}}, false, nil},
|
||||
{"enabled no key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true}}, false, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := extractECHInbound(tt.tls)
|
||||
if tt.expectNil {
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if result["enabled"] != true {
|
||||
t.Errorf("expected enabled=true, got %v", result["enabled"])
|
||||
}
|
||||
if tt.expectKey != nil {
|
||||
if !reflect.DeepEqual(result["key"], tt.expectKey) {
|
||||
t.Errorf("key: got %v, want %v", result["key"], tt.expectKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -42,8 +43,7 @@ type SingBox struct {
|
||||
|
||||
users []model.UserSpec
|
||||
nodeConfig *model.NodeSpec
|
||||
certFile string
|
||||
keyFile string
|
||||
tls kernel.TLSCert
|
||||
|
||||
// connTracker is our lightweight in-process byte/IP tracker.
|
||||
// Created fresh on every Start (full restart).
|
||||
@@ -85,15 +85,15 @@ func (s *SingBox) Capabilities() kernel.Capabilities {
|
||||
func (s *SingBox) Protocols() []string {
|
||||
return []string{
|
||||
"vmess", "vless", "trojan", "shadowsocks",
|
||||
"hysteria2", "tuic", "naive", "socks", "http", "anytls", "mieru",
|
||||
"hysteria", "hysteria2", "tuic", "naive", "socks", "http", "anytls", "mieru",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
|
||||
cfgMap := buildConfig(s.cfg, nodeConfig, users, tls)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
@@ -137,8 +137,7 @@ func (s *SingBox) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, cert
|
||||
s.cancel = cancel
|
||||
s.users = users
|
||||
s.nodeConfig = nodeConfig
|
||||
s.certFile = certFile
|
||||
s.keyFile = keyFile
|
||||
s.tls = tls
|
||||
|
||||
// Fresh tracker on full restart.
|
||||
s.connTracker = NewConnTracker(0)
|
||||
@@ -193,7 +192,7 @@ func recycleOldBox(oldBox *box.Box, oldCancel context.CancelFunc, oldCtx context
|
||||
// Reload hot-swaps the inbound users and routing rules without restarting the box.
|
||||
// Routes, outbounds, and the connTracker stay alive so in-flight connections
|
||||
// continue to be tracked correctly.
|
||||
func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -201,7 +200,7 @@ func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, cer
|
||||
return fmt.Errorf("not running")
|
||||
}
|
||||
|
||||
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
|
||||
cfgMap := buildConfig(s.cfg, nodeConfig, users, tls)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
@@ -232,7 +231,8 @@ func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, cer
|
||||
nopFactory := singLog.NewNOPFactory()
|
||||
|
||||
// Configuration hash check for inbound reconstruction
|
||||
configChanged := s.nodeConfig == nil || kernel.ComputeHash(nodeConfig, users) != kernel.ComputeHash(s.nodeConfig, s.users)
|
||||
tlsChanged := !bytes.Equal(s.tls.CertPEM, tls.CertPEM) || !bytes.Equal(s.tls.KeyPEM, tls.KeyPEM)
|
||||
configChanged := tlsChanged || s.nodeConfig == nil || kernel.ComputeHash(nodeConfig, users) != kernel.ComputeHash(s.nodeConfig, s.users)
|
||||
|
||||
for _, inb := range opts.Inbounds {
|
||||
tag := inb.Tag
|
||||
@@ -310,8 +310,7 @@ func (s *SingBox) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, cer
|
||||
nlog.Core().Debug("sing-box reloaded", "users", len(users))
|
||||
s.users = users
|
||||
s.nodeConfig = nodeConfig
|
||||
s.certFile = certFile
|
||||
s.keyFile = keyFile
|
||||
s.tls = tls
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -523,7 +522,7 @@ func (s *SingBox) UpdateUsers(users []model.UserSpec) (added, removed int, err e
|
||||
// reloadInboundsLocked hot-swaps inbound users using UpdatableInbound.
|
||||
// Must be called with s.mu held.
|
||||
func (s *SingBox) reloadInboundsLocked(users []model.UserSpec) error {
|
||||
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.certFile, s.keyFile)
|
||||
cfgMap := buildConfig(s.cfg, s.nodeConfig, users, s.tls)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
|
||||
@@ -23,6 +23,17 @@ func TestSingBoxCapabilities(t *testing.T) {
|
||||
if caps.BuiltInTrafficStats || caps.ForceCloseConnection {
|
||||
t.Fatalf("unexpected sing-box capabilities: %+v", caps)
|
||||
}
|
||||
protocols := s.Protocols()
|
||||
found := false
|
||||
for _, protocol := range protocols {
|
||||
if protocol == "hysteria" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Protocols() missing hysteria: %v", protocols)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+223
-78
@@ -2,7 +2,9 @@ package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
@@ -14,7 +16,7 @@ import (
|
||||
// M is a shorthand for building JSON-like maps
|
||||
type M = map[string]interface{}
|
||||
|
||||
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
var outbounds []M
|
||||
tags := make(map[string]bool)
|
||||
|
||||
@@ -66,7 +68,7 @@ func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.Use
|
||||
"outbounds": outbounds,
|
||||
}
|
||||
|
||||
inbound := buildInbound(nc, users, certFile, keyFile)
|
||||
inbound := buildInbound(nc, users, tc)
|
||||
if inbound != nil {
|
||||
cfg["inbounds"] = []M{inbound}
|
||||
} else {
|
||||
@@ -76,7 +78,7 @@ func buildConfig(kcfg config.KernelConfig, nc *model.NodeSpec, users []model.Use
|
||||
}
|
||||
|
||||
// Merge panel routes and static config routes
|
||||
cfg["routing"] = buildRouting(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
cfg["routing"] = buildRouting(nc.Routes, nc.CustomRouteRules, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
|
||||
mergeCustomXray(cfg, kcfg)
|
||||
return cfg
|
||||
@@ -195,7 +197,7 @@ func xrayLogLevel(singboxLevel string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildInbound(nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
listenAddr := "::"
|
||||
if nc.ListenIP != "" {
|
||||
listenAddr = nc.ListenIP
|
||||
@@ -214,19 +216,19 @@ func buildInbound(nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile
|
||||
|
||||
switch nc.Protocol {
|
||||
case "vmess":
|
||||
return buildVMess(base, nc, users, certFile, keyFile)
|
||||
return buildVMess(base, nc, users, tc)
|
||||
case "vless":
|
||||
return buildVLESS(base, nc, users, certFile, keyFile)
|
||||
return buildVLESS(base, nc, users, tc)
|
||||
case "trojan":
|
||||
return buildTrojan(base, nc, users, certFile, keyFile)
|
||||
return buildTrojan(base, nc, users, tc)
|
||||
case "shadowsocks":
|
||||
return buildShadowsocks(base, nc, users)
|
||||
case "socks":
|
||||
return buildSocks(base, users)
|
||||
case "http":
|
||||
return buildHTTP(base, nc, users, certFile, keyFile)
|
||||
return buildHTTP(base, nc, users, tc)
|
||||
case "hysteria":
|
||||
return buildHysteria(base, nc, users, certFile, keyFile)
|
||||
return buildHysteria(base, nc, users, tc)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -238,7 +240,7 @@ func userEmail(userID int) string {
|
||||
return fmt.Sprintf("user@%d", userID)
|
||||
}
|
||||
|
||||
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
clients = append(clients, M{
|
||||
@@ -249,11 +251,11 @@ func buildVMess(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
}
|
||||
base["settings"] = M{"clients": clients}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
return base
|
||||
}
|
||||
|
||||
func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
client := M{
|
||||
@@ -274,11 +276,11 @@ func buildVLESS(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, ke
|
||||
"decryption": decryption,
|
||||
}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
return base
|
||||
}
|
||||
|
||||
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
clients := make([]M, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
@@ -289,7 +291,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
}
|
||||
base["settings"] = M{"clients": clients}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
|
||||
// Trojan requires TLS or Reality to be enabled.
|
||||
// If the panel didn't explicitly set TLS=1 or TLS=2, but we have certs,
|
||||
@@ -297,7 +299,7 @@ func buildTrojan(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, k
|
||||
ss, _ := base["streamSettings"].(M)
|
||||
if security, ok := ss["security"].(string); !ok || (security != "tls" && security != "reality") {
|
||||
nc.TLS = 1 // Force internal state to trigger TLS build in applyStreamSettings
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
}
|
||||
|
||||
return base
|
||||
@@ -378,7 +380,7 @@ func buildSocks(base M, users []model.UserSpec) M {
|
||||
return base
|
||||
}
|
||||
|
||||
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
base["protocol"] = "http"
|
||||
accounts := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
@@ -393,13 +395,13 @@ func buildHTTP(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, key
|
||||
}
|
||||
|
||||
if nc.TLS == 1 {
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
applyStreamSettings(base, nc, tc)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// buildHysteria creates a Hysteria v2 inbound for xray-core.
|
||||
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) M {
|
||||
func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, tc kernel.TLSCert) M {
|
||||
if nc.Version != 2 {
|
||||
nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound")
|
||||
return nil
|
||||
@@ -435,15 +437,14 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
|
||||
}
|
||||
}
|
||||
|
||||
if certFile != "" && keyFile != "" {
|
||||
if tc.HasCert() {
|
||||
ss["security"] = "tls"
|
||||
tlsCert := M{
|
||||
"certificate": []string{string(tc.CertPEM)},
|
||||
"key": []string{string(tc.KeyPEM)},
|
||||
}
|
||||
ss["tlsSettings"] = M{
|
||||
"certificates": []M{
|
||||
{
|
||||
"certificateFile": certFile,
|
||||
"keyFile": keyFile,
|
||||
},
|
||||
},
|
||||
"certificates": []M{tlsCert},
|
||||
"alpn": []string{"h3"},
|
||||
}
|
||||
} else {
|
||||
@@ -458,7 +459,7 @@ func buildHysteria(base M, nc *model.NodeSpec, users []model.UserSpec, certFile,
|
||||
return base
|
||||
}
|
||||
|
||||
func applyStreamSettings(base M, nc *model.NodeSpec, certFile, keyFile string) {
|
||||
func applyStreamSettings(base M, nc *model.NodeSpec, tc kernel.TLSCert) {
|
||||
ss := M{}
|
||||
|
||||
// Network / transport
|
||||
@@ -546,14 +547,17 @@ func applyStreamSettings(base M, nc *model.NodeSpec, certFile, keyFile string) {
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
|
||||
tlsSettings["serverName"] = sn
|
||||
}
|
||||
// ECH server-side: extract key from tls_settings.ech
|
||||
if echKeys := extractECHServerKeys(nc.TLSSettings); echKeys != "" {
|
||||
tlsSettings["echServerKeys"] = echKeys
|
||||
}
|
||||
if certFile != "" && keyFile != "" {
|
||||
tlsSettings["certificates"] = []M{
|
||||
{
|
||||
"certificateFile": certFile,
|
||||
"keyFile": keyFile,
|
||||
},
|
||||
}
|
||||
if tc.HasCert() {
|
||||
tlsCert := M{
|
||||
"certificate": []string{string(tc.CertPEM)},
|
||||
"key": []string{string(tc.KeyPEM)},
|
||||
}
|
||||
tlsSettings["certificates"] = []M{tlsCert}
|
||||
} else {
|
||||
// Fallback placeholder for auto-TLS environments.
|
||||
// Xray allows empty certificates array in more cases than sing-box,
|
||||
@@ -616,10 +620,18 @@ func buildRealitySettings(nc *model.NodeSpec) M {
|
||||
return reality
|
||||
}
|
||||
|
||||
func buildRouting(rules []model.RouteRule, customRules []map[string]any) M {
|
||||
func buildRouting(rules []model.RouteRule, customRouteRules []model.CustomRouteRule, customRules []map[string]any) M {
|
||||
var xrayRules []M
|
||||
|
||||
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
|
||||
// Structured custom routes now take the highest priority for panel-managed overrides.
|
||||
for _, rule := range customRouteRules {
|
||||
if rule.Disabled {
|
||||
continue
|
||||
}
|
||||
xrayRules = append(xrayRules, compileCustomRouteRule(rule)...)
|
||||
}
|
||||
|
||||
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
|
||||
for _, cr := range customRules {
|
||||
xrayRules = append(xrayRules, M(cr))
|
||||
}
|
||||
@@ -643,49 +655,7 @@ func buildRouting(rules []model.RouteRule, customRules []map[string]any) M {
|
||||
})
|
||||
|
||||
for _, rule := range rules {
|
||||
if len(rule.Match) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var ips, domains []string
|
||||
for _, m := range rule.Match {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m, "geoip:") {
|
||||
ips = append(ips, m) // "geoip:cn" → ip field, passed as-is
|
||||
} else if strings.HasPrefix(m, "geosite:") {
|
||||
domains = append(domains, m) // "geosite:google" → domain field, passed as-is
|
||||
} else if strings.Contains(m, "/") {
|
||||
ips = append(ips, m)
|
||||
} else {
|
||||
m = strings.TrimPrefix(m, "*.")
|
||||
domains = append(domains, "domain:"+m)
|
||||
}
|
||||
}
|
||||
|
||||
outbound := "block"
|
||||
if rule.Action == "direct" {
|
||||
outbound = "direct"
|
||||
} else if rule.Action == "proxy" && rule.ActionValue != "" {
|
||||
outbound = rule.ActionValue
|
||||
}
|
||||
|
||||
if len(domains) > 0 {
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"domain": domains,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(ips) > 0 {
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"ip": ips,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
xrayRules = append(xrayRules, compilePanelRouteRule(rule)...)
|
||||
}
|
||||
|
||||
return M{
|
||||
@@ -693,3 +663,178 @@ func buildRouting(rules []model.RouteRule, customRules []map[string]any) M {
|
||||
"rules": xrayRules,
|
||||
}
|
||||
}
|
||||
|
||||
func compilePanelRouteRule(rule model.RouteRule) []M {
|
||||
if len(rule.Match) == 0 {
|
||||
return nil
|
||||
}
|
||||
match := model.RouteMatch{}
|
||||
for _, item := range rule.Match {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(item, "geoip:") || strings.Contains(item, "/") {
|
||||
match.IPCIDRs = append(match.IPCIDRs, item)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(item, "geosite:") {
|
||||
match.Domains = append(match.Domains, item)
|
||||
continue
|
||||
}
|
||||
match.DomainSuffixes = append(match.DomainSuffixes, strings.TrimPrefix(item, "*."))
|
||||
}
|
||||
action := model.RouteAction{Type: "block"}
|
||||
switch rule.Action {
|
||||
case "direct":
|
||||
action.Type = "direct"
|
||||
case "proxy":
|
||||
action.Type = "route"
|
||||
action.Target = rule.ActionValue
|
||||
}
|
||||
return compileCustomRouteRule(model.CustomRouteRule{Match: match, Action: action})
|
||||
}
|
||||
|
||||
func compileCustomRouteRule(rule model.CustomRouteRule) []M {
|
||||
outbound := xrayOutboundForAction(rule.Action)
|
||||
var compiled []M
|
||||
|
||||
if len(rule.Match.Domains) > 0 || len(rule.Match.DomainSuffixes) > 0 {
|
||||
domains := make([]string, 0, len(rule.Match.Domains)+len(rule.Match.DomainSuffixes))
|
||||
for _, value := range rule.Match.Domains {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, value)
|
||||
}
|
||||
for _, value := range rule.Match.DomainSuffixes {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, "domain:"+value)
|
||||
}
|
||||
if len(domains) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"domain": domains,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(rule.Match.IPCIDRs) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"ip": copyStrings(rule.Match.IPCIDRs),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.Ports) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"port": strings.Join(rule.Match.Ports, ","),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.Networks) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"network": strings.Join(rule.Match.Networks, ","),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.SourceCIDRs) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"source": copyStrings(rule.Match.SourceCIDRs),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(rule.Match.SourcePorts) > 0 {
|
||||
compiled = append(compiled, M{
|
||||
"type": "field",
|
||||
"sourcePort": strings.Join(rule.Match.SourcePorts, ","),
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
return compiled
|
||||
}
|
||||
|
||||
func xrayOutboundForAction(action model.RouteAction) string {
|
||||
switch action.Type {
|
||||
case "direct":
|
||||
return "direct"
|
||||
case "route":
|
||||
if action.Target != "" {
|
||||
return action.Target
|
||||
}
|
||||
return "block"
|
||||
default:
|
||||
return "block"
|
||||
}
|
||||
}
|
||||
|
||||
func copyStrings(src []string) []string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
|
||||
// and converts it from PEM to base64 for xray-core's echServerKeys field.
|
||||
func extractECHServerKeys(tlsSettings map[string]interface{}) string {
|
||||
echRaw, ok := tlsSettings["ech"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
echMap, ok := echRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
enabled, _ := echMap["enabled"].(bool)
|
||||
if !enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try inline key first, then key_path
|
||||
var pemData []byte
|
||||
if key, _ := echMap["key"].(string); key != "" {
|
||||
pemData = []byte(key)
|
||||
} else if keyPath, _ := echMap["key_path"].(string); keyPath != "" {
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
nlog.Core().Warn("ECH enabled but key_path read failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
pemData = data
|
||||
} else {
|
||||
nlog.Core().Warn("ECH enabled but no key or key_path provided")
|
||||
return ""
|
||||
}
|
||||
|
||||
return echPEMToBase64(pemData)
|
||||
}
|
||||
|
||||
// echPEMToBase64 parses an "ECH KEYS" PEM block and returns base64 of the raw bytes.
|
||||
// If the input is not valid PEM or has the wrong type, it returns empty string.
|
||||
func echPEMToBase64(data []byte) string {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if !strings.Contains(trimmed, "-----") {
|
||||
// Assume already raw base64
|
||||
return trimmed
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
nlog.Core().Warn("ECH key: invalid PEM data")
|
||||
return ""
|
||||
}
|
||||
if block.Type != "ECH KEYS" {
|
||||
nlog.Core().Warn("ECH key: unexpected PEM type", "expected", "ECH KEYS", "got", block.Type)
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(block.Bytes)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
@@ -46,7 +49,7 @@ func TestBuildConfig_OutboundPriority(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, "", "")
|
||||
cfg := buildConfig(kcfg, testNodeSpec(nc), testUsers, kernel.TLSCert{})
|
||||
outbounds := cfg["outbounds"].([]M)
|
||||
|
||||
// Since we overrode both 'direct' and 'block', the result should contain
|
||||
@@ -161,7 +164,7 @@ func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, "/cert.pem", "/key.pem")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&tc.nc), testUsers, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
@@ -194,7 +197,7 @@ func TestBuildConfig_VMess_Users(t *testing.T) {
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -234,7 +237,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
||||
"server_name": "example.com",
|
||||
},
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -257,7 +260,7 @@ func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRouting_Default(t *testing.T) {
|
||||
routing := buildRouting(nil, nil)
|
||||
routing := buildRouting(nil, nil, nil)
|
||||
rules := routing["rules"].([]M)
|
||||
|
||||
if len(rules) != 1 {
|
||||
@@ -287,7 +290,7 @@ func TestBuildRouting_WithRules(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
routing := buildRouting(testRouteRules(rules), nil)
|
||||
routing := buildRouting(testRouteRules(rules), nil, nil)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
|
||||
// 1 default + 2 domain rules + 1 IP rule = 4
|
||||
@@ -322,6 +325,63 @@ func TestBuildRouting_WithRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_WithCustomRouteRules(t *testing.T) {
|
||||
customRules := []model.CustomRouteRule{
|
||||
{
|
||||
Name: "proxy-web",
|
||||
Match: model.RouteMatch{
|
||||
Domains: []string{"full.example.com"},
|
||||
DomainSuffixes: []string{"example.org"},
|
||||
Ports: []string{"80", "443-445"},
|
||||
Networks: []string{"tcp", "udp"},
|
||||
SourceCIDRs: []string{"192.168.1.0/24"},
|
||||
SourcePorts: []string{"1000-1002"},
|
||||
},
|
||||
Action: model.RouteAction{Type: "route", Target: "warp-jp"},
|
||||
},
|
||||
}
|
||||
|
||||
routing := buildRouting(nil, customRules, nil)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
if len(xrayRules) != 6 {
|
||||
t.Fatalf("expected 6 rules, got %d", len(xrayRules))
|
||||
}
|
||||
if xrayRules[0]["outboundTag"] != "warp-jp" {
|
||||
t.Fatalf("expected first custom outbound warp-jp, got %v", xrayRules[0]["outboundTag"])
|
||||
}
|
||||
if got := xrayRules[0]["domain"].([]string); len(got) != 2 || got[0] != "full.example.com" || got[1] != "domain:example.org" {
|
||||
t.Fatalf("unexpected custom domains: %v", got)
|
||||
}
|
||||
if got := xrayRules[1]["port"]; got != "80,443-445" {
|
||||
t.Fatalf("unexpected port matcher: %v", got)
|
||||
}
|
||||
if got := xrayRules[2]["network"]; got != "tcp,udp" {
|
||||
t.Fatalf("unexpected network matcher: %v", got)
|
||||
}
|
||||
if got := xrayRules[3]["source"].([]string); len(got) != 1 || got[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("unexpected source cidr matcher: %v", got)
|
||||
}
|
||||
if got := xrayRules[4]["sourcePort"]; got != "1000-1002" {
|
||||
t.Fatalf("unexpected source port matcher: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_StructuredCustomRulesRemainFirst(t *testing.T) {
|
||||
raw := []map[string]any{{"type": "field", "domain": []string{"keyword:raw"}, "outboundTag": "raw-tag"}}
|
||||
custom := []model.CustomRouteRule{{
|
||||
Match: model.RouteMatch{DomainSuffixes: []string{"structured.example"}},
|
||||
Action: model.RouteAction{Type: "direct"},
|
||||
}}
|
||||
routing := buildRouting(nil, custom, raw)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
if xrayRules[0]["outboundTag"] != "direct" {
|
||||
t.Fatalf("expected structured rule first, got %v", xrayRules[0]["outboundTag"])
|
||||
}
|
||||
if xrayRules[1]["outboundTag"] != "raw-tag" {
|
||||
t.Fatalf("expected raw custom rule second, got %v", xrayRules[1]["outboundTag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_LogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -349,7 +409,7 @@ func TestBuildConfig_StatsEnabled(t *testing.T) {
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -379,7 +439,7 @@ func TestBuildConfig_Shadowsocks_MultiUserTraditional(t *testing.T) {
|
||||
ServerPort: 8388,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -412,7 +472,7 @@ func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) {
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "server-key",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -436,7 +496,7 @@ func TestBuildConfig_SocksStats(t *testing.T) {
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, "", "")
|
||||
cfg := buildConfig(testKernelCfg, testNodeSpec(&nc), testUsers, kernel.TLSCert{})
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
@@ -456,3 +516,59 @@ func TestBuildConfig_SocksStats(t *testing.T) {
|
||||
t.Errorf("expected email user@1 for socks account, got %v", a1["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEchPEMToBase64(t *testing.T) {
|
||||
// Valid ECH KEYS PEM
|
||||
rawBytes := []byte{0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x03, 0xCA, 0xFE, 0x00}
|
||||
pemBlock := &pem.Block{Type: "ECH KEYS", Bytes: rawBytes}
|
||||
pemStr := string(pem.EncodeToMemory(pemBlock))
|
||||
|
||||
result := echPEMToBase64([]byte(pemStr))
|
||||
expected := base64.StdEncoding.EncodeToString(rawBytes)
|
||||
if result != expected {
|
||||
t.Errorf("echPEMToBase64 got %q, want %q", result, expected)
|
||||
}
|
||||
|
||||
// Wrong PEM type should return empty
|
||||
wrongBlock := &pem.Block{Type: "ECH CONFIGS", Bytes: rawBytes}
|
||||
wrongPEM := string(pem.EncodeToMemory(wrongBlock))
|
||||
if got := echPEMToBase64([]byte(wrongPEM)); got != "" {
|
||||
t.Errorf("echPEMToBase64 should reject ECH CONFIGS, got %q", got)
|
||||
}
|
||||
|
||||
// Invalid PEM should return empty
|
||||
if got := echPEMToBase64([]byte("-----BEGIN GARBAGE-----\nwhat\n-----END GARBAGE-----")); got != "" {
|
||||
t.Errorf("echPEMToBase64 should reject invalid PEM, got %q", got)
|
||||
}
|
||||
|
||||
// Raw base64 passthrough
|
||||
raw := "AAQDQKDIAAMD"
|
||||
if got := echPEMToBase64([]byte(raw)); got != raw {
|
||||
t.Errorf("echPEMToBase64 raw passthrough: got %q, want %q", got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractECHServerKeys(t *testing.T) {
|
||||
rawBytes := []byte{0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x03, 0xCA, 0xFE, 0x00}
|
||||
pemBlock := &pem.Block{Type: "ECH KEYS", Bytes: rawBytes}
|
||||
pemStr := string(pem.EncodeToMemory(pemBlock))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tls map[string]interface{}
|
||||
expect string
|
||||
}{
|
||||
{"no ech", map[string]interface{}{}, ""},
|
||||
{"disabled", map[string]interface{}{"ech": map[string]interface{}{"enabled": false, "key": pemStr}}, ""},
|
||||
{"enabled with key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true, "key": pemStr}}, base64.StdEncoding.EncodeToString(rawBytes)},
|
||||
{"enabled no key", map[string]interface{}{"ech": map[string]interface{}{"enabled": true}}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractECHServerKeys(tt.tls)
|
||||
if got != tt.expect {
|
||||
t.Errorf("got %q, want %q", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ type Xray struct {
|
||||
limitDispatcher *LimitDispatcher
|
||||
users []model.UserSpec
|
||||
nodeConfig *model.NodeSpec
|
||||
certFile string
|
||||
keyFile string
|
||||
tls kernel.TLSCert
|
||||
protocol string
|
||||
inboundTag string
|
||||
lastKernelHash string
|
||||
@@ -98,7 +97,7 @@ func (x *Xray) Capabilities() kernel.Capabilities {
|
||||
func (x *Xray) Protocols() []string {
|
||||
return []string{
|
||||
"vmess", "vless", "trojan", "shadowsocks",
|
||||
"hysteria", "socks", "http", "dokodemo-door",
|
||||
"hysteria",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +114,11 @@ func (x *Xray) Protocols() []string {
|
||||
// Phase 3 – StartNew: instance.Start (no lock, potentially slow)
|
||||
// Phase 4 – Swap: store new, extract old (brief kernel lock)
|
||||
// Phase 5 – RecycleOld: close old in background (non-blocking)
|
||||
func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
// ── Phase 1: Build config (no shared state) ─────────────────────────
|
||||
x.ensureGeoData(nodeConfig)
|
||||
|
||||
data, err := marshalConfig(x.cfg, nodeConfig, users, certFile, keyFile)
|
||||
data, err := marshalConfig(x.cfg, nodeConfig, users, tls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,8 +151,7 @@ func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFil
|
||||
x.limitDispatcher = ld
|
||||
x.users = users
|
||||
x.nodeConfig = nodeConfig
|
||||
x.certFile = certFile
|
||||
x.keyFile = keyFile
|
||||
x.tls = tls
|
||||
x.protocol = nodeConfig.Protocol
|
||||
x.inboundTag = nodeConfig.Protocol + "-in"
|
||||
x.cumTraffic = make(map[int][2]int64)
|
||||
@@ -178,14 +176,14 @@ func (x *Xray) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFil
|
||||
// Since Xray does not support granular inbound reconstruction without an
|
||||
// instance restart for most transport/TLS settings, it triggers a full restart
|
||||
// if any kernel-affecting fields (hash mismatch) have changed.
|
||||
func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
x.updateDispatcherLimits(users)
|
||||
x.updateBandwidthLimits(users)
|
||||
|
||||
newHash := kernel.ComputeHash(nodeConfig, users)
|
||||
|
||||
x.mu.Lock()
|
||||
same := x.lastKernelHash == newHash
|
||||
same := x.lastKernelHash == newHash && tlsEqual(x.tls, tls)
|
||||
if same {
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
@@ -195,7 +193,7 @@ func (x *Xray) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFi
|
||||
x.mu.Unlock()
|
||||
|
||||
nlog.Core().Info("xray: configuration changed, performing full restart")
|
||||
return x.Start(nodeConfig, users, certFile, keyFile)
|
||||
return x.Start(nodeConfig, users, tls)
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the kernel, draining active connections first.
|
||||
@@ -217,6 +215,10 @@ func (x *Xray) Stop() {
|
||||
|
||||
func (x *Xray) IsRunning() bool { return x.running.Load() }
|
||||
|
||||
func tlsEqual(a, b kernel.TLSCert) bool {
|
||||
return bytes.Equal(a.CertPEM, b.CertPEM) && bytes.Equal(a.KeyPEM, b.KeyPEM)
|
||||
}
|
||||
|
||||
// ─── Observability ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetUserTraffic returns per-user cumulative traffic from xray's built-in
|
||||
@@ -319,10 +321,10 @@ func (x *Xray) AddUsers(users []model.UserSpec) (int, error) {
|
||||
um, err := x.getUserManager()
|
||||
if err != nil {
|
||||
// Protocol doesn't support UserManager → full restart
|
||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||
nc, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: AddUsers fallback to restart", "reason", err)
|
||||
if err := x.Start(nc, merged, cf, kf); err != nil {
|
||||
if err := x.Start(nc, merged, t); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(toAdd), nil
|
||||
@@ -392,10 +394,10 @@ func (x *Xray) RemoveUsers(users []model.UserSpec) (int, error) {
|
||||
|
||||
um, err := x.getUserManager()
|
||||
if err != nil {
|
||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||
nc, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: RemoveUsers fallback to restart", "reason", err)
|
||||
if err := x.Start(nc, kept, cf, kf); err != nil {
|
||||
if err := x.Start(nc, kept, t); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return removed, nil
|
||||
@@ -448,10 +450,10 @@ func (x *Xray) UpdateUsers(users []model.UserSpec) (added, removed int, err erro
|
||||
um, umErr := x.getUserManager()
|
||||
if umErr != nil {
|
||||
// Protocol doesn't support UserManager → full restart
|
||||
nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile
|
||||
nc, t := x.nodeConfig, x.tls
|
||||
x.mu.Unlock()
|
||||
nlog.Core().Debug("xray: UpdateUsers fallback to restart", "reason", umErr)
|
||||
if err = x.Start(nc, users, cf, kf); err != nil {
|
||||
if err = x.Start(nc, users, t); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return
|
||||
@@ -639,8 +641,8 @@ func (x *Xray) ensureGeoData(nc *model.NodeSpec) {
|
||||
}
|
||||
|
||||
// marshalConfig builds the xray JSON config and returns the raw bytes.
|
||||
func marshalConfig(cfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) ([]byte, error) {
|
||||
cfgMap := buildConfig(cfg, nc, users, certFile, keyFile)
|
||||
func marshalConfig(cfg config.KernelConfig, nc *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) ([]byte, error) {
|
||||
cfgMap := buildConfig(cfg, nc, users, tls)
|
||||
data, err := json.MarshalIndent(cfgMap, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal config: %w", err)
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// Package machine implements the machine-mode orchestrator that dynamically
|
||||
// discovers nodes from the panel's machine API and manages their lifecycles.
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/controlplane"
|
||||
"github.com/cedar2025/xboard-node/internal/monitor"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
"github.com/cedar2025/xboard-node/internal/service"
|
||||
)
|
||||
|
||||
// nodeHandle tracks a running node service.
|
||||
type nodeHandle struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
mailbox *controlplane.NodeMailbox
|
||||
}
|
||||
|
||||
// Orchestrator manages all nodes bound to a panel machine. It:
|
||||
// - discovers nodes via GET /machine/nodes
|
||||
// - starts / stops Service instances as nodes are added / removed
|
||||
// - maintains a shared WS connection that demuxes events by node_id
|
||||
// - reports machine-level load via POST /machine/status
|
||||
type Orchestrator struct {
|
||||
cfg *config.Config
|
||||
client *panel.Client // machine-level client (no node_id)
|
||||
|
||||
mu sync.Mutex
|
||||
nodes map[int]*nodeHandle // node_id → handle
|
||||
|
||||
// Per-node mailbox keyed by node_id. Shared WS events are aggregated here
|
||||
// and each node service drains the latest state when ready.
|
||||
eventsMu sync.RWMutex
|
||||
mailboxes map[int]*controlplane.NodeMailbox
|
||||
statuses map[int]chan<- controlplane.StatusChange
|
||||
|
||||
// Shared WS client (nil when WS is disabled).
|
||||
ws *panel.WSClient
|
||||
wsCancel context.CancelFunc
|
||||
|
||||
// runCtx is stored from Run() so that onWSEvent can trigger rediscover
|
||||
// for sync.nodes events without blocking the main loop.
|
||||
runCtx context.Context
|
||||
|
||||
pullInterval time.Duration
|
||||
pushInterval time.Duration
|
||||
}
|
||||
|
||||
// New creates a machine orchestrator from the given config.
|
||||
func New(cfg *config.Config) *Orchestrator {
|
||||
panelCfg := config.PanelConfig{
|
||||
URL: cfg.Panel.URL,
|
||||
Token: cfg.Machine.Token,
|
||||
MachineID: cfg.Machine.MachineID,
|
||||
}
|
||||
return &Orchestrator{
|
||||
cfg: cfg,
|
||||
client: panel.NewClient(panelCfg),
|
||||
nodes: make(map[int]*nodeHandle),
|
||||
mailboxes: make(map[int]*controlplane.NodeMailbox),
|
||||
statuses: make(map[int]chan<- controlplane.StatusChange),
|
||||
}
|
||||
}
|
||||
|
||||
// Run is the main loop. It blocks until ctx is cancelled.
|
||||
func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
o.runCtx = ctx
|
||||
nodesResp, err := o.client.GetMachineNodes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("initial node discovery: %w", err)
|
||||
}
|
||||
|
||||
o.applyIntervals(nodesResp.BaseConfig)
|
||||
nlog.Core().Info(fmt.Sprintf("machine %d: discovered %d nodes",
|
||||
o.cfg.Machine.MachineID, len(nodesResp.Nodes)))
|
||||
|
||||
// Start machine-level WS as early as possible so sync.nodes can reach an
|
||||
// empty machine before the first node is attached.
|
||||
o.tryStartWS(ctx)
|
||||
|
||||
// Start initial nodes.
|
||||
for _, n := range nodesResp.Nodes {
|
||||
o.startNode(ctx, n)
|
||||
}
|
||||
|
||||
discoveryTicker := time.NewTicker(o.pullInterval)
|
||||
statusTicker := time.NewTicker(o.pushInterval)
|
||||
defer discoveryTicker.Stop()
|
||||
defer statusTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
o.stopAll()
|
||||
return nil
|
||||
|
||||
case <-discoveryTicker.C:
|
||||
o.rediscover(ctx)
|
||||
|
||||
case <-statusTicker.C:
|
||||
o.reportMachineStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Node lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
func (o *Orchestrator) startNode(ctx context.Context, mn panel.MachineNode) {
|
||||
o.mu.Lock()
|
||||
if _, exists := o.nodes[mn.ID]; exists {
|
||||
o.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
nodeCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
mb := controlplane.NewNodeMailbox()
|
||||
o.nodes[mn.ID] = &nodeHandle{cancel: cancel, done: done, mailbox: mb}
|
||||
o.mu.Unlock()
|
||||
|
||||
o.eventsMu.Lock()
|
||||
o.mailboxes[mn.ID] = mb
|
||||
o.eventsMu.Unlock()
|
||||
|
||||
nodeCfg := o.cfg.ExpandMachineNode(mn.ID, mn.Type)
|
||||
|
||||
perNodeClient := o.client.ForNode(mn.ID)
|
||||
|
||||
var push controlplane.PushClient
|
||||
if o.ws != nil {
|
||||
push = &machineNodePush{
|
||||
nodeID: mn.ID,
|
||||
ws: o.ws,
|
||||
}
|
||||
}
|
||||
|
||||
// The registerFn is called by MachinePanelControlPlane.Initial() to expose
|
||||
// the node mailbox + status channel to the Service.
|
||||
nodeID := mn.ID
|
||||
registerFn := func(st chan<- controlplane.StatusChange) *controlplane.NodeMailbox {
|
||||
o.registerNode(nodeID, st)
|
||||
return mb
|
||||
}
|
||||
|
||||
cp := controlplane.NewMachinePanelControlPlane(perNodeClient, nodeCfg.Kernel, push, registerFn)
|
||||
svc := service.NewWithControlPlane(nodeCfg, cp)
|
||||
|
||||
nlog.Core().Info(fmt.Sprintf("machine: starting node %d (%s/%s)",
|
||||
mn.ID, mn.Type, mn.Name))
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer o.unregisterNode(mn.ID)
|
||||
if err := svc.Run(nodeCtx); err != nil {
|
||||
nlog.Core().Error("machine node exited with error",
|
||||
"node_id", mn.ID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (o *Orchestrator) stopNode(nodeID int) {
|
||||
o.mu.Lock()
|
||||
h, ok := o.nodes[nodeID]
|
||||
if !ok {
|
||||
o.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(o.nodes, nodeID)
|
||||
o.mu.Unlock()
|
||||
|
||||
o.eventsMu.Lock()
|
||||
delete(o.mailboxes, nodeID)
|
||||
o.eventsMu.Unlock()
|
||||
|
||||
nlog.Core().Info(fmt.Sprintf("machine: stopping node %d", nodeID))
|
||||
h.cancel()
|
||||
<-h.done
|
||||
}
|
||||
|
||||
func (o *Orchestrator) stopAll() {
|
||||
o.mu.Lock()
|
||||
handles := make(map[int]*nodeHandle, len(o.nodes))
|
||||
for id, h := range o.nodes {
|
||||
handles[id] = h
|
||||
}
|
||||
o.mu.Unlock()
|
||||
|
||||
for id, h := range handles {
|
||||
nlog.Core().Info(fmt.Sprintf("machine: stopping node %d", id))
|
||||
h.cancel()
|
||||
}
|
||||
for _, h := range handles {
|
||||
<-h.done
|
||||
}
|
||||
|
||||
if o.wsCancel != nil {
|
||||
o.wsCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Node discovery ──────────────────────────────────────────────────────
|
||||
|
||||
func (o *Orchestrator) rediscover(ctx context.Context) {
|
||||
nodesResp, err := o.client.GetMachineNodes()
|
||||
if err != nil {
|
||||
nlog.Core().Warn("machine node discovery failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
wanted := make(map[int]panel.MachineNode, len(nodesResp.Nodes))
|
||||
for _, n := range nodesResp.Nodes {
|
||||
wanted[n.ID] = n
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
var toRemove []int
|
||||
for id := range o.nodes {
|
||||
if _, ok := wanted[id]; !ok {
|
||||
toRemove = append(toRemove, id)
|
||||
}
|
||||
}
|
||||
o.mu.Unlock()
|
||||
|
||||
for _, id := range toRemove {
|
||||
o.stopNode(id)
|
||||
}
|
||||
|
||||
for _, n := range nodesResp.Nodes {
|
||||
o.startNode(ctx, n) // no-op if already running
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Machine status reporting ────────────────────────────────────────────
|
||||
|
||||
func (o *Orchestrator) reportMachineStatus() {
|
||||
s := monitor.Collect()
|
||||
if err := o.client.ReportMachineStatus(
|
||||
s.CPU,
|
||||
[2]uint64{s.MemTotal, s.MemUsed},
|
||||
[2]uint64{s.SwapTotal, s.SwapUsed},
|
||||
[2]uint64{s.DiskTotal, s.DiskUsed},
|
||||
); err != nil {
|
||||
nlog.Core().Warn("machine status report failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WS mux ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (o *Orchestrator) tryStartWS(ctx context.Context) {
|
||||
hs, err := o.client.Handshake()
|
||||
if err != nil {
|
||||
nlog.Core().Warn("machine ws handshake failed, REST only", "error", err)
|
||||
return
|
||||
}
|
||||
if !hs.WebSocket.Enabled || hs.WebSocket.WSURL == "" {
|
||||
nlog.Core().Info("machine: ws disabled by panel, REST only")
|
||||
return
|
||||
}
|
||||
|
||||
wsCfg := panel.WSClientConfig{
|
||||
StatusInterval: time.Duration(o.cfg.WS.StatusInterval) * time.Second,
|
||||
HandshakeTimeout: time.Duration(o.cfg.WS.HandshakeTimeout) * time.Second,
|
||||
BackoffInitial: time.Duration(o.cfg.WS.BackoffInitial) * time.Second,
|
||||
BackoffMax: time.Duration(o.cfg.WS.BackoffMax) * time.Second,
|
||||
MachineID: o.cfg.Machine.MachineID,
|
||||
}
|
||||
|
||||
o.ws = panel.NewWSClient(
|
||||
hs.WebSocket.WSURL,
|
||||
o.cfg.Machine.Token,
|
||||
0, // no single node_id
|
||||
wsCfg,
|
||||
o.onWSEvent,
|
||||
o.onWSStatus,
|
||||
nil, // per-node status is sent via machineNodePush
|
||||
)
|
||||
|
||||
wsCtx, wsCancel := context.WithCancel(ctx)
|
||||
o.wsCancel = wsCancel
|
||||
go o.ws.Run(wsCtx)
|
||||
|
||||
nlog.Core().Info("machine: ws mux started")
|
||||
}
|
||||
|
||||
// onWSEvent routes a WS event to the correct node's channel.
|
||||
// sync.nodes is a machine-level event that triggers immediate rediscovery.
|
||||
func (o *Orchestrator) onWSEvent(event panel.WSEvent) {
|
||||
// sync.nodes is a machine-level event, not per-node
|
||||
if event.Type == panel.WSEventSyncNodes {
|
||||
nlog.Core().Info("machine received sync.nodes, triggering immediate rediscovery")
|
||||
go o.rediscover(o.runCtx)
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := event.NodeID
|
||||
if nodeID == 0 {
|
||||
nlog.Core().Debug("machine ws event missing node_id, dropping", "type", event.Type)
|
||||
return
|
||||
}
|
||||
|
||||
translated, err := controlplane.TranslateWSEvent(event, o.cfg.Kernel)
|
||||
if err != nil {
|
||||
nlog.Core().Warn("machine ws event translation failed",
|
||||
"type", event.Type, "node_id", nodeID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
o.eventsMu.RLock()
|
||||
mailbox, ok := o.mailboxes[nodeID]
|
||||
o.eventsMu.RUnlock()
|
||||
if !ok {
|
||||
nlog.Core().Debug("machine ws event for unknown node", "node_id", nodeID, "type", event.Type)
|
||||
return
|
||||
}
|
||||
mailbox.Apply(translated)
|
||||
}
|
||||
|
||||
// onWSStatus broadcasts WS connectivity changes to all registered nodes.
|
||||
func (o *Orchestrator) onWSStatus(status panel.WSStatusChange) {
|
||||
change := controlplane.StatusChange{Connected: status.Connected}
|
||||
o.eventsMu.RLock()
|
||||
defer o.eventsMu.RUnlock()
|
||||
for _, ch := range o.statuses {
|
||||
select {
|
||||
case ch <- change:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Orchestrator) registerNode(nodeID int, st chan<- controlplane.StatusChange) {
|
||||
o.eventsMu.Lock()
|
||||
o.statuses[nodeID] = st
|
||||
o.eventsMu.Unlock()
|
||||
}
|
||||
|
||||
func (o *Orchestrator) unregisterNode(nodeID int) {
|
||||
o.eventsMu.Lock()
|
||||
delete(o.mailboxes, nodeID)
|
||||
delete(o.statuses, nodeID)
|
||||
o.eventsMu.Unlock()
|
||||
}
|
||||
|
||||
func (o *Orchestrator) applyIntervals(bc panel.MachineBaseConfig) {
|
||||
o.pullInterval = time.Duration(bc.PullInterval) * time.Second
|
||||
if o.pullInterval < 30*time.Second {
|
||||
o.pullInterval = 60 * time.Second
|
||||
}
|
||||
o.pushInterval = time.Duration(bc.PushInterval) * time.Second
|
||||
if o.pushInterval < 10*time.Second {
|
||||
o.pushInterval = 60 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Virtual PushClient ─────────────────────────────────────────────────
|
||||
|
||||
// machineNodePush implements controlplane.PushClient for a single node
|
||||
// backed by the shared machine WS connection. Events are routed by the
|
||||
// WS mux directly to the Service's channels; this adapter only provides
|
||||
// connectivity status and send capabilities.
|
||||
type machineNodePush struct {
|
||||
nodeID int
|
||||
ws *panel.WSClient
|
||||
}
|
||||
|
||||
func (p *machineNodePush) Run(ctx context.Context) {
|
||||
// The shared WS mux pushes events into our channels; we just wait.
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func (p *machineNodePush) IsConnected() bool {
|
||||
return p.ws != nil && p.ws.IsConnected()
|
||||
}
|
||||
|
||||
func (p *machineNodePush) SendDeviceReport(devices map[int][]string) {
|
||||
if p.ws == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"node_id": p.nodeID,
|
||||
}
|
||||
// Flatten into the standard format with node_id wrapper.
|
||||
strDevices := make(map[string][]string, len(devices))
|
||||
for uid, ips := range devices {
|
||||
strDevices[fmt.Sprintf("%d", uid)] = ips
|
||||
}
|
||||
payload["devices"] = strDevices
|
||||
data, _ := json.Marshal(payload)
|
||||
p.ws.SendRaw(panel.WSEventReportDevices, data)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type outboundTagSource struct {
|
||||
Tag string
|
||||
Source string
|
||||
}
|
||||
|
||||
func collectAdditionalOutboundTagSources(path string, rawOutbounds []map[string]any) ([]outboundTagSource, error) {
|
||||
sources := collectRawOutboundTagSources(rawOutbounds)
|
||||
customSources, err := collectCustomConfigOutboundTagSources(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, customSources...)
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func collectRawOutboundTagSources(outbounds []map[string]any) []outboundTagSource {
|
||||
if len(outbounds) == 0 {
|
||||
return nil
|
||||
}
|
||||
sources := make([]outboundTagSource, 0, len(outbounds))
|
||||
for i, outbound := range outbounds {
|
||||
tag, _ := outbound["tag"].(string)
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, outboundTagSource{Tag: tag, Source: fmt.Sprintf("kernel.custom_outbound[%d]", i)})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func collectCustomConfigOutboundTagSources(path string) ([]outboundTagSource, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read custom config %q: %w", path, err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decoded := make(map[string]any)
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as JSON: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := yaml.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as YAML: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
items, ok := toSliceOfMaps(decoded["outbounds"])
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
sources := make([]outboundTagSource, 0, len(items))
|
||||
for i, item := range items {
|
||||
tag, _ := item["tag"].(string)
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, outboundTagSource{Tag: tag, Source: fmt.Sprintf("kernel.custom_config.outbounds[%d]", i)})
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func toSliceOfMaps(v any) ([]map[string]any, bool) {
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
switch items := v.(type) {
|
||||
case []any:
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
mapped, ok := toMap(item)
|
||||
if ok {
|
||||
result = append(result, mapped)
|
||||
}
|
||||
}
|
||||
return result, len(result) > 0
|
||||
case []map[string]any:
|
||||
return items, len(items) > 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func toMap(v any) (map[string]any, bool) {
|
||||
switch mapped := v.(type) {
|
||||
case map[string]any:
|
||||
return mapped, true
|
||||
case map[any]any:
|
||||
result := make(map[string]any, len(mapped))
|
||||
for k, val := range mapped {
|
||||
result[fmt.Sprint(k)] = val
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func validateOutboundTagCollisions(structured []OutboundConfig, additional []outboundTagSource) error {
|
||||
seen := make(map[string]string, len(additional))
|
||||
for _, source := range additional {
|
||||
key := strings.ToLower(strings.TrimSpace(source.Tag))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if prev, exists := seen[key]; exists {
|
||||
return fmt.Errorf("%s.tag duplicates %q from %s", source.Source, source.Tag, prev)
|
||||
}
|
||||
seen[key] = source.Source
|
||||
}
|
||||
for i, outbound := range structured {
|
||||
key := strings.ToLower(strings.TrimSpace(outbound.Tag))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if prev, exists := seen[key]; exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].tag duplicates %q from %s", i, outbound.Tag, prev)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func additionalTagNames(sources []outboundTagSource) []string {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
tags := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
tags = append(tags, source.Tag)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestNodeSpecFromPanelValidated_AllowsTargetsFromCustomConfigOutbounds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
customConfigPath := filepath.Join(dir, "custom.json")
|
||||
if err := os.WriteFile(customConfigPath, []byte(`{"outbounds":[{"tag":"wg-exit","protocol":"wireguard"}]}`), 0o644); err != nil {
|
||||
t.Fatalf("write custom config: %v", err)
|
||||
}
|
||||
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "proxy",
|
||||
Protocol: "socks",
|
||||
ProxyTag: "wg-exit",
|
||||
Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
|
||||
}},
|
||||
CustomRouteRules: []panel.CustomRouteRule{{
|
||||
Action: panel.RouteAction{Type: "route", Target: "wg-exit"},
|
||||
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox", CustomConfig: customConfigPath})
|
||||
if err != nil {
|
||||
t.Fatalf("expected custom_config outbound tags to be available, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSpecFromPanelValidated_RejectsDuplicateTagsAcrossSources(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
customConfigPath := filepath.Join(dir, "custom.yaml")
|
||||
if err := os.WriteFile(customConfigPath, []byte("outbounds:\n - tag: warp\n protocol: wireguard\n"), 0o644); err != nil {
|
||||
t.Fatalf("write custom config: %v", err)
|
||||
}
|
||||
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "warp",
|
||||
Protocol: "socks",
|
||||
Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
|
||||
}},
|
||||
}, config.KernelConfig{
|
||||
Type: "singbox",
|
||||
CustomConfig: customConfigPath,
|
||||
CustomOutbound: []map[string]any{{
|
||||
"tag": "proxy",
|
||||
"protocol": "socks",
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `custom_outbounds[0].tag duplicates "warp" from kernel.custom_config.outbounds[0]`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundTagCollisions_RejectsDuplicateRawSources(t *testing.T) {
|
||||
err := validateOutboundTagCollisions(nil, []outboundTagSource{
|
||||
{Tag: "warp", Source: "kernel.custom_outbound[0]"},
|
||||
{Tag: "warp", Source: "kernel.custom_config.outbounds[0]"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `kernel.custom_config.outbounds[0].tag duplicates "warp" from kernel.custom_outbound[0]`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
func OutboundSupportMatrix() map[string]KernelOutboundSupport {
|
||||
return map[string]KernelOutboundSupport{
|
||||
"xray": {
|
||||
Protocols: []string{
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"shadowsocks",
|
||||
"socks",
|
||||
"http",
|
||||
"wireguard",
|
||||
},
|
||||
Features: []string{
|
||||
"tag",
|
||||
"protocol",
|
||||
"settings",
|
||||
"proxy_tag",
|
||||
},
|
||||
},
|
||||
"singbox": {
|
||||
Protocols: []string{
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"shadowsocks",
|
||||
"socks",
|
||||
"http",
|
||||
"wireguard",
|
||||
"tuic",
|
||||
"hysteria2",
|
||||
"anytls",
|
||||
"naive",
|
||||
"mieru",
|
||||
},
|
||||
Features: []string{
|
||||
"tag",
|
||||
"protocol",
|
||||
"settings",
|
||||
"proxy_tag",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type KernelOutboundSupport struct {
|
||||
Protocols []string
|
||||
Features []string
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateCustomOutbounds(outbounds []OutboundConfig) error {
|
||||
return ValidateCustomOutboundsForKernel(outbounds, "", nil)
|
||||
}
|
||||
|
||||
func ValidateCustomOutboundsForKernel(outbounds []OutboundConfig, kernelType string, additionalTags []string) error {
|
||||
if len(outbounds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
kernelType = strings.ToLower(strings.TrimSpace(kernelType))
|
||||
allowedProtocols := map[string]struct{}{}
|
||||
if kernelType != "" {
|
||||
support, ok := OutboundSupportMatrix()[kernelType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported kernel type %q", kernelType)
|
||||
}
|
||||
for _, protocol := range support.Protocols {
|
||||
allowedProtocols[strings.ToLower(protocol)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(outbounds))
|
||||
availableTags := make(map[string]struct{}, len(outbounds)+len(additionalTags)+2)
|
||||
availableTags["direct"] = struct{}{}
|
||||
availableTags["block"] = struct{}{}
|
||||
for _, tag := range additionalTags {
|
||||
tag = strings.ToLower(strings.TrimSpace(tag))
|
||||
if tag != "" {
|
||||
availableTags[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for i, outbound := range outbounds {
|
||||
tag := strings.TrimSpace(outbound.Tag)
|
||||
protocol := strings.ToLower(strings.TrimSpace(outbound.Protocol))
|
||||
if tag == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].tag is required", i)
|
||||
}
|
||||
if protocol == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].protocol is required", i)
|
||||
}
|
||||
if kernelType != "" {
|
||||
if _, ok := allowedProtocols[protocol]; !ok {
|
||||
return fmt.Errorf("custom_outbounds[%d].protocol %q is not supported by kernel %q", i, outbound.Protocol, kernelType)
|
||||
}
|
||||
}
|
||||
if err := validateOutboundSettings(i, outbound.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
key := strings.ToLower(tag)
|
||||
if _, exists := seen[key]; exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].tag duplicates %q", i, tag)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
availableTags[key] = struct{}{}
|
||||
}
|
||||
|
||||
for i, outbound := range outbounds {
|
||||
if outbound.ProxyTag == "" {
|
||||
continue
|
||||
}
|
||||
proxyTag := strings.ToLower(strings.TrimSpace(outbound.ProxyTag))
|
||||
if proxyTag == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].proxy_tag must not be blank", i)
|
||||
}
|
||||
if proxyTag == strings.ToLower(strings.TrimSpace(outbound.Tag)) {
|
||||
return fmt.Errorf("custom_outbounds[%d].proxy_tag must not reference itself", i)
|
||||
}
|
||||
if _, exists := availableTags[proxyTag]; !exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].proxy_tag references unknown outbound %q", i, outbound.ProxyTag)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutboundSettings(index int, settings map[string]any) error {
|
||||
if len(settings) == 0 {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings is required", index)
|
||||
}
|
||||
for _, reservedKey := range []string{"tag", "protocol", "proxy_tag", "proxyTag"} {
|
||||
if _, exists := settings[reservedKey]; exists {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.%s is reserved", index, reservedKey)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"server", "uuid", "password", "method", "cipher", "private_key", "secret_key", "privateKey", "secretKey"} {
|
||||
if value, ok := settings[field]; ok {
|
||||
if strings.TrimSpace(fmt.Sprintf("%v", value)) == "" {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.%s must not be blank", index, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok := settings["server_port"]; ok {
|
||||
if _, err := asPort(value); err != nil {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.server_port %w", index, err)
|
||||
}
|
||||
}
|
||||
if value, ok := settings["serverPort"]; ok {
|
||||
if _, err := asPort(value); err != nil {
|
||||
return fmt.Errorf("custom_outbounds[%d].settings.serverPort %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asPort(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
if v < 1 || v > 65535 {
|
||||
return 0, fmt.Errorf("must be between 1 and 65535")
|
||||
}
|
||||
return v, nil
|
||||
case int32:
|
||||
return asPort(int(v))
|
||||
case int64:
|
||||
return asPort(int(v))
|
||||
case float64:
|
||||
if v != float64(int(v)) {
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
return asPort(int(v))
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
return asPort(parsed)
|
||||
default:
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCustomOutbounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outbounds []OutboundConfig
|
||||
kernel string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid chain",
|
||||
outbounds: []OutboundConfig{
|
||||
{Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"}},
|
||||
{Tag: "proxy", Protocol: "socks", ProxyTag: "warp", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
},
|
||||
kernel: "singbox",
|
||||
},
|
||||
{
|
||||
name: "valid native vmess settings preserved",
|
||||
outbounds: []OutboundConfig{{Tag: "vmess-native", Protocol: "vmess", Settings: map[string]any{"vnext": []any{map[string]any{"address": "1.1.1.1", "port": 443}}}}},
|
||||
kernel: "xray",
|
||||
},
|
||||
{
|
||||
name: "missing tag",
|
||||
outbounds: []OutboundConfig{{Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].tag is required",
|
||||
},
|
||||
{
|
||||
name: "missing protocol",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].protocol is required",
|
||||
},
|
||||
{
|
||||
name: "duplicate tag",
|
||||
outbounds: []OutboundConfig{
|
||||
{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
{Tag: "proxy", Protocol: "http", Settings: map[string]any{"server": "3.3.3.3", "server_port": 8080}},
|
||||
},
|
||||
wantErr: `custom_outbounds[1].tag duplicates "proxy"`,
|
||||
},
|
||||
{
|
||||
name: "self proxy tag",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "proxy", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].proxy_tag must not reference itself",
|
||||
},
|
||||
{
|
||||
name: "unknown proxy tag",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: `custom_outbounds[0].proxy_tag references unknown outbound "missing"`,
|
||||
},
|
||||
{
|
||||
name: "built-in proxy tag allowed",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", ProxyTag: "direct", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}}},
|
||||
},
|
||||
{
|
||||
name: "unsupported protocol for kernel",
|
||||
outbounds: []OutboundConfig{{Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443}}},
|
||||
kernel: "xray",
|
||||
wantErr: `custom_outbounds[0].protocol "hysteria2" is not supported by kernel "xray"`,
|
||||
},
|
||||
{
|
||||
name: "missing settings",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks"}},
|
||||
wantErr: "custom_outbounds[0].settings is required",
|
||||
},
|
||||
{
|
||||
name: "reserved snake case key in settings",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"proxy_tag": "bad", "server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].settings.proxy_tag is reserved",
|
||||
},
|
||||
{
|
||||
name: "reserved camel case key in settings",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"proxyTag": "bad", "server": "2.2.2.2", "server_port": 1080}}},
|
||||
wantErr: "custom_outbounds[0].settings.proxyTag is reserved",
|
||||
},
|
||||
{
|
||||
name: "invalid server_port",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "server_port": 70000}}},
|
||||
wantErr: "custom_outbounds[0].settings.server_port must be between 1 and 65535",
|
||||
},
|
||||
{
|
||||
name: "invalid camel case serverPort",
|
||||
outbounds: []OutboundConfig{{Tag: "proxy", Protocol: "socks", Settings: map[string]any{"server": "2.2.2.2", "serverPort": 70000}}},
|
||||
wantErr: "custom_outbounds[0].settings.serverPort must be between 1 and 65535",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCustomOutboundsForKernel(tc.outbounds, tc.kernel, nil)
|
||||
if tc.wantErr == "" && err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package model
|
||||
|
||||
type CustomRouteRule struct {
|
||||
Name string
|
||||
Disabled bool
|
||||
Match RouteMatch
|
||||
Action RouteAction
|
||||
}
|
||||
|
||||
type RouteMatch struct {
|
||||
Domains []string
|
||||
DomainSuffixes []string
|
||||
IPCIDRs []string
|
||||
Ports []string
|
||||
Networks []string
|
||||
SourceCIDRs []string
|
||||
SourcePorts []string
|
||||
}
|
||||
|
||||
type RouteAction struct {
|
||||
Type string
|
||||
Target string
|
||||
}
|
||||
|
||||
func cloneCustomRouteRules(src []CustomRouteRule) []CustomRouteRule {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]CustomRouteRule, 0, len(src))
|
||||
for _, rule := range src {
|
||||
out = append(out, CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: cloneRouteMatch(rule.Match),
|
||||
Action: cloneRouteAction(rule.Action),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRouteMatch(src RouteMatch) RouteMatch {
|
||||
return RouteMatch{
|
||||
Domains: cloneStringSlice(src.Domains),
|
||||
DomainSuffixes: cloneStringSlice(src.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(src.IPCIDRs),
|
||||
Ports: cloneStringSlice(src.Ports),
|
||||
Networks: cloneStringSlice(src.Networks),
|
||||
SourceCIDRs: cloneStringSlice(src.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(src.SourcePorts),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRouteAction(src RouteAction) RouteAction {
|
||||
return RouteAction{
|
||||
Type: src.Type,
|
||||
Target: src.Target,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
func RouteSupportMatrix() map[string]KernelRouteSupport {
|
||||
return map[string]KernelRouteSupport{
|
||||
"xray": {
|
||||
Matchers: []string{
|
||||
"domains",
|
||||
"domain_suffixes",
|
||||
"ip_cidrs",
|
||||
"ports",
|
||||
"networks",
|
||||
"source_cidrs",
|
||||
"source_ports",
|
||||
},
|
||||
Actions: []string{"block", "direct", "route"},
|
||||
},
|
||||
"singbox": {
|
||||
Matchers: []string{
|
||||
"domains",
|
||||
"domain_suffixes",
|
||||
"ip_cidrs",
|
||||
"ports",
|
||||
"networks",
|
||||
"source_cidrs",
|
||||
"source_ports",
|
||||
},
|
||||
Actions: []string{"block", "direct", "route"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type KernelRouteSupport struct {
|
||||
Matchers []string
|
||||
Actions []string
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateCustomRouteRules(rules []CustomRouteRule, kernelType string, availableTags map[string]struct{}) error {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
kernelType = strings.ToLower(strings.TrimSpace(kernelType))
|
||||
matcherSupport := map[string]struct{}{}
|
||||
actionSupport := map[string]struct{}{}
|
||||
if kernelType != "" {
|
||||
support, ok := RouteSupportMatrix()[kernelType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported kernel type %q", kernelType)
|
||||
}
|
||||
for _, matcher := range support.Matchers {
|
||||
matcherSupport[strings.ToLower(matcher)] = struct{}{}
|
||||
}
|
||||
for _, action := range support.Actions {
|
||||
actionSupport[strings.ToLower(action)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for i, rule := range rules {
|
||||
if rule.Disabled {
|
||||
continue
|
||||
}
|
||||
if !hasRouteMatch(rule.Match) {
|
||||
return fmt.Errorf("custom_route_rules[%d].match is required", i)
|
||||
}
|
||||
if err := ensureRouteMatcherSupported(i, kernelType, matcherSupport, rule.Match); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePortSpecs(rule.Match.Ports, fmt.Sprintf("custom_route_rules[%d].match.ports", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePortSpecs(rule.Match.SourcePorts, fmt.Sprintf("custom_route_rules[%d].match.source_ports", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNetworks(rule.Match.Networks, fmt.Sprintf("custom_route_rules[%d].match.networks", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionType := strings.ToLower(strings.TrimSpace(rule.Action.Type))
|
||||
if actionType == "" {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.type is required", i)
|
||||
}
|
||||
if kernelType != "" {
|
||||
if _, ok := actionSupport[actionType]; !ok {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.type %q is not supported by kernel %q", i, rule.Action.Type, kernelType)
|
||||
}
|
||||
}
|
||||
target := strings.TrimSpace(rule.Action.Target)
|
||||
switch actionType {
|
||||
case "route":
|
||||
if target == "" {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.target is required when action.type is route", i)
|
||||
}
|
||||
if availableTags != nil {
|
||||
if _, ok := availableTags[strings.ToLower(target)]; !ok {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.target references unknown outbound %q", i, rule.Action.Target)
|
||||
}
|
||||
}
|
||||
case "direct", "block":
|
||||
if target != "" {
|
||||
return fmt.Errorf("custom_route_rules[%d].action.target is only allowed when action.type is route", i)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("custom_route_rules[%d].action.type %q is not supported", i, rule.Action.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasRouteMatch(match RouteMatch) bool {
|
||||
for _, values := range [][]string{
|
||||
match.Domains,
|
||||
match.DomainSuffixes,
|
||||
match.IPCIDRs,
|
||||
match.Ports,
|
||||
match.Networks,
|
||||
match.SourceCIDRs,
|
||||
match.SourcePorts,
|
||||
} {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureRouteMatcherSupported(index int, kernelType string, matcherSupport map[string]struct{}, match RouteMatch) error {
|
||||
if kernelType == "" {
|
||||
return nil
|
||||
}
|
||||
checks := map[string][]string{
|
||||
"domains": match.Domains,
|
||||
"domain_suffixes": match.DomainSuffixes,
|
||||
"ip_cidrs": match.IPCIDRs,
|
||||
"ports": match.Ports,
|
||||
"networks": match.Networks,
|
||||
"source_cidrs": match.SourceCIDRs,
|
||||
"source_ports": match.SourcePorts,
|
||||
}
|
||||
for matcher, values := range checks {
|
||||
if !hasAnyNonBlank(values) {
|
||||
continue
|
||||
}
|
||||
if _, ok := matcherSupport[matcher]; !ok {
|
||||
return fmt.Errorf("custom_route_rules[%d].match.%s is not supported by kernel %q", index, matcher, kernelType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAnyNonBlank(values []string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateNetworks(values []string, field string) error {
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if value != "tcp" && value != "udp" {
|
||||
return fmt.Errorf("%s contains unsupported network %q", field, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePortSpecs(values []string, field string) error {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(value, "-") || strings.Contains(value, ":") {
|
||||
delimiter := "-"
|
||||
if strings.Contains(value, ":") {
|
||||
delimiter = ":"
|
||||
}
|
||||
parts := strings.Split(value, delimiter)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("%s contains invalid port range %q", field, value)
|
||||
}
|
||||
start, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || start < 1 || start > 65535 {
|
||||
return fmt.Errorf("%s contains invalid port range %q", field, value)
|
||||
}
|
||||
end, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || end < 1 || end > 65535 || end < start {
|
||||
return fmt.Errorf("%s contains invalid port range %q", field, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("%s contains invalid port %q", field, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCustomRouteRules(t *testing.T) {
|
||||
available := map[string]struct{}{"direct": {}, "block": {}, "warp": {}, "proxy": {}}
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []CustomRouteRule
|
||||
kernel string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid route target",
|
||||
rules: []CustomRouteRule{{
|
||||
Match: RouteMatch{DomainSuffixes: []string{"example.com"}, Ports: []string{"443"}, Networks: []string{"tcp"}},
|
||||
Action: RouteAction{Type: "route", Target: "warp"},
|
||||
}},
|
||||
kernel: "singbox",
|
||||
},
|
||||
{
|
||||
name: "missing match",
|
||||
rules: []CustomRouteRule{{Action: RouteAction{Type: "direct"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: "custom_route_rules[0].match is required",
|
||||
},
|
||||
{
|
||||
name: "unknown target",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "route", Target: "missing"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].action.target references unknown outbound "missing"`,
|
||||
},
|
||||
{
|
||||
name: "route target required",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "route"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: "custom_route_rules[0].action.target is required when action.type is route",
|
||||
},
|
||||
{
|
||||
name: "direct target forbidden",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "direct", Target: "warp"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: "custom_route_rules[0].action.target is only allowed when action.type is route",
|
||||
},
|
||||
{
|
||||
name: "unsupported action",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{DomainSuffixes: []string{"example.com"}}, Action: RouteAction{Type: "dns"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].action.type "dns" is not supported by kernel "singbox"`,
|
||||
},
|
||||
{
|
||||
name: "invalid port range",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{Ports: []string{"2000-1000"}}, Action: RouteAction{Type: "direct"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].match.ports contains invalid port range "2000-1000"`,
|
||||
},
|
||||
{
|
||||
name: "invalid network",
|
||||
rules: []CustomRouteRule{{Match: RouteMatch{Networks: []string{"icmp"}}, Action: RouteAction{Type: "direct"}}},
|
||||
kernel: "singbox",
|
||||
wantErr: `custom_route_rules[0].match.networks contains unsupported network "icmp"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCustomRouteRules(tc.rules, tc.kernel, available)
|
||||
if tc.wantErr == "" && err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,27 @@ func NodeSpecFromPanel(nc *panel.NodeConfig) *NodeSpec {
|
||||
})
|
||||
}
|
||||
|
||||
customRouteRules := make([]CustomRouteRule, 0, len(nc.CustomRouteRules))
|
||||
for _, rule := range nc.CustomRouteRules {
|
||||
customRouteRules = append(customRouteRules, CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: RouteMatch{
|
||||
Domains: cloneStringSlice(rule.Match.Domains),
|
||||
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
|
||||
Ports: cloneStringSlice(rule.Match.Ports),
|
||||
Networks: cloneStringSlice(rule.Match.Networks),
|
||||
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
|
||||
},
|
||||
Action: RouteAction{
|
||||
Type: rule.Action.Type,
|
||||
Target: rule.Action.Target,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &NodeSpec{
|
||||
Protocol: nc.Protocol,
|
||||
ListenIP: nc.ListenIP,
|
||||
@@ -76,6 +97,7 @@ func NodeSpecFromPanel(nc *panel.NodeConfig) *NodeSpec {
|
||||
KernelLogLevel: nc.KernelLogLevel,
|
||||
CustomOutbounds: outbounds,
|
||||
CustomRoutes: cloneMapSlice(nc.CustomRoutes),
|
||||
CustomRouteRules: customRouteRules,
|
||||
CertConfig: certCfg,
|
||||
AutoTLS: nc.AutoTLS,
|
||||
Domain: nc.Domain,
|
||||
@@ -103,6 +125,14 @@ func NodeSpecFromPanel(nc *panel.NodeConfig) *NodeSpec {
|
||||
}
|
||||
}
|
||||
|
||||
func NodeSpecFromPanelValidated(nc *panel.NodeConfig, kcfg config.KernelConfig) (*NodeSpec, error) {
|
||||
spec := NodeSpecFromPanel(nc)
|
||||
if err := ValidateNodeSpec(spec, kcfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func UserSpecsFromPanel(users []panel.User) []UserSpec {
|
||||
if users == nil {
|
||||
return nil
|
||||
@@ -174,6 +204,27 @@ func (n *NodeSpec) ToPanel() *panel.NodeConfig {
|
||||
})
|
||||
}
|
||||
|
||||
customRouteRules := make([]panel.CustomRouteRule, 0, len(n.CustomRouteRules))
|
||||
for _, rule := range n.CustomRouteRules {
|
||||
customRouteRules = append(customRouteRules, panel.CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: panel.RouteMatch{
|
||||
Domains: cloneStringSlice(rule.Match.Domains),
|
||||
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
|
||||
Ports: cloneStringSlice(rule.Match.Ports),
|
||||
Networks: cloneStringSlice(rule.Match.Networks),
|
||||
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
|
||||
},
|
||||
Action: panel.RouteAction{
|
||||
Type: rule.Action.Type,
|
||||
Target: rule.Action.Target,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &panel.NodeConfig{
|
||||
Protocol: n.Protocol,
|
||||
ListenIP: n.ListenIP,
|
||||
@@ -185,6 +236,7 @@ func (n *NodeSpec) ToPanel() *panel.NodeConfig {
|
||||
KernelLogLevel: n.KernelLogLevel,
|
||||
CustomOutbounds: outbounds,
|
||||
CustomRoutes: cloneMapSlice(n.CustomRoutes),
|
||||
CustomRouteRules: customRouteRules,
|
||||
CertConfig: certCfg,
|
||||
AutoTLS: n.AutoTLS,
|
||||
Domain: n.Domain,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestNodeSpecFromPanelValidated(t *testing.T) {
|
||||
t.Run("valid custom outbounds and route targets", func(t *testing.T) {
|
||||
node, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{
|
||||
{Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"}},
|
||||
{Tag: "proxy", Protocol: "socks", ProxyTag: "warp", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
},
|
||||
CustomRouteRules: []panel.CustomRouteRule{{
|
||||
Action: panel.RouteAction{Type: "route", Target: "proxy"},
|
||||
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err != nil {
|
||||
t.Fatalf("NodeSpecFromPanelValidated: %v", err)
|
||||
}
|
||||
if node == nil || len(node.CustomOutbounds) != 2 {
|
||||
t.Fatalf("unexpected node spec: %#v", node)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid custom outbounds", func(t *testing.T) {
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject unsupported protocol for kernel", func(t *testing.T) {
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "xray"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `protocol "hysteria2" is not supported by kernel "xray"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject unknown route target", func(t *testing.T) {
|
||||
_, err := NodeSpecFromPanelValidated(&panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panel.OutboundConfig{{
|
||||
Tag: "warp", Protocol: "wireguard", Settings: map[string]any{"server": "1.1.1.1", "server_port": 2408, "private_key": "pk"},
|
||||
}},
|
||||
CustomRouteRules: []panel.CustomRouteRule{{
|
||||
Action: panel.RouteAction{Type: "route", Target: "missing"},
|
||||
Match: panel.RouteMatch{DomainSuffixes: []string{"example.com"}},
|
||||
}},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `custom_route_rules[0].action.target references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,24 @@ func NodeSpecFromStandalone(cfg *config.Config) *NodeSpec {
|
||||
})
|
||||
}
|
||||
|
||||
customRouteRules := make([]CustomRouteRule, 0, len(sc.Node.CustomRouteRules))
|
||||
for _, rule := range sc.Node.CustomRouteRules {
|
||||
customRouteRules = append(customRouteRules, CustomRouteRule{
|
||||
Name: rule.Name,
|
||||
Disabled: rule.Disabled,
|
||||
Match: RouteMatch{
|
||||
Domains: cloneStringSlice(rule.Match.Domains),
|
||||
DomainSuffixes: cloneStringSlice(rule.Match.DomainSuffixes),
|
||||
IPCIDRs: cloneStringSlice(rule.Match.IPCIDRs),
|
||||
Ports: cloneStringSlice(rule.Match.Ports),
|
||||
Networks: cloneStringSlice(rule.Match.Networks),
|
||||
SourceCIDRs: cloneStringSlice(rule.Match.SourceCIDRs),
|
||||
SourcePorts: cloneStringSlice(rule.Match.SourcePorts),
|
||||
},
|
||||
Action: RouteAction{Type: rule.Action.Type, Target: rule.Action.Target},
|
||||
})
|
||||
}
|
||||
|
||||
var multiplex *MultiplexConfig
|
||||
if sc.Node.Multiplex != nil {
|
||||
multiplex = &MultiplexConfig{
|
||||
@@ -44,6 +62,7 @@ func NodeSpecFromStandalone(cfg *config.Config) *NodeSpec {
|
||||
Network: sc.Node.Network,
|
||||
NetworkSettings: cloneAnyMap(sc.Node.NetworkSettings),
|
||||
Routes: routes,
|
||||
CustomRouteRules: customRouteRules,
|
||||
KernelType: cfg.Kernel.Type,
|
||||
KernelLogLevel: cfg.Kernel.LogLevel,
|
||||
Cipher: sc.Node.Cipher,
|
||||
@@ -70,6 +89,14 @@ func NodeSpecFromStandalone(cfg *config.Config) *NodeSpec {
|
||||
}
|
||||
}
|
||||
|
||||
func NodeSpecFromStandaloneValidated(cfg *config.Config) (*NodeSpec, error) {
|
||||
spec := NodeSpecFromStandalone(cfg)
|
||||
if err := ValidateNodeSpec(spec, cfg.Kernel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func UserSpecsFromStandalone(cfg *config.Config) []UserSpec {
|
||||
if cfg.Standalone == nil {
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,7 @@ type NodeSpec struct {
|
||||
KernelLogLevel string
|
||||
CustomOutbounds []OutboundConfig
|
||||
CustomRoutes []map[string]any
|
||||
CustomRouteRules []CustomRouteRule
|
||||
CertConfig *config.CertConfig
|
||||
AutoTLS bool
|
||||
Domain string
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
)
|
||||
|
||||
func ValidateNodeSpec(n *NodeSpec, kcfg config.KernelConfig) error {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
effectiveKernelType := strings.TrimSpace(kcfg.Type)
|
||||
if effectiveKernelType == "" {
|
||||
effectiveKernelType = strings.TrimSpace(n.KernelType)
|
||||
}
|
||||
kernelType, err := normalizeKernelType(effectiveKernelType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize kernel type: %w", err)
|
||||
}
|
||||
|
||||
additionalOutboundSources, err := collectAdditionalOutboundTagSources(kcfg.CustomConfig, kcfg.CustomOutbound)
|
||||
if err != nil {
|
||||
return fmt.Errorf("collect additional outbound tags: %w", err)
|
||||
}
|
||||
if err := validateOutboundTagCollisions(n.CustomOutbounds, additionalOutboundSources); err != nil {
|
||||
return fmt.Errorf("validate outbound tags: %w", err)
|
||||
}
|
||||
additionalTags := additionalTagNames(additionalOutboundSources)
|
||||
availableTags := buildAvailableOutboundTags(n.CustomOutbounds, additionalTags)
|
||||
if err := ValidateCustomOutboundsForKernel(n.CustomOutbounds, kernelType, additionalTags); err != nil {
|
||||
return fmt.Errorf("validate custom outbounds: %w", err)
|
||||
}
|
||||
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
|
||||
return fmt.Errorf("validate custom route rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeKernelType(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "singbox", "sing-box":
|
||||
return "singbox", nil
|
||||
case "xray":
|
||||
return "xray", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported kernel type %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func buildAvailableOutboundTags(structured []OutboundConfig, rawTags []string) map[string]struct{} {
|
||||
available := map[string]struct{}{
|
||||
"direct": {},
|
||||
"block": {},
|
||||
}
|
||||
for _, outbound := range structured {
|
||||
tag := strings.ToLower(strings.TrimSpace(outbound.Tag))
|
||||
if tag != "" {
|
||||
available[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, tag := range rawTags {
|
||||
tag = strings.ToLower(strings.TrimSpace(tag))
|
||||
if tag != "" {
|
||||
available[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
return available
|
||||
}
|
||||
+108
-25
@@ -25,12 +25,15 @@ var (
|
||||
onlineMapPool = sync.Pool{New: func() interface{} { return make(map[string]int) }}
|
||||
)
|
||||
|
||||
// Client communicates with the Xboard panel API
|
||||
// Client communicates with the Xboard panel API.
|
||||
// When machineID > 0 the client uses machine-level authentication
|
||||
// (machine_id + token) instead of the legacy (token + node_type) scheme.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
nodeID int
|
||||
nodeType string
|
||||
machineID int
|
||||
httpClient *http.Client
|
||||
|
||||
configETag string
|
||||
@@ -40,13 +43,14 @@ type Client struct {
|
||||
apiFailure atomic.Uint64
|
||||
}
|
||||
|
||||
// NewClient creates a new panel API client
|
||||
// NewClient creates a new panel API client.
|
||||
func NewClient(cfg config.PanelConfig) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.URL, "/"),
|
||||
token: cfg.Token,
|
||||
nodeID: cfg.NodeID,
|
||||
nodeType: cfg.NodeType,
|
||||
machineID: cfg.MachineID,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
@@ -58,6 +62,19 @@ func NewClient(cfg config.PanelConfig) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ForNode returns a new client bound to a specific node_id, sharing the
|
||||
// same base URL, auth and HTTP transport. ETags and metric counters start fresh.
|
||||
func (c *Client) ForNode(nodeID int) *Client {
|
||||
return &Client{
|
||||
baseURL: c.baseURL,
|
||||
token: c.token,
|
||||
nodeID: nodeID,
|
||||
nodeType: c.nodeType,
|
||||
machineID: c.machineID,
|
||||
httpClient: c.httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Handshake calls the new v2 API to get WS config + initial data in one shot.
|
||||
func (c *Client) Handshake() (*HandshakeResponse, error) {
|
||||
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
|
||||
@@ -174,9 +191,26 @@ func decodeWeakRaw(input map[string]interface{}, output interface{}) error {
|
||||
return decoder.Decode(input)
|
||||
}
|
||||
|
||||
// configPath returns the API path for config fetching.
|
||||
// Machine mode uses the v2 endpoint; legacy mode uses the v1 UniProxy endpoint.
|
||||
func (c *Client) configPath() string {
|
||||
if c.machineID > 0 {
|
||||
return "/api/v2/server/config"
|
||||
}
|
||||
return "/api/v1/server/UniProxy/config"
|
||||
}
|
||||
|
||||
// userPath returns the API path for user fetching.
|
||||
func (c *Client) userPath() string {
|
||||
if c.machineID > 0 {
|
||||
return "/api/v2/server/user"
|
||||
}
|
||||
return "/api/v1/server/UniProxy/user"
|
||||
}
|
||||
|
||||
// GetConfig fetches node configuration. Returns nil if not modified (304).
|
||||
func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/config", nil, c.configETag)
|
||||
resp, err := c.doRequest("GET", c.configPath(), nil, c.configETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config: %w", err)
|
||||
}
|
||||
@@ -214,7 +248,7 @@ func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||
|
||||
// GetUsers fetches available users. Returns nil if not modified (304).
|
||||
func (c *Client) GetUsers() ([]User, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/user", nil, c.userETag)
|
||||
resp, err := c.doRequest("GET", c.userPath(), nil, c.userETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get users: %w", err)
|
||||
}
|
||||
@@ -280,14 +314,74 @@ func (c *Client) ResetETags() {
|
||||
c.userETag = ""
|
||||
}
|
||||
|
||||
// postJSON marshals a map payload (with auth fields injected) and POSTs it.
|
||||
// Auth fields are injected before marshalling — single marshal pass.
|
||||
func (c *Client) postJSON(path string, payload map[string]interface{}) error {
|
||||
payload["token"] = c.token
|
||||
payload["node_id"] = c.nodeID
|
||||
if c.nodeType != "" {
|
||||
payload["node_type"] = c.nodeType
|
||||
// GetMachineNodes fetches the list of active nodes bound to this machine.
|
||||
func (c *Client) GetMachineNodes() (*MachineNodesResponse, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v2/server/machine/nodes", nil, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get machine nodes: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("machine nodes status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var out MachineNodesResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode machine nodes: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ReportMachineStatus sends machine-level load metrics to the panel.
|
||||
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64) error {
|
||||
payload := map[string]interface{}{
|
||||
"cpu": cpu,
|
||||
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
||||
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
||||
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
||||
}
|
||||
return c.postJSON("/api/v2/server/machine/status", payload)
|
||||
}
|
||||
|
||||
// injectAuth writes authentication fields into a payload map.
|
||||
func (c *Client) injectAuth(m map[string]interface{}) {
|
||||
m["token"] = c.token
|
||||
if c.machineID > 0 {
|
||||
m["machine_id"] = c.machineID
|
||||
if c.nodeID > 0 {
|
||||
m["node_id"] = c.nodeID
|
||||
}
|
||||
} else {
|
||||
m["node_id"] = c.nodeID
|
||||
if c.nodeType != "" {
|
||||
m["node_type"] = c.nodeType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// authQuery builds URL query parameters for GET requests.
|
||||
func (c *Client) authQuery() url.Values {
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
if c.machineID > 0 {
|
||||
q.Set("machine_id", strconv.Itoa(c.machineID))
|
||||
if c.nodeID > 0 {
|
||||
q.Set("node_id", strconv.Itoa(c.nodeID))
|
||||
}
|
||||
} else {
|
||||
q.Set("node_id", strconv.Itoa(c.nodeID))
|
||||
if c.nodeType != "" {
|
||||
q.Set("node_type", c.nodeType)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// postJSON marshals a map payload (with auth fields injected) and POSTs it.
|
||||
func (c *Client) postJSON(path string, payload map[string]interface{}) error {
|
||||
c.injectAuth(payload)
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -313,23 +407,12 @@ func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string)
|
||||
|
||||
var bodyReader io.Reader
|
||||
if method == "GET" {
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
q.Set("node_id", strconv.Itoa(c.nodeID))
|
||||
if c.nodeType != "" {
|
||||
q.Set("node_type", c.nodeType)
|
||||
}
|
||||
fullURL += "?" + q.Encode()
|
||||
fullURL += "?" + c.authQuery().Encode()
|
||||
} else if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
} else {
|
||||
authOnly := map[string]interface{}{
|
||||
"token": c.token,
|
||||
"node_id": c.nodeID,
|
||||
}
|
||||
if c.nodeType != "" {
|
||||
authOnly["node_type"] = c.nodeType
|
||||
}
|
||||
authOnly := make(map[string]interface{})
|
||||
c.injectAuth(authOnly)
|
||||
merged, _ := json.Marshal(authOnly)
|
||||
bodyReader = bytes.NewReader(merged)
|
||||
}
|
||||
|
||||
+64
-2
@@ -62,8 +62,29 @@ type Settings struct {
|
||||
PullInterval int `json:"pull_interval"`
|
||||
}
|
||||
|
||||
// MachineNode is a single entry returned by GET /api/v2/server/machine/nodes.
|
||||
type MachineNode struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// MachineNodesResponse is the response from GET /api/v2/server/machine/nodes.
|
||||
type MachineNodesResponse struct {
|
||||
Nodes []MachineNode `json:"nodes"`
|
||||
BaseConfig MachineBaseConfig `json:"base_config"`
|
||||
}
|
||||
|
||||
// MachineBaseConfig holds polling intervals for machine mode.
|
||||
type MachineBaseConfig struct {
|
||||
PushInterval int `json:"push_interval"`
|
||||
PullInterval int `json:"pull_interval"`
|
||||
}
|
||||
|
||||
// NodeConfig is the response from GET /api/v1/server/UniProxy/config
|
||||
type NodeConfig struct {
|
||||
// NodeID is populated in machine-mode WS events for routing.
|
||||
NodeID int `json:"node_id,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
ListenIP string `json:"listen_ip"`
|
||||
ServerPort int `json:"server_port"`
|
||||
@@ -77,6 +98,7 @@ type NodeConfig struct {
|
||||
KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc.
|
||||
CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"`
|
||||
CustomRoutes []map[string]any `json:"custom_routes,omitempty"`
|
||||
CustomRouteRules []CustomRouteRule `json:"custom_route_rules,omitempty"`
|
||||
|
||||
// Certificate settings (Xboard extension)
|
||||
CertConfig *CertConfig `json:"cert_config,omitempty"`
|
||||
@@ -155,9 +177,11 @@ type BrutalConfig struct {
|
||||
DownMbps int `json:"down_mbps"`
|
||||
}
|
||||
|
||||
// CertConfig holds certificate automation settings from the panel
|
||||
// CertConfig holds certificate automation settings from the panel.
|
||||
// The panel may send the mode field as either "cert_mode" or "mode";
|
||||
// a custom UnmarshalJSON handles both.
|
||||
type CertConfig struct {
|
||||
CertMode string `json:"cert_mode"` // none, dns, http, self, file
|
||||
CertMode string `json:"cert_mode"` // none, dns, http, self, file, content
|
||||
Domain string `json:"domain"` // Certificate domain
|
||||
Email string `json:"email"` // ACME email
|
||||
DNSProvider string `json:"dns_provider"` // dns mode: cloudflare, alidns, etc.
|
||||
@@ -169,6 +193,22 @@ type CertConfig struct {
|
||||
KeyContent string `json:"key_content"` // content mode: private key raw string
|
||||
}
|
||||
|
||||
func (c *CertConfig) UnmarshalJSON(data []byte) error {
|
||||
type plain CertConfig
|
||||
var raw struct {
|
||||
plain
|
||||
ModeFallback string `json:"mode"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = CertConfig(raw.plain)
|
||||
if c.CertMode == "" && raw.ModeFallback != "" {
|
||||
c.CertMode = raw.ModeFallback
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutboundConfig defines a custom outbound for kernel
|
||||
type OutboundConfig struct {
|
||||
Tag string `json:"tag"` // Unique tag for routing
|
||||
@@ -189,6 +229,28 @@ type RouteRule struct {
|
||||
ActionValue string `json:"action_value,omitempty"`
|
||||
}
|
||||
|
||||
type CustomRouteRule struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
Match RouteMatch `json:"match,omitempty"`
|
||||
Action RouteAction `json:"action"`
|
||||
}
|
||||
|
||||
type RouteMatch struct {
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
DomainSuffixes []string `json:"domain_suffixes,omitempty"`
|
||||
IPCIDRs []string `json:"ip_cidrs,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Networks []string `json:"networks,omitempty"`
|
||||
SourceCIDRs []string `json:"source_cidrs,omitempty"`
|
||||
SourcePorts []string `json:"source_ports,omitempty"`
|
||||
}
|
||||
|
||||
type RouteAction struct {
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
// User represents a user returned by the panel
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
+89
-29
@@ -20,6 +20,7 @@ const (
|
||||
WSEventSyncUsers = "sync.users"
|
||||
WSEventSyncUserDelta = "sync.user.delta"
|
||||
WSEventSyncDevices = "sync.devices" // panel → node: global device state
|
||||
WSEventSyncNodes = "sync.nodes" // panel → machine: node list changed
|
||||
WSEventReportDevices = "report.devices" // node → panel: report device snapshot
|
||||
)
|
||||
|
||||
@@ -33,6 +34,10 @@ type WSEvent struct {
|
||||
|
||||
// Device sync fields
|
||||
DeviceUsers map[int][]string // userID -> IPs (for sync.devices)
|
||||
NodeID int
|
||||
|
||||
// Machine node discovery fields (for sync.nodes)
|
||||
Nodes []MachineNode
|
||||
}
|
||||
|
||||
// WSStatusChange notifies the service when WS connectivity changes.
|
||||
@@ -47,27 +52,36 @@ type wsMessage struct {
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// Payload structures for data events
|
||||
// Payload structures for data events.
|
||||
type syncConfigPayload struct {
|
||||
Config NodeConfig `json:"config"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
type syncUsersPayload struct {
|
||||
Users []User `json:"users"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
type syncUserDeltaPayload struct {
|
||||
Action string `json:"action"`
|
||||
Users []User `json:"users"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
// syncDevicesPayload carries global device state from panel.
|
||||
type syncDevicesPayload struct {
|
||||
Users map[int][]string `json:"users"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
// syncNodesPayload carries the updated node list for a machine.
|
||||
type syncNodesPayload struct {
|
||||
Nodes []MachineNode `json:"nodes"`
|
||||
}
|
||||
|
||||
// WSClientConfig holds WebSocket client tuning options.
|
||||
@@ -76,6 +90,7 @@ type WSClientConfig struct {
|
||||
HandshakeTimeout time.Duration
|
||||
BackoffInitial time.Duration
|
||||
BackoffMax time.Duration
|
||||
MachineID int
|
||||
}
|
||||
|
||||
// WSClient connects to the panel's Workerman WS server using native WebSocket.
|
||||
@@ -182,14 +197,17 @@ func (w *WSClient) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
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)
|
||||
if w.cfg.MachineID > 0 {
|
||||
q.Set("machine_id", strconv.Itoa(w.cfg.MachineID))
|
||||
} else {
|
||||
q.Set("node_id", strconv.Itoa(w.nodeID))
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
nlog.Core().Debug("ws connecting", "url", u.String())
|
||||
@@ -238,7 +256,11 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
reportTicker := time.NewTicker(w.cfg.StatusInterval)
|
||||
defer reportTicker.Stop()
|
||||
|
||||
msgCh := make(chan wsMessage, 16)
|
||||
// writeCh decouples data collection from network I/O.
|
||||
writeCh := make(chan wsMessage, 16)
|
||||
w.writeCh = writeCh
|
||||
defer func() { w.writeCh = nil }()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -253,21 +275,17 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
return
|
||||
}
|
||||
nlog.Core().Debug("ws recv", "event", msg.Event, "data", string(msg.Data))
|
||||
w.handleMessage(msg)
|
||||
if msg.Event == "ping" {
|
||||
select {
|
||||
case msgCh <- msg:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case writeCh <- wsMessage{Event: "pong"}:
|
||||
default:
|
||||
nlog.Core().Warn("ws: message channel full, dropping message", "event", msg.Event)
|
||||
nlog.Core().Warn("ws write channel full, skipping pong")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// writeCh decouples data collection from network I/O.
|
||||
writeCh := make(chan wsMessage, 16)
|
||||
w.writeCh = writeCh
|
||||
defer func() { w.writeCh = nil }()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -280,17 +298,6 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("read: %w", err)
|
||||
|
||||
case msg := <-msgCh:
|
||||
w.handleMessage(msg)
|
||||
if msg.Event == "ping" {
|
||||
// Queue an immediate pong
|
||||
select {
|
||||
case writeCh <- wsMessage{Event: "pong"}:
|
||||
default:
|
||||
nlog.Core().Warn("ws write channel full, skipping pong")
|
||||
}
|
||||
}
|
||||
|
||||
case <-reportTicker.C:
|
||||
// Send periodic node.status via WebSocket
|
||||
if w.onPing != nil {
|
||||
@@ -339,6 +346,9 @@ func (w *WSClient) handleMessage(msg wsMessage) {
|
||||
case WSEventSyncDevices:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
case WSEventSyncNodes:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
default:
|
||||
nlog.Core().Debug("ws unknown event", "event", msg.Event)
|
||||
}
|
||||
@@ -370,6 +380,11 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
return
|
||||
}
|
||||
event.Config = &p.Config
|
||||
if p.NodeID > 0 {
|
||||
event.NodeID = p.NodeID
|
||||
} else if p.Config.NodeID > 0 {
|
||||
event.NodeID = p.Config.NodeID
|
||||
}
|
||||
|
||||
case WSEventSyncUsers:
|
||||
nlog.Core().Debug("ws sync users event received")
|
||||
@@ -383,6 +398,7 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
return
|
||||
}
|
||||
event.Users = p.Users
|
||||
event.NodeID = p.NodeID
|
||||
|
||||
case WSEventSyncUserDelta:
|
||||
nlog.Core().Debug("ws sync user delta event received")
|
||||
@@ -401,6 +417,7 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
}
|
||||
event.DeltaAction = p.Action
|
||||
event.DeltaUsers = p.Users
|
||||
event.NodeID = p.NodeID
|
||||
|
||||
case WSEventSyncDevices:
|
||||
nlog.Core().Debug("ws sync devices event received")
|
||||
@@ -410,27 +427,49 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
return
|
||||
}
|
||||
event.DeviceUsers = p.Users
|
||||
event.NodeID = p.NodeID
|
||||
|
||||
case WSEventSyncNodes:
|
||||
nlog.Core().Info("ws sync nodes event received (machine node list changed)")
|
||||
var p syncNodesPayload
|
||||
if err := decodeData(msg.Data, &p); err != nil {
|
||||
nlog.Core().Warn("ws: cannot decode nodes payload", "error", err)
|
||||
return
|
||||
}
|
||||
event.Nodes = p.Nodes
|
||||
}
|
||||
|
||||
w.onEvent(event)
|
||||
}
|
||||
|
||||
// SendDeviceReport sends local device snapshot to panel via WS.
|
||||
// Format: {"123": ["1.1.1.1", "2.2.2.2"], "456": ["3.3.3.3"]}
|
||||
func (w *WSClient) SendDeviceReport(devices map[int][]string) {
|
||||
w.SendDeviceReportForNode(0, devices)
|
||||
}
|
||||
|
||||
// SendDeviceReportForNode sends a device report tagged with a specific node_id.
|
||||
// nodeID == 0 omits the field (legacy single-node mode).
|
||||
func (w *WSClient) SendDeviceReportForNode(nodeID int, devices map[int][]string) {
|
||||
if !w.connected.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
msg := wsMessage{
|
||||
Event: WSEventReportDevices,
|
||||
Timestamp: time.Now().Unix(),
|
||||
var payload interface{}
|
||||
if nodeID > 0 {
|
||||
payload = map[string]interface{}{"node_id": nodeID, "devices": devices}
|
||||
} else {
|
||||
payload = devices
|
||||
}
|
||||
d, err := json.Marshal(devices)
|
||||
|
||||
d, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg.Data = d
|
||||
msg := wsMessage{
|
||||
Event: WSEventReportDevices,
|
||||
Data: d,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
select {
|
||||
case w.writeCh <- msg:
|
||||
@@ -439,6 +478,27 @@ func (w *WSClient) SendDeviceReport(devices map[int][]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SendNodeStatus sends a node.status event with the given node_id (machine mode).
|
||||
func (w *WSClient) SendNodeStatus(nodeID int, stats map[string]interface{}) {
|
||||
if !w.connected.Load() || stats == nil {
|
||||
return
|
||||
}
|
||||
if nodeID > 0 {
|
||||
stats["node_id"] = nodeID
|
||||
}
|
||||
data, _ := json.Marshal(stats)
|
||||
msg := wsMessage{
|
||||
Event: "node.status",
|
||||
Data: data,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
select {
|
||||
case w.writeCh <- msg:
|
||||
default:
|
||||
nlog.Core().Warn("ws write channel full, skipping node status")
|
||||
}
|
||||
}
|
||||
|
||||
// SendRaw sends a raw message to the panel via WebSocket.
|
||||
func (w *WSClient) SendRaw(event string, data json.RawMessage) {
|
||||
if !w.connected.Load() {
|
||||
|
||||
+233
-19
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -68,6 +69,9 @@ type Service struct {
|
||||
wsStatusCh chan controlplane.StatusChange // receives push connectivity notifications
|
||||
wsCancel context.CancelFunc // cancels the WS client goroutine
|
||||
wsDisconnectAt time.Time // when WS last disconnected (zero if connected)
|
||||
wsResyncPending atomic.Bool
|
||||
machineMailbox *controlplane.NodeMailbox
|
||||
machineMailboxCh <-chan struct{}
|
||||
|
||||
// metricsMu: lastUsers, lastConfig, wsClient, wsDisconnectAt (buildMetrics vs main loop).
|
||||
metricsMu sync.RWMutex
|
||||
@@ -115,6 +119,23 @@ func (b *apiBackoff) onFailure() {
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Service {
|
||||
var cp controlplane.ControlPlane
|
||||
if cfg.IsStandalone() {
|
||||
cp = controlplane.NewLocalControlPlane(cfg)
|
||||
} else {
|
||||
cp = controlplane.NewPanelControlPlane(cfg.Panel, cfg.WS, cfg.Kernel)
|
||||
}
|
||||
return newService(cfg, cp)
|
||||
}
|
||||
|
||||
// NewWithControlPlane creates a Service with an externally-provided
|
||||
// ControlPlane. Used by the machine orchestrator to inject a
|
||||
// MachinePanelControlPlane with WS mux routing.
|
||||
func NewWithControlPlane(cfg *config.Config, cp controlplane.ControlPlane) *Service {
|
||||
return newService(cfg, cp)
|
||||
}
|
||||
|
||||
func newService(cfg *config.Config, cp controlplane.ControlPlane) *Service {
|
||||
certMgr := cert.NewManager(cfg.Cert)
|
||||
|
||||
var k kernel.Kernel
|
||||
@@ -124,20 +145,13 @@ func New(cfg *config.Config) *Service {
|
||||
case "xray":
|
||||
k = xray.New(cfg.Kernel)
|
||||
default:
|
||||
nlog.Core().Error("unsupported kernel type, defaulting to sing-box", "type", cfg.Kernel.Type)
|
||||
nlog.Core().Warn("unsupported kernel type, defaulting to sing-box", "type", cfg.Kernel.Type)
|
||||
k = singbox.New(cfg.Kernel)
|
||||
}
|
||||
|
||||
l := limiter.New()
|
||||
st := limiter.NewSpeedTracker(l)
|
||||
|
||||
var cp controlplane.ControlPlane
|
||||
if cfg.IsStandalone() {
|
||||
cp = controlplane.NewLocalControlPlane(cfg)
|
||||
} else {
|
||||
cp = controlplane.NewPanelControlPlane(cfg.Panel, cfg.WS)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
source: cp,
|
||||
@@ -166,7 +180,6 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
}
|
||||
defer s.kernel.Stop()
|
||||
|
||||
|
||||
// Set up tickers
|
||||
trackTicker := time.NewTicker(time.Duration(s.cfg.Node.TrackInterval) * time.Second)
|
||||
pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second
|
||||
@@ -221,6 +234,9 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
case status := <-s.wsStatusCh:
|
||||
s.handleWSStatus(ctx, status)
|
||||
|
||||
case <-s.machineMailboxCh:
|
||||
s.drainMachineMailbox(ctx)
|
||||
|
||||
case event := <-s.wsEvents:
|
||||
s.handleWSEvent(ctx, event)
|
||||
}
|
||||
@@ -232,7 +248,6 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
||||
s.kernel.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
|
||||
s.kernel.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID)
|
||||
|
||||
|
||||
bootstrap, err := s.source.Initial(ctx, s.wsMetrics, s.wsEvents, s.wsStatusCh)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -258,11 +273,23 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
||||
|
||||
if bootstrap.Push != nil {
|
||||
s.wsClient = bootstrap.Push
|
||||
return nil
|
||||
}
|
||||
s.machineMailbox = bootstrap.Mailbox
|
||||
if s.machineMailbox != nil {
|
||||
s.machineMailboxCh = s.machineMailbox.NotifyCh()
|
||||
}
|
||||
if bootstrap.Config == nil {
|
||||
if bootstrap.Push != nil {
|
||||
// In machine mode a shared WS client may be available before the first
|
||||
// per-node snapshot arrives. In that case we wait for subsequent WS/REST
|
||||
// updates instead of failing startup.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("initial config is nil")
|
||||
}
|
||||
if err := validateNodeRuntime(s.cfg, s.kernel.Protocols(), bootstrap.Config, s.cert.TLSCert()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.metricsMu.Lock()
|
||||
s.lastConfig = bootstrap.Config
|
||||
@@ -278,6 +305,7 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
||||
|
||||
if len(bootstrap.Users) == 0 {
|
||||
nlog.Core().Warn("no users, kernel will not start until users are available")
|
||||
s.markMailboxReadyAndDrain(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,6 +313,7 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
||||
if !s.startKernel(bootstrap.Config, bootstrap.Users) {
|
||||
return fmt.Errorf("start kernel")
|
||||
}
|
||||
s.markMailboxReadyAndDrain(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -334,7 +363,7 @@ func (s *Service) applyNodeCert(ctx context.Context, newCfg *config.CertConfig)
|
||||
}
|
||||
s.cfg.Cert = cfgCopy
|
||||
if changed {
|
||||
msg := fmt.Sprintf("cert: paths updated cert=%s key=%s", s.cert.CertFile(), s.cert.KeyFile())
|
||||
msg := fmt.Sprintf("cert: material updated, has_cert=%v", s.cert.HasCert())
|
||||
if s.nodeLog != nil {
|
||||
s.nodeLog.Info(msg)
|
||||
} else {
|
||||
@@ -354,6 +383,52 @@ func (s *Service) startWSClient(ctx context.Context) {
|
||||
go s.wsClient.Run(wsCtx)
|
||||
}
|
||||
|
||||
func (s *Service) markMailboxReadyAndDrain(ctx context.Context) {
|
||||
if s.machineMailbox == nil {
|
||||
return
|
||||
}
|
||||
// Seed mailbox with bootstrap state so delta events can be applied
|
||||
// incrementally instead of always triggering REST reconciliation.
|
||||
s.metricsMu.RLock()
|
||||
users := s.lastUsers
|
||||
config := s.lastConfig
|
||||
s.metricsMu.RUnlock()
|
||||
s.machineMailbox.SeedBaseline(users, config)
|
||||
s.machineMailbox.MarkReady()
|
||||
s.drainMachineMailbox(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) drainMachineMailbox(ctx context.Context) {
|
||||
if s.machineMailbox == nil {
|
||||
return
|
||||
}
|
||||
state := s.machineMailbox.DrainIfReady()
|
||||
if state.HasConfig {
|
||||
s.handleWSEvent(ctx, controlplane.Event{Type: controlplane.EventSyncConfig, Config: state.Config})
|
||||
}
|
||||
if state.HasUsers {
|
||||
s.handleWSEvent(ctx, controlplane.Event{Type: controlplane.EventSyncUsers, Users: state.Users})
|
||||
}
|
||||
if state.HasDevices {
|
||||
s.handleWSEvent(ctx, controlplane.Event{Type: controlplane.EventSyncDevices, DeviceUsers: state.DeviceUsers})
|
||||
}
|
||||
if state.NeedsReconcile {
|
||||
s.requestWSResync(ctx, "machine_mailbox_reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) requestWSResync(ctx context.Context, reason string) {
|
||||
if !s.wsResyncPending.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
if s.nodeLog != nil {
|
||||
s.nodeLog.Warn("ws state may be stale, scheduling REST reconciliation", "reason", reason)
|
||||
} else {
|
||||
nlog.Core().Warn("ws state may be stale, scheduling REST reconciliation", "reason", reason)
|
||||
}
|
||||
s.pullViaAPIAsync(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) wsMetrics() map[string]interface{} {
|
||||
status := monitor.Collect()
|
||||
m := s.buildMetrics(status)
|
||||
@@ -363,10 +438,12 @@ func (s *Service) wsMetrics() map[string]interface{} {
|
||||
|
||||
// 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 controlplane.StatusChange) {
|
||||
if status.NeedsResync {
|
||||
s.requestWSResync(ctx, "drop_detected")
|
||||
}
|
||||
if status.Connected {
|
||||
s.metricsMu.Lock()
|
||||
s.wsDisconnectAt = time.Time{}
|
||||
@@ -465,6 +542,10 @@ func (s *Service) handleWSEvent(ctx context.Context, event controlplane.Event) {
|
||||
if newConfigHash == s.lastConfigHash {
|
||||
return
|
||||
}
|
||||
if err := validateNodeRuntime(s.cfg, s.kernel.Protocols(), event.Config, s.cert.TLSCert()); err != nil {
|
||||
nlog.Core().Warn("ws config validation failed, ignoring update", "error", err)
|
||||
return
|
||||
}
|
||||
// Initialize nodeLog on first config
|
||||
if s.nodeLog == nil {
|
||||
s.nodeLog = nlog.ForNode(event.Config.Protocol, event.Config.ServerPort)
|
||||
@@ -561,6 +642,7 @@ func (s *Service) pullViaAPIAsync(ctx context.Context) {
|
||||
|
||||
// applyPullResult processes the result of an async pullViaAPI on the main goroutine.
|
||||
func (s *Service) applyPullResult(ctx context.Context, result pullResult) {
|
||||
s.wsResyncPending.Store(false)
|
||||
configChanged := false
|
||||
|
||||
if result.certChanged {
|
||||
@@ -569,6 +651,10 @@ func (s *Service) applyPullResult(ctx context.Context, result pullResult) {
|
||||
}
|
||||
|
||||
if result.config != nil {
|
||||
if err := validateNodeRuntime(s.cfg, s.kernel.Protocols(), result.config, s.cert.TLSCert()); err != nil {
|
||||
nlog.Core().Warn("runtime config validation failed", "error", err)
|
||||
result.config = nil
|
||||
} else {
|
||||
configChanged = true
|
||||
// Initialize or update node logger
|
||||
if s.nodeLog == nil {
|
||||
@@ -583,6 +669,7 @@ func (s *Service) applyPullResult(ctx context.Context, result pullResult) {
|
||||
configChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.users != nil {
|
||||
usersChanged := result.userHash != s.lastUserHash
|
||||
@@ -643,7 +730,7 @@ func (s *Service) restoreUserState(users []model.UserSpec, hash string) {
|
||||
// startKernel starts (or restarts) the kernel with the given config/users and
|
||||
// records the successfully applied state. Returns false on error.
|
||||
func (s *Service) startKernel(nc *model.NodeSpec, users []model.UserSpec) bool {
|
||||
if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
||||
if err := s.kernel.Start(nc, users, s.cert.TLSCert()); err != nil {
|
||||
nlog.Core().Error("failed to start kernel", "error", err)
|
||||
return false
|
||||
}
|
||||
@@ -833,7 +920,7 @@ func (s *Service) applyChanges(ctx context.Context, configChanged, usersChanged
|
||||
// If config changed, delegate to kernel.Reload. The kernel implementation
|
||||
// decides whether to hot-swap users, reconstruct inbounds, or restart itself.
|
||||
if configChanged && s.kernel.IsRunning() {
|
||||
if err := s.kernel.Reload(s.lastConfig, s.lastUsers, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
||||
if err := s.kernel.Reload(s.lastConfig, s.lastUsers, s.cert.TLSCert()); err != nil {
|
||||
nlog.Core().Warn(fmt.Sprintf("reload failed, restarting: %v", err))
|
||||
s.startKernel(s.lastConfig, s.lastUsers)
|
||||
} else {
|
||||
@@ -897,9 +984,13 @@ func (s *Service) pushReportAsync() {
|
||||
go func() {
|
||||
defer s.pushActive.Store(false)
|
||||
if err := s.sink.Report(controlplane.ReportPayload{Traffic: traffic, Alive: aliveIPs, Online: online, CPU: status.CPU, Mem: [2]uint64{status.MemTotal, status.MemUsed}, Swap: [2]uint64{status.SwapTotal, status.SwapUsed}, Disk: [2]uint64{status.DiskTotal, status.DiskUsed}, Metrics: metrics}); err != nil {
|
||||
nlog.Core().Error("failed to push report", "error", err)
|
||||
if len(traffic) > 0 { s.tracker.RestoreTraffic(traffic) }
|
||||
if len(aliveIPs) > 0 { s.tracker.RestoreAliveIPs(aliveIPs) }
|
||||
nlog.Core().Warn("failed to push report", "error", err)
|
||||
if len(traffic) > 0 {
|
||||
s.tracker.RestoreTraffic(traffic)
|
||||
}
|
||||
if len(aliveIPs) > 0 {
|
||||
s.tracker.RestoreAliveIPs(aliveIPs)
|
||||
}
|
||||
s.pushBackoff.onFailure()
|
||||
return
|
||||
}
|
||||
@@ -921,7 +1012,7 @@ func (s *Service) pushReportSync() {
|
||||
metrics["kernel_status"] = s.kernel.IsRunning()
|
||||
|
||||
if err := s.sink.Report(controlplane.ReportPayload{Traffic: traffic, Alive: aliveIPs, Online: online, CPU: status.CPU, Mem: [2]uint64{status.MemTotal, status.MemUsed}, Swap: [2]uint64{status.SwapTotal, status.SwapUsed}, Disk: [2]uint64{status.DiskTotal, status.DiskUsed}, Metrics: metrics}); err != nil {
|
||||
nlog.Core().Error("failed to push final report", "error", err)
|
||||
nlog.Core().Warn("failed to push final report", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1056,3 +1147,126 @@ func (s *Service) sendDeviceBatch() {
|
||||
func (s *Service) reportDevices() {
|
||||
s.sendDeviceBatch()
|
||||
}
|
||||
|
||||
// ─── Runtime validation ─────────────────────────────────────────────────
|
||||
|
||||
func validateNodeRuntime(cfg *config.Config, kcfgSupported []string, spec *model.NodeSpec, tls kernel.TLSCert) error {
|
||||
if spec == nil {
|
||||
return fmt.Errorf("node spec is nil")
|
||||
}
|
||||
if !containsString(kcfgSupported, spec.Protocol) {
|
||||
return fmt.Errorf("protocol %q is not supported by kernel %q", spec.Protocol, cfg.Kernel.Type)
|
||||
}
|
||||
if err := validateTLSRequirements(spec, tls, cfgKernelType(cfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRuntimeCertConfig(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTLSRequirements(spec *model.NodeSpec, tls kernel.TLSCert, kernelType string) error {
|
||||
needsCert := false
|
||||
switch spec.Protocol {
|
||||
case "hysteria", "hysteria2", "tuic", "anytls":
|
||||
needsCert = true
|
||||
case "trojan":
|
||||
if spec.TLS != 2 {
|
||||
needsCert = true
|
||||
}
|
||||
}
|
||||
if needsCert && !hasUsableTLSConfig(spec, tls) {
|
||||
return fmt.Errorf("protocol %q requires TLS certificate files", spec.Protocol)
|
||||
}
|
||||
if spec.TLS == 2 {
|
||||
if err := validateRealityRequirements(spec, kernelType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasUsableTLSConfig(spec *model.NodeSpec, tls kernel.TLSCert) bool {
|
||||
if tls.HasCert() {
|
||||
return true
|
||||
}
|
||||
if spec == nil || spec.CertConfig == nil {
|
||||
return false
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(spec.CertConfig.CertMode))
|
||||
switch mode {
|
||||
case "self":
|
||||
return true
|
||||
case "content":
|
||||
return strings.TrimSpace(spec.CertConfig.CertContent) != "" && strings.TrimSpace(spec.CertConfig.KeyContent) != ""
|
||||
case "file":
|
||||
return strings.TrimSpace(spec.CertConfig.CertFile) != "" && strings.TrimSpace(spec.CertConfig.KeyFile) != ""
|
||||
case "http":
|
||||
return strings.TrimSpace(spec.CertConfig.Domain) != ""
|
||||
case "dns":
|
||||
return strings.TrimSpace(spec.CertConfig.Domain) != "" && strings.TrimSpace(spec.CertConfig.DNSProvider) != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateRuntimeCertConfig(spec *model.NodeSpec) error {
|
||||
if spec == nil || spec.CertConfig == nil {
|
||||
return nil
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(spec.CertConfig.CertMode))
|
||||
if mode != "dns" {
|
||||
return nil
|
||||
}
|
||||
provider := strings.ToLower(strings.TrimSpace(spec.CertConfig.DNSProvider))
|
||||
switch provider {
|
||||
case "cloudflare", "cf", "alidns", "aliyun":
|
||||
return nil
|
||||
case "":
|
||||
return fmt.Errorf("dns cert mode requires cert_config.dns_provider")
|
||||
default:
|
||||
return fmt.Errorf("unsupported cert_config.dns_provider %q (supported: cloudflare, alidns)", spec.CertConfig.DNSProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func validateRealityRequirements(spec *model.NodeSpec, _ string) error {
|
||||
if spec.TLSSettings == nil {
|
||||
return fmt.Errorf("reality tls requires tls_settings")
|
||||
}
|
||||
privateKey := strings.TrimSpace(stringValue(spec.TLSSettings["private_key"]))
|
||||
serverName := strings.TrimSpace(stringValue(spec.TLSSettings["server_name"]))
|
||||
dest := strings.TrimSpace(stringValue(spec.TLSSettings["dest"]))
|
||||
if privateKey == "" {
|
||||
return fmt.Errorf("reality tls requires tls_settings.private_key")
|
||||
}
|
||||
if serverName == "" && dest == "" {
|
||||
return fmt.Errorf("reality tls requires tls_settings.server_name or tls_settings.dest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cfgKernelType(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(cfg.Kernel.Type))
|
||||
}
|
||||
|
||||
func containsString(items []string, target string) bool {
|
||||
for _, item := range items {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ type fakeKernel struct {
|
||||
func (f *fakeKernel) Name() string { return "fake" }
|
||||
func (f *fakeKernel) Protocols() []string { return []string{"vless"} }
|
||||
func (f *fakeKernel) Capabilities() kernel.Capabilities { return kernel.Capabilities{} }
|
||||
func (f *fakeKernel) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
_, _, _, _ = nodeConfig, users, certFile, keyFile
|
||||
func (f *fakeKernel) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
_, _, _ = nodeConfig, users, tls
|
||||
f.startCalls++
|
||||
if f.startErr != nil {
|
||||
return f.startErr
|
||||
@@ -47,8 +47,8 @@ func (f *fakeKernel) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, c
|
||||
}
|
||||
func (f *fakeKernel) Stop() { f.running = false }
|
||||
func (f *fakeKernel) IsRunning() bool { return f.running }
|
||||
func (f *fakeKernel) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error {
|
||||
_, _, _, _ = nodeConfig, users, certFile, keyFile
|
||||
func (f *fakeKernel) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, tls kernel.TLSCert) error {
|
||||
_, _, _ = nodeConfig, users, tls
|
||||
return nil
|
||||
}
|
||||
func (f *fakeKernel) AddUsers(users []model.UserSpec) (int, error) {
|
||||
@@ -96,15 +96,13 @@ func (f *fakeKernel) UpdateGlobalDevices(users map[int][]string) { _ = users }
|
||||
func (f *fakeKernel) ClearGlobalDevices() {}
|
||||
|
||||
func newTestService(k *fakeKernel) *Service {
|
||||
sharedLimiter := limiter.New()
|
||||
s := &Service{
|
||||
kernel: k,
|
||||
limiter: limiter.New(),
|
||||
speedTracker: limiter.NewSpeedTracker(limiter.New()),
|
||||
limiter: sharedLimiter,
|
||||
speedTracker: limiter.NewSpeedTracker(sharedLimiter),
|
||||
cert: cert.NewManager(config.CertConfig{}),
|
||||
}
|
||||
// Use a shared limiter instance for device + speed bookkeeping.
|
||||
s.limiter = limiter.New()
|
||||
s.speedTracker = limiter.NewSpeedTracker(s.limiter)
|
||||
k.SetSpeedLimitFunc(s.speedTracker.GetLimiter)
|
||||
k.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID)
|
||||
return s
|
||||
@@ -200,42 +198,100 @@ func TestApplyUserDeltaAddPreparesLimiterBeforeKernelUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
|
||||
func TestApplyUserDeltaAddRemovesOldUUIDBeforeAdd(t *testing.T) {
|
||||
k := &fakeKernel{running: true}
|
||||
s := newTestService(k)
|
||||
s.lastConfig = &model.NodeSpec{Protocol: "vless"}
|
||||
oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}}
|
||||
s.updateUserState(oldUsers)
|
||||
|
||||
delta := []model.UserSpec{{ID: 1, UUID: "uuid-new", SpeedLimit: 8}}
|
||||
removalsBeforeAdd := 0
|
||||
k.onRemoveUsers = func(users []model.UserSpec) {
|
||||
removalsBeforeAdd = k.addCalls
|
||||
if len(users) != 1 || users[0].UUID != "uuid-old" {
|
||||
t.Fatalf("unexpected users passed to RemoveUsers: %#v", users)
|
||||
func TestValidateNodeRuntimeRejectsUnsupportedDNSProvider(t *testing.T) {
|
||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
||||
err := validateNodeRuntime(cfg, []string{"http"}, &model.NodeSpec{
|
||||
Protocol: "http",
|
||||
CertConfig: &config.CertConfig{
|
||||
CertMode: "dns",
|
||||
DNSProvider: "3123123",
|
||||
Domain: "example.com",
|
||||
},
|
||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Fatal("expected non-empty error")
|
||||
}
|
||||
k.onAddUsers = func(users []model.UserSpec) {
|
||||
if len(users) != 1 || users[0].UUID != "uuid-new" {
|
||||
t.Fatalf("unexpected users passed to AddUsers: %#v", users)
|
||||
}
|
||||
if k.removeCalls != 1 || removalsBeforeAdd != 0 {
|
||||
t.Fatalf("expected RemoveUsers before AddUsers, removeCalls=%d removalsBeforeAdd=%d", k.removeCalls, removalsBeforeAdd)
|
||||
}
|
||||
if got := k.speedLimitFunc("uuid-new"); got == nil {
|
||||
t.Fatal("expected replacement user's limiter to be visible before kernel AddUsers")
|
||||
}
|
||||
}
|
||||
|
||||
s.applyUserDelta(context.Background(), "add", delta)
|
||||
|
||||
if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" {
|
||||
t.Fatalf("lastUsers = %#v, want replacement user", s.lastUsers)
|
||||
}
|
||||
if s.speedTracker.GetLimiter("uuid-old") != nil {
|
||||
t.Fatal("expected old UUID limiter to be removed after replacement")
|
||||
}
|
||||
if s.speedTracker.GetLimiter("uuid-new") == nil {
|
||||
t.Fatal("expected new UUID limiter to be installed after replacement")
|
||||
if got := err.Error(); got != "unsupported cert_config.dns_provider \"3123123\" (supported: cloudflare, alidns)" {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeRuntimeAllowsSelfManagedTLSBeforeFilesExist(t *testing.T) {
|
||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
||||
err := validateNodeRuntime(cfg, []string{"anytls", "hysteria"}, &model.NodeSpec{
|
||||
Protocol: "anytls",
|
||||
CertConfig: &config.CertConfig{
|
||||
CertMode: "self",
|
||||
Domain: "example.com",
|
||||
},
|
||||
}, kernel.TLSCert{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected self-managed TLS config to pass validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeRuntimeAllowsSingboxRealityWithRequiredFields(t *testing.T) {
|
||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
||||
Protocol: "vless",
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]any{
|
||||
"private_key": "test-key",
|
||||
"server_name": "example.com",
|
||||
},
|
||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
if err != nil {
|
||||
t.Fatalf("expected sing-box reality validation to pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeRuntimeRejectsRealityWithoutTLSSettings(t *testing.T) {
|
||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
||||
Protocol: "vless",
|
||||
TLS: 2,
|
||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); got != "reality tls requires tls_settings" {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeRuntimeRejectsRealityWithoutPrivateKey(t *testing.T) {
|
||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
||||
Protocol: "vless",
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]any{
|
||||
"server_name": "example.com",
|
||||
},
|
||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); got != "reality tls requires tls_settings.private_key" {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeRuntimeRejectsRealityWithoutServerNameOrDest(t *testing.T) {
|
||||
cfg := &config.Config{Kernel: config.KernelConfig{Type: "singbox"}}
|
||||
err := validateNodeRuntime(cfg, []string{"vless"}, &model.NodeSpec{
|
||||
Protocol: "vless",
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]any{
|
||||
"private_key": "test-key",
|
||||
},
|
||||
}, kernel.TLSCert{CertPEM: []byte("CERT"), KeyPEM: []byte("KEY")})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); got != "reality tls requires tls_settings.server_name or tls_settings.dest" {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user