panel v0.9.2 — public release

Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:43:35 -07:00
commit 4ccccc6fe2
2164 changed files with 301480 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
package updater
import (
"archive/tar"
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/ulikunitz/xz"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// makeFactorioTarXz builds a tiny tar.xz shaped like factorio's
// headless tarball (factorio/bin/x64/factorio).
func makeFactorioTarXz(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
xw, err := xz.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
tw := tar.NewWriter(xw)
body := []byte("ELF-not-really")
if err := tw.WriteHeader(&tar.Header{Name: "factorio/bin/x64/factorio", Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := xw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func testCtx(t *testing.T, dataPath string) *Context {
t.Helper()
return &Context{
InstanceID: "test",
DataPath: dataPath,
Log: func(string) {},
}
}
// The factorio case: archive content + extensionless URL + extract:true
// must land the extracted tree under TargetPath, not a raw blob.
func TestDirectExtractsArchiveIntoTargetDir(t *testing.T) {
blob := makeFactorioTarXz(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(blob)
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/get-download/stable/headless/linux64", TargetPath: "/game", Extract: true}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")
data, err := os.ReadFile(bin)
if err != nil {
t.Fatalf("expected extracted binary at %s: %v", bin, err)
}
if string(data) != "ELF-not-really" {
t.Fatalf("binary content mismatch: %q", data)
}
// The old bug: raw blob written AS the target path.
if fi, err := os.Stat(filepath.Join(dir, "game")); err != nil || !fi.IsDir() {
t.Fatalf("target path should be a directory, got err=%v", err)
}
}
// Same archive, no explicit extract flag: extensionless TargetPath
// still triggers extraction (directory heuristic).
func TestDirectHeuristicExtractsWithoutFlag(t *testing.T) {
blob := makeFactorioTarXz(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(blob)
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/linux64", TargetPath: "/game"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")); err != nil {
t.Fatalf("expected extraction: %v", err)
}
}
// The terraria/minecraft guard: a TargetPath WITH a file extension is
// written verbatim even when the content has archive magic (jars are
// zips; terraria's entrypoint unzips its own .zip).
func TestDirectWritesArchiveVerbatimWhenTargetHasExtension(t *testing.T) {
// A zip blob (like a .jar or terraria-server zip).
var zipBuf bytes.Buffer
zipBuf.Write([]byte{0x50, 0x4b, 0x03, 0x04}) // zip local-header magic
zipBuf.WriteString("rest-of-zip-not-parsed")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(zipBuf.Bytes())
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/terraria-server-1449.zip", TargetPath: "/terraria-server.zip"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "terraria-server.zip"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, zipBuf.Bytes()) {
t.Fatal("zip should have been written verbatim")
}
}
// extract:true on non-archive content is a hard error, not a silent
// verbatim write.
func TestDirectExtractFlagOnNonArchiveErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("just text"))
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/game", Extract: true}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err == nil {
t.Fatal("expected error for extract on non-archive content")
}
}
// containerTargetAbs: absolute targets matching a declared root or
// volume mount stay container-absolute (factorio's /game volume);
// anything else keeps the historical BrowseableRoot join (terraria's
// /terraria-server.zip → /game-saves/terraria-server.zip).
func TestContainerTargetAbs(t *testing.T) {
uc := &Context{
BrowseableRoot: "/game-saves",
Manifest: &modulepkg.Manifest{
Runtime: modulepkg.Runtime{Docker: &modulepkg.DockerSpec{
BrowseableRoots: []modulepkg.BrowseableRoot{{Name: "Saves", Path: "/game-saves"}},
Volumes: []modulepkg.Volume{
{Type: "volume", Name: "panel-$INSTANCE_ID-saves", Container: "/game-saves"},
{Type: "volume", Name: "panel-$INSTANCE_ID-game", Container: "/game"},
},
}},
},
}
cases := map[string]string{
"/game": "/game",
"/game/sub": "/game/sub",
"/game-saves/mods": "/game-saves/mods",
"/terraria-server.zip": "/game-saves/terraria-server.zip",
"relative/file": "/game-saves/relative/file",
}
for in, want := range cases {
if got := containerTargetAbs(uc, in); got != want {
t.Errorf("containerTargetAbs(%q) = %q, want %q", in, got, want)
}
}
}
// Non-archive content with an extensionless target keeps the old
// single-file write behavior.
func TestDirectNonArchiveStillWritesFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("binary-ish payload"))
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/some/file"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "some", "file"))
if err != nil {
t.Fatal(err)
}
if string(data) != "binary-ish payload" {
t.Fatalf("content mismatch: %q", data)
}
}