5232609719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
276 lines
9.6 KiB
Go
276 lines
9.6 KiB
Go
package dispatch
|
|
|
|
// DayZ mod install/uninstall + FsSymlink handlers. The controller drives
|
|
// the high-level workflow (SteamCMD workshop_download runs controller-side,
|
|
// writing to the shared panel-dayz-workshop volume); the agent's job is to
|
|
// wire the downloaded content into the instance's /game tree:
|
|
//
|
|
// 1. Create /game/<folder_name> as a symlink to
|
|
// /game/steamapps/workshop/content/221100/<workshop_id>
|
|
// 2. Copy every .bikey from that mod's keys/ (or Keys/) subfolder into
|
|
// /game/keys/ so BattlEye signs mod content on boot.
|
|
// 3. On uninstall, remove the symlink + every bikey whose content hash
|
|
// matches a bikey in the workshop mod's keys dir (so we don't remove
|
|
// keys that belong to OTHER installed mods of the same vendor).
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
|
)
|
|
|
|
// ---- FsSymlink ----
|
|
//
|
|
// The symlink is created inside the instance's container namespace so that
|
|
// any `/game/@Mod` symlink resolves correctly from the DayZServer binary's
|
|
// perspective.
|
|
|
|
func (d *Dispatcher) handleFsSymlink(corrID string, req *panelv1.FsSymlinkRequest) {
|
|
rec, err := d.lookupRecord(req.InstanceId)
|
|
if err != nil {
|
|
d.sendFsSymlinkResult(corrID, err.Error())
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
targetID, err := d.getFsTargetContainerID(ctx, rec)
|
|
if err != nil {
|
|
d.sendFsSymlinkResult(corrID, err.Error())
|
|
return
|
|
}
|
|
// `ln -sfn` replaces an existing symlink atomically. We require
|
|
// abs paths for both target and link_path and refuse anything outside
|
|
// the configured browseable roots for link_path (the symlink itself;
|
|
// target can point outside for workshop shares).
|
|
if !strings.HasPrefix(req.LinkPath, "/") || !strings.HasPrefix(req.Target, "/") {
|
|
d.sendFsSymlinkResult(corrID, "target and link_path must be absolute")
|
|
return
|
|
}
|
|
if _, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.LinkPath); err != nil {
|
|
d.sendFsSymlinkResult(corrID, "link_path outside browseable roots: "+err.Error())
|
|
return
|
|
}
|
|
// `ln` errors with "No such file or directory" if the parent doesn't
|
|
// exist yet — common on first mod install for modules whose entrypoint
|
|
// hasn't lazily created the Mods/ folder yet (Conan Exiles). `mkdir -p`
|
|
// the parent before linking so the symlink-then-game-restart flow works
|
|
// even on a fresh recreate. Idempotent: mkdir on existing dir is a
|
|
// no-op, and the safeJoinAny check above already proved link_path is
|
|
// inside a declared root so we're not creating dirs in arbitrary spots.
|
|
parentDir := path.Dir(req.LinkPath)
|
|
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
|
|
fmt.Sprintf("mkdir -p %q && ln -sfn %q %q", parentDir, req.Target, req.LinkPath)})
|
|
if err != nil {
|
|
d.sendFsSymlinkResult(corrID, err.Error())
|
|
return
|
|
}
|
|
if code != 0 {
|
|
d.sendFsSymlinkResult(corrID, fmt.Sprintf("ln exited %d: %s", code, strings.TrimSpace(string(stderr))))
|
|
return
|
|
}
|
|
d.sendFsSymlinkResult(corrID, "")
|
|
}
|
|
|
|
func (d *Dispatcher) sendFsSymlinkResult(corrID, errMsg string) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
CorrelationId: corrID,
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_FsSymlinkResult{
|
|
FsSymlinkResult: &panelv1.FsSymlinkResult{Error: errMsg},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---- DayzModInstall ----
|
|
|
|
func (d *Dispatcher) handleDayzModInstall(corrID string, req *panelv1.DayzModInstallRequest) {
|
|
d.log.Info("dayz mod install", "instance_id", req.InstanceId, "workshop_id", req.WorkshopId, "folder", req.FolderName)
|
|
rec, err := d.lookupRecord(req.InstanceId)
|
|
if err != nil {
|
|
d.sendDayzModInstallResult(corrID, err.Error(), nil, "")
|
|
return
|
|
}
|
|
if req.WorkshopId == "" || req.FolderName == "" {
|
|
d.sendDayzModInstallResult(corrID, "workshop_id and folder_name are required", nil, "")
|
|
return
|
|
}
|
|
// Normalize folder name: must start with '@', no slashes, no whitespace.
|
|
folder := strings.TrimSpace(req.FolderName)
|
|
if !strings.HasPrefix(folder, "@") {
|
|
folder = "@" + folder
|
|
}
|
|
if strings.ContainsAny(folder, "/\\ \t\n") {
|
|
d.sendDayzModInstallResult(corrID, "folder_name must not contain slashes or whitespace", nil, "")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
targetID, err := d.getFsTargetContainerID(ctx, rec)
|
|
if err != nil {
|
|
d.sendDayzModInstallResult(corrID, err.Error(), nil, "")
|
|
return
|
|
}
|
|
|
|
modPath := "/game/steamapps/workshop/content/221100/" + req.WorkshopId
|
|
linkPath := "/game/" + folder
|
|
|
|
// 1. Verify workshop content exists.
|
|
_, _, code, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
|
|
fmt.Sprintf("test -d %q", modPath)})
|
|
if code != 0 {
|
|
d.sendDayzModInstallResult(corrID, "workshop content not found at "+modPath+" — did SteamCMD download succeed?", nil, modPath)
|
|
return
|
|
}
|
|
|
|
// 2. Create symlink.
|
|
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
|
|
fmt.Sprintf("ln -sfn %q %q", modPath, linkPath)})
|
|
if err != nil {
|
|
d.sendDayzModInstallResult(corrID, "symlink: "+err.Error(), nil, modPath)
|
|
return
|
|
}
|
|
if code != 0 {
|
|
d.sendDayzModInstallResult(corrID, "symlink failed: "+strings.TrimSpace(string(stderr)), nil, modPath)
|
|
return
|
|
}
|
|
|
|
// 3. Copy bikeys. DayZ mods ship them under keys/ OR Keys/ inside the
|
|
// workshop tree. Copy every .bikey we find there into /game/keys/.
|
|
// Keep a manifest of copied filenames so uninstall can remove them.
|
|
// Use `find` + `cp` for portability; busybox and GNU both understand.
|
|
stdout, stderr2, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
|
|
fmt.Sprintf(`mkdir -p /game/keys && \
|
|
for src in %q/keys %q/Keys; do \
|
|
[ -d "$src" ] || continue; \
|
|
for f in "$src"/*.bikey "$src"/*.BIKEY; do \
|
|
[ -f "$f" ] || continue; \
|
|
base=$(basename "$f"); \
|
|
cp -f "$f" /game/keys/"$base"; \
|
|
printf '%%s\n' "$base"; \
|
|
done; \
|
|
done`, modPath, modPath)})
|
|
if err != nil {
|
|
d.sendDayzModInstallResult(corrID, "copy bikeys: "+err.Error(), nil, modPath)
|
|
return
|
|
}
|
|
if code != 0 {
|
|
d.sendDayzModInstallResult(corrID, "copy bikeys failed: "+strings.TrimSpace(string(stderr2)), nil, modPath)
|
|
return
|
|
}
|
|
var bikeys []string
|
|
for _, ln := range strings.Split(string(stdout), "\n") {
|
|
ln = strings.TrimSpace(strings.TrimPrefix(ln, `\n`))
|
|
if ln == "" {
|
|
continue
|
|
}
|
|
bikeys = append(bikeys, ln)
|
|
}
|
|
d.sendDayzModInstallResult(corrID, "", bikeys, modPath)
|
|
}
|
|
|
|
func (d *Dispatcher) sendDayzModInstallResult(corrID, errMsg string, bikeys []string, resolvedPath string) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
CorrelationId: corrID,
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_DayzModInstallResult{
|
|
DayzModInstallResult: &panelv1.DayzModInstallResult{
|
|
Error: errMsg,
|
|
BikeysCopied: bikeys,
|
|
ResolvedModPath: resolvedPath,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---- DayzModUninstall ----
|
|
|
|
func (d *Dispatcher) handleDayzModUninstall(corrID string, req *panelv1.DayzModUninstallRequest) {
|
|
rec, err := d.lookupRecord(req.InstanceId)
|
|
if err != nil {
|
|
d.sendDayzModUninstallResult(corrID, err.Error(), nil)
|
|
return
|
|
}
|
|
folder := strings.TrimSpace(req.FolderName)
|
|
if !strings.HasPrefix(folder, "@") || strings.ContainsAny(folder, "/\\ \t\n") {
|
|
d.sendDayzModUninstallResult(corrID, "invalid folder_name", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
targetID, err := d.getFsTargetContainerID(ctx, rec)
|
|
if err != nil {
|
|
d.sendDayzModUninstallResult(corrID, err.Error(), nil)
|
|
return
|
|
}
|
|
linkPath := "/game/" + folder
|
|
|
|
// Resolve the symlink to find the workshop directory.
|
|
stdout, _, code, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
|
|
fmt.Sprintf("readlink -f %q 2>/dev/null || true", linkPath)})
|
|
modPath := strings.TrimSpace(string(stdout))
|
|
if code != 0 || modPath == "" {
|
|
// Symlink is already gone or broken — just drop stale /game/keys entries.
|
|
modPath = ""
|
|
}
|
|
|
|
// Remove bikeys whose *name* matches a bikey in the workshop mod's keys.
|
|
// Matching by name (not hash) is what DayZ tooling does — the server
|
|
// looks up bikeys by exact basename, and mods typically ship unique
|
|
// key names.
|
|
var removed []string
|
|
if modPath != "" {
|
|
out, _, _, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
|
|
fmt.Sprintf(`for d in %q/keys %q/Keys; do \
|
|
[ -d "$d" ] || continue; \
|
|
for f in "$d"/*.bikey "$d"/*.BIKEY; do \
|
|
[ -f "$f" ] || continue; \
|
|
base=$(basename "$f"); \
|
|
if [ -f "/game/keys/$base" ]; then \
|
|
rm -f "/game/keys/$base"; \
|
|
echo "$base"; \
|
|
fi; \
|
|
done; \
|
|
done`, modPath, modPath)})
|
|
for _, ln := range strings.Split(string(out), "\n") {
|
|
ln = strings.TrimSpace(ln)
|
|
if ln == "" {
|
|
continue
|
|
}
|
|
removed = append(removed, ln)
|
|
}
|
|
}
|
|
|
|
// Remove the symlink itself.
|
|
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
|
|
fmt.Sprintf("rm -f %q", linkPath)})
|
|
if err != nil {
|
|
d.sendDayzModUninstallResult(corrID, "rm symlink: "+err.Error(), removed)
|
|
return
|
|
}
|
|
if code != 0 {
|
|
d.sendDayzModUninstallResult(corrID, "rm symlink failed: "+strings.TrimSpace(string(stderr)), removed)
|
|
return
|
|
}
|
|
d.sendDayzModUninstallResult(corrID, "", removed)
|
|
}
|
|
|
|
func (d *Dispatcher) sendDayzModUninstallResult(corrID, errMsg string, bikeys []string) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
CorrelationId: corrID,
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_DayzModUninstallResult{
|
|
DayzModUninstallResult: &panelv1.DayzModUninstallResult{
|
|
Error: errMsg,
|
|
BikeysRemoved: bikeys,
|
|
},
|
|
},
|
|
})
|
|
}
|