5232609719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
265 lines
9.3 KiB
Go
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,
|
|
},
|
|
},
|
|
})
|
|
}
|