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