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>
242 lines
5.0 KiB
Go
242 lines
5.0 KiB
Go
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
|
|
}
|