Files
panel/agent/internal/dispatch/ark_save_restore.go
T
2026-07-14 23:25:11 -07:00

265 lines
9.3 KiB
Go

package dispatch
// ArkSaveRestore: swap a SavedArks/<subdir>/ rolling backup into the
// active <subdir>.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 <subdir>/<subdir>.ark -> <subdir>/<subdir>_<dd.mm.yyyy>_<HH.MM.SS>_replaced.ark
// 4. cp <subdir>/<target_filename> -> <subdir>/<subdir>.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,
},
},
})
}