package dispatch // ArkSaveRestore: swap a SavedArks// rolling backup into the // active .ark slot. The previously-active save is moved aside // (NEVER deleted) — even abandoned attempts are recoverable from the // timestamped *_replaced.ark filename. // // Sequence (all server-side, atomic from the operator's POV): // 1. Validate inputs. // 2. Confirm the main container is NOT running. We refuse the swap // if it is, because mid-write file ops in ARK's save dir corrupt // the rolling backup chain. // 3. mv /.ark -> /___replaced.ark // 4. cp / -> /.ark // For .arkrbf rolling backups the dest still ends in .ark — the // engine doesn't care about the source file's extension, only the // slot name. // // Caller (controller) is responsible for stopping the container before // this RPC. If the operator started the container in the meantime, // this handler short-circuits cleanly with a friendly error. import ( "context" "fmt" "io" "os" "path" "path/filepath" "strings" "time" "google.golang.org/protobuf/types/known/timestamppb" panelv1 "github.com/dbledeez/panel/proto/panel/v1" ) // arkSavedArksContainerRoot is the relative path to ARK's rolling-save // directory FROM the ark-sa module's BrowseableRoot. The module roots // browsing at ".../ShooterGame/Saved" (see modules/ark-sa/module.yaml), // so this is the segment beneath that — NOT the full container path. const arkSavedArksContainerRoot = "SavedArks" func (d *Dispatcher) handleArkSaveRestore(corrID string, req *panelv1.ArkSaveRestoreRequest) { rec, err := d.lookupRecord(req.InstanceId) if err != nil { d.sendArkSaveRestoreResult(corrID, "", "", err.Error()) return } subdir := strings.TrimSpace(req.SavedArksSubdir) target := strings.TrimSpace(req.TargetFilename) if subdir == "" || target == "" { d.sendArkSaveRestoreResult(corrID, "", "", "saved_arks_subdir and target_filename are required") return } // Path-safety: subdir is one segment under SavedArks/, target is // one segment under that. Reject any traversal attempt up front. if strings.ContainsAny(subdir, "/\\") || subdir == "." || subdir == ".." { d.sendArkSaveRestoreResult(corrID, "", "", "saved_arks_subdir must be a single directory name") return } if strings.ContainsAny(target, "/\\") || target == "." || target == ".." { d.sendArkSaveRestoreResult(corrID, "", "", "target_filename must be a single file name") return } // Sanity-check extension. Active slot is .ark; sources may be .ark // or .arkrbf (rolling backup). .bak is ARK's anti-corruption file // and we don't restore from it directly — operators rename to .ark // manually if they need to. lowerTarget := strings.ToLower(target) if !strings.HasSuffix(lowerTarget, ".ark") && !strings.HasSuffix(lowerTarget, ".arkrbf") { d.sendArkSaveRestoreResult(corrID, "", "", "target_filename must end in .ark or .arkrbf") return } activeName := subdir + ".ark" if target == activeName { d.sendArkSaveRestoreResult(corrID, "", "", "target is already the active save") return } // Stamp the aside name with current wall time. We use the user's // "dd.mm.yyyy_HH.MM.SS" format from ARK's own rolling backups so // the aside file sorts naturally next to genuine engine backups // in the file browser. Suffix "_replaced" disambiguates them. // Uses UTC because that's what the engine writes — keeps the // frontend's parser (which assumes UTC) honest for both sources. now := time.Now().UTC() asideName := fmt.Sprintf("%s_%s_replaced.ark", subdir, now.Format("02.01.2006_15.04.05")) if d.useContainerOps(rec) { d.arkRestoreInContainer(corrID, rec, subdir, target, activeName, asideName) return } d.arkRestoreOnHost(corrID, rec, subdir, target, activeName, asideName) } func (d *Dispatcher) arkRestoreInContainer(corrID string, rec *instanceRecord, subdir, target, activeName, asideName string) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() // Refuse if the main game container is still running. We don't // want file ops happening inside SavedArks while the engine is // writing rolling backups. mainName := "panel-" + rec.InstanceID if st, err := d.runtime.InspectByName(ctx, mainName); err == nil && st.Status == "running" { d.sendArkSaveRestoreResult(corrID, "", "", "refusing to restore while server is running — stop the server first") return } subdirRel := path.Join(arkSavedArksContainerRoot, subdir) subdirAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), subdirRel) if err != nil { d.sendArkSaveRestoreResult(corrID, "", "", err.Error()) return } activeAbs := path.Join(subdirAbs, activeName) asideAbs := path.Join(subdirAbs, asideName) targetAbs := path.Join(subdirAbs, target) targetID, err := d.getFsTargetContainerID(ctx, rec) if err != nil { d.sendArkSaveRestoreResult(corrID, "", "", err.Error()) return } // Verify the target file exists before we touch the active slot. // `test -f` returns exit 1 (no file), 0 (exists), other (error). _, _, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c", "test -f " + shellQuote(targetAbs)}) if err != nil { d.sendArkSaveRestoreResult(corrID, "", "", err.Error()) return } if code != 0 { d.sendArkSaveRestoreResult(corrID, "", "", fmt.Sprintf("target save %q not found", target)) return } // Move the previously-active save aside (only if it exists; some // ops may run on a freshly-installed instance with no active save). finalAside := "" _, _, code, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c", "test -f " + shellQuote(activeAbs)}) if code == 0 { _, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c", "mv -- " + shellQuote(activeAbs) + " " + shellQuote(asideAbs)}) if err != nil { d.sendArkSaveRestoreResult(corrID, "", "", err.Error()) return } if code != 0 { msg := strings.TrimSpace(string(stderr)) if msg == "" { msg = fmt.Sprintf("mv aside exited %d", code) } d.sendArkSaveRestoreResult(corrID, "", "", msg) return } finalAside = asideName } // Copy the chosen backup into the active slot. cp preserves the // source so the operator can pick it again later if they change // their mind. -p preserves mtime so the engine sees the original // save's modification time. _, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c", "cp -p -- " + shellQuote(targetAbs) + " " + shellQuote(activeAbs)}) if err != nil { d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error()) return } if code != 0 { msg := strings.TrimSpace(string(stderr)) if msg == "" { msg = fmt.Sprintf("cp exited %d", code) } // Roll the aside back if cp failed mid-flight. if finalAside != "" { _, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c", "mv -- " + shellQuote(asideAbs) + " " + shellQuote(activeAbs)}) finalAside = "" } d.sendArkSaveRestoreResult(corrID, finalAside, "", msg) return } d.sendArkSaveRestoreResult(corrID, finalAside, activeName, "") } func (d *Dispatcher) arkRestoreOnHost(corrID string, rec *instanceRecord, subdir, target, activeName, asideName string) { if rec.DataPath == "" { d.sendArkSaveRestoreResult(corrID, "", "", "no file storage available") return } subdirRel := filepath.Join(arkSavedArksContainerRoot, subdir) subdirAbs, err := safeJoinHost(rec.DataPath, subdirRel) if err != nil { d.sendArkSaveRestoreResult(corrID, "", "", err.Error()) return } activeAbs := filepath.Join(subdirAbs, activeName) asideAbs := filepath.Join(subdirAbs, asideName) targetAbs := filepath.Join(subdirAbs, target) if _, err := os.Stat(targetAbs); err != nil { d.sendArkSaveRestoreResult(corrID, "", "", fmt.Sprintf("target save %q not found", target)) return } finalAside := "" if _, err := os.Stat(activeAbs); err == nil { if err := os.Rename(activeAbs, asideAbs); err != nil { d.sendArkSaveRestoreResult(corrID, "", "", err.Error()) return } finalAside = asideName } in, err := os.Open(targetAbs) if err != nil { d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error()) return } defer in.Close() out, err := os.OpenFile(activeAbs, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { // Roll aside back so we don't leave the server with no save. if finalAside != "" { _ = os.Rename(asideAbs, activeAbs) finalAside = "" } d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error()) return } if _, err := io.Copy(out, in); err != nil { _ = out.Close() if finalAside != "" { _ = os.Remove(activeAbs) _ = os.Rename(asideAbs, activeAbs) finalAside = "" } d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error()) return } if err := out.Close(); err != nil { d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error()) return } d.sendArkSaveRestoreResult(corrID, finalAside, activeName, "") } func (d *Dispatcher) sendArkSaveRestoreResult(corrID, asideFilename, installedFilename, errMsg string) { d.sendEnv(&panelv1.AgentEnvelope{ CorrelationId: corrID, SentAt: timestamppb.Now(), Payload: &panelv1.AgentEnvelope_ArkSaveRestoreResult{ ArkSaveRestoreResult: &panelv1.ArkSaveRestoreResult{ AsideFilename: asideFilename, InstalledFilename: installedFilename, Error: errMsg, }, }, }) }