feat: Add Go companion agent for bare metal server management

Implements complete companion agent for Rust servers not on managed panels.

Features:
- NATS integration with token auth and auto-reconnect
- Game server process management (start/stop/restart/monitor)
- File operations (read/write/delete/list) via NATS
- SteamCMD integration for automated updates
- Self-update capability with download and replace
- Heartbeat publishing every 60s with server status
- Graceful shutdown handling (SIGTERM/SIGINT)
- Zombie process prevention via cmd.Wait()
- Cross-platform builds (Linux amd64, Windows amd64)

Structure:
- cmd/agent/main.go: Entry point, config, signal handling
- internal/app/daemon.go: Main loop, NATS subscriptions
- internal/client/nats.go: NATS connection with reconnect
- internal/process/gameserver.go: Process management
- internal/process/steamcmd.go: Steam update execution
- internal/files/operations.go: File system operations
- internal/update/updater.go: Self-update logic
- Makefile: Cross-compilation targets
- README.md: Installation and configuration guide

NATS Subjects:
- Publishes: corrosion.{license_id}.companion.heartbeat
- Publishes: corrosion.{license_id}.files.response
- Subscribes: corrosion.{license_id}.cmd.server
- Subscribes: corrosion.{license_id}.files.{get|put|delete|list}
- Subscribes: corrosion.{license_id}.update.steam
- Subscribes: corrosion.{license_id}.update.companion

Built binaries: 7.0MB (Linux), 7.2MB (Windows)
Total code: 1,356 LOC across 8 files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 12:05:23 -05:00
parent 8bea889145
commit a62715409f
13 changed files with 1735 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
package process
import (
"fmt"
"log"
"os"
"os/exec"
"sync"
"syscall"
"time"
)
// GameServer manages the game server process
type GameServer struct {
path string
args string
cmd *exec.Cmd
mu sync.RWMutex
startTime time.Time
isRunning bool
lastStatus string
}
// NewGameServer creates a new game server manager
func NewGameServer(path, args string) *GameServer {
return &GameServer{
path: path,
args: args,
lastStatus: "stopped",
}
}
// Start starts the game server process
func (gs *GameServer) Start() error {
gs.mu.Lock()
defer gs.mu.Unlock()
if gs.isRunning {
return fmt.Errorf("server is already running")
}
// Check if executable exists
if _, err := os.Stat(gs.path); os.IsNotExist(err) {
return fmt.Errorf("server executable not found: %s", gs.path)
}
log.Printf("Starting game server: %s %s", gs.path, gs.args)
// Create command
gs.cmd = exec.Command(gs.path)
// Parse args if provided
if gs.args != "" {
// Simple space-split parsing (TODO: handle quoted args properly)
gs.cmd.Args = append(gs.cmd.Args, splitArgs(gs.args)...)
}
// Set working directory to server directory
gs.cmd.Dir = getDirectory(gs.path)
// Redirect output to our logs
gs.cmd.Stdout = os.Stdout
gs.cmd.Stderr = os.Stderr
// Start the process
if err := gs.cmd.Start(); err != nil {
gs.isRunning = false
gs.lastStatus = "failed"
return fmt.Errorf("failed to start server: %w", err)
}
gs.isRunning = true
gs.startTime = time.Now()
gs.lastStatus = "running"
// Monitor process in background to prevent zombies
go gs.monitorProcess()
log.Printf("Game server started with PID %d", gs.cmd.Process.Pid)
return nil
}
// Stop stops the game server process
func (gs *GameServer) Stop() error {
gs.mu.Lock()
defer gs.mu.Unlock()
if !gs.isRunning || gs.cmd == nil || gs.cmd.Process == nil {
return fmt.Errorf("server is not running")
}
log.Printf("Stopping game server (PID %d)", gs.cmd.Process.Pid)
// Send SIGTERM for graceful shutdown
if err := gs.cmd.Process.Signal(syscall.SIGTERM); err != nil {
log.Printf("Failed to send SIGTERM, forcing kill: %v", err)
// Force kill if SIGTERM fails
if killErr := gs.cmd.Process.Kill(); killErr != nil {
return fmt.Errorf("failed to kill process: %w", killErr)
}
}
// Wait for process to exit (with timeout)
done := make(chan error, 1)
go func() {
done <- gs.cmd.Wait()
}()
select {
case <-done:
log.Println("Server stopped gracefully")
case <-time.After(30 * time.Second):
log.Println("Server did not stop gracefully, forcing kill")
gs.cmd.Process.Kill()
<-done
}
gs.isRunning = false
gs.lastStatus = "stopped"
gs.cmd = nil
return nil
}
// Restart restarts the game server
func (gs *GameServer) Restart() error {
log.Println("Restarting game server...")
// Stop if running
if gs.isRunning {
if err := gs.Stop(); err != nil {
return fmt.Errorf("failed to stop server for restart: %w", err)
}
}
// Wait a moment for cleanup
time.Sleep(2 * time.Second)
// Start again
return gs.Start()
}
// Status returns the current server status
func (gs *GameServer) Status() string {
gs.mu.RLock()
defer gs.mu.RUnlock()
if !gs.isRunning {
return "stopped"
}
// Check if process is actually alive
if gs.cmd != nil && gs.cmd.Process != nil {
// Send signal 0 to check if process exists
if err := gs.cmd.Process.Signal(syscall.Signal(0)); err != nil {
return "crashed"
}
}
return gs.lastStatus
}
// Uptime returns how long the server has been running
func (gs *GameServer) Uptime() time.Duration {
gs.mu.RLock()
defer gs.mu.RUnlock()
if !gs.isRunning {
return 0
}
return time.Since(gs.startTime)
}
// IsRunning returns whether the server is currently running
func (gs *GameServer) IsRunning() bool {
gs.mu.RLock()
defer gs.mu.RUnlock()
return gs.isRunning
}
// monitorProcess waits for the process to exit and updates state
// This prevents zombie processes by calling Wait()
func (gs *GameServer) monitorProcess() {
if gs.cmd == nil || gs.cmd.Process == nil {
return
}
// Wait for process to exit (blocks until process dies)
err := gs.cmd.Wait()
gs.mu.Lock()
defer gs.mu.Unlock()
gs.isRunning = false
if err != nil {
log.Printf("Game server process exited with error: %v", err)
gs.lastStatus = "crashed"
} else {
log.Println("Game server process exited normally")
gs.lastStatus = "stopped"
}
// TODO: Could trigger crash recovery notification here
}
// Helper functions
func getDirectory(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
return path[:i]
}
}
return "."
}
func splitArgs(args string) []string {
// Simple space-based splitting
// TODO: Handle quoted strings properly for args with spaces
var result []string
current := ""
for _, char := range args {
if char == ' ' {
if current != "" {
result = append(result, current)
current = ""
}
} else {
current += string(char)
}
}
if current != "" {
result = append(result, current)
}
return result
}

View File

@@ -0,0 +1,138 @@
package process
import (
"fmt"
"log"
"os"
"os/exec"
"time"
)
const (
rustAppID = "258550" // Rust Dedicated Server App ID
)
// SteamCMD handles SteamCMD operations for game server updates
type SteamCMD struct {
path string
}
// NewSteamCMD creates a new SteamCMD instance
func NewSteamCMD(path string) *SteamCMD {
return &SteamCMD{
path: path,
}
}
// UpdateRustServer updates the Rust Dedicated Server via SteamCMD
func (sc *SteamCMD) UpdateRustServer(validate bool) error {
log.Printf("Starting SteamCMD update for Rust Server (validate=%v)", validate)
// Check if SteamCMD exists
if _, err := os.Stat(sc.path); os.IsNotExist(err) {
return fmt.Errorf("steamcmd not found at: %s", sc.path)
}
startTime := time.Now()
// Build SteamCMD command
// +login anonymous +force_install_dir /path/to/rust +app_update 258550 validate +quit
args := []string{
"+login", "anonymous",
"+force_install_dir", getServerInstallDir(),
"+app_update", rustAppID,
}
if validate {
args = append(args, "validate")
}
args = append(args, "+quit")
log.Printf("Executing: %s %v", sc.path, args)
// Create command
cmd := exec.Command(sc.path, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Run SteamCMD (this will block until update completes)
if err := cmd.Run(); err != nil {
return fmt.Errorf("steamcmd update failed: %w", err)
}
duration := time.Since(startTime)
log.Printf("SteamCMD update completed in %v", duration)
return nil
}
// UpdateRustServerWithPath updates the Rust server to a specific install directory
func (sc *SteamCMD) UpdateRustServerWithPath(installPath string, validate bool) error {
log.Printf("Starting SteamCMD update for Rust Server at %s (validate=%v)", installPath, validate)
// Check if SteamCMD exists
if _, err := os.Stat(sc.path); os.IsNotExist(err) {
return fmt.Errorf("steamcmd not found at: %s", sc.path)
}
startTime := time.Now()
// Build SteamCMD command
args := []string{
"+login", "anonymous",
"+force_install_dir", installPath,
"+app_update", rustAppID,
}
if validate {
args = append(args, "validate")
}
args = append(args, "+quit")
log.Printf("Executing: %s %v", sc.path, args)
// Create command
cmd := exec.Command(sc.path, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Run SteamCMD
if err := cmd.Run(); err != nil {
return fmt.Errorf("steamcmd update failed: %w", err)
}
duration := time.Since(startTime)
log.Printf("SteamCMD update completed in %v", duration)
return nil
}
// CheckSteamCMDInstalled verifies SteamCMD is installed and executable
func (sc *SteamCMD) CheckSteamCMDInstalled() error {
if _, err := os.Stat(sc.path); os.IsNotExist(err) {
return fmt.Errorf("steamcmd not found at: %s", sc.path)
}
// Try to execute with --help or similar to verify it's executable
cmd := exec.Command(sc.path, "+quit")
if err := cmd.Run(); err != nil {
return fmt.Errorf("steamcmd is not executable or working: %w", err)
}
return nil
}
// getServerInstallDir returns the default server installation directory
// This should ideally come from configuration, but we provide a fallback
func getServerInstallDir() string {
// Try to determine from GAME_SERVER_PATH environment variable
serverPath := os.Getenv("GAME_SERVER_PATH")
if serverPath != "" {
return getDirectory(serverPath)
}
// Default fallback paths by OS
return "/home/rustserver/server"
}