Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07c37023ab | |||
| 71c53e1071 | |||
| 65d97f3da1 |
@@ -42,13 +42,6 @@ curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.s
|
|||||||
curl -fsSL https://raw.githubusercontent.com/cedar2025/xboard-node/dev/install.sh | \
|
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
|
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
|
## xbctl
|
||||||
|
|
||||||
Run `xbctl` after installation for help. Common commands:
|
Run `xbctl` after installation for help. Common commands:
|
||||||
|
|||||||
+465
-42
@@ -5,9 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,11 +25,12 @@ const (
|
|||||||
defaultConfigPath = "/etc/xboard-node/config.yml"
|
defaultConfigPath = "/etc/xboard-node/config.yml"
|
||||||
defaultMetaPath = "/etc/xboard-node/install-meta.json"
|
defaultMetaPath = "/etc/xboard-node/install-meta.json"
|
||||||
defaultCredentialsPath = "/etc/xboard-node/credentials.env"
|
defaultCredentialsPath = "/etc/xboard-node/credentials.env"
|
||||||
defaultInstallerPath = "/etc/xboard-node/install.sh"
|
|
||||||
defaultBinaryPath = "/usr/local/bin/xboard-node"
|
defaultBinaryPath = "/usr/local/bin/xboard-node"
|
||||||
defaultCLIPath = "/usr/local/bin/xbctl"
|
defaultCLIPath = "/usr/local/bin/xbctl"
|
||||||
defaultHealthURL = "http://127.0.0.1:65530/healthz"
|
|
||||||
serviceName = "xboard-node.service"
|
serviceName = "xboard-node.service"
|
||||||
|
serviceFilePath = "/etc/systemd/system/xboard-node.service"
|
||||||
|
defaultInstallRoot = "/etc/xboard-node"
|
||||||
|
downloadBase = "https://github.com/cedar2025/xboard-node/releases"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -158,7 +162,7 @@ func run(args []string) error {
|
|||||||
return runInstance(args[1:])
|
return runInstance(args[1:])
|
||||||
case "service":
|
case "service":
|
||||||
return runService(args[1:])
|
return runService(args[1:])
|
||||||
case "logs":
|
case "logs", "log":
|
||||||
return runService([]string{"logs"})
|
return runService([]string{"logs"})
|
||||||
case "health":
|
case "health":
|
||||||
return runHealth()
|
return runHealth()
|
||||||
@@ -172,12 +176,12 @@ func run(args []string) error {
|
|||||||
return runBind(append([]string{"remove-node"}, args[1:]...))
|
return runBind(append([]string{"remove-node"}, args[1:]...))
|
||||||
case "unbind-machine":
|
case "unbind-machine":
|
||||||
return runBind(append([]string{"remove-machine"}, args[1:]...))
|
return runBind(append([]string{"remove-machine"}, args[1:]...))
|
||||||
case "start", "stop", "restart":
|
case "start", "stop", "restart", "enable", "disable":
|
||||||
return runService(args)
|
return runService(args)
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
return runInstaller(append([]string{"upgrade"}, args[1:]...))
|
return runUpgrade(args[1:])
|
||||||
case "uninstall":
|
case "uninstall":
|
||||||
return runInstaller(append([]string{"uninstall"}, args[1:]...))
|
return runUninstall(args[1:])
|
||||||
case "version", "-v", "--version":
|
case "version", "-v", "--version":
|
||||||
fmt.Printf("xbctl %s (built %s)\n", version, buildTime)
|
fmt.Printf("xbctl %s (built %s)\n", version, buildTime)
|
||||||
return nil
|
return nil
|
||||||
@@ -200,22 +204,53 @@ func printUsage() {
|
|||||||
xbctl instance get <id> [--output text|json]
|
xbctl instance get <id> [--output text|json]
|
||||||
xbctl config init --mode node|machine --panel-url URL --token TOKEN [flags]
|
xbctl config init --mode node|machine --panel-url URL --token TOKEN [flags]
|
||||||
xbctl config health-port [--config PATH]
|
xbctl config health-port [--config PATH]
|
||||||
xbctl service status|start|stop|restart|logs
|
xbctl service status|start|stop|restart|enable|disable|logs
|
||||||
xbctl health
|
xbctl health
|
||||||
xbctl bind add-node --panel URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray]
|
xbctl bind add-node --panel-url 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 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-node --panel URL --node-id ID
|
||||||
xbctl bind remove-machine --panel URL --machine-id ID
|
xbctl bind remove-machine --panel URL --machine-id ID
|
||||||
xbctl bind-node ...
|
xbctl upgrade [--version VERSION]
|
||||||
xbctl bind-machine ...
|
xbctl uninstall [--purge] [--yes]
|
||||||
xbctl unbind-node --panel URL --node-id ID
|
xbctl version
|
||||||
xbctl unbind-machine --panel URL --machine-id ID
|
|
||||||
xbctl upgrade [...]
|
shortcuts:
|
||||||
xbctl uninstall [...]`)
|
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 {
|
func runStatus() error {
|
||||||
return runInstaller([]string{"status"})
|
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 {
|
func runList(args []string) error {
|
||||||
@@ -256,14 +291,14 @@ func runInstance(args []string) error {
|
|||||||
|
|
||||||
func runService(args []string) error {
|
func runService(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("usage: xbctl service <status|start|stop|restart|logs>")
|
return errors.New("usage: xbctl service <status|start|stop|restart|enable|disable|logs>")
|
||||||
}
|
}
|
||||||
sub := args[0]
|
sub := args[0]
|
||||||
rest := args[1:]
|
rest := args[1:]
|
||||||
switch sub {
|
switch sub {
|
||||||
case "status":
|
case "status":
|
||||||
return runCommand("sudo", append([]string{"systemctl", "status", serviceName, "--no-pager"}, rest...)...)
|
return runCommand("sudo", append([]string{"systemctl", "status", serviceName, "--no-pager"}, rest...)...)
|
||||||
case "start", "stop", "restart":
|
case "start", "stop", "restart", "enable", "disable":
|
||||||
return runCommand("sudo", append([]string{"systemctl", sub, serviceName}, rest...)...)
|
return runCommand("sudo", append([]string{"systemctl", sub, serviceName}, rest...)...)
|
||||||
case "logs":
|
case "logs":
|
||||||
if len(rest) == 0 {
|
if len(rest) == 0 {
|
||||||
@@ -276,50 +311,330 @@ func runService(args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runHealth() error {
|
func runHealth() error {
|
||||||
return runCommand("curl", "-fsS", defaultHealthURL)
|
h := instanceAwareHealth()
|
||||||
|
fmt.Println(h)
|
||||||
|
if h == "down" {
|
||||||
|
return errors.New("health check failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runBind(args []string) error {
|
func runBind(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("usage: xbctl bind <add-node|add-machine|remove-node|remove-machine> ...")
|
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]
|
sub := args[0]
|
||||||
rest := args[1:]
|
rest := args[1:]
|
||||||
switch sub {
|
switch sub {
|
||||||
case "add-node":
|
case "add-node":
|
||||||
return runInstaller(append([]string{"--mode", "node"}, rest...))
|
return runBindAdd("node", rest)
|
||||||
case "add-machine":
|
case "add-machine":
|
||||||
return runInstaller(append([]string{"--mode", "machine"}, rest...))
|
return runBindAdd("machine", rest)
|
||||||
case "remove-node":
|
case "remove-node":
|
||||||
panel, nodeID, err := parseRemoveNodeArgs(rest)
|
panel, nodeID, err := parseRemoveNodeArgs(rest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return removeBinding(panel, nodeID, 0)
|
return removeBinding(panel, nodeID, 0, "")
|
||||||
case "remove-machine":
|
case "remove-machine":
|
||||||
panel, machineID, err := parseRemoveMachineArgs(rest)
|
panel, machineID, err := parseRemoveMachineArgs(rest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return removeBinding(panel, 0, machineID)
|
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:
|
default:
|
||||||
return fmt.Errorf("unknown bind command: %s", sub)
|
return fmt.Errorf("unknown bind command: %s", sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInstaller(args []string) error {
|
func runBindAdd(mode string, args []string) error {
|
||||||
installerPath := defaultInstallerPath
|
// Build configInit args from bind args
|
||||||
usingSystemInstaller := false
|
initArgs := []string{
|
||||||
if _, err := os.Stat(installerPath); err == nil {
|
"--mode", mode,
|
||||||
usingSystemInstaller = true
|
"--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 {
|
} else {
|
||||||
installerPath = "./install.sh"
|
fmt.Printf("Removed %s\n", defaultInstallRoot)
|
||||||
}
|
}
|
||||||
all := append([]string{"bash", installerPath}, args...)
|
} else {
|
||||||
if usingSystemInstaller {
|
os.Remove(defaultMetaPath)
|
||||||
all = append(all, "--binary", defaultBinaryPath, "--xbctl-binary", defaultCLIPath)
|
fmt.Printf("Config preserved under %s\n", defaultInstallRoot)
|
||||||
}
|
}
|
||||||
return runCommand("sudo", all...)
|
|
||||||
|
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 {
|
||||||
|
if version == "latest" {
|
||||||
|
return downloadBase + "/latest/download/" + artifact
|
||||||
|
}
|
||||||
|
return downloadBase + "/download/" + version + "/" + artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(url, dest string) error {
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
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 {
|
func runCommand(name string, args ...string) error {
|
||||||
@@ -413,7 +728,7 @@ func parsePositiveInt(raw string, field string) (int, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeBinding(panelURL string, nodeID int, machineID int) error {
|
func removeBinding(panelURL string, nodeID int, machineID int, instanceID string) error {
|
||||||
root, err := loadWritableRootConfig(defaultConfigPath)
|
root, err := loadWritableRootConfig(defaultConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -426,13 +741,20 @@ func removeBinding(panelURL string, nodeID int, machineID int) error {
|
|||||||
kept := make([]config.Config, 0, len(instances))
|
kept := make([]config.Config, 0, len(instances))
|
||||||
removed := make([]config.Config, 0, 1)
|
removed := make([]config.Config, 0, 1)
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
matched := strings.TrimSpace(inst.Panel.URL) == strings.TrimSpace(panelURL)
|
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 {
|
if nodeID > 0 {
|
||||||
matched = matched && !inst.IsMachineMode() && inst.Panel.NodeID == nodeID
|
matched = matched && !inst.IsMachineMode() && inst.Panel.NodeID == nodeID
|
||||||
}
|
}
|
||||||
if machineID > 0 {
|
if machineID > 0 {
|
||||||
matched = matched && inst.IsMachineMode() && inst.Machine != nil && inst.Machine.MachineID == machineID
|
matched = matched && inst.IsMachineMode() && inst.Machine != nil && inst.Machine.MachineID == machineID
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if matched {
|
if matched {
|
||||||
removed = append(removed, inst)
|
removed = append(removed, inst)
|
||||||
continue
|
continue
|
||||||
@@ -440,13 +762,31 @@ func removeBinding(panelURL string, nodeID int, machineID int) error {
|
|||||||
kept = append(kept, inst)
|
kept = append(kept, inst)
|
||||||
}
|
}
|
||||||
if len(removed) == 0 {
|
if len(removed) == 0 {
|
||||||
|
if instanceID != "" {
|
||||||
|
return fmt.Errorf("binding not found: id=%s", instanceID)
|
||||||
|
}
|
||||||
if nodeID > 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 node_id=%d", panelURL, nodeID)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("binding not found: panel=%s machine_id=%d", panelURL, machineID)
|
return fmt.Errorf("binding not found: panel=%s machine_id=%d", panelURL, machineID)
|
||||||
}
|
}
|
||||||
if len(kept) == 0 {
|
if len(kept) == 0 {
|
||||||
return errors.New("refusing to remove the last binding; use xbctl uninstall if you want to remove everything")
|
// 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
|
root.Instances = kept
|
||||||
@@ -460,7 +800,7 @@ func removeBinding(panelURL string, nodeID int, machineID int) error {
|
|||||||
if err := writeInstallMeta(defaultMetaPath, root); err != nil {
|
if err := writeInstallMeta(defaultMetaPath, root); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := runCommand("sudo", "systemctl", "restart", serviceName); err != nil {
|
if err := runCommand("systemctl", "restart", serviceName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("removed %d binding(s)\n", len(removed))
|
fmt.Printf("removed %d binding(s)\n", len(removed))
|
||||||
@@ -490,11 +830,18 @@ func normalizeRootInstances(root *config.RootConfig) []config.Config {
|
|||||||
if len(root.Instances) > 0 {
|
if len(root.Instances) > 0 {
|
||||||
return append([]config.Config(nil), root.Instances...)
|
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 []config.Config{root.Config}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRootConfig(path string, root *config.RootConfig) error {
|
func writeRootConfig(path string, root *config.RootConfig) error {
|
||||||
instances := normalizeRootInstances(root)
|
instances := root.Instances
|
||||||
|
if len(instances) == 0 && root.Config.Panel.URL != "" {
|
||||||
|
instances = []config.Config{root.Config}
|
||||||
|
}
|
||||||
out := fileRootConfig{Instances: make([]fileInstance, 0, len(instances))}
|
out := fileRootConfig{Instances: make([]fileInstance, 0, len(instances))}
|
||||||
|
|
||||||
// Preserve top-level shared settings so instances can inherit them.
|
// Preserve top-level shared settings so instances can inherit them.
|
||||||
@@ -723,19 +1070,62 @@ func printRows(rows []instanceRow, output string) error {
|
|||||||
|
|
||||||
func systemctlState() string {
|
func systemctlState() string {
|
||||||
cmd := exec.Command("systemctl", "is-active", serviceName)
|
cmd := exec.Command("systemctl", "is-active", serviceName)
|
||||||
out, err := cmd.Output()
|
out, err := cmd.CombinedOutput()
|
||||||
|
state := strings.TrimSpace(string(out))
|
||||||
|
if state != "" {
|
||||||
|
return state
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out))
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
func healthStatus() string {
|
func healthStatus() string {
|
||||||
cmd := exec.Command("curl", "-fsS", defaultHealthURL)
|
return instanceAwareHealth()
|
||||||
if err := cmd.Run(); err != nil {
|
}
|
||||||
|
|
||||||
|
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"
|
return "down"
|
||||||
}
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
return "ok"
|
return "ok"
|
||||||
|
}
|
||||||
|
return "down"
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatTarget(nodeID *int, machineID *int) string {
|
func formatTarget(nodeID *int, machineID *int) string {
|
||||||
@@ -756,6 +1146,39 @@ func intPtr(v int) *int {
|
|||||||
return &vv
|
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://github.com/cedar2025/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 {
|
func machineIDPtr(cfg *config.Config) *int {
|
||||||
if cfg.Machine == nil || cfg.Machine.MachineID <= 0 {
|
if cfg.Machine == nil || cfg.Machine.MachineID <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -529,6 +529,31 @@ func applyStreamSettings(base M, nc *model.NodeSpec, tc kernel.TLSCert) {
|
|||||||
}
|
}
|
||||||
ss["httpSettings"] = h2Settings
|
ss["httpSettings"] = h2Settings
|
||||||
|
|
||||||
|
case "xhttp", "splithttp":
|
||||||
|
ss["network"] = "xhttp"
|
||||||
|
xhttpSettings := M{}
|
||||||
|
if nc.NetworkSettings != nil {
|
||||||
|
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||||
|
xhttpSettings["path"] = v
|
||||||
|
}
|
||||||
|
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||||
|
xhttpSettings["host"] = v
|
||||||
|
}
|
||||||
|
if v, ok := nc.NetworkSettings["mode"]; ok {
|
||||||
|
xhttpSettings["mode"] = v
|
||||||
|
}
|
||||||
|
if v, ok := nc.NetworkSettings["extra"]; ok {
|
||||||
|
// PHP sends empty arrays [] instead of {} for empty objects;
|
||||||
|
// xray rejects [] for fields that expect objects (e.g. sockopt, tlsSettings).
|
||||||
|
// Recursively strip empty arrays from the extra map before passing to xray.
|
||||||
|
if m, ok := v.(map[string]interface{}); ok && len(m) > 0 {
|
||||||
|
sanitizeEmptyArrays(m)
|
||||||
|
xhttpSettings["extra"] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ss["xhttpSettings"] = xhttpSettings
|
||||||
|
|
||||||
case "tcp":
|
case "tcp":
|
||||||
// default, no extra settings
|
// default, no extra settings
|
||||||
}
|
}
|
||||||
@@ -784,6 +809,22 @@ func copyStrings(src []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeEmptyArrays recursively removes empty slices from a map.
|
||||||
|
// PHP encodes empty objects as [] instead of {}; xray-core rejects []
|
||||||
|
// for fields that expect struct types (e.g. sockopt, tlsSettings).
|
||||||
|
func sanitizeEmptyArrays(m map[string]interface{}) {
|
||||||
|
for k, v := range m {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
if len(val) == 0 {
|
||||||
|
delete(m, k)
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
sanitizeEmptyArrays(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
|
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
|
||||||
// and converts it from PEM to base64 for xray-core's echServerKeys field.
|
// and converts it from PEM to base64 for xray-core's echServerKeys field.
|
||||||
func extractECHServerKeys(tlsSettings map[string]interface{}) string {
|
func extractECHServerKeys(tlsSettings map[string]interface{}) string {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/config"
|
"github.com/cedar2025/xboard-node/internal/config"
|
||||||
"github.com/cedar2025/xboard-node/internal/controlplane"
|
"github.com/cedar2025/xboard-node/internal/controlplane"
|
||||||
|
"github.com/cedar2025/xboard-node/internal/model"
|
||||||
"github.com/cedar2025/xboard-node/internal/monitor"
|
"github.com/cedar2025/xboard-node/internal/monitor"
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
"github.com/cedar2025/xboard-node/internal/panel"
|
"github.com/cedar2025/xboard-node/internal/panel"
|
||||||
@@ -134,6 +135,19 @@ func (o *Orchestrator) startNode(ctx context.Context, mn panel.MachineNode) {
|
|||||||
|
|
||||||
perNodeClient := o.client.ForNode(mn.ID)
|
perNodeClient := o.client.ForNode(mn.ID)
|
||||||
|
|
||||||
|
// Pre-fetch node config to detect transport-based kernel requirements.
|
||||||
|
// If the transport (e.g. xhttp) is incompatible with the configured kernel
|
||||||
|
// (e.g. singbox), auto-switch to the required kernel for this node.
|
||||||
|
if cfgSnapshot, err := perNodeClient.GetConfig(); err == nil && cfgSnapshot != nil {
|
||||||
|
if resolved := model.ResolveKernelForTransport(cfgSnapshot.Network, nodeCfg.Kernel.Type); resolved != nodeCfg.Kernel.Type {
|
||||||
|
nlog.Core().Info(fmt.Sprintf("machine: auto-switching kernel for node %d (%s→%s, transport=%s)",
|
||||||
|
mn.ID, nodeCfg.Kernel.Type, resolved, cfgSnapshot.Network))
|
||||||
|
nodeCfg.Kernel.Type = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset cached ETag so the subsequent GetConfig in Initial() gets a full response.
|
||||||
|
perNodeClient.ResetConfigETag()
|
||||||
|
|
||||||
var push controlplane.PushClient
|
var push controlplane.PushClient
|
||||||
if o.ws != nil {
|
if o.ws != nil {
|
||||||
push = &machineNodePush{
|
push = &machineNodePush{
|
||||||
@@ -247,6 +261,7 @@ func (o *Orchestrator) reportMachineStatus() {
|
|||||||
[2]uint64{s.MemTotal, s.MemUsed},
|
[2]uint64{s.MemTotal, s.MemUsed},
|
||||||
[2]uint64{s.SwapTotal, s.SwapUsed},
|
[2]uint64{s.SwapTotal, s.SwapUsed},
|
||||||
[2]uint64{s.DiskTotal, s.DiskUsed},
|
[2]uint64{s.DiskTotal, s.DiskUsed},
|
||||||
|
s.NetInSpeed, s.NetOutSpeed,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
nlog.Core().Warn("machine status report failed", "error", err)
|
nlog.Core().Warn("machine status report failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,38 @@ func ValidateNodeSpec(n *NodeSpec, kcfg config.KernelConfig) error {
|
|||||||
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
|
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
|
||||||
return fmt.Errorf("validate custom route rules: %w", err)
|
return fmt.Errorf("validate custom route rules: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := validateTransportKernel(n.Network, kernelType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// singboxUnsupportedTransports lists transport types that sing-box does not support.
|
||||||
|
var singboxUnsupportedTransports = map[string]bool{
|
||||||
|
"xhttp": true,
|
||||||
|
"splithttp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTransportKernel(network, kernelType string) error {
|
||||||
|
net := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
if kernelType == "singbox" && singboxUnsupportedTransports[net] {
|
||||||
|
return fmt.Errorf("transport %q is not supported by sing-box kernel; use xray kernel instead", net)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveKernelForTransport returns the kernel type required by the given
|
||||||
|
// transport. If the configured kernel cannot handle the transport, it returns
|
||||||
|
// the kernel that can. Otherwise it returns configuredKernel unchanged.
|
||||||
|
// This is used in machine mode to auto-switch kernel per node.
|
||||||
|
func ResolveKernelForTransport(network, configuredKernel string) string {
|
||||||
|
net := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
if configuredKernel == "singbox" && singboxUnsupportedTransports[net] {
|
||||||
|
return "xray"
|
||||||
|
}
|
||||||
|
return configuredKernel
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeKernelType(value string) (string, error) {
|
func normalizeKernelType(value string) (string, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "singbox", "sing-box":
|
case "singbox", "sing-box":
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
var startTime = time.Now()
|
var startTime = time.Now()
|
||||||
@@ -18,6 +21,9 @@ func init() {
|
|||||||
// always returns 0% because it has no prior sample. This throwaway call
|
// always returns 0% because it has no prior sample. This throwaway call
|
||||||
// seeds the baseline so subsequent Collect() calls return real values.
|
// seeds the baseline so subsequent Collect() calls return real values.
|
||||||
cpu.Percent(500*time.Millisecond, false)
|
cpu.Percent(500*time.Millisecond, false)
|
||||||
|
|
||||||
|
// Seed the network baseline so the first Collect() can compute rates.
|
||||||
|
collectNetSpeed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status holds system resource metrics
|
// Status holds system resource metrics
|
||||||
@@ -36,11 +42,81 @@ type Status struct {
|
|||||||
DiskUsed uint64
|
DiskUsed uint64
|
||||||
Goroutines int
|
Goroutines int
|
||||||
|
|
||||||
|
// Network speed (bytes/sec), -1 means unavailable (first sample).
|
||||||
|
NetInSpeed float64
|
||||||
|
NetOutSpeed float64
|
||||||
|
|
||||||
// GC metrics (process-wide)
|
// GC metrics (process-wide)
|
||||||
NumGC uint32
|
NumGC uint32
|
||||||
LastPauseMS float64
|
LastPauseMS float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// netBaseline tracks the previous network counters for rate calculation.
|
||||||
|
var (
|
||||||
|
netMu sync.Mutex
|
||||||
|
netPrevRecv uint64
|
||||||
|
netPrevSent uint64
|
||||||
|
netPrevTime time.Time
|
||||||
|
netHasBase bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// skipInterface returns true for loopback and common virtual interfaces.
|
||||||
|
func skipInterface(name string) bool {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
for _, prefix := range []string{"lo", "docker", "veth", "br-", "virbr", "vnet", "tun", "tap"} {
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectNetSpeed calculates network in/out bytes per second since last call.
|
||||||
|
// Returns -1, -1 on first call or if counters decreased (reboot).
|
||||||
|
func collectNetSpeed() (inSpeed, outSpeed float64) {
|
||||||
|
counters, err := net.IOCounters(true) // per-interface
|
||||||
|
if err != nil {
|
||||||
|
nlog.Core().Debug("failed to get network counters", "error", err)
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRecv, totalSent uint64
|
||||||
|
for _, c := range counters {
|
||||||
|
if skipInterface(c.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalRecv += c.BytesRecv
|
||||||
|
totalSent += c.BytesSent
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
netMu.Lock()
|
||||||
|
defer netMu.Unlock()
|
||||||
|
|
||||||
|
if !netHasBase {
|
||||||
|
netPrevRecv, netPrevSent, netPrevTime, netHasBase = totalRecv, totalSent, now, true
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(netPrevTime).Seconds()
|
||||||
|
if elapsed <= 0 {
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter decreased → system reboot or interface reset; reset baseline.
|
||||||
|
if totalRecv < netPrevRecv || totalSent < netPrevSent {
|
||||||
|
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
inSpeed = float64(totalRecv-netPrevRecv) / elapsed
|
||||||
|
outSpeed = float64(totalSent-netPrevSent) / elapsed
|
||||||
|
|
||||||
|
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
|
||||||
|
return inSpeed, outSpeed
|
||||||
|
}
|
||||||
|
|
||||||
// Collect gathers current system metrics
|
// Collect gathers current system metrics
|
||||||
func Collect() Status {
|
func Collect() Status {
|
||||||
var s Status
|
var s Status
|
||||||
@@ -79,6 +155,8 @@ func Collect() Status {
|
|||||||
s.DiskUsed = diskStat.Used
|
s.DiskUsed = diskStat.Used
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.NetInSpeed, s.NetOutSpeed = collectNetSpeed()
|
||||||
|
|
||||||
// GC metrics
|
// GC metrics
|
||||||
var ms runtime.MemStats
|
var ms runtime.MemStats
|
||||||
runtime.ReadMemStats(&ms)
|
runtime.ReadMemStats(&ms)
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ func (c *Client) ForNode(nodeID int) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetConfigETag clears the cached config ETag so the next GetConfig call
|
||||||
|
// returns a full response instead of 304. Used by machine mode after a
|
||||||
|
// pre-fetch to probe the transport type.
|
||||||
|
func (c *Client) ResetConfigETag() {
|
||||||
|
c.configETag = ""
|
||||||
|
}
|
||||||
|
|
||||||
// Handshake calls the new v2 API to get WS config + initial data in one shot.
|
// Handshake calls the new v2 API to get WS config + initial data in one shot.
|
||||||
func (c *Client) Handshake() (*HandshakeResponse, error) {
|
func (c *Client) Handshake() (*HandshakeResponse, error) {
|
||||||
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
|
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
|
||||||
@@ -335,13 +342,17 @@ func (c *Client) GetMachineNodes() (*MachineNodesResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ReportMachineStatus sends machine-level load metrics to the panel.
|
// ReportMachineStatus sends machine-level load metrics to the panel.
|
||||||
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64) error {
|
// netIn/netOut are bytes/sec; negative values mean "unavailable" (first sample).
|
||||||
|
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64, netIn, netOut float64) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"cpu": cpu,
|
"cpu": cpu,
|
||||||
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
||||||
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
||||||
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
||||||
}
|
}
|
||||||
|
if netIn >= 0 && netOut >= 0 {
|
||||||
|
payload["net"] = map[string]interface{}{"in_speed": netIn, "out_speed": netOut}
|
||||||
|
}
|
||||||
return c.postJSON("/api/v2/server/machine/status", payload)
|
return c.postJSON("/api/v2/server/machine/status", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user