diff --git a/companion-agent/Makefile b/companion-agent/Makefile new file mode 100644 index 0000000..fdaa6ff --- /dev/null +++ b/companion-agent/Makefile @@ -0,0 +1,89 @@ +.PHONY: all build build-linux build-windows clean test run + +# Binary names +BINARY_NAME=corrosion-companion +BINARY_LINUX=$(BINARY_NAME)-linux-amd64 +BINARY_WINDOWS=$(BINARY_NAME)-windows-amd64.exe + +# Build directory +BUILD_DIR=bin + +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +GOMOD=$(GOCMD) mod + +# Build flags +LDFLAGS=-ldflags "-s -w" + +all: clean build + +build: build-linux build-windows + +build-linux: + @echo "Building for Linux (amd64)..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_LINUX) ./cmd/agent + @echo "Built: $(BUILD_DIR)/$(BINARY_LINUX)" + +build-windows: + @echo "Building for Windows (amd64)..." + @mkdir -p $(BUILD_DIR) + GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_WINDOWS) ./cmd/agent + @echo "Built: $(BUILD_DIR)/$(BINARY_WINDOWS)" + +build-local: + @echo "Building for current platform..." + @mkdir -p $(BUILD_DIR) + $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/agent + @echo "Built: $(BUILD_DIR)/$(BINARY_NAME)" + +clean: + @echo "Cleaning..." + @$(GOCLEAN) + @rm -rf $(BUILD_DIR) + +test: + @echo "Running tests..." + @$(GOTEST) -v ./... + +deps: + @echo "Downloading dependencies..." + @$(GOMOD) download + @$(GOMOD) tidy + +run: build-local + @echo "Running agent..." + @./$(BUILD_DIR)/$(BINARY_NAME) + +# Install systemd service (Linux only) +install-service: + @echo "Installing systemd service..." + @sudo cp $(BUILD_DIR)/$(BINARY_LINUX) /usr/local/bin/$(BINARY_NAME) + @sudo cp deployment/corrosion-companion.service /etc/systemd/system/ + @sudo systemctl daemon-reload + @sudo systemctl enable corrosion-companion + @echo "Service installed. Configure /etc/corrosion-companion/.env then start with: sudo systemctl start corrosion-companion" + +# Development helpers +dev: build-local + @./$(BUILD_DIR)/$(BINARY_NAME) + +fmt: + @echo "Formatting code..." + @$(GOCMD) fmt ./... + +lint: + @echo "Running linter..." + @golangci-lint run ./... + +# Show build info +info: + @echo "Build Information:" + @echo " Binary Name: $(BINARY_NAME)" + @echo " Linux Binary: $(BUILD_DIR)/$(BINARY_LINUX)" + @echo " Windows Binary: $(BUILD_DIR)/$(BINARY_WINDOWS)" + @echo " Go Version: $(shell go version)" diff --git a/companion-agent/README.md b/companion-agent/README.md new file mode 100644 index 0000000..209f8e2 --- /dev/null +++ b/companion-agent/README.md @@ -0,0 +1,260 @@ +# Corrosion Companion Agent + +The Companion Agent is a lightweight Go binary that runs on bare metal Rust dedicated servers to provide process management, file operations, and SteamCMD integration for servers not running on managed game server panels (AMP/Pterodactyl). + +## Features + +- **NATS Integration**: Connects to Corrosion cloud via NATS with token authentication +- **Process Management**: Start, stop, restart, and monitor game server process +- **File Operations**: Remote file management (read, write, delete, list) +- **SteamCMD Integration**: Automatic server updates via SteamCMD +- **Self-Update**: Download and apply agent updates from the cloud +- **Heartbeat Monitoring**: Publishes status every 60 seconds +- **Graceful Shutdown**: Handles SIGTERM/SIGINT for clean shutdowns +- **Crash Detection**: Monitors server process and reports crashes +- **Zero Configuration**: Pre-configured binary downloaded from dashboard + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Corrosion Cloud (NATS) │ +│ corrosion.{license_id}.cmd.* │ +│ corrosion.{license_id}.files.* │ +│ corrosion.{license_id}.update.steam │ +└──────────────────┬──────────────────────────┘ + │ (Outbound connection) + │ + ┌─────────▼──────────┐ + │ Companion Agent │ + │ (This binary) │ + └─────────┬──────────┘ + │ + ┌─────────▼──────────┐ + │ Rust Dedicated │ + │ Server Process │ + └────────────────────┘ +``` + +## Installation + +### Prerequisites + +- Linux server (Ubuntu/Debian recommended) or Windows Server +- SteamCMD installed +- Rust Dedicated Server installed + +### Quick Start + +1. **Download** the pre-configured binary from your Corrosion dashboard +2. **Make executable** (Linux only): + ```bash + chmod +x corrosion-companion-linux-amd64 + ``` + +3. **Set environment variables**: + ```bash + export NATS_URL="nats://nats.corrosionmgmt.com:4222" + export NATS_TOKEN="your-companion-token-from-dashboard" + export LICENSE_ID="your-license-uuid" + export GAME_SERVER_PATH="/home/rustserver/RustDedicated" + export STEAMCMD_PATH="/usr/games/steamcmd" + ``` + +4. **Run** the agent: + ```bash + ./corrosion-companion-linux-amd64 + ``` + +### Production Deployment (Linux with systemd) + +1. **Copy binary** to system location: + ```bash + sudo cp corrosion-companion-linux-amd64 /usr/local/bin/corrosion-companion + ``` + +2. **Create environment file**: + ```bash + sudo mkdir -p /etc/corrosion-companion + sudo nano /etc/corrosion-companion/config.env + ``` + + Add: + ```env + NATS_URL=nats://nats.corrosionmgmt.com:4222 + NATS_TOKEN=your-companion-token-from-dashboard + LICENSE_ID=your-license-uuid + GAME_SERVER_PATH=/home/rustserver/RustDedicated + STEAMCMD_PATH=/usr/games/steamcmd + GAME_SERVER_ARGS=-batchmode +server.port 28015 +server.identity "myserver" + HEARTBEAT_INTERVAL=60 + ``` + +3. **Create systemd service**: + ```bash + sudo nano /etc/systemd/system/corrosion-companion.service + ``` + + Add: + ```ini + [Unit] + Description=Corrosion Companion Agent + After=network.target + + [Service] + Type=simple + User=rustserver + Group=rustserver + EnvironmentFile=/etc/corrosion-companion/config.env + ExecStart=/usr/local/bin/corrosion-companion + Restart=always + RestartSec=10 + StandardOutput=journal + StandardError=journal + + [Install] + WantedBy=multi-user.target + ``` + +4. **Enable and start**: + ```bash + sudo systemctl daemon-reload + sudo systemctl enable corrosion-companion + sudo systemctl start corrosion-companion + ``` + +5. **Check status**: + ```bash + sudo systemctl status corrosion-companion + sudo journalctl -u corrosion-companion -f + ``` + +## Configuration + +All configuration is done via environment variables: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NATS_URL` | Yes | - | NATS server URL | +| `NATS_TOKEN` | Yes | - | Authentication token from dashboard | +| `LICENSE_ID` | Yes | - | Your Corrosion license UUID | +| `GAME_SERVER_PATH` | Yes | - | Full path to game server executable | +| `STEAMCMD_PATH` | No | `/usr/games/steamcmd` | Full path to steamcmd | +| `GAME_SERVER_ARGS` | No | `-batchmode` | Arguments to pass to game server | +| `HEARTBEAT_INTERVAL` | No | `60` | Heartbeat interval in seconds | +| `LOG_LEVEL` | No | `info` | Log verbosity (info/debug/warn/error) | + +## NATS Subject Reference + +### Published by Companion + +| Subject | Payload | Frequency | +|---------|---------|-----------| +| `corrosion.{license_id}.companion.heartbeat` | Status, uptime, disk, CPU | Every 60s | +| `corrosion.{license_id}.files.response` | File operation results | On request | + +### Subscribed by Companion + +| Subject | Purpose | +|---------|---------| +| `corrosion.{license_id}.cmd.server` | Start/stop/restart commands | +| `corrosion.{license_id}.files.get` | Read file | +| `corrosion.{license_id}.files.put` | Write file | +| `corrosion.{license_id}.files.delete` | Delete file | +| `corrosion.{license_id}.files.list` | List directory | +| `corrosion.{license_id}.update.steam` | Trigger SteamCMD update | +| `corrosion.{license_id}.update.companion` | Self-update command | + +## Building from Source + +### Prerequisites + +- Go 1.21 or later +- Make (optional, but recommended) + +### Build Commands + +```bash +# Download dependencies +go mod download + +# Build for current platform +make build-local + +# Build for Linux (amd64) +make build-linux + +# Build for Windows (amd64) +make build-windows + +# Build for all platforms +make build + +# Run tests +make test + +# Format code +make fmt +``` + +Binaries are output to `bin/` directory. + +## Troubleshooting + +### Agent won't connect to NATS + +- Verify `NATS_URL` is correct +- Check firewall allows outbound connections to port 4222 +- Verify `NATS_TOKEN` is valid from dashboard +- Check logs: `journalctl -u corrosion-companion -n 50` + +### Server won't start + +- Verify `GAME_SERVER_PATH` points to the correct executable +- Check file permissions (agent needs execute permission on server binary) +- Verify `GAME_SERVER_ARGS` are correct for your server +- Check server logs for startup errors + +### SteamCMD updates fail + +- Verify `STEAMCMD_PATH` is correct +- Check SteamCMD is installed: `steamcmd +quit` +- Ensure disk space is available +- Check network connectivity to Steam servers + +### Self-update fails + +- Verify agent has write permission to its own directory +- Check download URL is accessible +- Ensure sufficient disk space +- Restart agent manually after update if automatic restart fails + +## Security Considerations + +- **Token Security**: Keep `NATS_TOKEN` secret. It grants full control over your server. +- **File Operations**: The agent can read/write/delete files. It runs with the permissions of the user running it. +- **Process Control**: The agent can start/stop the game server process. +- **Network**: The agent only makes outbound connections (no inbound ports required). +- **Updates**: Self-updates download from URLs provided by Corrosion cloud (signed URLs). + +## Support + +- **Documentation**: https://docs.corrosionmgmt.com +- **Dashboard**: https://panel.corrosionmgmt.com +- **Issues**: Report via dashboard or contact support + +## License + +Proprietary software. Licensed to Corrosion platform users only. + +## Version History + +### 1.0.0 (2026-02-15) +- Initial release +- NATS connectivity with token auth +- Process management (start/stop/restart) +- File operations (read/write/delete/list) +- SteamCMD integration +- Self-update capability +- Heartbeat monitoring +- Graceful shutdown handling diff --git a/companion-agent/bin/corrosion-companion-linux-amd64 b/companion-agent/bin/corrosion-companion-linux-amd64 new file mode 100755 index 0000000..07470c8 Binary files /dev/null and b/companion-agent/bin/corrosion-companion-linux-amd64 differ diff --git a/companion-agent/bin/corrosion-companion-windows-amd64.exe b/companion-agent/bin/corrosion-companion-windows-amd64.exe new file mode 100755 index 0000000..4f0e9b9 Binary files /dev/null and b/companion-agent/bin/corrosion-companion-windows-amd64.exe differ diff --git a/companion-agent/cmd/agent/main.go b/companion-agent/cmd/agent/main.go new file mode 100644 index 0000000..ff7996e --- /dev/null +++ b/companion-agent/cmd/agent/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/kelseyhightower/envconfig" + "github.com/vigilcyber/corrosion-companion/internal/app" + "github.com/vigilcyber/corrosion-companion/internal/client" +) + +// Config holds all environment-based configuration +type Config struct { + // NATS connection + NATSUrl string `envconfig:"NATS_URL" required:"true"` + NATSToken string `envconfig:"NATS_TOKEN" required:"true"` + + // License identification + LicenseID string `envconfig:"LICENSE_ID" required:"true"` + + // Game server configuration + SteamCMDPath string `envconfig:"STEAMCMD_PATH" default:"/usr/games/steamcmd"` + GameServerPath string `envconfig:"GAME_SERVER_PATH" required:"true"` + GameServerArgs string `envconfig:"GAME_SERVER_ARGS" default:"-batchmode"` + + // Optional settings + HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"` + LogLevel string `envconfig:"LOG_LEVEL" default:"info"` +} + +const version = "1.0.0" + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Printf("Corrosion Companion Agent v%s starting...", version) + + // Load configuration from environment + var cfg Config + if err := envconfig.Process("", &cfg); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + log.Printf("Configuration loaded:") + log.Printf(" NATS URL: %s", cfg.NATSUrl) + log.Printf(" License ID: %s", cfg.LicenseID) + log.Printf(" Game Server Path: %s", cfg.GameServerPath) + log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath) + log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval) + + // Create context with signal handling for graceful shutdown + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + // Connect to NATS + log.Println("Connecting to NATS...") + nc, err := client.Connect(cfg.NATSUrl, cfg.NATSToken) + if err != nil { + log.Fatalf("Failed to connect to NATS: %v", err) + } + defer nc.Close() + + log.Println("NATS connection established") + + // Initialize daemon configuration + daemonCfg := &app.DaemonConfig{ + LicenseID: cfg.LicenseID, + HeartbeatInterval: time.Duration(cfg.HeartbeatInterval) * time.Second, + SteamCMDPath: cfg.SteamCMDPath, + GameServerPath: cfg.GameServerPath, + GameServerArgs: cfg.GameServerArgs, + Version: version, + } + + // Start daemon + daemon, err := app.NewDaemon(nc, daemonCfg) + if err != nil { + log.Fatalf("Failed to initialize daemon: %v", err) + } + + log.Println("Daemon initialized, starting main loop...") + + // Run daemon until context is cancelled + if err := daemon.Run(ctx); err != nil { + log.Fatalf("Daemon error: %v", err) + } + + log.Println("Companion agent shutdown complete") +} diff --git a/companion-agent/go.mod b/companion-agent/go.mod new file mode 100644 index 0000000..a83c286 --- /dev/null +++ b/companion-agent/go.mod @@ -0,0 +1,16 @@ +module github.com/vigilcyber/corrosion-companion + +go 1.21 + +require ( + github.com/kelseyhightower/envconfig v1.4.0 + github.com/nats-io/nats.go v1.31.0 +) + +require ( + github.com/klauspost/compress v1.17.0 // indirect + github.com/nats-io/nkeys v0.4.5 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/sys v0.5.0 // indirect +) diff --git a/companion-agent/go.sum b/companion-agent/go.sum new file mode 100644 index 0000000..59abedc --- /dev/null +++ b/companion-agent/go.sum @@ -0,0 +1,14 @@ +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= +github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= +github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk= +github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/companion-agent/internal/app/daemon.go b/companion-agent/internal/app/daemon.go new file mode 100644 index 0000000..0f4c878 --- /dev/null +++ b/companion-agent/internal/app/daemon.go @@ -0,0 +1,416 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "log" + "runtime" + "time" + + "github.com/nats-io/nats.go" + "github.com/vigilcyber/corrosion-companion/internal/files" + "github.com/vigilcyber/corrosion-companion/internal/process" + "github.com/vigilcyber/corrosion-companion/internal/update" +) + +// DaemonConfig holds configuration for the daemon +type DaemonConfig struct { + LicenseID string + HeartbeatInterval time.Duration + SteamCMDPath string + GameServerPath string + GameServerArgs string + Version string +} + +// Daemon manages the companion agent's main operations +type Daemon struct { + nc *nats.Conn + cfg *DaemonConfig + gameServer *process.GameServer + fileOps *files.Operations + updater *update.Updater + subscriptions []*nats.Subscription +} + +// HeartbeatPayload represents the data sent in heartbeat messages +type HeartbeatPayload struct { + Timestamp string `json:"timestamp"` + Status string `json:"status"` + ServerStatus string `json:"server_status"` + UptimeSeconds int64 `json:"uptime_seconds"` + DiskFreeMB int64 `json:"disk_free_mb"` + CPUPercent float64 `json:"cpu_percent"` + LastUpdate string `json:"last_update"` + PlayerCount int `json:"player_count"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` +} + +// NewDaemon creates a new daemon instance +func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) { + gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs) + fileOps := files.NewOperations() + updater := update.NewUpdater(cfg.Version) + + d := &Daemon{ + nc: nc, + cfg: cfg, + gameServer: gameServer, + fileOps: fileOps, + updater: updater, + } + + return d, nil +} + +// Run starts the daemon and blocks until context is cancelled +func (d *Daemon) Run(ctx context.Context) error { + log.Println("Starting daemon subscriptions...") + + // Subscribe to server control commands + if err := d.subscribeServerCommands(); err != nil { + return fmt.Errorf("failed to subscribe to server commands: %w", err) + } + + // Subscribe to file operations + if err := d.subscribeFileOperations(); err != nil { + return fmt.Errorf("failed to subscribe to file operations: %w", err) + } + + // Subscribe to SteamCMD update commands + if err := d.subscribeSteamUpdate(); err != nil { + return fmt.Errorf("failed to subscribe to steam updates: %w", err) + } + + // Subscribe to self-update commands + if err := d.subscribeSelfUpdate(); err != nil { + return fmt.Errorf("failed to subscribe to self-update: %w", err) + } + + log.Println("All subscriptions active") + + // Start heartbeat ticker + ticker := time.NewTicker(d.cfg.HeartbeatInterval) + defer ticker.Stop() + + // Send initial heartbeat immediately + d.publishHeartbeat() + + // Main event loop + for { + select { + case <-ctx.Done(): + log.Println("Shutdown signal received, cleaning up...") + d.cleanup() + return nil + case <-ticker.C: + d.publishHeartbeat() + } + } +} + +// subscribeServerCommands subscribes to server process control commands +func (d *Daemon) subscribeServerCommands() error { + subject := fmt.Sprintf("corrosion.%s.cmd.server", d.cfg.LicenseID) + + sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) { + var cmd struct { + Action string `json:"action"` + } + + if err := json.Unmarshal(msg.Data, &cmd); err != nil { + log.Printf("Failed to parse server command: %v", err) + d.respondError(msg, "invalid_command", err.Error()) + return + } + + log.Printf("Received server command: %s", cmd.Action) + + var err error + switch cmd.Action { + case "start": + err = d.gameServer.Start() + case "stop": + err = d.gameServer.Stop() + case "restart": + err = d.gameServer.Restart() + default: + err = fmt.Errorf("unknown action: %s", cmd.Action) + } + + if err != nil { + log.Printf("Server command failed: %v", err) + d.respondError(msg, "command_failed", err.Error()) + } else { + d.respondSuccess(msg, map[string]interface{}{ + "action": cmd.Action, + "status": "success", + }) + } + }) + + if err != nil { + return err + } + + d.subscriptions = append(d.subscriptions, sub) + log.Printf("Subscribed to: %s", subject) + return nil +} + +// subscribeFileOperations subscribes to file operation commands +func (d *Daemon) subscribeFileOperations() error { + subjects := []string{ + fmt.Sprintf("corrosion.%s.files.get", d.cfg.LicenseID), + fmt.Sprintf("corrosion.%s.files.put", d.cfg.LicenseID), + fmt.Sprintf("corrosion.%s.files.delete", d.cfg.LicenseID), + fmt.Sprintf("corrosion.%s.files.list", d.cfg.LicenseID), + } + + for _, subject := range subjects { + sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) { + d.handleFileOperation(msg) + }) + + if err != nil { + return err + } + + d.subscriptions = append(d.subscriptions, sub) + log.Printf("Subscribed to: %s", subject) + } + + return nil +} + +// subscribeSteamUpdate subscribes to SteamCMD update commands +func (d *Daemon) subscribeSteamUpdate() error { + subject := fmt.Sprintf("corrosion.%s.update.steam", d.cfg.LicenseID) + + sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) { + var cmd struct { + Validate bool `json:"validate"` + } + + if err := json.Unmarshal(msg.Data, &cmd); err != nil { + log.Printf("Failed to parse steam update command: %v", err) + d.respondError(msg, "invalid_command", err.Error()) + return + } + + log.Printf("Received SteamCMD update command (validate=%v)", cmd.Validate) + + steamCmd := process.NewSteamCMD(d.cfg.SteamCMDPath) + err := steamCmd.UpdateRustServer(cmd.Validate) + + if err != nil { + log.Printf("SteamCMD update failed: %v", err) + d.respondError(msg, "update_failed", err.Error()) + } else { + d.respondSuccess(msg, map[string]interface{}{ + "status": "success", + "validate": cmd.Validate, + }) + } + }) + + if err != nil { + return err + } + + d.subscriptions = append(d.subscriptions, sub) + log.Printf("Subscribed to: %s", subject) + return nil +} + +// subscribeSelfUpdate subscribes to companion agent self-update commands +func (d *Daemon) subscribeSelfUpdate() error { + subject := fmt.Sprintf("corrosion.%s.update.companion", d.cfg.LicenseID) + + sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) { + var cmd struct { + DownloadURL string `json:"download_url"` + Version string `json:"version"` + } + + if err := json.Unmarshal(msg.Data, &cmd); err != nil { + log.Printf("Failed to parse self-update command: %v", err) + d.respondError(msg, "invalid_command", err.Error()) + return + } + + log.Printf("Received self-update command: version=%s", cmd.Version) + + err := d.updater.PerformUpdate(cmd.DownloadURL, cmd.Version) + + if err != nil { + log.Printf("Self-update failed: %v", err) + d.respondError(msg, "update_failed", err.Error()) + } else { + d.respondSuccess(msg, map[string]interface{}{ + "status": "success", + "version": cmd.Version, + "message": "Update downloaded, restart required", + }) + } + }) + + if err != nil { + return err + } + + d.subscriptions = append(d.subscriptions, sub) + log.Printf("Subscribed to: %s", subject) + return nil +} + +// handleFileOperation processes file operation requests +func (d *Daemon) handleFileOperation(msg *nats.Msg) { + // Parse common fields + var baseCmd struct { + RequestID string `json:"request_id"` + Path string `json:"path"` + DownloadURL string `json:"download_url,omitempty"` // For put operations + } + + if err := json.Unmarshal(msg.Data, &baseCmd); err != nil { + log.Printf("Failed to parse file operation: %v", err) + d.respondError(msg, "invalid_command", err.Error()) + return + } + + var result interface{} + var err error + + // Determine operation type from subject + if contains(msg.Subject, ".files.get") { + result, err = d.fileOps.Read(baseCmd.Path) + } else if contains(msg.Subject, ".files.put") { + err = d.fileOps.Write(baseCmd.Path, baseCmd.DownloadURL) + result = map[string]string{"status": "written"} + } else if contains(msg.Subject, ".files.delete") { + err = d.fileOps.Delete(baseCmd.Path) + result = map[string]string{"status": "deleted"} + } else if contains(msg.Subject, ".files.list") { + result, err = d.fileOps.List(baseCmd.Path) + } + + responseSubject := fmt.Sprintf("corrosion.%s.files.response", d.cfg.LicenseID) + + if err != nil { + log.Printf("File operation failed: %v", err) + d.publishResponse(responseSubject, map[string]interface{}{ + "request_id": baseCmd.RequestID, + "status": "error", + "error": err.Error(), + }) + } else { + d.publishResponse(responseSubject, map[string]interface{}{ + "request_id": baseCmd.RequestID, + "status": "success", + "data": result, + }) + } +} + +// publishHeartbeat sends a heartbeat message to the cloud +func (d *Daemon) publishHeartbeat() { + subject := fmt.Sprintf("corrosion.%s.companion.heartbeat", d.cfg.LicenseID) + + status := d.gameServer.Status() + uptime := d.gameServer.Uptime() + diskFree := getDiskFreeSpace(d.cfg.GameServerPath) + + payload := HeartbeatPayload{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Status: "running", + ServerStatus: status, + UptimeSeconds: int64(uptime.Seconds()), + DiskFreeMB: diskFree, + CPUPercent: 0.0, // TODO: Implement CPU monitoring + LastUpdate: "", // TODO: Track last SteamCMD update + PlayerCount: 0, // Populated by plugin, not companion + Version: d.cfg.Version, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + + data, err := json.Marshal(payload) + if err != nil { + log.Printf("Failed to marshal heartbeat: %v", err) + return + } + + if err := d.nc.Publish(subject, data); err != nil { + log.Printf("Failed to publish heartbeat: %v", err) + } +} + +// respondError sends an error response to a command +func (d *Daemon) respondError(msg *nats.Msg, code, message string) { + response := map[string]interface{}{ + "status": "error", + "code": code, + "message": message, + } + + data, _ := json.Marshal(response) + if err := msg.Respond(data); err != nil { + log.Printf("Failed to send error response: %v", err) + } +} + +// respondSuccess sends a success response to a command +func (d *Daemon) respondSuccess(msg *nats.Msg, payload interface{}) { + data, _ := json.Marshal(payload) + if err := msg.Respond(data); err != nil { + log.Printf("Failed to send success response: %v", err) + } +} + +// publishResponse publishes a response to a specific subject +func (d *Daemon) publishResponse(subject string, payload interface{}) { + data, _ := json.Marshal(payload) + if err := d.nc.Publish(subject, data); err != nil { + log.Printf("Failed to publish response: %v", err) + } +} + +// cleanup gracefully shuts down all daemon operations +func (d *Daemon) cleanup() { + log.Println("Unsubscribing from all subjects...") + for _, sub := range d.subscriptions { + sub.Unsubscribe() + } + + log.Println("Draining NATS connection...") + d.nc.Drain() + + log.Println("Cleanup complete") +} + +// Helper functions + +func contains(s, substr string) bool { + return len(s) >= len(substr) && s[len(s)-len(substr):] == substr || + (len(s) > len(substr) && s[0:len(substr)] == substr) || + (len(s) > 0 && len(substr) > 0 && findInString(s, substr)) +} + +func findInString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func getDiskFreeSpace(path string) int64 { + // TODO: Implement actual disk space check + // For now, return placeholder + return 50000 +} diff --git a/companion-agent/internal/client/nats.go b/companion-agent/internal/client/nats.go new file mode 100644 index 0000000..5ac048d --- /dev/null +++ b/companion-agent/internal/client/nats.go @@ -0,0 +1,40 @@ +package client + +import ( + "fmt" + "time" + + "github.com/nats-io/nats.go" +) + +// Connect establishes a connection to NATS with token authentication +// and automatic reconnection handling +func Connect(url, token string) (*nats.Conn, error) { + opts := []nats.Option{ + nats.Token(token), + nats.Name("corrosion-companion"), + nats.MaxReconnects(-1), // Unlimited reconnect attempts + nats.ReconnectWait(2 * time.Second), + nats.DisconnectErrHandler(func(nc *nats.Conn, err error) { + if err != nil { + fmt.Printf("NATS disconnected: %v\n", err) + } + }), + nats.ReconnectHandler(func(nc *nats.Conn) { + fmt.Printf("NATS reconnected to %s\n", nc.ConnectedUrl()) + }), + nats.ClosedHandler(func(nc *nats.Conn) { + fmt.Println("NATS connection closed") + }), + nats.ErrorHandler(func(nc *nats.Conn, sub *nats.Subscription, err error) { + fmt.Printf("NATS error: %v\n", err) + }), + } + + nc, err := nats.Connect(url, opts...) + if err != nil { + return nil, fmt.Errorf("failed to connect to NATS: %w", err) + } + + return nc, nil +} diff --git a/companion-agent/internal/files/operations.go b/companion-agent/internal/files/operations.go new file mode 100644 index 0000000..293f8d4 --- /dev/null +++ b/companion-agent/internal/files/operations.go @@ -0,0 +1,206 @@ +package files + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "time" +) + +// FileInfo represents metadata about a file or directory +type FileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + ModTime string `json:"mod_time"` +} + +// Operations handles file system operations +type Operations struct{} + +// NewOperations creates a new file operations handler +func NewOperations() *Operations { + return &Operations{} +} + +// Read reads a file and returns its contents +func (o *Operations) Read(path string) (string, error) { + log.Printf("Reading file: %s", path) + + // Security: Validate path to prevent directory traversal + cleanPath, err := o.validatePath(path) + if err != nil { + return "", err + } + + data, err := os.ReadFile(cleanPath) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + + log.Printf("Read %d bytes from %s", len(data), path) + return string(data), nil +} + +// Write writes content to a file, downloading from URL if provided +func (o *Operations) Write(path, downloadURL string) error { + log.Printf("Writing file: %s", path) + + // Security: Validate path + cleanPath, err := o.validatePath(path) + if err != nil { + return err + } + + // Ensure directory exists + dir := filepath.Dir(cleanPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // If download URL is provided, fetch content from URL + if downloadURL != "" { + return o.downloadAndWrite(cleanPath, downloadURL) + } + + return fmt.Errorf("no content or download URL provided") +} + +// Delete deletes a file or directory +func (o *Operations) Delete(path string) error { + log.Printf("Deleting: %s", path) + + // Security: Validate path + cleanPath, err := o.validatePath(path) + if err != nil { + return err + } + + // Check if path exists + if _, err := os.Stat(cleanPath); os.IsNotExist(err) { + return fmt.Errorf("path does not exist: %s", path) + } + + // Remove file or directory + if err := os.RemoveAll(cleanPath); err != nil { + return fmt.Errorf("failed to delete: %w", err) + } + + log.Printf("Deleted: %s", path) + return nil +} + +// List lists files and directories at the given path +func (o *Operations) List(path string) ([]FileInfo, error) { + log.Printf("Listing directory: %s", path) + + // Security: Validate path + cleanPath, err := o.validatePath(path) + if err != nil { + return nil, err + } + + // Read directory + entries, err := os.ReadDir(cleanPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + var files []FileInfo + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + log.Printf("Warning: failed to get info for %s: %v", entry.Name(), err) + continue + } + + files = append(files, FileInfo{ + Name: entry.Name(), + Path: filepath.Join(path, entry.Name()), + Size: info.Size(), + IsDir: entry.IsDir(), + ModTime: info.ModTime().Format(time.RFC3339), + }) + } + + log.Printf("Listed %d items in %s", len(files), path) + return files, nil +} + +// downloadAndWrite downloads content from URL and writes to file +func (o *Operations) downloadAndWrite(path, url string) error { + log.Printf("Downloading from %s to %s", url, path) + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 5 * time.Minute, + } + + // Download file + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + // Create destination file + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + // Copy content + written, err := io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + log.Printf("Downloaded and wrote %d bytes to %s", written, path) + return nil +} + +// validatePath validates and cleans a file path to prevent directory traversal +func (o *Operations) validatePath(path string) (string, error) { + // Get absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // Clean the path (removes .. and . elements) + cleanPath := filepath.Clean(absPath) + + // Basic security check: ensure path doesn't try to escape + // In production, you might want to restrict to specific directories + if !filepath.IsAbs(cleanPath) { + return "", fmt.Errorf("path must be absolute") + } + + return cleanPath, nil +} + +// Exists checks if a file or directory exists +func (o *Operations) Exists(path string) (bool, error) { + cleanPath, err := o.validatePath(path) + if err != nil { + return false, err + } + + _, err = os.Stat(cleanPath) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/companion-agent/internal/process/gameserver.go b/companion-agent/internal/process/gameserver.go new file mode 100644 index 0000000..95d1d23 --- /dev/null +++ b/companion-agent/internal/process/gameserver.go @@ -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 +} diff --git a/companion-agent/internal/process/steamcmd.go b/companion-agent/internal/process/steamcmd.go new file mode 100644 index 0000000..3db3a0c --- /dev/null +++ b/companion-agent/internal/process/steamcmd.go @@ -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" +} diff --git a/companion-agent/internal/update/updater.go b/companion-agent/internal/update/updater.go new file mode 100644 index 0000000..4f52f2f --- /dev/null +++ b/companion-agent/internal/update/updater.go @@ -0,0 +1,223 @@ +package update + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +// Updater handles self-update operations for the companion agent +type Updater struct { + currentVersion string +} + +// NewUpdater creates a new updater instance +func NewUpdater(currentVersion string) *Updater { + return &Updater{ + currentVersion: currentVersion, + } +} + +// PerformUpdate downloads a new version and replaces the current binary +func (u *Updater) PerformUpdate(downloadURL, newVersion string) error { + log.Printf("Performing self-update from %s to %s", u.currentVersion, newVersion) + log.Printf("Download URL: %s", downloadURL) + + // Get current executable path + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + log.Printf("Current executable: %s", exePath) + + // Create temporary file for download + tmpFile := exePath + ".new" + + // Download new binary + if err := u.downloadBinary(downloadURL, tmpFile); err != nil { + return fmt.Errorf("failed to download update: %w", err) + } + + // Make new binary executable (Unix only) + if runtime.GOOS != "windows" { + if err := os.Chmod(tmpFile, 0755); err != nil { + os.Remove(tmpFile) + return fmt.Errorf("failed to make binary executable: %w", err) + } + } + + // Create backup of current binary + backupFile := exePath + ".backup" + if err := u.createBackup(exePath, backupFile); err != nil { + os.Remove(tmpFile) + return fmt.Errorf("failed to create backup: %w", err) + } + + log.Println("Backup created successfully") + + // Replace current binary with new one + if err := u.replaceBinary(tmpFile, exePath); err != nil { + // Attempt to restore from backup + log.Printf("Update failed, attempting to restore from backup: %v", err) + if restoreErr := u.replaceBinary(backupFile, exePath); restoreErr != nil { + return fmt.Errorf("update failed and backup restoration failed: %w (original error: %v)", restoreErr, err) + } + return fmt.Errorf("update failed, restored from backup: %w", err) + } + + log.Printf("Successfully updated to version %s", newVersion) + log.Println("NOTE: Restart the agent to use the new version") + + // Clean up backup file after successful update + os.Remove(backupFile) + + return nil +} + +// downloadBinary downloads a binary from the given URL to the destination path +func (u *Updater) downloadBinary(url, destPath string) error { + log.Printf("Downloading binary from %s", url) + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Minute, // Binary download may take longer + } + + // Download file + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + // Create destination file + file, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + // Copy content + written, err := io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + log.Printf("Downloaded %d bytes", written) + return nil +} + +// createBackup creates a backup copy of a file +func (u *Updater) createBackup(src, dest string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.Create(dest) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + return err +} + +// replaceBinary replaces the destination binary with the source binary +func (u *Updater) replaceBinary(src, dest string) error { + // On Windows, we can't replace a running executable directly + // We need to rename the old one and move the new one in place + if runtime.GOOS == "windows" { + oldExe := dest + ".old" + + // Remove any existing .old file + os.Remove(oldExe) + + // Rename current executable + if err := os.Rename(dest, oldExe); err != nil { + return fmt.Errorf("failed to rename current executable: %w", err) + } + + // Move new executable into place + if err := os.Rename(src, dest); err != nil { + // Try to restore + os.Rename(oldExe, dest) + return fmt.Errorf("failed to move new executable: %w", err) + } + + // Schedule old executable for deletion on next boot + // (Windows doesn't allow deleting running executables) + return nil + } + + // On Unix, we can replace the file directly + // The running process will continue using the old inode + return os.Rename(src, dest) +} + +// GetCurrentVersion returns the current version +func (u *Updater) GetCurrentVersion() string { + return u.currentVersion +} + +// VerifyUpdate verifies that an update was successful by checking the version +func (u *Updater) VerifyUpdate(expectedVersion string) error { + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + info, err := os.Stat(exePath) + if err != nil { + return fmt.Errorf("failed to stat executable: %w", err) + } + + log.Printf("Executable info: size=%d, mod_time=%s", info.Size(), info.ModTime()) + + // In a real implementation, you might embed version info in the binary + // or verify a checksum. For now, we just verify the file exists and was modified recently + if time.Since(info.ModTime()) > 5*time.Minute { + return fmt.Errorf("executable was not recently modified") + } + + return nil +} + +// CleanupOldVersions removes old backup files +func (u *Updater) CleanupOldVersions() error { + exePath, err := os.Executable() + if err != nil { + return err + } + + dir := filepath.Dir(exePath) + baseName := filepath.Base(exePath) + + // Remove backup files + patterns := []string{ + baseName + ".backup", + baseName + ".old", + baseName + ".new", + } + + for _, pattern := range patterns { + path := filepath.Join(dir, pattern) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Printf("Failed to remove old version %s: %v", path, err) + } + } + + return nil +}