Panel — public source drop (v0.9.0)

Self-hostable game-server control panel: controller + agent + 26 game
modules. One-line install (prebuilt release, no Go required):

  curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:17:39 -07:00
commit a00bd620a1
2160 changed files with 300574 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
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,
},
},
})
}