All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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>
251 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|