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/ as a symlink to // /game/steamapps/workshop/content/221100/ // 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, }, }, }) }