diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7ad4dd..ce917cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index f205547..f461857 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,11 @@ vendor/ config.json benchmark/ third_party -bench-results \ No newline at end of file +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 \ No newline at end of file diff --git a/Makefile b/Makefile index 7b3f828..2f5c9ca 100644 --- a/Makefile +++ b/Makefile @@ -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; \ diff --git a/README.md b/README.md index 8292a98..c0dbbb1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/xbctl/main.go b/cmd/xbctl/main.go new file mode 100644 index 0000000..e5e05a3 --- /dev/null +++ b/cmd/xbctl/main.go @@ -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 [--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 [--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 ") + } + 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 ...") + } + 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 +} diff --git a/cmd/xboard-node/main.go b/cmd/xboard-node/main.go index 113a7f5..152c91e 100644 --- a/cmd/xboard-node/main.go +++ b/cmd/xboard-node/main.go @@ -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) - errCh <- err - cancel() + 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") } } diff --git a/config.yml.example b/config.yml.example index 4d89875..030bb65 100644 --- a/config.yml.example +++ b/config.yml.example @@ -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 -# 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 +# ── Multiple instances ── +# kernel: +# type: "singbox" +# log: +# level: "info" +# instances: +# - panel: +# url: "https://panel.example.com" +# token: "api-key-1" +# node_id: 1 +# - panel: +# url: "https://panel.example.com" +# machine: +# machine_id: 2 +# token: "machine-token-2" +# log: +# level: "debug" # override for this instance only diff --git a/docs-custom-outbounds.md b/docs-custom-outbounds.md new file mode 100644 index 0000000..a1c54bc --- /dev/null +++ b/docs-custom-outbounds.md @@ -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 diff --git a/docs-custom-routes.md b/docs-custom-routes.md new file mode 100644 index 0000000..8f4f9df --- /dev/null +++ b/docs-custom-routes.md @@ -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) diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 7d4cd83..2639b32 --- a/install.sh +++ b/install.sh @@ -1,22 +1,5 @@ -#!/bin/bash -set -e - -# xboard-node Multi-Node Deploy Script -# Supports: Ubuntu 20+, Debian 11+, CentOS 8+, Alpine 3.18+ -# -# One-command deploy (non-interactive): -# bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 1 -# bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 2 -k xray -# bash install.sh -a https://panel.example.com -t YOUR_TOKEN -n 3 --docker -# -# Interactive: -# bash install.sh -# -# Management: -# bash install.sh list -# bash install.sh remove -# bash install.sh update -# bash install.sh uninstall +#!/usr/bin/env bash +set -Eeuo pipefail RED='\033[0;31m' GREEN='\033[0;32m' @@ -25,99 +8,338 @@ CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' -INSTALL_DIR="/usr/local/bin" -CONFIG_DIR="/etc/xboard-node" -SERVICE_TEMPLATE="xboard-node@.service" -DOCKER_COMPOSE_FILE="${CONFIG_DIR}/docker-compose.yml" -DOCKER_IMAGE="ghcr.io/cedar2025/xboard-node:latest" +APP_NAME="xboard-node" +INSTALL_ROOT="/etc/xboard-node" +BACKUP_DIR="${INSTALL_ROOT}/backups" +INSTALL_META="${INSTALL_ROOT}/install-meta.json" +CONFIG_FILE="${INSTALL_ROOT}/config.yml" +CREDENTIALS_FILE="${INSTALL_ROOT}/credentials.env" +BINARY_PATH="/usr/local/bin/xboard-node" +SERVICE_NAME="xboard-node.service" +SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}" +CLI_PATH="/usr/local/bin/xbctl" +INSTALLER_COPY_PATH="${INSTALL_ROOT}/install.sh" +CLI_BINARY_SOURCE="" +DEFAULT_HEALTH_PORT=65530 +DEFAULT_KERNEL="singbox" +DEFAULT_MODE="node" +DEFAULT_ACTION="install" +DEFAULT_RELEASE_VERSION="latest" +DEFAULT_LOG_LEVEL="info" +DEFAULT_KERNEL_LOG_LEVEL="warn" +DEFAULT_DOWNLOAD_BASE="https://github.com/cedar2025/xboard-node/releases" -# Parsed parameters (populated by parse_args) +ACTION="${DEFAULT_ACTION}" +MODE="" PANEL_URL="" -PANEL_TOKEN="" +TOKEN="" NODE_ID="" NODE_TYPE="" -KERNEL_TYPE="singbox" -DOCKER_MODE=0 -SUBCOMMAND="" +MACHINE_ID="" +KERNEL_TYPE="${DEFAULT_KERNEL}" +RELEASE_VERSION="${DEFAULT_RELEASE_VERSION}" +HEALTH_PORT="${DEFAULT_HEALTH_PORT}" +HEALTH_ENABLED=1 +RUNTIME_GOMEMLIMIT="" +RUNTIME_GOGC="" +BINARY_SOURCE="" +CLI_BINARY_SOURCE="" +FORCE_RECONFIGURE=0 +PURGE=0 +YES=0 +ARCH="" +OS="" +DOWNLOAD_URL="" +CURRENT_STATE="fresh" +TMP_DIR="" +BACKUP_PATH="" +SERVICE_EXISTED=0 +CLEANUP_DONE=0 log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_step() { echo -e "${CYAN}[STEP]${NC} ${BOLD}$1${NC}"; } -# ─── Argument Parsing ───────────────────────────────────────────────── +cleanup_tmp() { + if [ "$CLEANUP_DONE" -eq 1 ]; then + return + fi + CLEANUP_DONE=1 + if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then + rm -rf "$TMP_DIR" + fi +} + +load_health_port_from_config() { + local cfg_path="$1" + if [ ! -f "$cfg_path" ]; then + return + fi + local parsed + parsed=$(python3 - "$cfg_path" <<'PYCFG' +import sys +try: + import yaml +except Exception: + sys.exit(0) +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) or {} +port = data.get('health_port', 0) +if isinstance(port, int): + print(port) +PYCFG +) + if [ -n "$parsed" ] && [[ "$parsed" =~ ^[0-9]+$ ]]; then + HEALTH_PORT="$parsed" + if [ "$HEALTH_PORT" -eq 0 ]; then + HEALTH_ENABLED=0 + else + HEALTH_ENABLED=1 + fi + fi +} + +rollback_install() { + log_warn "Rolling back installation" + if [ -n "$BACKUP_PATH" ] && [ -d "$BACKUP_PATH" ]; then + if [ -f "$BACKUP_PATH/xboard-node" ]; then + install -m 755 "$BACKUP_PATH/xboard-node" "$BINARY_PATH" + else + rm -f "$BINARY_PATH" + fi + if [ -f "$BACKUP_PATH/config.yml" ]; then + install -m 600 "$BACKUP_PATH/config.yml" "$CONFIG_FILE" + else + rm -f "$CONFIG_FILE" + fi + if [ -f "$BACKUP_PATH/credentials.env" ]; then + install -m 600 "$BACKUP_PATH/credentials.env" "$CREDENTIALS_FILE" + else + rm -f "$CREDENTIALS_FILE" + fi + if [ -f "$BACKUP_PATH/install-meta.json" ]; then + install -m 644 "$BACKUP_PATH/install-meta.json" "$INSTALL_META" + else + rm -f "$INSTALL_META" + fi + if [ -f "$BACKUP_PATH/xbctl" ]; then + install -m 755 "$BACKUP_PATH/xbctl" "$CLI_PATH" + else + rm -f "$CLI_PATH" + fi + if [ -f "$BACKUP_PATH/${SERVICE_NAME}" ]; then + install -m 644 "$BACKUP_PATH/${SERVICE_NAME}" "$SERVICE_PATH" + else + rm -f "$SERVICE_PATH" + fi + fi + load_health_port_from_config "$CONFIG_FILE" + systemctl daemon-reload || true + if [ "$SERVICE_EXISTED" -eq 1 ] || [ -f "$SERVICE_PATH" ]; then + systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true + systemctl restart "$SERVICE_NAME" >/dev/null 2>&1 || true + if ! wait_for_health; then + log_error "Rollback completed but restored service did not become healthy" + show_recent_logs + return 1 + fi + else + systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true + fi + log_warn "Rollback complete" +} + +on_error() { + local exit_code=$? + local line_no=${1:-unknown} + if [ "$exit_code" -ne 0 ]; then + log_error "Install failed at line ${line_no} (exit=${exit_code})" + if [ -n "$BACKUP_PATH" ]; then + rollback_install || true + fi + fi + cleanup_tmp + exit "$exit_code" +} +trap 'on_error $LINENO' ERR +trap cleanup_tmp EXIT + +usage() { + cat <<'HELP' + + xboard-node Installer + + ACTIONS: + install Install or reconcile the configured deployment (default) + upgrade Upgrade binary and restart service + uninstall Remove installed service and binary (config kept unless --purge) + status Show current installation status + help Show this help + + MODES (auto-detected from --node-id or --machine-id if omitted): + --mode node Panel single-node mode (default) + --mode machine Panel machine mode + + REQUIRED FOR NODE MODE: + --panel, -a Panel URL + --token, -t Panel server token + --node-id, -n Node ID + + REQUIRED FOR MACHINE MODE: + --panel, -a Panel URL + --token, -t Machine token + --machine-id Machine ID + + OPTIONAL: + --node-type, -T Explicit node type for node mode + --kernel, -k singbox or xray (default: singbox) + --version Release version or latest (default: latest) + --binary Use a local xboard-node binary path instead of downloading + --xbctl-binary Use a local xbctl binary path instead of downloading + --health-port Local health port (default: 65530, use 0 to disable) + --gomemlimit Runtime GOMEMLIMIT value, e.g. 256MiB + --gogc Runtime GOGC value, e.g. 50 + --force-reconfigure Overwrite an existing install even if mode/target changed + --purge With uninstall, delete /etc/xboard-node too + --yes, -y Non-interactive confirmation for destructive operations + + EXAMPLES: + sudo bash install.sh --panel https://panel.example.com --token TOKEN --node-id 1 + sudo bash install.sh --panel https://panel.example.com --token TOKEN --machine-id 1 + sudo bash install.sh upgrade + sudo bash install.sh uninstall --purge --yes + +HELP +} parse_args() { + local positional=() while [ $# -gt 0 ]; do case "$1" in - -a|--api) PANEL_URL="$2"; shift 2 ;; - -t|--token) PANEL_TOKEN="$2"; shift 2 ;; - -n|--node-id) NODE_ID="$2"; shift 2 ;; - -T|--node-type) NODE_TYPE="$2"; shift 2 ;; - -k|--kernel) KERNEL_TYPE="$2"; shift 2 ;; - --docker) DOCKER_MODE=1; shift ;; - add|remove|list|update|uninstall|help|--help|-h) - if [ -z "$SUBCOMMAND" ]; then - SUBCOMMAND="$1" - fi + install|upgrade|uninstall|status|help) + ACTION="$1" + shift + ;; + --mode) + MODE="$2" + shift 2 + ;; + --panel|-a|--api) + PANEL_URL="$2" + shift 2 + ;; + --token|-t) + TOKEN="$2" + shift 2 + ;; + --node-id|-n) + NODE_ID="$2" + shift 2 + ;; + --node-type|-T) + NODE_TYPE="$2" + shift 2 + ;; + --machine-id) + MACHINE_ID="$2" + shift 2 + ;; + --kernel|-k) + KERNEL_TYPE="$2" + shift 2 + ;; + --version) + RELEASE_VERSION="$2" + shift 2 + ;; + --binary) + BINARY_SOURCE="$2" + shift 2 + ;; + --xbctl-binary) + CLI_BINARY_SOURCE="$2" + shift 2 + ;; + --health-port) + HEALTH_PORT="$2" + shift 2 + ;; + --gomemlimit) + RUNTIME_GOMEMLIMIT="$2" + shift 2 + ;; + --gogc) + RUNTIME_GOGC="$2" + shift 2 + ;; + --force-reconfigure) + FORCE_RECONFIGURE=1 + shift + ;; + --purge) + PURGE=1 + shift + ;; + --yes|-y) + YES=1 + shift + ;; + --help|-h) + ACTION="help" shift ;; *) - if [ "$SUBCOMMAND" = "remove" ] && [ -z "$NODE_ID" ]; then - NODE_ID="$1" - fi + positional+=("$1") shift ;; esac done - # Normalize kernel type + if [ ${#positional[@]} -gt 0 ] && [ "$ACTION" = "install" ]; then + ACTION="${positional[0]}" + fi + case "$KERNEL_TYPE" in + singbox|SingBox|SINGBOX) KERNEL_TYPE="singbox" ;; xray|Xray|XRAY) KERNEL_TYPE="xray" ;; - *) KERNEL_TYPE="singbox" ;; + *) ;; + esac + + # Auto-detect mode from arguments when --mode is not specified. + if [ -z "$MODE" ]; then + if [ -n "$MACHINE_ID" ]; then + MODE="machine" + else + MODE="node" + fi + fi + + case "$MODE" in + node|machine) ;; + *) + log_error "Unsupported mode: $MODE" + usage + exit 1 + ;; esac } -has_all_params() { - [ -n "$PANEL_URL" ] && [ -n "$PANEL_TOKEN" ] && [ -n "$NODE_ID" ] -} - -validate_params() { - if [ -z "$PANEL_URL" ]; then - log_error "Panel URL is required (-a/--api)" - exit 1 - fi - if [ -z "$PANEL_TOKEN" ]; then - log_error "Server Token is required (-t/--token)" - exit 1 - fi - if [ -z "$NODE_ID" ]; then - log_error "Node ID is required (-n/--node-id)" - exit 1 - fi - if ! [[ "$NODE_ID" =~ ^[0-9]+$ ]]; then - log_error "Node ID must be a positive integer, got: $NODE_ID" - exit 1 - fi -} - -# ─── System Detection ──────────────────────────────────────────────── - check_root() { - if [ "$(id -u)" != "0" ]; then - log_error "Please run as root (or with sudo)" + if [ "$(id -u)" -ne 0 ]; then + log_error "Please run as root or with sudo" exit 1 fi } detect_arch() { - ARCH=$(uname -m) - case "$ARCH" in + local raw + raw=$(uname -m) + case "$raw" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; - armv7l) ARCH="armv7" ;; *) - log_error "Unsupported architecture: $ARCH" + log_error "Unsupported architecture: $raw" exit 1 ;; esac @@ -126,607 +348,709 @@ detect_arch() { detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release - OS=$ID - elif [ -f /etc/alpine-release ]; then - OS="alpine" + OS="$ID" else OS="unknown" fi } -install_deps() { +ensure_systemd() { + if ! command -v systemctl >/dev/null 2>&1; then + log_error "systemd is required for this installer" + exit 1 + fi + if [ ! -d /run/systemd/system ]; then + log_error "This host does not appear to be running systemd" + exit 1 + fi +} + +run_with_retry() { + local attempts="$1" + local delay="$2" + shift 2 + local i=1 + while [ "$i" -le "$attempts" ]; do + if "$@"; then + return 0 + fi + if [ "$i" -lt "$attempts" ]; then + log_warn "Command failed, retrying in ${delay}s: $*" + sleep "$delay" + fi + i=$((i + 1)) + done + return 1 +} + +install_dependencies() { case "$OS" in ubuntu|debian) - apt-get update -qq - apt-get install -y -qq wget curl tar >/dev/null 2>&1 + DEBIAN_FRONTEND=noninteractive run_with_retry 10 3 apt-get update -qq + DEBIAN_FRONTEND=noninteractive run_with_retry 10 3 apt-get install -y -qq curl wget ca-certificates >/dev/null 2>&1 ;; centos|rhel|rocky|almalinux|fedora) - yum install -y -q wget curl tar >/dev/null 2>&1 + if command -v dnf >/dev/null 2>&1; then + run_with_retry 5 3 dnf install -y -q curl wget ca-certificates >/dev/null 2>&1 + else + run_with_retry 5 3 yum install -y -q curl wget ca-certificates >/dev/null 2>&1 + fi ;; - alpine) - apk add --no-cache wget curl tar >/dev/null 2>&1 + *) + log_warn "OS ${OS} is not in the official support set; continuing best-effort" ;; esac } -# ─── Binary Install ────────────────────────────────────────────────── - -is_binary_installed() { - [ -x "${INSTALL_DIR}/xboard-node" ] +ensure_dirs() { + mkdir -p "$INSTALL_ROOT" "$BACKUP_DIR" + chmod 700 "$INSTALL_ROOT" } -install_binary() { - if is_binary_installed; then - log_info "xboard-node binary already installed" +validate_positive_int() { + local label="$1" + local value="$2" + if ! [[ "$value" =~ ^[0-9]+$ ]] || [ "$value" -le 0 ]; then + log_error "${label} must be a positive integer, got: ${value}" + exit 1 + fi +} + +validate_install_request() { + if [ -z "$PANEL_URL" ]; then + log_error "Panel URL is required" + exit 1 + fi + if [ -z "$TOKEN" ]; then + log_error "Token is required" + exit 1 + fi + if ! [[ "$HEALTH_PORT" =~ ^[0-9]+$ ]]; then + log_error "health-port must be a non-negative integer" + exit 1 + fi + if [ "$HEALTH_PORT" -eq 0 ]; then + HEALTH_ENABLED=0 + fi + case "$KERNEL_TYPE" in + singbox|xray) ;; + *) + log_error "Kernel must be singbox or xray" + exit 1 + ;; + esac + case "$MODE" in + node) + validate_positive_int "Node ID" "$NODE_ID" + ;; + machine) + validate_positive_int "Machine ID" "$MACHINE_ID" + ;; + esac +} + +json_escape() { + python3 - <<'PY' "$1" +import json, sys +print(json.dumps(sys.argv[1])) +PY +} + +current_mode() { :; } + +current_target() { :; } + +detect_current_state() { + local has_binary=0 has_config=0 has_service=0 + [ -x "$BINARY_PATH" ] && has_binary=1 + [ -f "$CONFIG_FILE" ] && has_config=1 + [ -f "$SERVICE_PATH" ] && has_service=1 + + if [ "$has_binary" -eq 0 ] && [ "$has_config" -eq 0 ] && [ "$has_service" -eq 0 ]; then + CURRENT_STATE="fresh" + elif [ "$has_binary" -eq 1 ] && [ "$has_config" -eq 1 ] && [ "$has_service" -eq 1 ]; then + CURRENT_STATE="installed" + else + CURRENT_STATE="partial" + fi +} + +require_reconfigure_confirmation() { + return +} + +select_binary_source() { + if [ -n "$BINARY_SOURCE" ]; then + if [ ! -f "$BINARY_SOURCE" ]; then + log_error "Binary source not found: $BINARY_SOURCE" + exit 1 + fi + echo "$BINARY_SOURCE" return fi - - log_step "Installing xboard-node binary..." - - local src="" - if [ -f "./xboard-node" ]; then - src="./xboard-node" - elif [ -f "./xboard-node-linux-${ARCH}" ]; then - src="./xboard-node-linux-${ARCH}" + echo "./xboard-node" + return fi + if [ -f "./xboard-node-linux-${ARCH}" ]; then + echo "./xboard-node-linux-${ARCH}" + return + fi + echo "" +} - if [ -n "$src" ]; then - cp "$src" "${INSTALL_DIR}/xboard-node" - log_info "Installed from local file: $src" +resolve_download_url() { + local artifact="$1" + if [ "$RELEASE_VERSION" = "latest" ]; then + DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/latest/download/${artifact}" else - local url="https://github.com/cedar2025/xboard-node/releases/latest/download/xboard-node-linux-${ARCH}" - log_info "Downloading from GitHub releases..." - if wget -q "$url" -O "${INSTALL_DIR}/xboard-node" 2>/dev/null; then - log_info "Downloaded successfully" - elif curl -fsSL "$url" -o "${INSTALL_DIR}/xboard-node" 2>/dev/null; then - log_info "Downloaded successfully" - else - log_error "Failed to download. Place xboard-node binary in current directory and retry." + DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/download/${RELEASE_VERSION}/${artifact}" + fi +} + +stage_binary() { + local staged="$TMP_DIR/xboard-node" + local local_src + local_src=$(select_binary_source) + if [ -n "$local_src" ]; then + log_step "Using local binary: ${local_src}" + cp "$local_src" "$staged" + else + resolve_download_url "xboard-node-linux-${ARCH}" + log_step "Downloading binary: ${DOWNLOAD_URL}" + if ! curl -fsSL "$DOWNLOAD_URL" -o "$staged"; then + log_error "Failed to download binary from ${DOWNLOAD_URL}" exit 1 fi fi - - chmod +x "${INSTALL_DIR}/xboard-node" - log_info "xboard-node installed to ${INSTALL_DIR}/xboard-node" + chmod +x "$staged" + if ! "$staged" -v >/dev/null 2>&1; then + log_error "Downloaded binary failed version check" + exit 1 + fi } -# ─── Systemd Template ──────────────────────────────────────────────── - -install_systemd_template() { - if ! command -v systemctl >/dev/null 2>&1; then - return +stage_xbctl() { + local staged="$TMP_DIR/xbctl" + local local_src="" + if [ -n "$CLI_BINARY_SOURCE" ]; then + if [ ! -f "$CLI_BINARY_SOURCE" ]; then + log_error "xbctl binary source not found: $CLI_BINARY_SOURCE" + exit 1 + fi + local_src="$CLI_BINARY_SOURCE" + elif [ -f "./xbctl" ]; then + local_src="./xbctl" + elif [ -f "./xbctl-linux-${ARCH}" ]; then + local_src="./xbctl-linux-${ARCH}" fi - - if [ -f "/etc/systemd/system/${SERVICE_TEMPLATE}" ]; then - return + if [ -n "$local_src" ]; then + log_step "Using local xbctl binary: ${local_src}" + cp "$local_src" "$staged" + else + resolve_download_url "xbctl-linux-${ARCH}" + log_step "Downloading xbctl: ${DOWNLOAD_URL}" + if ! curl -fsSL "$DOWNLOAD_URL" -o "$staged"; then + log_error "Failed to download xbctl from ${DOWNLOAD_URL}" + exit 1 + fi fi + chmod +x "$staged" + if ! "$staged" version > /dev/null 2>&1; then + log_error "Downloaded xbctl failed version check" + exit 1 + fi +} - log_step "Installing systemd service template..." +render_runtime_block() { + local output="" + if [ -n "$RUNTIME_GOMEMLIMIT" ] || [ -n "$RUNTIME_GOGC" ]; then + output+=$'runtime:\n' + if [ -n "$RUNTIME_GOMEMLIMIT" ]; then + output+=" gomemlimit: \"${RUNTIME_GOMEMLIMIT}\"\n" + fi + if [ -n "$RUNTIME_GOGC" ]; then + output+=" gogc: ${RUNTIME_GOGC}\n" + fi + fi + printf '%b' "$output" +} - cat > "/etc/systemd/system/${SERVICE_TEMPLATE}" << 'UNIT' +render_config() { + INSTANCE_ID=$(python3 - "$MODE" "$PANEL_URL" "$NODE_ID" "$MACHINE_ID" <<'PY_INST' +import hashlib +import sys +from urllib.parse import urlparse +mode, panel_url, node_id, machine_id = sys.argv[1:] +node_id = int(node_id or 0) +machine_id = int(machine_id or 0) +u = urlparse((panel_url or '').strip()) +if not u.scheme or not u.netloc: + raise SystemExit(f"invalid panel url: {panel_url}") +scheme = u.scheme.lower() +host = u.netloc.lower() +path = u.path.rstrip('/') +normalized = f"{scheme}://{host}{path}" +slug_src = f"{host}{('-' + path.strip('/')) if path.strip('/') else ''}".lower() +slug = [] +last_dash = False +for ch in slug_src: + if ch.isalnum(): + slug.append(ch) + last_dash = False + elif not last_dash: + slug.append('-') + last_dash = True +slug = ''.join(slug).strip('-') or 'panel' +slug = (slug[:48].strip('-') or 'panel') +target_value = str(machine_id) if mode == "machine" else str(node_id) +target_label = f"machine-{machine_id}" if mode == "machine" else f"node-{node_id}" +short = hashlib.sha1(f"{normalized}|{mode}|{target_value}".encode()).hexdigest()[:6] +print(f"{slug}-{target_label}-{short}") +PY_INST +) + + python3 - "$CONFIG_FILE" "$TMP_DIR/config.yml" "$MODE" "$PANEL_URL" "$NODE_ID" "$NODE_TYPE" "$MACHINE_ID" "$KERNEL_TYPE" "$HEALTH_PORT" "$RUNTIME_GOMEMLIMIT" "$RUNTIME_GOGC" "$INSTALL_ROOT" "$INSTANCE_ID" <<'PY_CFG' +import os +import sys +from copy import deepcopy +import yaml +config_file, out_file, mode, panel_url, node_id, node_type, machine_id, kernel_type, health_port, gomemlimit, gogc, install_root, instance_id = sys.argv[1:] +node_id = int(node_id or 0) +machine_id = int(machine_id or 0) +health_port = int(health_port or 0) +if os.path.exists(config_file): + with open(config_file, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) or {} +else: + data = {} +legacy_keys = ["panel", "node", "kernel", "cert", "log", "runtime", "ws", "standalone", "health_port", "nodes", "machine"] +instances = data.get("instances") +if not isinstance(instances, list): + instances = [] + if any(k in data for k in legacy_keys): + legacy = {k: deepcopy(data[k]) for k in legacy_keys if k in data} + if legacy: + instances.append(legacy) +for k in legacy_keys: + data.pop(k, None) + +def normalize_existing_id(inst): + panel = inst.get("panel") or {} + machine = inst.get("machine") or {} + panel_url_existing = panel.get("url", "") + from urllib.parse import urlparse + u = urlparse((panel_url_existing or '').strip()) + if not u.scheme or not u.netloc: + return inst.get("id", "") + normalized_existing = f"{u.scheme.lower()}://{u.netloc.lower()}{u.path.rstrip('/')}" + if machine and machine.get("machine_id"): + existing_mode = "machine" + target_value = str(machine.get("machine_id")) + target_label = f"machine-{machine.get('machine_id')}" + else: + existing_mode = "node" + target_value = str(panel.get("node_id", 0)) + target_label = f"node-{panel.get('node_id', 0)}" + slug_src = f"{u.netloc.lower()}{('-' + u.path.rstrip('/').strip('/')) if u.path.rstrip('/').strip('/') else ''}".lower() + slug = [] + last_dash = False + for ch in slug_src: + if ch.isalnum(): + slug.append(ch) + last_dash = False + elif not last_dash: + slug.append('-') + last_dash = True + slug = ''.join(slug).strip('-') or 'panel' + slug = (slug[:48].strip('-') or 'panel') + short = __import__('hashlib').sha1(f"{normalized_existing}|{existing_mode}|{target_value}".encode()).hexdigest()[:6] + return f"{slug}-{target_label}-{short}" + +normalized_instances = [] +seen_ids = set() +for inst in instances: + inst = deepcopy(inst) + normalized_id = normalize_existing_id(inst) + if normalized_id: + inst["id"] = normalized_id + current_id = inst.get("id") + if current_id and current_id in seen_ids: + continue + if current_id: + seen_ids.add(current_id) + normalized_instances.append(inst) +instances = normalized_instances + +env_key = f"INSTANCE_{instance_id.upper().replace('-', '_')}_{'MACHINE_TOKEN' if mode == 'machine' else 'API_KEY'}" +new_inst = { + "id": instance_id, + "panel": {"url": panel_url}, + "kernel": { + "type": kernel_type, + "config_dir": f"{install_root}/instances/{instance_id}", + "log_level": "warn", + }, + "log": {"level": "info", "output": "stdout"}, +} +if health_port > 0: + new_inst["health_port"] = health_port +if gomemlimit or gogc: + new_inst["runtime"] = {} + if gomemlimit: + new_inst["runtime"]["gomemlimit"] = gomemlimit + if gogc: + new_inst["runtime"]["gogc"] = int(gogc) +if mode == 'machine': + new_inst["machine"] = {"machine_id": machine_id, "token_env": env_key} +else: + new_inst["panel"]["node_id"] = node_id + new_inst["panel"]["token_env"] = env_key + if node_type: + new_inst["panel"]["node_type"] = node_type + new_inst["node"] = {"push_interval": 0, "pull_interval": 0} +replaced = False +for i, inst in enumerate(instances): + if inst.get("id") == instance_id: + instances[i] = new_inst + replaced = True + break +if not replaced: + instances.append(new_inst) +data["instances"] = instances +with open(out_file, 'w', encoding='utf-8') as f: + yaml.safe_dump(data, f, sort_keys=False, allow_unicode=False) +PY_CFG + + local env_key + if [ "$MODE" = "machine" ]; then + env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_MACHINE_TOKEN" + else + env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_API_KEY" + fi + python3 - "$CREDENTIALS_FILE" "$TMP_DIR/credentials.env" "$env_key" "$TOKEN" <<'PY_ENV' +import os +import sys +src, out, key, value = sys.argv[1:] +entries = {} +order = [] +if os.path.exists(src): + with open(src, 'r', encoding='utf-8') as f: + for raw in f: + line = raw.rstrip('\n') + if not line or line.startswith('#') or '=' not in line: + continue + k, v = line.split('=', 1) + if k not in entries: + order.append(k) + entries[k] = v +if key not in entries: + order.append(key) +entries[key] = value +with open(out, 'w', encoding='utf-8') as f: + for k in order: + f.write(f"{k}={entries[k]}\n") +PY_ENV + chmod 600 "$TMP_DIR/credentials.env" +} + +render_install_meta() { + python3 - "$TMP_DIR/config.yml" "$TMP_DIR/install-meta.json" "$RELEASE_VERSION" "$INSTANCE_ID" <<'PY_META' +import json +import sys +import yaml +from datetime import datetime, timezone +cfg_path, out_path, version, latest_id = sys.argv[1:] +with open(cfg_path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) or {} +instances = [] +for inst in data.get('instances', []) or []: + item = { + 'id': inst.get('id', ''), + 'panel_url': ((inst.get('panel') or {}).get('url', '')), + 'mode': 'machine' if inst.get('machine') else ('standalone' if inst.get('standalone') else 'node'), + 'node_id': ((inst.get('panel') or {}).get('node_id')), + 'machine_id': ((inst.get('machine') or {}).get('machine_id')), + 'health_port': inst.get('health_port', 0), + } + instances.append(item) +meta = { + 'config_mode': 'instances', + 'version': version, + 'latest_instance_id': latest_id, + 'instance_count': len(instances), + 'instances': instances, + 'updated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), +} +with open(out_path, 'w', encoding='utf-8') as f: + json.dump(meta, f, indent=2) +PY_META +} + +render_service() { + cat >"$TMP_DIR/${SERVICE_NAME}" </dev/null 2>&1; then + systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true + fi +} + +install_staged_files() { + stop_existing_service + install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH" + install -m 600 "$TMP_DIR/config.yml" "$CONFIG_FILE" + install -m 600 "$TMP_DIR/credentials.env" "$CREDENTIALS_FILE" + install -m 644 "$TMP_DIR/install-meta.json" "$INSTALL_META" + if [ "$(realpath "$0")" != "$(realpath "$INSTALLER_COPY_PATH" 2>/dev/null || echo "$INSTALLER_COPY_PATH")" ]; then + install -m 755 "$0" "$INSTALLER_COPY_PATH" + fi + install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH" + install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH" systemctl daemon-reload - log_info "Systemd template installed: ${SERVICE_TEMPLATE}" + systemctl enable "$SERVICE_NAME" > /dev/null 2>&1 } -# ─── Migrate Legacy Config ─────────────────────────────────────────── - -migrate_legacy_config() { - if [ -f "${CONFIG_DIR}/config.yml" ] && [ ! -d "${CONFIG_DIR}/config.yml" ]; then - local legacy_id - legacy_id=$(grep -E '^\s*node_id:\s*' "${CONFIG_DIR}/config.yml" 2>/dev/null | head -1 | sed 's/[^0-9]*//g') - - if [ -z "$legacy_id" ]; then - legacy_id="default" +wait_for_health() { + if ! systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1; then + return 1 + fi + if [ "$HEALTH_ENABLED" -eq 0 ]; then + return 0 + fi + local attempt=0 + local max_attempts=30 + while [ "$attempt" -lt "$max_attempts" ]; do + if ! systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1; then + return 1 fi - - if [ ! -d "${CONFIG_DIR}/${legacy_id}" ]; then - log_warn "Migrating legacy config to multi-node layout: node ${legacy_id}" - mkdir -p "${CONFIG_DIR}/${legacy_id}" - mv "${CONFIG_DIR}/config.yml" "${CONFIG_DIR}/${legacy_id}/config.yml" - - if command -v systemctl >/dev/null 2>&1; then - systemctl stop xboard-node 2>/dev/null || true - systemctl disable xboard-node 2>/dev/null || true - rm -f /etc/systemd/system/xboard-node.service - - install_systemd_template - systemctl enable "xboard-node@${legacy_id}" 2>/dev/null || true - systemctl start "xboard-node@${legacy_id}" 2>/dev/null || true - log_info "Migrated service: xboard-node → xboard-node@${legacy_id}" - fi + if curl -fsS "http://127.0.0.1:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then + return 0 fi - fi -} - -# ─── Interactive Prompts ────────────────────────────────────────────── - -prompt_missing_params() { - echo "" - log_step "=== Node Configuration ===" - echo "" - - if [ -z "$PANEL_URL" ]; then - read -rp " Panel URL (e.g. https://panel.example.com): " PANEL_URL - else - echo -e " Panel URL: ${CYAN}${PANEL_URL}${NC}" - fi - - if [ -z "$PANEL_TOKEN" ]; then - read -rp " Server Token: " PANEL_TOKEN - else - echo -e " Server Token: ${CYAN}${PANEL_TOKEN:0:8}***${NC}" - fi - - if [ -z "$NODE_ID" ]; then - read -rp " Node ID: " NODE_ID - else - echo -e " Node ID: ${CYAN}${NODE_ID}${NC}" - fi - - if [ -n "$NODE_TYPE" ]; then - echo -e " Node Type: ${CYAN}${NODE_TYPE}${NC}" - fi - - if [ -n "$KERNEL_TYPE" ] && [ "$KERNEL_TYPE" != "singbox" ]; then - echo -e " Kernel: ${CYAN}${KERNEL_TYPE}${NC}" - else - echo "" - echo " Kernel type:" - echo " 1) singbox (default, recommended)" - echo " 2) xray" - read -rp " Choose [1/2]: " KERNEL_CHOICE - case "$KERNEL_CHOICE" in - 2) KERNEL_TYPE="xray" ;; - *) KERNEL_TYPE="singbox" ;; - esac - fi - - echo "" - validate_params -} - -# ─── Node Operations ───────────────────────────────────────────────── - -write_node_config() { - local node_id="$1" - local node_dir="${CONFIG_DIR}/${node_id}" - - mkdir -p "$node_dir" - - local node_type_line="" - if [ -n "$NODE_TYPE" ]; then - node_type_line=" node_type: \"${NODE_TYPE}\"" - fi - - cat > "${node_dir}/config.yml" << EOF -panel: - url: "${PANEL_URL}" - token: "${PANEL_TOKEN}" - node_id: ${node_id} -${node_type_line} - -node: - push_interval: 0 - pull_interval: 0 - -kernel: - type: "${KERNEL_TYPE}" - config_dir: "${node_dir}" - log_level: "warn" - -log: - level: "info" - output: "stdout" -EOF - - log_info "Config written: ${node_dir}/config.yml" -} - -add_node_native() { - local node_id="$1" - - if [ -d "${CONFIG_DIR}/${node_id}" ]; then - log_error "Node ${node_id} already exists at ${CONFIG_DIR}/${node_id}/" - log_info "To reconfigure, first remove it: $0 remove ${node_id}" - exit 1 - fi - - write_node_config "$node_id" - - if command -v systemctl >/dev/null 2>&1; then - install_systemd_template - systemctl enable "xboard-node@${node_id}" - systemctl start "xboard-node@${node_id}" - log_info "Service started: xboard-node@${node_id}" - else - log_warn "No systemd found. Start manually:" - echo " xboard-node -c ${CONFIG_DIR}/${node_id}/config.yml" - fi -} - -add_node_docker() { - local node_id="$1" - - if [ -d "${CONFIG_DIR}/${node_id}" ]; then - log_error "Node ${node_id} already exists at ${CONFIG_DIR}/${node_id}/" - log_info "To reconfigure, first remove it: $0 remove ${node_id}" - exit 1 - fi - - write_node_config "$node_id" - regenerate_docker_compose - log_info "Docker compose updated: ${DOCKER_COMPOSE_FILE}" - - if command -v docker >/dev/null 2>&1; then - if docker compose version >/dev/null 2>&1; then - COMPOSE_CMD="docker compose" - elif command -v docker-compose >/dev/null 2>&1; then - COMPOSE_CMD="docker-compose" - else - log_warn "docker compose not found. Start manually:" - echo " cd ${CONFIG_DIR} && docker compose up -d" - return - fi - cd "${CONFIG_DIR}" - ${COMPOSE_CMD} up -d "node-${node_id}" - log_info "Container started: xboard-node-${node_id}" - else - log_warn "Docker not installed. Install Docker first, then run:" - echo " cd ${CONFIG_DIR} && docker compose up -d" - fi -} - -regenerate_docker_compose() { - local nodes=() - for dir in "${CONFIG_DIR}"/*/; do - [ -f "${dir}config.yml" ] || continue - local nid - nid=$(basename "$dir") - nodes+=("$nid") + sleep 1 + attempt=$((attempt + 1)) done + return 1 +} - if [ ${#nodes[@]} -eq 0 ]; then - rm -f "${DOCKER_COMPOSE_FILE}" +show_recent_logs() { + if command -v journalctl >/dev/null 2>&1; then + journalctl -u "$SERVICE_NAME" -n 30 --no-pager || true + fi +} + +start_service() { + if systemctl is-enabled "$SERVICE_NAME" >/dev/null 2>&1; then + systemctl restart "$SERVICE_NAME" + else + systemctl start "$SERVICE_NAME" + fi + if ! wait_for_health; then + log_error "Service failed health check" + show_recent_logs + return 1 + fi +} + +perform_install() { + validate_install_request + detect_current_state + require_reconfigure_confirmation + TMP_DIR=$(mktemp -d) + ensure_dirs + stage_binary + stage_xbctl + render_config + render_install_meta + render_service + backup_existing_state + install_staged_files + start_service + + log_info "Installation succeeded" + log_info "Service: ${SERVICE_NAME}" + log_info "Config: ${CONFIG_FILE}" + log_info "Credentials: ${CREDENTIALS_FILE}" + if [ "$HEALTH_ENABLED" -eq 1 ]; then + log_info "Health: http://127.0.0.1:${HEALTH_PORT}/healthz" + fi +} + +perform_upgrade() { + detect_current_state + if [ "$CURRENT_STATE" = "fresh" ]; then + log_warn "No existing install found; falling back to install" + perform_install return fi - - cat > "${DOCKER_COMPOSE_FILE}" << 'HEADER' -# Auto-generated by install.sh — do not edit manually. -# Regenerated each time a node is added or removed. - -services: -HEADER - - for nid in "${nodes[@]}"; do - cat >> "${DOCKER_COMPOSE_FILE}" << EOF - node-${nid}: - image: ${DOCKER_IMAGE} - container_name: xboard-node-${nid} - restart: always - network_mode: host - volumes: - - ./${nid}/config.yml:/etc/xboard-node/config.yml:ro - - ./${nid}:/etc/xboard-node/data - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - -EOF - done + TMP_DIR=$(mktemp -d) + ensure_dirs + stage_binary + stage_xbctl + render_service + backup_existing_state + install -m 755 "$TMP_DIR/xboard-node" "$BINARY_PATH" + install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH" + install -m 644 "$TMP_DIR/${SERVICE_NAME}" "$SERVICE_PATH" + systemctl daemon-reload + systemctl restart "$SERVICE_NAME" + if ! wait_for_health; then + log_error "Upgrade health check failed" + show_recent_logs + return 1 + fi + log_info "Upgrade succeeded" } -# ─── Deploy Node (unified entry) ───────────────────────────────────── +confirm_uninstall() { + if [ "$YES" -eq 1 ]; then + return + fi + echo + read -r -p "Proceed with uninstall? [y/N]: " answer + if ! [[ "$answer" =~ ^[Yy]$ ]]; then + log_warn "Uninstall cancelled" + exit 0 + fi +} -deploy_node() { - if has_all_params; then - validate_params - log_info "Deploying node ${NODE_ID} (${KERNEL_TYPE}) → ${PANEL_URL}" +perform_uninstall() { + confirm_uninstall + if [ -f "$SERVICE_PATH" ]; then + systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true + systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true + rm -f "$SERVICE_PATH" + systemctl daemon-reload || true + fi + rm -f "$BINARY_PATH" + rm -f "$CLI_PATH" + rm -f "$INSTALL_META" + if [ "$PURGE" -eq 1 ]; then + rm -rf "$INSTALL_ROOT" + log_info "Removed ${INSTALL_ROOT}" else - prompt_missing_params + rm -f "$CONFIG_FILE" "$CREDENTIALS_FILE" + log_info "Config preserved under ${INSTALL_ROOT}" fi - - if [ "$DOCKER_MODE" -eq 1 ]; then - add_node_docker "$NODE_ID" - else - add_node_native "$NODE_ID" - fi - - echo "" - echo -e "${GREEN}=== Node ${NODE_ID} Deployed ===${NC}" - echo "" - - if [ "$DOCKER_MODE" -eq 1 ]; then - echo " Manage:" - echo " Logs: docker logs -f xboard-node-${NODE_ID}" - echo " Stop: cd ${CONFIG_DIR} && docker compose stop node-${NODE_ID}" - echo " Restart: cd ${CONFIG_DIR} && docker compose restart node-${NODE_ID}" - else - echo " Manage:" - echo " Status: systemctl status xboard-node@${NODE_ID}" - echo " Logs: journalctl -u xboard-node@${NODE_ID} -f" - echo " Stop: systemctl stop xboard-node@${NODE_ID}" - echo " Restart: systemctl restart xboard-node@${NODE_ID}" - fi - - echo "" - echo " Config: ${CONFIG_DIR}/${NODE_ID}/config.yml" - echo "" + log_info "Uninstall complete" } -# ─── Remove Node ────────────────────────────────────────────────────── - -remove_node() { - local node_id="$1" - - if [ -z "$node_id" ]; then - log_error "Usage: $0 remove " - exit 1 +perform_status() { + detect_current_state + echo + echo -e "${BOLD}xboard-node install status${NC}" + echo " state: ${CURRENT_STATE}" + if [ -f "$INSTALL_META" ]; then + echo " meta: ${INSTALL_META}" + python3 - "$INSTALL_META" <<'PY' +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +config_mode = data.get("config_mode") +if config_mode: + print(f" config_mode: {config_mode}") +for key in ("mode", "panel_url", "kernel", "node_id", "machine_id", "health_port", "version", "updated_at", "latest_instance_id", "instance_count"): + if key in data: + print(f" {key}: {data[key]}") +instances = data.get("instances") or [] +if instances: + print(" instances:") + for item in instances: + mode = item.get("mode", "") + panel_url = item.get("panel_url", "") + node_id = item.get("node_id") + machine_id = item.get("machine_id") + target = f"node_id={node_id}" if node_id else (f"machine_id={machine_id}" if machine_id else "") + print(f" - id={item.get('id','')} mode={mode} panel_url={panel_url} {target}".rstrip()) +PY fi - - if [ ! -d "${CONFIG_DIR}/${node_id}" ]; then - log_error "Node ${node_id} not found" - exit 1 - fi - - log_step "Removing node ${node_id}..." - - if command -v systemctl >/dev/null 2>&1; then - systemctl stop "xboard-node@${node_id}" 2>/dev/null || true - systemctl disable "xboard-node@${node_id}" 2>/dev/null || true - log_info "Systemd service stopped and disabled" - fi - - if command -v docker >/dev/null 2>&1; then - docker rm -f "xboard-node-${node_id}" 2>/dev/null || true - fi - - rm -rf "${CONFIG_DIR}/${node_id}" - log_info "Config removed: ${CONFIG_DIR}/${node_id}/" - - regenerate_docker_compose - log_info "Node ${node_id} removed" -} - -# ─── List Nodes ─────────────────────────────────────────────────────── - -list_nodes() { - echo "" - echo -e "${BOLD} Deployed Nodes${NC}" - echo -e " ────────────────────────────────────────────" - - local found=0 - for dir in "${CONFIG_DIR}"/*/; do - [ -f "${dir}config.yml" ] || continue - found=1 - - local nid - nid=$(basename "$dir") - - local panel_url kernel_type - panel_url=$(grep -E '^\s*url:' "${dir}config.yml" 2>/dev/null | head -1 | sed 's/.*url:\s*"\?\([^"]*\)"\?.*/\1/') - kernel_type=$(grep -E '^\s*type:' "${dir}config.yml" 2>/dev/null | head -1 | sed 's/.*type:\s*"\?\([^"]*\)"\?.*/\1/') - - local status="${RED}stopped${NC}" - if command -v systemctl >/dev/null 2>&1; then - if systemctl is-active "xboard-node@${nid}" >/dev/null 2>&1; then - status="${GREEN}running (systemd)${NC}" - fi - fi - if command -v docker >/dev/null 2>&1; then - if docker inspect -f '{{.State.Running}}' "xboard-node-${nid}" 2>/dev/null | grep -q true; then - status="${GREEN}running (docker)${NC}" - fi - fi - - printf " ${BOLD}Node %-6s${NC} kernel=%-8s panel=%s\n" "$nid" "${kernel_type:-singbox}" "${panel_url:-unknown}" - echo -e " status=${status}" - echo "" - done - - if [ "$found" -eq 0 ]; then - echo " No nodes deployed yet." - echo "" - echo " Deploy your first node:" - echo " $0 -a https://panel.example.com -t TOKEN -n 1" - echo " $0 -a https://panel.example.com -t TOKEN -n 1 --docker" - fi - echo "" -} - -# ─── Update / Uninstall ────────────────────────────────────────────── - -update_binary() { - log_step "Updating xboard-node binary..." - - detect_arch - - local url="https://github.com/cedar2025/xboard-node/releases/latest/download/xboard-node-linux-${ARCH}" - local tmp="/tmp/xboard-node-update" - - if wget -q "$url" -O "$tmp" 2>/dev/null || curl -fsSL "$url" -o "$tmp" 2>/dev/null; then - chmod +x "$tmp" - mv "$tmp" "${INSTALL_DIR}/xboard-node" - log_info "Binary updated" - else - log_error "Failed to download update" - rm -f "$tmp" - exit 1 - fi - - if command -v systemctl >/dev/null 2>&1; then - for dir in "${CONFIG_DIR}"/*/; do - [ -f "${dir}config.yml" ] || continue - local nid - nid=$(basename "$dir") - if systemctl is-active "xboard-node@${nid}" >/dev/null 2>&1; then - systemctl restart "xboard-node@${nid}" - log_info "Restarted: xboard-node@${nid}" - fi - done - fi - - if [ -f "${DOCKER_COMPOSE_FILE}" ] && command -v docker >/dev/null 2>&1; then - log_info "For Docker nodes, pull the latest image and restart:" - echo " cd ${CONFIG_DIR} && docker compose pull && docker compose up -d" + if [ -f "$SERVICE_PATH" ]; then + echo " service: ${SERVICE_NAME}" + systemctl status "$SERVICE_NAME" --no-pager || true fi } -do_uninstall() { - log_step "Uninstalling xboard-node..." - - if command -v systemctl >/dev/null 2>&1; then - for dir in "${CONFIG_DIR}"/*/; do - [ -f "${dir}config.yml" ] || continue - local nid - nid=$(basename "$dir") - systemctl stop "xboard-node@${nid}" 2>/dev/null || true - systemctl disable "xboard-node@${nid}" 2>/dev/null || true - done - systemctl stop xboard-node 2>/dev/null || true - systemctl disable xboard-node 2>/dev/null || true - rm -f /etc/systemd/system/xboard-node.service - rm -f "/etc/systemd/system/${SERVICE_TEMPLATE}" - systemctl daemon-reload - fi - - if command -v docker >/dev/null 2>&1; then - for dir in "${CONFIG_DIR}"/*/; do - [ -f "${dir}config.yml" ] || continue - local nid - nid=$(basename "$dir") - docker rm -f "xboard-node-${nid}" 2>/dev/null || true - done - fi - - rm -f "${INSTALL_DIR}/xboard-node" - log_info "Binary removed" - - echo "" - read -rp " Delete all configs? (${CONFIG_DIR}) [y/N]: " DELETE_ALL - if [[ "$DELETE_ALL" =~ ^[Yy]$ ]]; then - rm -rf "${CONFIG_DIR}" - log_info "All configs removed" - else - log_info "Configs preserved at ${CONFIG_DIR}/" - fi - - log_info "xboard-node uninstalled" -} - -# ─── Print Help ─────────────────────────────────────────────────────── - -print_help() { - cat << 'HELP' - - xboard-node Deploy Script - - DEPLOY A NODE (one command, repeat for each node): - - install.sh -a -t -n [-T ] [-k singbox|xray] [--docker] - - OPTIONS: - -a, --api Panel URL (e.g. https://panel.example.com) - -t, --token Server Token (from panel settings) - -n, --node-id Node ID (positive integer) - -T, --node-type Node type (optional, auto-detected from panel) - -k, --kernel Kernel type (singbox or xray, default: singbox) - --docker Use Docker instead of systemd - - EXAMPLES: - - # Deploy node 1 (sing-box, systemd): - bash install.sh -a https://panel.example.com -t mytoken123 -n 1 - - # Deploy node 2 (xray, systemd): - bash install.sh -a https://panel.example.com -t mytoken123 -n 2 -k xray - - # Deploy node 3 with explicit type (Docker): - bash install.sh -a https://panel.example.com -t mytoken123 -n 3 -T shadowsocks --docker - - # Interactive mode (will prompt for all params): - bash install.sh - - MANAGEMENT: - - bash install.sh list List all deployed nodes - bash install.sh remove Remove a node - bash install.sh update Update binary + restart all nodes - bash install.sh uninstall Remove everything - - DOCKER (env-var mode, no config file needed): - - docker run -d --restart=always --network=host \ - -e apiHost=https://panel.example.com \ - -e apiKey=YOUR_TOKEN \ - -e nodeID=1 \ - ghcr.io/cedar2025/xboard-node:latest - -HELP -} - -# ─── Main ───────────────────────────────────────────────────────────── - main() { parse_args "$@" + case "$ACTION" in + help) + usage + exit 0 + ;; + status) + ensure_systemd + perform_status + exit 0 + ;; + esac - case "$SUBCOMMAND" in - remove) - check_root - remove_node "$NODE_ID" + check_root + detect_arch + detect_os + ensure_systemd + install_dependencies + + case "$ACTION" in + install) + perform_install ;; - list) - list_nodes - ;; - update) - check_root - update_binary + upgrade) + perform_upgrade ;; uninstall) - check_root - do_uninstall - ;; - help|--help|-h) - print_help - ;; - add|"") - check_root - detect_arch - detect_os - install_deps - mkdir -p "$CONFIG_DIR" - migrate_legacy_config - - if [ "$DOCKER_MODE" -eq 0 ]; then - install_binary - install_systemd_template - fi - - deploy_node + perform_uninstall ;; *) - log_error "Unknown command: $SUBCOMMAND" - print_help + log_error "Unknown action: $ACTION" + usage exit 1 ;; esac diff --git a/internal/cert/cert.go b/internal/cert/cert.go index e449e92..1b7be20 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -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 + cfg config.CertConfig + + // Cert material stored atomically so the ACME renewal goroutine can + // swap it without racing with TLSCert() readers. + mat atomic.Pointer[certMaterial] magic *certmagic.Config renewed atomic.Bool - acmeStarted bool // 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": - dir := m.cfg.CertDir - if dir == "" { - dir = "/etc/xboard-node/certs" - } - domain := m.cfg.Domain - if domain == "" { - domain = "localhost" - } - m.certFile = filepath.Join(dir, domain+".crt") - m.keyFile = filepath.Join(dir, domain+".key") - default: - m.certFile = "" - m.keyFile = "" +// storePEM atomically swaps the in-memory cert material and persists to disk +// so that restarts can reload without re-generating or re-requesting. +func (m *Manager) storePEM(cert, key []byte) { + m.mat.Store(&certMaterial{certPEM: cert, keyPEM: key}) + m.persistPEM(cert, key) +} + +// persistPEM writes cert/key to cert_dir for restart recovery. +// Errors are logged but not returned — persistence is best-effort. +func (m *Manager) persistPEM(cert, key []byte) { + dir := m.cfg.CertDir + if dir == "" { + return + } + if err := os.MkdirAll(dir, 0o755); err != nil { + nlog.Core().Warn("cert: failed to create cert_dir", "dir", dir, "error", err) + return + } + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + if err := atomicWriteFile(certPath, cert, 0o644); err != nil { + nlog.Core().Warn("cert: failed to persist cert", "path", certPath, "error", err) + } + if err := atomicWriteFile(keyPath, key, 0o600); err != nil { + nlog.Core().Warn("cert: failed to persist key", "path", keyPath, "error", err) } } +// atomicWriteFile writes data to a temp file and renames, preventing partial reads. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, perm); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// loadPersistedPEM loads previously persisted cert material from cert_dir. +// Returns true if valid material was loaded. +func (m *Manager) loadPersistedPEM() bool { + dir := m.cfg.CertDir + if dir == "" { + return false + } + certPEM, err := os.ReadFile(filepath.Join(dir, "cert.pem")) + if err != nil { + return false + } + keyPEM, err := os.ReadFile(filepath.Join(dir, "key.pem")) + if err != nil { + return false + } + if err := validateKeyPair(certPEM, keyPEM); err != nil { + nlog.Core().Warn("cert: persisted cert invalid, will regenerate", "error", err) + return false + } + // Store in memory only (don't re-persist what we just read). + m.mat.Store(&certMaterial{certPEM: certPEM, keyPEM: keyPEM}) + nlog.Core().Info("cert: loaded persisted certificate from disk", "dir", dir) + return true +} + // Reconfigure applies a new cert configuration at runtime (e.g. from panel push). -// 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) - 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 + nlog.Core().Info("TLS certificate loaded from ACME storage", "domain", m.cfg.Domain, "renewal", renewal) + if renewal { + m.renewed.Store(true) } - 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. diff --git a/internal/config/config.go b/internal/config/config.go index 830f307..eb8605c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -73,10 +93,12 @@ type RuntimeConfig struct { } type PanelConfig struct { - URL string `yaml:"url"` - Token string `yaml:"token"` - NodeID int `yaml:"node_id"` - NodeType string `yaml:"node_type"` + 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7849a51..91431d7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/config/standalone.go b/internal/config/standalone.go index 3ccf8e9..69f769f 100644 --- a/internal/config/standalone.go +++ b/internal/config/standalone.go @@ -19,17 +19,18 @@ type StandaloneNodeConfig struct { ServerPort int `yaml:"server_port"` Network string `yaml:"network,omitempty"` - NetworkSettings map[string]any `yaml:"network_settings,omitempty"` - Routes []StandaloneRouteRule `yaml:"routes,omitempty"` + NetworkSettings map[string]any `yaml:"network_settings,omitempty"` + Routes []StandaloneRouteRule `yaml:"routes,omitempty"` + CustomRouteRules []StandaloneCustomRouteRule `yaml:"custom_route_rules,omitempty"` Cipher string `yaml:"cipher,omitempty"` Plugin string `yaml:"plugin,omitempty"` PluginOpt string `yaml:"plugin_opts,omitempty"` ServerKey string `yaml:"server_key,omitempty"` - TLS int `yaml:"tls,omitempty"` - Flow string `yaml:"flow,omitempty"` - Decryption string `yaml:"decryption,omitempty"` + TLS int `yaml:"tls,omitempty"` + Flow string `yaml:"flow,omitempty"` + Decryption string `yaml:"decryption,omitempty"` TLSSettings map[string]any `yaml:"tls_settings,omitempty"` Host string `yaml:"host,omitempty"` @@ -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"` diff --git a/internal/config/watcher.go b/internal/config/watcher.go index b6a06f4..abc9789 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -13,10 +13,11 @@ import ( // Watcher reloads the config file on change (debounced) and calls onChange. type Watcher struct { - path string - debounce time.Duration - onChange func(*Config) - watcher *fsnotify.Watcher + path string + debounce time.Duration + onChange func(*Config) + onChangeRoot func(*RootConfig) + watcher *fsnotify.Watcher stopOnce sync.Once stopCh chan struct{} @@ -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) diff --git a/internal/controlplane/local.go b/internal/controlplane/local.go index 4ebd0bd..67a8fcc 100644 --- a/internal/controlplane/local.go +++ b/internal/controlplane/local.go @@ -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 } diff --git a/internal/controlplane/machine.go b/internal/controlplane/machine.go new file mode 100644 index 0000000..8911c60 --- /dev/null +++ b/internal/controlplane/machine.go @@ -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} +} diff --git a/internal/controlplane/mailbox.go b/internal/controlplane/mailbox.go new file mode 100644 index 0000000..3dc4fb4 --- /dev/null +++ b/internal/controlplane/mailbox.go @@ -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 +} diff --git a/internal/controlplane/mailbox_test.go b/internal/controlplane/mailbox_test.go new file mode 100644 index 0000000..6f5ccf3 --- /dev/null +++ b/internal/controlplane/mailbox_test.go @@ -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") + } +} diff --git a/internal/controlplane/panel.go b/internal/controlplane/panel.go index 128e422..ab93366 100644 --- a/internal/controlplane/panel.go +++ b/internal/controlplane/panel.go @@ -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) IsConnected() bool { return p.inner.IsConnected() } +func (p *panelPushClient) SendDeviceReport(devices map[int][]string) { + p.inner.SendDeviceReport(devices) +} diff --git a/internal/controlplane/panel_test.go b/internal/controlplane/panel_test.go new file mode 100644 index 0000000..16d93cd --- /dev/null +++ b/internal/controlplane/panel_test.go @@ -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) + } +} diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go index 5f1a486..1416d10 100644 --- a/internal/controlplane/types.go +++ b/internal/controlplane/types.go @@ -25,7 +25,8 @@ type Event struct { } type StatusChange struct { - Connected bool + 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 { diff --git a/internal/kernel/custom_route_compile.go b/internal/kernel/custom_route_compile.go new file mode 100644 index 0000000..1916aab --- /dev/null +++ b/internal/kernel/custom_route_compile.go @@ -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 +} diff --git a/internal/kernel/kernel.go b/internal/kernel/kernel.go index 1efc554..474f3ff 100644 --- a/internal/kernel/kernel.go +++ b/internal/kernel/kernel.go @@ -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. diff --git a/internal/kernel/singbox/config.go b/internal/kernel/singbox/config.go index a69a58d..fe11766 100644 --- a/internal/kernel/singbox/config.go +++ b/internal/kernel/singbox/config.go @@ -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 +} diff --git a/internal/kernel/singbox/config_test.go b/internal/kernel/singbox/config_test.go index 7f7ac33..e21739c 100644 --- a/internal/kernel/singbox/config_test.go +++ b/internal/kernel/singbox/config_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/kernel/singbox/singbox.go b/internal/kernel/singbox/singbox.go index c5d5eb7..90189af 100644 --- a/internal/kernel/singbox/singbox.go +++ b/internal/kernel/singbox/singbox.go @@ -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) diff --git a/internal/kernel/singbox/singbox_test.go b/internal/kernel/singbox/singbox_test.go index 5d720ec..1ad8732 100644 --- a/internal/kernel/singbox/singbox_test.go +++ b/internal/kernel/singbox/singbox_test.go @@ -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) + } } diff --git a/internal/kernel/xray/config.go b/internal/kernel/xray/config.go index b96ada3..9acb459 100644 --- a/internal/kernel/xray/config.go +++ b/internal/kernel/xray/config.go @@ -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,16 +437,15 @@ 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, - }, - }, - "alpn": []string{"h3"}, + "certificates": []M{tlsCert}, + "alpn": []string{"h3"}, } } else { nlog.Core().Warn("hysteria requires TLS certificate files; configure cert_mode (self, file, http, dns, or content)") @@ -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 } - } - if certFile != "" && keyFile != "" { - tlsSettings["certificates"] = []M{ - { - "certificateFile": certFile, - "keyFile": keyFile, - }, + // ECH server-side: extract key from tls_settings.ech + if echKeys := extractECHServerKeys(nc.TLSSettings); echKeys != "" { + tlsSettings["echServerKeys"] = echKeys } + } + if tc.HasCert() { + tlsCert := M{ + "certificate": []string{string(tc.CertPEM)}, + "key": []string{string(tc.KeyPEM)}, + } + tlsSettings["certificates"] = []M{tlsCert} } else { // Fallback placeholder for auto-TLS environments. // Xray allows empty certificates array in more cases than sing-box, @@ -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) +} diff --git a/internal/kernel/xray/config_test.go b/internal/kernel/xray/config_test.go index 700c38f..cfab5e7 100644 --- a/internal/kernel/xray/config_test.go +++ b/internal/kernel/xray/config_test.go @@ -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) + } + }) + } +} diff --git a/internal/kernel/xray/xray.go b/internal/kernel/xray/xray.go index 607ae7e..1a10626 100644 --- a/internal/kernel/xray/xray.go +++ b/internal/kernel/xray/xray.go @@ -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) diff --git a/internal/machine/machine.go b/internal/machine/machine.go new file mode 100644 index 0000000..cd94694 --- /dev/null +++ b/internal/machine/machine.go @@ -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) +} diff --git a/internal/model/custom_config_tags.go b/internal/model/custom_config_tags.go new file mode 100644 index 0000000..74186f1 --- /dev/null +++ b/internal/model/custom_config_tags.go @@ -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 +} diff --git a/internal/model/custom_config_tags_test.go b/internal/model/custom_config_tags_test.go new file mode 100644 index 0000000..ab407a1 --- /dev/null +++ b/internal/model/custom_config_tags_test.go @@ -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) + } +} diff --git a/internal/model/custom_outbound_support.go b/internal/model/custom_outbound_support.go new file mode 100644 index 0000000..af2850a --- /dev/null +++ b/internal/model/custom_outbound_support.go @@ -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 +} diff --git a/internal/model/custom_outbound_validate.go b/internal/model/custom_outbound_validate.go new file mode 100644 index 0000000..495e5e5 --- /dev/null +++ b/internal/model/custom_outbound_validate.go @@ -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") + } +} diff --git a/internal/model/custom_outbound_validate_test.go b/internal/model/custom_outbound_validate_test.go new file mode 100644 index 0000000..d154a75 --- /dev/null +++ b/internal/model/custom_outbound_validate_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/model/custom_route.go b/internal/model/custom_route.go new file mode 100644 index 0000000..caf756a --- /dev/null +++ b/internal/model/custom_route.go @@ -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, + } +} diff --git a/internal/model/custom_route_support.go b/internal/model/custom_route_support.go new file mode 100644 index 0000000..cbb564e --- /dev/null +++ b/internal/model/custom_route_support.go @@ -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 +} diff --git a/internal/model/custom_route_validate.go b/internal/model/custom_route_validate.go new file mode 100644 index 0000000..96ea385 --- /dev/null +++ b/internal/model/custom_route_validate.go @@ -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 +} diff --git a/internal/model/custom_route_validate_test.go b/internal/model/custom_route_validate_test.go new file mode 100644 index 0000000..8fc1382 --- /dev/null +++ b/internal/model/custom_route_validate_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/model/panel.go b/internal/model/panel.go index d08ba7a..c7d90d3 100644 --- a/internal/model/panel.go +++ b/internal/model/panel.go @@ -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, diff --git a/internal/model/panel_test.go b/internal/model/panel_test.go new file mode 100644 index 0000000..c1ca8f3 --- /dev/null +++ b/internal/model/panel_test.go @@ -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) + } + }) +} diff --git a/internal/model/standalone.go b/internal/model/standalone.go index a9d8e48..c9d42ff 100644 --- a/internal/model/standalone.go +++ b/internal/model/standalone.go @@ -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 diff --git a/internal/model/standalone_test.go b/internal/model/standalone_test.go index 39fb454..a89b10b 100644 --- a/internal/model/standalone_test.go +++ b/internal/model/standalone_test.go @@ -12,18 +12,18 @@ func TestNodeSpecFromStandalone(t *testing.T) { Standalone: &config.StandaloneConfig{ Enabled: true, Node: config.StandaloneNodeConfig{ - Protocol: "vless", - ListenIP: "::", - ServerPort: 8443, - Network: "ws", - NetworkSettings: map[string]any{"path": "/ws"}, + Protocol: "vless", + ListenIP: "::", + ServerPort: 8443, + Network: "ws", + NetworkSettings: map[string]any{"path": "/ws"}, TLS: 1, Host: "node.example.com", ServerName: "node.example.com", AcceptProxyProtocol: true, PaddingScheme: "stop=8\n0=30-30", - Routes: []config.StandaloneRouteRule{{ID: 1, Match: []string{"example.com"}, Action: "direct"}}, - Multiplex: &config.StandaloneMultiplexConfig{Enabled: true, Protocol: "smux", MaxConnections: 4}, + Routes: []config.StandaloneRouteRule{{ID: 1, Match: []string{"example.com"}, Action: "direct"}}, + Multiplex: &config.StandaloneMultiplexConfig{Enabled: true, Protocol: "smux", MaxConnections: 4}, }, Users: []config.StandaloneUser{{ID: 7, UUID: "11111111-1111-1111-1111-111111111111", SpeedLimit: 50, DeviceLimit: 2}}, }, diff --git a/internal/model/types.go b/internal/model/types.go index aab9c8d..6b6b9ee 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -10,13 +10,14 @@ type NodeSpec struct { NetworkSettings map[string]any Routes []RouteRule - KernelType string - KernelLogLevel string - CustomOutbounds []OutboundConfig - CustomRoutes []map[string]any - CertConfig *config.CertConfig - AutoTLS bool - Domain string + KernelType string + KernelLogLevel string + CustomOutbounds []OutboundConfig + CustomRoutes []map[string]any + CustomRouteRules []CustomRouteRule + CertConfig *config.CertConfig + AutoTLS bool + Domain string Cipher string Plugin string diff --git a/internal/model/validate.go b/internal/model/validate.go new file mode 100644 index 0000000..506b9de --- /dev/null +++ b/internal/model/validate.go @@ -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 +} diff --git a/internal/panel/client.go b/internal/panel/client.go index 3f5fda0..1957e37 100644 --- a/internal/panel/client.go +++ b/internal/panel/client.go @@ -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, + 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) } diff --git a/internal/panel/types.go b/internal/panel/types.go index c9e613e..14a5cfd 100644 --- a/internal/panel/types.go +++ b/internal/panel/types.go @@ -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"` @@ -73,10 +94,11 @@ type NodeConfig struct { Routes []RouteRule `json:"routes"` // Kernel settings (Xboard extension) - KernelType string `json:"kernel_type,omitempty"` // "singbox" or "xray" - KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc. - CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"` - CustomRoutes []map[string]any `json:"custom_routes,omitempty"` + KernelType string `json:"kernel_type,omitempty"` // "singbox" or "xray" + KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc. + CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"` + CustomRoutes []map[string]any `json:"custom_routes,omitempty"` + CustomRouteRules []CustomRouteRule `json:"custom_route_rules,omitempty"` // Certificate settings (Xboard extension) CertConfig *CertConfig `json:"cert_config,omitempty"` @@ -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"` diff --git a/internal/panel/ws.go b/internal/panel/ws.go index ac66d34..3642aa2 100644 --- a/internal/panel/ws.go +++ b/internal/panel/ws.go @@ -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) - q.Set("node_id", strconv.Itoa(w.nodeID)) + 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)) - select { - case msgCh <- msg: - case <-ctx.Done(): - return - default: - nlog.Core().Warn("ws: message channel full, dropping message", "event", msg.Event) + w.handleMessage(msg) + if msg.Event == "ping" { + select { + case writeCh <- wsMessage{Event: "pong"}: + default: + 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() { diff --git a/internal/service/service.go b/internal/service/service.go index 87321ea..9148aed 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -9,6 +9,7 @@ import ( "io" "math" "sort" + "strings" "sync" "sync/atomic" "time" @@ -63,11 +64,14 @@ type Service struct { // pullResults delivers async pullViaAPI results back to the main goroutine. pullResults chan pullResult - wsClient controlplane.PushClient // Push client (nil if push is not enabled) - wsEvents chan controlplane.Event // receives data events from push transport - 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) + wsClient controlplane.PushClient // Push client (nil if push is not enabled) + wsEvents chan controlplane.Event // receives data events from push transport + 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,7 +119,24 @@ func (b *apiBackoff) onFailure() { } func New(cfg *config.Config) *Service { - certMgr := cert.NewManager(cfg.Cert) + 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 switch cfg.Kernel.Type { @@ -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,18 +651,23 @@ func (s *Service) applyPullResult(ctx context.Context, result pullResult) { } if result.config != nil { - configChanged = true - // Initialize or update node logger - if s.nodeLog == nil { - s.nodeLog = nlog.ForNode(result.config.Protocol, result.config.ServerPort) - } - s.nodeLog.Info(fmt.Sprintf("config updated, %d users", len(s.lastUsers))) - s.metricsMu.Lock() - s.lastConfig = result.config - s.metricsMu.Unlock() - s.lastConfigHash = result.configHash - if s.applyRemoteOverrides(ctx, result.config) { + 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 { + s.nodeLog = nlog.ForNode(result.config.Protocol, result.config.ServerPort) + } + s.nodeLog.Info(fmt.Sprintf("config updated, %d users", len(s.lastUsers))) + s.metricsMu.Lock() + s.lastConfig = result.config + s.metricsMu.Unlock() + s.lastConfigHash = result.configHash + if s.applyRemoteOverrides(ctx, result.config) { + configChanged = true + } } } @@ -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 "" + } +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index e016adb..b16259c 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -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") } - 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") - } + if err.Error() == "" { + t.Fatal("expected non-empty error") } - - 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) } }