From 380ab2700c3ae22cc47f3b7c3f8603069004ec10 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sun, 22 Feb 2026 01:53:27 -0500 Subject: [PATCH] feat: Add Oxide/uMod installer package + wire into companion agent daemon New oxide package downloads latest Oxide.Rust release from GitHub, extracts over the server directory, and restarts the game server. Progress published to NATS (corrosion.{license_id}.oxide.status). Heartbeat now reports oxide_installed status. Co-Authored-By: Claude Opus 4.6 --- companion-agent/internal/app/daemon.go | 83 +++++-- companion-agent/internal/oxide/installer.go | 250 ++++++++++++++++++++ companion-agent/internal/oxide/status.go | 31 +++ 3 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 companion-agent/internal/oxide/installer.go create mode 100644 companion-agent/internal/oxide/status.go diff --git a/companion-agent/internal/app/daemon.go b/companion-agent/internal/app/daemon.go index c98a355..330d28b 100644 --- a/companion-agent/internal/app/daemon.go +++ b/companion-agent/internal/app/daemon.go @@ -11,6 +11,7 @@ import ( "github.com/nats-io/nats.go" "github.com/vigilcyber/corrosion-companion/internal/deploy" "github.com/vigilcyber/corrosion-companion/internal/filemanager" + "github.com/vigilcyber/corrosion-companion/internal/oxide" "github.com/vigilcyber/corrosion-companion/internal/files" "github.com/vigilcyber/corrosion-companion/internal/process" "github.com/vigilcyber/corrosion-companion/internal/rcon" @@ -32,14 +33,15 @@ type DaemonConfig struct { // Daemon manages the companion agent's main operations type Daemon struct { - nc *nats.Conn - cfg *DaemonConfig - gameServer *process.GameServer - fileOps *files.Operations - fm *filemanager.FileManager - updater *update.Updater - deployer *deploy.Deployer - subscriptions []*nats.Subscription + nc *nats.Conn + cfg *DaemonConfig + gameServer *process.GameServer + fileOps *files.Operations + fm *filemanager.FileManager + updater *update.Updater + deployer *deploy.Deployer + oxideInstaller *oxide.OxideInstaller + subscriptions []*nats.Subscription } // HeartbeatPayload represents the data sent in heartbeat messages @@ -56,6 +58,7 @@ type HeartbeatPayload struct { OS string `json:"os"` Arch string `json:"arch"` ServerInstalled bool `json:"server_installed"` + OxideInstalled bool `json:"oxide_installed"` } // gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter @@ -74,6 +77,15 @@ func (a *gameServerAdapter) UpdatePath(path string) { *a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs) } +// restartAdapter wraps process.GameServer to satisfy oxide.GameServerRestarter +type restartAdapter struct { + gs *process.GameServer +} + +func (a *restartAdapter) Restart() error { + return a.gs.Restart() +} + // NewDaemon creates a new daemon instance func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) { gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs) @@ -82,15 +94,18 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) { updater := update.NewUpdater(cfg.Version) adapter := &gameServerAdapter{gs: gameServer, cfg: cfg} deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter) + restarter := &restartAdapter{gs: gameServer} + oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter) d := &Daemon{ - nc: nc, - cfg: cfg, - gameServer: gameServer, - fileOps: fileOps, - fm: fm, - updater: updater, - deployer: deployer, + nc: nc, + cfg: cfg, + gameServer: gameServer, + fileOps: fileOps, + fm: fm, + updater: updater, + deployer: deployer, + oxideInstaller: oxideInst, } return d, nil @@ -125,6 +140,11 @@ func (d *Daemon) Run(ctx context.Context) error { return fmt.Errorf("failed to subscribe to deploy commands: %w", err) } + // Subscribe to Oxide install commands + if err := d.subscribeOxideInstall(); err != nil { + return fmt.Errorf("failed to subscribe to oxide install commands: %w", err) + } + // Subscribe to file manager commands (VueFinder-compatible request-reply) if err := d.subscribeFileManager(); err != nil { return fmt.Errorf("failed to subscribe to file manager commands: %w", err) @@ -389,6 +409,38 @@ func (d *Daemon) subscribeFileManager() error { return nil } +// subscribeOxideInstall subscribes to Oxide installation commands +func (d *Daemon) subscribeOxideInstall() error { + subject := fmt.Sprintf("corrosion.%s.cmd.oxide", d.cfg.LicenseID) + + sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) { + log.Println("Received Oxide install command") + + // Run installation in goroutine (it's long-running) + go func() { + if err := d.oxideInstaller.Install(); err != nil { + log.Printf("Oxide installation failed: %v", err) + } else { + log.Println("Oxide installation completed successfully") + } + }() + + // Immediately acknowledge the command + d.respondSuccess(msg, map[string]interface{}{ + "status": "accepted", + "message": "Oxide installation started, progress will be published to oxide.status", + }) + }) + + 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 @@ -459,6 +511,7 @@ func (d *Daemon) publishHeartbeat() { OS: runtime.GOOS, Arch: runtime.GOARCH, ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir), + OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir), } data, err := json.Marshal(payload) diff --git a/companion-agent/internal/oxide/installer.go b/companion-agent/internal/oxide/installer.go new file mode 100644 index 0000000..c299887 --- /dev/null +++ b/companion-agent/internal/oxide/installer.go @@ -0,0 +1,250 @@ +package oxide + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/nats-io/nats.go" +) + +// GameServerRestarter abstracts the game server process manager so the installer +// can restart the server after extracting Oxide files. +type GameServerRestarter interface { + Restart() error +} + +// OxideInstaller handles downloading and extracting Oxide/uMod over a Rust server installation. +type OxideInstaller struct { + nc *nats.Conn + licenseID string + installDir string + gameServer GameServerRestarter +} + +// NewOxideInstaller creates a new OxideInstaller instance. +func NewOxideInstaller(nc *nats.Conn, licenseID, installDir string, gs GameServerRestarter) *OxideInstaller { + return &OxideInstaller{ + nc: nc, + licenseID: licenseID, + installDir: installDir, + gameServer: gs, + } +} + +// githubRelease represents the relevant fields from the GitHub Releases API response. +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Install performs the full Oxide installation pipeline: +// 1. Fetch latest release info from GitHub +// 2. Download the zip +// 3. Extract over {installDir}/server/ +// 4. Restart the game server +func (o *OxideInstaller) Install() error { + // Stage 1: Fetch latest release + log.Printf("Oxide: fetching latest release for license %s", o.licenseID) + o.publishStatus("fetching_release", 0, "Checking latest Oxide release...") + + release, err := o.fetchLatestRelease() + if err != nil { + o.publishStatus("failed", 0, "Failed to fetch Oxide release info", err.Error()) + return fmt.Errorf("fetch release failed: %w", err) + } + + if len(release.Assets) == 0 { + err := fmt.Errorf("no assets found in release %s", release.TagName) + o.publishStatus("failed", 0, "No download assets in release", err.Error()) + return err + } + + downloadURL := release.Assets[0].BrowserDownloadURL + version := release.TagName + log.Printf("Oxide: latest version is %s, download URL: %s", version, downloadURL) + o.publishStatus("fetching_release", 100, fmt.Sprintf("Found Oxide %s", version)) + + // Stage 2: Download zip + log.Printf("Oxide: downloading %s", downloadURL) + o.publishStatus("downloading", 0, fmt.Sprintf("Downloading Oxide %s...", version)) + + tmpPath := filepath.Join(os.TempDir(), "oxide-latest.zip") + if err := o.downloadFile(downloadURL, tmpPath); err != nil { + o.publishStatus("failed", 0, "Failed to download Oxide", err.Error()) + return fmt.Errorf("download failed: %w", err) + } + defer os.Remove(tmpPath) + + log.Printf("Oxide: download complete") + o.publishStatus("downloading", 100, "Download complete") + + // Stage 3: Extract over server directory + serverDir := filepath.Join(o.installDir, "server") + log.Printf("Oxide: extracting to %s", serverDir) + o.publishStatus("installing", 0, "Extracting Oxide over server directory...") + + if err := o.extractZip(tmpPath, serverDir); err != nil { + o.publishStatus("failed", 0, "Failed to extract Oxide", err.Error()) + return fmt.Errorf("extract failed: %w", err) + } + + log.Printf("Oxide: extraction complete") + o.publishStatus("installing", 100, "Oxide files extracted") + + // Stage 4: Restart server + log.Printf("Oxide: restarting server") + o.publishStatus("restarting", 0, "Restarting server to load Oxide...") + + if err := o.gameServer.Restart(); err != nil { + o.publishStatus("failed", 0, "Server restart failed", err.Error()) + return fmt.Errorf("server restart failed: %w", err) + } + + log.Printf("Oxide: server restarted, installation complete") + o.publishStatus("complete", 100, fmt.Sprintf("Oxide %s installed successfully", version)) + + return nil +} + +// fetchLatestRelease queries the GitHub API for the latest Oxide.Rust release. +func (o *OxideInstaller) fetchLatestRelease() (*githubRelease, error) { + client := &http.Client{Timeout: 30 * time.Second} + + resp, err := client.Get("https://api.github.com/repos/OxideMod/Oxide.Rust/releases/latest") + if err != nil { + return nil, fmt.Errorf("GitHub API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to parse GitHub API response: %w", err) + } + + return &release, nil +} + +// downloadFile downloads a URL to a local file path. +func (o *OxideInstaller) downloadFile(url, destPath string) error { + client := &http.Client{Timeout: 5 * time.Minute} + + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("HTTP GET failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned status %d", resp.StatusCode) + } + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("failed to write download: %w", err) + } + + return nil +} + +// extractZip extracts a zip file to a destination directory, overwriting existing files. +// This is used to overlay Oxide's DLLs over the Rust server's Managed directory +// and create the oxide/ folder structure. +func (o *OxideInstaller) extractZip(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer r.Close() + + for _, f := range r.File { + targetPath := filepath.Join(destDir, f.Name) + + // Security: prevent path traversal + if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) && targetPath != filepath.Clean(destDir) { + log.Printf("Oxide: skipping potentially unsafe path: %s", f.Name) + continue + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(targetPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", targetPath, err) + } + continue + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err) + } + + outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", targetPath, err) + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err) + } + + _, err = io.Copy(outFile, rc) + rc.Close() + outFile.Close() + + if err != nil { + return fmt.Errorf("failed to extract %s: %w", f.Name, err) + } + } + + return nil +} + +// publishStatus publishes an OxideStatus message to NATS. Publish errors are logged +// but do not fail the installation — losing a progress update is not fatal. +func (o *OxideInstaller) publishStatus(stage string, progress int, message string, errDetail ...string) { + subject := fmt.Sprintf("corrosion.%s.oxide.status", o.licenseID) + + status := OxideStatus{ + Stage: stage, + Progress: progress, + Message: message, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + if len(errDetail) > 0 && errDetail[0] != "" { + status.Error = errDetail[0] + } + + data, err := json.Marshal(status) + if err != nil { + log.Printf("Failed to marshal oxide status: %v", err) + return + } + + if err := o.nc.Publish(subject, data); err != nil { + log.Printf("Failed to publish oxide status to %s: %v", subject, err) + } +} diff --git a/companion-agent/internal/oxide/status.go b/companion-agent/internal/oxide/status.go new file mode 100644 index 0000000..861fc25 --- /dev/null +++ b/companion-agent/internal/oxide/status.go @@ -0,0 +1,31 @@ +package oxide + +import ( + "os" + "path/filepath" +) + +// OxideStatus represents a progress update published to NATS during Oxide installation. +// The frontend listens on corrosion.{license_id}.oxide.status for these messages. +type OxideStatus struct { + Stage string `json:"stage"` + Progress int `json:"progress"` + Message string `json:"message"` + Error string `json:"error,omitempty"` + Timestamp string `json:"timestamp"` +} + +// Valid installation stages: +// fetching_release - Querying GitHub API for latest Oxide.Rust release +// downloading - Downloading the Oxide zip file +// installing - Extracting zip over server directory +// restarting - Restarting the game server to load Oxide +// complete - Oxide installation finished successfully +// failed - Installation failed at some stage + +// CheckOxideInstalled returns true if the oxide/ directory exists in the +// server installation directory, indicating that Oxide/uMod has been installed. +func CheckOxideInstalled(installDir string) bool { + _, err := os.Stat(filepath.Join(installDir, "server", "oxide")) + return err == nil +}