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:
89
companion-agent/Makefile
Normal file
89
companion-agent/Makefile
Normal file
@@ -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)"
|
||||||
260
companion-agent/README.md
Normal file
260
companion-agent/README.md
Normal file
@@ -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
|
||||||
BIN
companion-agent/bin/corrosion-companion-linux-amd64
Executable file
BIN
companion-agent/bin/corrosion-companion-linux-amd64
Executable file
Binary file not shown.
BIN
companion-agent/bin/corrosion-companion-windows-amd64.exe
Executable file
BIN
companion-agent/bin/corrosion-companion-windows-amd64.exe
Executable file
Binary file not shown.
92
companion-agent/cmd/agent/main.go
Normal file
92
companion-agent/cmd/agent/main.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
16
companion-agent/go.mod
Normal file
16
companion-agent/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
14
companion-agent/go.sum
Normal file
14
companion-agent/go.sum
Normal file
@@ -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=
|
||||||
416
companion-agent/internal/app/daemon.go
Normal file
416
companion-agent/internal/app/daemon.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
40
companion-agent/internal/client/nats.go
Normal file
40
companion-agent/internal/client/nats.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
206
companion-agent/internal/files/operations.go
Normal file
206
companion-agent/internal/files/operations.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
138
companion-agent/internal/process/steamcmd.go
Normal file
138
companion-agent/internal/process/steamcmd.go
Normal 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"
|
||||||
|
}
|
||||||
223
companion-agent/internal/update/updater.go
Normal file
223
companion-agent/internal/update/updater.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user