package main import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "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 "config": return runConfig(args[1:]) 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 config init --mode node|machine --panel-url URL --token TOKEN [flags] xbctl config health-port [--config PATH] 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 { ver := "unknown" if meta, err := loadInstallMeta(path); err == nil && strings.TrimSpace(meta.Version) != "" { ver = meta.Version } return writeInstallMetaVersioned(path, root, ver, "") } 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 } // ── config subcommand ───────────────────────────────────────────────── func runConfig(args []string) error { if len(args) == 0 { return errors.New("usage: xbctl config ") } switch args[0] { case "init": return runConfigInit(args[1:]) case "health-port": return runConfigHealthPort(args[1:]) default: return fmt.Errorf("unknown config command: %s", args[0]) } } // runConfigInit generates/merges an instance into config.yml, writes // credentials.env and install-meta.json. It replaces the Python PY_INST, // PY_CFG, PY_ENV, and PY_META heredoc blocks in install.sh. // // Output (stdout, one per line): // // INSTANCE_ID= // ENV_KEY= func runConfigInit(args []string) error { var ( configIn string configOut string credentialsIn string credentialsOut string metaPath string mode string panelURL string nodeID int nodeType string machineID int kernelType string healthPort int gomemlimit string gogc int installRoot string token string releaseVersion string ) for i := 0; i < len(args); i++ { if i+1 >= len(args) { break } switch args[i] { case "--config": i++ configIn = args[i] case "--output": i++ configOut = args[i] case "--credentials-in": i++ credentialsIn = args[i] case "--credentials-out": i++ credentialsOut = args[i] case "--meta": i++ metaPath = args[i] case "--mode": i++ mode = args[i] case "--panel-url": i++ panelURL = args[i] case "--node-id": i++ v, err := strconv.Atoi(args[i]) if err != nil { return fmt.Errorf("invalid --node-id: %w", err) } nodeID = v case "--node-type": i++ nodeType = args[i] case "--machine-id": i++ v, err := strconv.Atoi(args[i]) if err != nil { return fmt.Errorf("invalid --machine-id: %w", err) } machineID = v case "--kernel": i++ kernelType = args[i] case "--health-port": i++ v, err := strconv.Atoi(args[i]) if err != nil { return fmt.Errorf("invalid --health-port: %w", err) } healthPort = v case "--gomemlimit": i++ gomemlimit = args[i] case "--gogc": i++ v, err := strconv.Atoi(args[i]) if err != nil { return fmt.Errorf("invalid --gogc: %w", err) } gogc = v case "--install-root": i++ installRoot = args[i] case "--token": i++ token = args[i] case "--version": i++ releaseVersion = args[i] } } if mode == "" { return errors.New("--mode is required (node or machine)") } if panelURL == "" { return errors.New("--panel-url is required") } if mode == "node" && nodeID <= 0 { return errors.New("--node-id is required for node mode") } if mode == "machine" && machineID <= 0 { return errors.New("--machine-id is required for machine mode") } // Build the new instance. inst := config.Config{ Panel: config.PanelConfig{URL: panelURL}, Kernel: config.KernelConfig{ Type: kernelType, LogLevel: "warn", }, Log: config.LogConfig{Level: "info", Output: "stdout"}, HealthPort: healthPort, } if mode == "machine" { inst.Machine = &config.MachineConfig{MachineID: machineID} } else { inst.Panel.NodeID = nodeID if nodeType != "" { inst.Panel.NodeType = nodeType } } // Generate deterministic instance ID. instanceID, err := inst.AutoInstanceID() if err != nil { return fmt.Errorf("generate instance ID: %w", err) } inst.InstanceID = instanceID if installRoot == "" { installRoot = "/etc/xboard-node" } inst.Kernel.ConfigDir = filepath.Join(installRoot, "instances", instanceID) // Build credential env key. envKey := "INSTANCE_" + strings.ToUpper(strings.ReplaceAll(instanceID, "-", "_")) if mode == "machine" { envKey += "_MACHINE_TOKEN" inst.Machine.TokenEnv = envKey } else { envKey += "_API_KEY" inst.Panel.TokenEnv = envKey } if gomemlimit != "" { inst.Runtime.GoMemLimit = gomemlimit } if gogc > 0 { inst.Runtime.GoGCPercent = gogc } // Load existing config (if any). root := &config.RootConfig{} hasExisting := false if configIn != "" { if loaded, loadErr := loadWritableRootConfig(configIn); loadErr == nil { root = loaded hasExisting = len(root.Instances) > 0 || root.Config.Panel.URL != "" || root.Config.Kernel.Type != "" } } // Normalise existing instance IDs so dedup works correctly. var instances []config.Config if hasExisting { instances = normalizeRootInstances(root) seen := make(map[string]bool) deduped := make([]config.Config, 0, len(instances)) for _, existing := range instances { autoID, idErr := existing.AutoInstanceID() if idErr == nil && autoID != "" { existing.InstanceID = autoID } id := existing.InstanceID if id != "" && seen[id] { continue } if id != "" { seen[id] = true } deduped = append(deduped, existing) } instances = deduped } // Merge: replace if same ID exists, otherwise append. replaced := false for i, existing := range instances { if existing.InstanceID == instanceID { instances[i] = inst replaced = true break } } if !replaced { instances = append(instances, inst) } root.Instances = instances // Write config. if configOut == "" { configOut = configIn } if configOut == "" { return errors.New("--output (or --config) is required") } if err := writeRootConfig(configOut, root); err != nil { return err } // Write credentials. if token != "" && credentialsOut != "" { if err := mergeCredentials(credentialsIn, credentialsOut, envKey, token); err != nil { return err } } // Write install-meta.json. if metaPath != "" { if err := writeInstallMetaVersioned(metaPath, root, releaseVersion, instanceID); err != nil { return err } } // Output for bash capture. fmt.Printf("INSTANCE_ID=%s\n", instanceID) fmt.Printf("ENV_KEY=%s\n", envKey) return nil } // mergeCredentials reads existing key=value credentials, adds/replaces // the given key, and writes the result preserving insertion order. func mergeCredentials(srcPath, dstPath, key, value string) error { entries := make(map[string]string) var order []string if srcPath != "" { data, err := os.ReadFile(srcPath) if err == nil { for _, raw := range strings.Split(string(data), "\n") { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { continue } k, v, _ := strings.Cut(line, "=") if _, exists := entries[k]; !exists { order = append(order, k) } entries[k] = v } } } if _, exists := entries[key]; !exists { order = append(order, key) } entries[key] = value var buf strings.Builder for _, k := range order { fmt.Fprintf(&buf, "%s=%s\n", k, entries[k]) } return os.WriteFile(dstPath, []byte(buf.String()), 0o600) } // writeInstallMetaVersioned writes install-meta.json with an explicit // version and latest-instance-ID. When latestID is empty, the last // instance in the config is used (backward compat with writeInstallMeta). func writeInstallMetaVersioned(path string, root *config.RootConfig, ver, latestID string) error { if ver == "" { ver = "unknown" } instances := normalizeRootInstances(root) items := make([]instanceSummary, 0, len(instances)) for _, inst := range instances { id, err := inst.AutoInstanceID() if err != nil { return err } if latestID == "" { 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: ver, 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) } // runConfigHealthPort reads health_port from an existing config file and // prints it to stdout. Exits silently if the file does not exist or has // no health_port. func runConfigHealthPort(args []string) error { cfgPath := defaultConfigPath for i := 0; i < len(args); i++ { if args[i] == "--config" && i+1 < len(args) { i++ cfgPath = args[i] } } data, err := os.ReadFile(cfgPath) if err != nil { return nil // file not found → no health port } root := &config.RootConfig{} if err := yaml.Unmarshal(data, root); err != nil { return nil } // Check instances first, then legacy top-level. if len(root.Instances) > 0 { for _, inst := range root.Instances { if inst.HealthPort > 0 { fmt.Println(inst.HealthPort) return nil } } } if root.Config.HealthPort > 0 { fmt.Println(root.Config.HealthPort) } return nil }