Files
xboard-node/cmd/xbctl/main.go
T
will 34125823df
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
fix: use gitea.fp-will.xyz instead of raw IP:3000
2026-07-01 18:10:27 +09:00

1568 lines
42 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"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"
defaultBinaryPath = "/usr/local/bin/xboard-node"
defaultCLIPath = "/usr/local/bin/xbctl"
serviceName = "xboard-node.service"
serviceFilePath = "/etc/systemd/system/xboard-node.service"
defaultInstallRoot = "/etc/xboard-node"
downloadBase = "https://gitea.fp-will.xyz/will/xboard-node"
downloadToken = "cf6634b57d2c60c66030dbb29d77c4ed25370e94"
)
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", "log":
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", "enable", "disable":
return runService(args)
case "upgrade":
return runUpgrade(args[1:])
case "uninstall":
return runUninstall(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 <id> [--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|enable|disable|logs
xbctl health
xbctl bind add-node --panel-url URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray]
xbctl bind add-machine --panel-url URL --token TOKEN --machine-id ID [--kernel singbox|xray]
xbctl bind remove <instance-id>
xbctl bind remove-node --panel URL --node-id ID
xbctl bind remove-machine --panel URL --machine-id ID
xbctl upgrade [--version VERSION]
xbctl uninstall [--purge] [--yes]
xbctl version
shortcuts:
xbctl start|stop|restart = xbctl service start|stop|restart
xbctl log|logs = xbctl service logs
xbctl bind-node ... = xbctl bind add-node ...
xbctl bind-machine ... = xbctl bind add-machine ...
xbctl unbind-node ... = xbctl bind remove-node ...
xbctl unbind-machine ... = xbctl bind remove-machine ...`)
}
func runStatus() error {
fmt.Println("xboard-node status")
fmt.Println()
// Version from install-meta.json
ver := "unknown"
if meta, err := loadInstallMeta(defaultMetaPath); err == nil {
ver = meta.Version
}
fmt.Printf(" version: %s\n", ver)
// Service status
svc := systemctlState()
fmt.Printf(" service: %s\n", svc)
// Health
health := instanceAwareHealth()
fmt.Printf(" health: %s\n", health)
fmt.Println()
// Instance list
rows, err := collectInstanceRows()
if err != nil {
fmt.Printf(" (no instances found: %v)\n", err)
return nil
}
return printRows(rows, "text")
}
func runList(args []string) error {
output := parseOutput(args)
rows, err := collectInstanceRows()
if err != nil {
return err
}
return printRows(rows, output)
}
func runInstance(args []string) error {
if len(args) == 0 {
return runList(nil)
}
if args[0] == "list" {
return runList(args[1:])
}
if args[0] != "get" {
return fmt.Errorf("unknown instance command: %s", args[0])
}
if len(args) < 2 {
return errors.New("usage: xbctl instance get <id> [--output text|json]")
}
id := args[1]
output := parseOutput(args[2:])
rows, err := collectInstanceRows()
if err != nil {
return err
}
for _, row := range rows {
if row.ID == id {
return printRows([]instanceRow{row}, output)
}
}
return fmt.Errorf("instance not found: %s", id)
}
func runService(args []string) error {
if len(args) == 0 {
return errors.New("usage: xbctl service <status|start|stop|restart|enable|disable|logs>")
}
sub := args[0]
rest := args[1:]
switch sub {
case "status":
return runCommand("sudo", append([]string{"systemctl", "status", serviceName, "--no-pager"}, rest...)...)
case "start", "stop", "restart", "enable", "disable":
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 {
h := instanceAwareHealth()
fmt.Println(h)
if h == "down" {
return errors.New("health check failed")
}
return nil
}
func runBind(args []string) error {
if len(args) == 0 {
return errors.New("usage: xbctl bind <add-node|add-machine|remove-node|remove-machine> ...")
}
if err := ensureRoot("bind"); err != nil {
return err
}
sub := args[0]
rest := args[1:]
switch sub {
case "add-node":
return runBindAdd("node", rest)
case "add-machine":
return runBindAdd("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, "")
case "remove":
if len(rest) == 0 {
return errors.New("usage: xbctl bind remove <instance-id>")
}
return removeBinding("", 0, 0, rest[0])
default:
return fmt.Errorf("unknown bind command: %s", sub)
}
}
func runBindAdd(mode string, args []string) error {
// Build configInit args from bind args
initArgs := []string{
"--mode", mode,
"--config", defaultConfigPath,
"--output", defaultConfigPath,
"--credentials-in", defaultCredentialsPath,
"--credentials-out", defaultCredentialsPath,
"--meta", defaultMetaPath,
"--install-root", defaultInstallRoot,
}
// Pass through remaining args (--panel-url, --token, --node-id, --machine-id, --kernel, etc.)
initArgs = append(initArgs, args...)
if err := runConfigInit(initArgs); err != nil {
return fmt.Errorf("bind failed: %w", err)
}
// Restart service to pick up new config
fmt.Println("Restarting service...")
if err := runCommand("systemctl", "restart", serviceName); err != nil {
return fmt.Errorf("service restart failed: %w", err)
}
fmt.Println("Binding added successfully")
return nil
}
func runUpgrade(args []string) error {
if err := ensureRoot("upgrade"); err != nil {
return err
}
version := "latest"
for i := 0; i < len(args); i++ {
if args[i] == "--version" && i+1 < len(args) {
version = args[i+1]
i++
}
}
arch := runtime.GOARCH
if arch != "amd64" && arch != "arm64" {
return fmt.Errorf("unsupported architecture: %s", arch)
}
fmt.Println("Starting upgrade...")
binaryDir := filepath.Dir(defaultBinaryPath)
cliDir := filepath.Dir(defaultCLIPath)
newBinary := filepath.Join(binaryDir, ".xboard-node.new")
newCLI := filepath.Join(cliDir, ".xbctl.new")
binaryURL := resolveDownloadURL(fmt.Sprintf("xboard-node-linux-%s", arch), version)
cliURL := resolveDownloadURL(fmt.Sprintf("xbctl-linux-%s", arch), version)
fmt.Printf("Downloading %s...\n", binaryURL)
if err := downloadFile(binaryURL, newBinary); err != nil {
return fmt.Errorf("download binary: %w", err)
}
fmt.Printf("Downloading %s...\n", cliURL)
if err := downloadFile(cliURL, newCLI); err != nil {
os.Remove(newBinary)
return fmt.Errorf("download xbctl: %w", err)
}
if err := os.Chmod(newBinary, 0o755); err != nil {
return cleanupFiles(newBinary, newCLI, fmt.Errorf("chmod binary: %w", err))
}
if err := os.Chmod(newCLI, 0o755); err != nil {
return cleanupFiles(newBinary, newCLI, fmt.Errorf("chmod xbctl: %w", err))
}
// Validate downloaded binaries
if out, err := exec.Command(newBinary, "-v").CombinedOutput(); err != nil {
return cleanupFiles(newBinary, newCLI, fmt.Errorf("binary version check failed: %s", string(out)))
}
if out, err := exec.Command(newCLI, "version").CombinedOutput(); err != nil {
return cleanupFiles(newBinary, newCLI, fmt.Errorf("xbctl version check failed: %s", string(out)))
}
// Backup existing binaries
backupBinary := defaultBinaryPath + ".bak"
backupCLI := defaultCLIPath + ".bak"
// Backup existing binaries
if fileExists(defaultBinaryPath) {
if err := copyFile(defaultBinaryPath, backupBinary); err != nil {
return cleanupFiles(newBinary, newCLI, fmt.Errorf("backup binary: %w", err))
}
}
if fileExists(defaultCLIPath) {
if err := copyFile(defaultCLIPath, backupCLI); err != nil {
return cleanupFiles(newBinary, newCLI, fmt.Errorf("backup xbctl: %w", err))
}
}
// Atomic rename
if err := os.Rename(newBinary, defaultBinaryPath); err != nil {
return cleanupFiles(newBinary, newCLI, fmt.Errorf("replace binary: %w", err))
}
if err := os.Rename(newCLI, defaultCLIPath); err != nil {
if fileExists(backupBinary) {
os.Rename(backupBinary, defaultBinaryPath)
}
os.Remove(newCLI)
return fmt.Errorf("replace xbctl: %w", err)
}
// Recreate /usr/bin/xbctl symlink
os.Remove("/usr/bin/xbctl")
os.Symlink(defaultCLIPath, "/usr/bin/xbctl")
// Restart service
fmt.Println("Restarting service...")
runCommand("systemctl", "daemon-reload")
if err := runCommand("systemctl", "restart", serviceName); err != nil {
fmt.Println("Restart failed, rolling back...")
rollbackOK := true
if fileExists(backupBinary) {
if e := os.Rename(backupBinary, defaultBinaryPath); e != nil {
fmt.Printf("Warning: rollback binary failed: %v\n", e)
rollbackOK = false
}
}
if fileExists(backupCLI) {
if e := os.Rename(backupCLI, defaultCLIPath); e != nil {
fmt.Printf("Warning: rollback xbctl failed: %v\n", e)
rollbackOK = false
}
}
runCommand("systemctl", "daemon-reload")
if e := runCommand("systemctl", "restart", serviceName); e != nil {
return fmt.Errorf("upgrade and rollback restart both failed: %w", e)
}
if rollbackOK {
return errors.New("upgrade failed: service restart failed, rolled back successfully")
}
return errors.New("upgrade failed: partial rollback, check binary state manually")
}
// Clean up backups
os.Remove(backupBinary)
os.Remove(backupCLI)
// Update install-meta.json
newVer := "unknown"
if out, err := exec.Command(defaultBinaryPath, "-v").CombinedOutput(); err == nil {
newVer = strings.TrimSpace(string(out))
}
if root, err := loadWritableRootConfig(defaultConfigPath); err == nil {
instances, _ := root.NormalizeInstances()
writeInstallMetaVersioned(defaultMetaPath, root, newVer, latestInstanceID(instances))
}
fmt.Printf("Upgrade complete (version: %s)\n", newVer)
return nil
}
func runUninstall(args []string) error {
if err := ensureRoot("uninstall"); err != nil {
return err
}
purge := false
yes := false
for _, a := range args {
switch a {
case "--purge":
purge = true
case "--yes", "-y":
yes = true
}
}
if !yes {
fmt.Print("Proceed with uninstall? [y/N]: ")
var answer string
fmt.Scanln(&answer)
if answer != "y" && answer != "Y" {
fmt.Println("Uninstall cancelled")
return nil
}
}
var warnings []string
// Stop and disable service
if fileExists(serviceFilePath) {
if err := runCommand("systemctl", "stop", serviceName); err != nil {
warnings = append(warnings, fmt.Sprintf("stop service: %v", err))
}
if err := runCommand("systemctl", "disable", serviceName); err != nil {
warnings = append(warnings, fmt.Sprintf("disable service: %v", err))
}
if err := os.Remove(serviceFilePath); err != nil {
warnings = append(warnings, fmt.Sprintf("remove service file: %v", err))
}
runCommand("systemctl", "daemon-reload")
}
// Remove binaries
// Remove binaries and symlinks
for _, p := range []string{defaultBinaryPath, defaultCLIPath, "/usr/bin/xbctl"} {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
warnings = append(warnings, fmt.Sprintf("remove %s: %v", p, err))
}
}
if purge {
if err := os.RemoveAll(defaultInstallRoot); err != nil {
warnings = append(warnings, fmt.Sprintf("remove %s: %v", defaultInstallRoot, err))
} else {
fmt.Printf("Removed %s\n", defaultInstallRoot)
}
} else {
os.Remove(defaultMetaPath)
fmt.Printf("Config preserved under %s\n", defaultInstallRoot)
}
if len(warnings) > 0 {
fmt.Println("Uninstall completed with warnings:")
for _, w := range warnings {
fmt.Printf(" - %s\n", w)
}
return nil
}
fmt.Println("Uninstall complete")
return nil
}
func ensureRoot(cmd string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("%s requires root privileges; run with sudo", cmd)
}
return nil
}
func resolveDownloadURL(artifact, version string) string {
// Gitea release download URL: {base}/releases/download/{tag}/{file}
return downloadBase + "/releases/download/" + version + "/" + artifact
}
func downloadFile(url, dest string) error {
client := &http.Client{Timeout: 120 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
if downloadToken != "" {
req.Header.Set("Authorization", "token "+downloadToken)
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
func cleanupFiles(a, b string, err error) error {
os.Remove(a)
os.Remove(b)
return err
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o755)
}
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, instanceID string) 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 {
var matched bool
if instanceID != "" {
// Match by instance ID
id, _ := inst.AutoInstanceID()
matched = id == instanceID || inst.InstanceID == instanceID
} else {
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 instanceID != "" {
return fmt.Errorf("binding not found: id=%s", instanceID)
}
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 {
// Last binding removed — stop the service to prevent crash-loop
root.Instances = nil
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
}
runCommand("systemctl", "stop", serviceName)
fmt.Printf("removed %d binding(s)\n", len(removed))
fmt.Println("All bindings removed. Service stopped.")
fmt.Println("Use 'xbctl bind add-node/add-machine' to add a new binding, or 'xbctl uninstall' to fully uninstall.")
return nil
}
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("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...)
}
// Only treat legacy single-config mode if the embedded Config is valid
if root.Config.Panel.URL != "" {
return []config.Config{root.Config}
}
return nil
}
func writeRootConfig(path string, root *config.RootConfig) error {
instances := root.Instances
if len(instances) == 0 && root.Config.Panel.URL != "" {
instances = []config.Config{root.Config}
}
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.CombinedOutput()
state := strings.TrimSpace(string(out))
if state != "" {
return state
}
if err != nil {
return "unknown"
}
return state
}
func healthStatus() string {
return instanceAwareHealth()
}
func instanceAwareHealth() string {
port := 0
if meta, err := loadInstallMeta(defaultMetaPath); err == nil {
for _, inst := range meta.Instances {
if inst.HealthPort > 0 {
port = inst.HealthPort
break
}
}
}
if port == 0 {
if root, err := loadWritableRootConfig(defaultConfigPath); err == nil {
// Check top-level health_port first (inherited by all instances)
if root.HealthPort > 0 {
port = root.HealthPort
}
if port == 0 {
instances, _ := root.NormalizeInstances()
for _, inst := range instances {
if inst.HealthPort > 0 {
port = inst.HealthPort
break
}
}
}
}
}
if port == 0 {
return "disabled"
}
url := fmt.Sprintf("http://127.0.0.1:%d/healthz", port)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "down"
}
resp.Body.Close()
if resp.StatusCode == 200 {
return "ok"
}
return "down"
}
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 latestInstanceID(instances []*config.Config) string {
if len(instances) > 0 {
id, _ := instances[len(instances)-1].AutoInstanceID()
return id
}
return ""
}
func regenerateServiceFile() error {
unit := fmt.Sprintf(`[Unit]
Description=Xboard Node Backend
Documentation=https://gitea.fp-will.xyz/will/xboard-node
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%s
EnvironmentFile=-%s
ExecStart=%s -c %s
Restart=always
RestartSec=5
LimitNOFILE=1048576
NoNewPrivileges=true
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`, defaultInstallRoot, defaultCredentialsPath, defaultBinaryPath, defaultConfigPath)
return os.WriteFile(serviceFilePath, []byte(unit), 0o644)
}
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 <init|health-port>")
}
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=<generated-id>
// ENV_KEY=<credential-env-var-name>
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
}