panel v0.9.2 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:06:16 -07:00
commit 0f6aea796c
2164 changed files with 301480 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
package dispatch
import (
"context"
"fmt"
"os"
"path"
"time"
"github.com/dbledeez/panel/agent/internal/runtime"
)
func medicEnvOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// Medic host paths on the agent box (figaro, for 7DTD). Overridable via env so
// the same agent binary works if 7DTD ever moves hosts.
func regionRollingRoot() string {
return medicEnvOr("PANEL_REGION_ROLLING_DIR", "/home/refuge/region-rolling")
}
func regionMedicBin() string {
return medicEnvOr("PANEL_REGION_MEDIC_BIN", "/home/refuge/panel/bin/region-medic")
}
func discordTokenFile() string {
return medicEnvOr("PANEL_DISCORD_TOKEN_FILE", "/home/refuge/panel/discord-bot-token")
}
func medicFileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
// runRegionMedicOnStart runs the autoheal CLI in a throwaway debian sidecar while
// the 7DTD container is STILL STOPPED, just before the agent starts it:
// - validate every .7rg in the active world's Region dir,
// - heal corrupt ones from the newest clean rolling snapshot, then the panel
// tar backups as a fallback,
// - snapshot the now-clean Region dir to a fresh rolling slot,
// - post a Discord summary (naming the server) if anything healed.
//
// The per-server medic config (enable / rolling-keep / Discord channel) lives in
// the SAVES volume at region-medic.json — written by the panel's medic-config
// endpoint via FsWrite (NO container recreate) and read by the CLI here in the
// sidecar. The agent therefore always launches the sidecar for 7DTD and lets the
// CLI decide; an absent/empty config just means defaults + no Discord.
//
// Best-effort by contract: any failure is logged + swallowed, never blocking the
// start. 10-min hard cap so a wedged sidecar/daemon can't stall a boot.
func (d *Dispatcher) runRegionMedicOnStart(ctx context.Context, rec *instanceRecord) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
log := d.log.With("instance_id", rec.InstanceID, "medic", true)
medicBin := regionMedicBin()
if !medicFileExists(medicBin) {
log.Warn("region-medic: binary missing, skipping pre-start heal", "bin", medicBin)
return
}
savesVol := fmt.Sprintf("panel-%s-saves", rec.InstanceID)
rollingDir := path.Join(regionRollingRoot(), rec.InstanceID)
_ = os.MkdirAll(rollingDir, 0o755)
backupsDir := path.Join(d.backupDir, rec.InstanceID)
cmd := []string{"/rm", "autoheal",
"-saves-root", "/saves",
"-rolling-dir", "/rolling",
"-keep", "2", // default; overridden by region-medic.json in the saves volume
"-label", rec.InstanceID,
"-apply",
}
vols := []runtime.VolumeSpec{
{Type: "volume", VolumeName: savesVol, ContainerPath: "/saves"},
{Type: "bind", HostPath: rollingDir, ContainerPath: "/rolling"},
{Type: "bind", HostPath: medicBin, ContainerPath: "/rm", ReadOnly: true},
}
// debian:12-slim ships no CA bundle, so the CLI's HTTPS POST to Discord can't
// verify the TLS cert ("x509: certificate signed by unknown authority"). Mount
// the host's CA certs read-only — Go's x509 finds ca-certificates.crt there.
if _, err := os.Stat("/etc/ssl/certs"); err == nil {
vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: "/etc/ssl/certs", ContainerPath: "/etc/ssl/certs", ReadOnly: true})
}
// Panel tar backups are the heal fallback when no rolling slot has a clean
// copy (e.g. very first boot, before any snapshot). Only mount if present.
if st, err := os.Stat(backupsDir); err == nil && st.IsDir() {
cmd = append(cmd, "-backups-dir", "/backups")
vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: backupsDir, ContainerPath: "/backups", ReadOnly: true})
}
// The token is mounted whenever it exists; the CLI only posts if the saves
// config names a channel. No -discord-channel flag — the CLI reads it from
// /saves/region-medic.json.
if tf := discordTokenFile(); medicFileExists(tf) {
cmd = append(cmd, "-discord-token-file", "/discord-token")
vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: tf, ContainerPath: "/discord-token", ReadOnly: true})
}
spec := runtime.InstanceSpec{
InstanceID: rec.InstanceID + "-medic",
IsSidecar: true,
Image: "debian:12-slim",
Command: cmd,
RestartPolicy: "no",
Volumes: vols,
}
log.Info("region-medic: pre-start scan+heal")
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
log.Warn("region-medic: create sidecar failed", "err", err)
return
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
log.Warn("region-medic: start sidecar failed", "err", err)
return
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
log.Info("region-medic", "line", line)
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
log.Warn("region-medic: wait sidecar failed", "err", err)
return
}
if exitCode != 0 {
log.Warn("region-medic: sidecar exited non-zero", "code", exitCode)
return
}
log.Info("region-medic: pre-start heal complete")
}