panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user