Files
Vantz Stockwell 380ab2700c
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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 <noreply@anthropic.com>
2026-02-22 01:53:27 -05:00

251 lines
7.4 KiB
Go

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)
}
}