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:
241
companion-agent/internal/process/gameserver.go
Normal file
241
companion-agent/internal/process/gameserver.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user