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