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
+388
View File
@@ -0,0 +1,388 @@
// Package archive walks and re-encodes archive files (zip / tar /
// tar.gz / tar.bz2 / tar.xz / 7z / rar) fully in-process — no native
// deps. It is shared by the file browser's extract/compress endpoints
// (internal/dispatch) and the direct update provider
// (internal/updater), which extracts archive downloads into the
// instance instead of dumping the raw archive bytes.
//
// 7z and rar use pure-Go libraries (bodgit/sevenzip,
// nwaples/rardecode/v2). Encrypted rars and solid 7z volumes are
// surfaced as errors, not silently skipped.
package archive
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/bzip2"
"compress/gzip"
"errors"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/bodgit/sevenzip"
"github.com/nwaples/rardecode/v2"
"github.com/ulikunitz/xz"
)
// Entry-count / byte caps so a malformed or hostile archive can't pin
// the agent forever walking phantom records.
const (
MaxEntries = 200000
MaxTotalBytes = 8 * 1024 * 1024 * 1024
EntrySoftBytes = 4 * 1024 * 1024 * 1024
)
// EntryFunc is called once per archive entry. body is a streaming
// reader (nil for directories); the callback may read fully or skip.
type EntryFunc func(name string, mode int64, isDir bool, body io.Reader) error
// Walk opens `data` as an archive (auto-detected via signature plus
// filename hint) and calls fn for each entry. Returns entry and byte
// counts alongside any error.
func Walk(data []byte, name string, fn EntryFunc) (int64, int64, error) {
switch DetectFormat(data, name) {
case "zip":
return walkZip(data, fn)
case "tar.gz":
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return 0, 0, fmt.Errorf("gzip open: %w", err)
}
defer gz.Close()
return walkTar(gz, fn)
case "tar.bz2":
return walkTar(bzip2.NewReader(bytes.NewReader(data)), fn)
case "tar.xz":
xr, err := xz.NewReader(bytes.NewReader(data))
if err != nil {
return 0, 0, fmt.Errorf("xz open: %w", err)
}
return walkTar(xr, fn)
case "tar":
return walkTar(bytes.NewReader(data), fn)
case "7z":
return walk7z(data, fn)
case "rar":
return walkRar(data, fn)
default:
return 0, 0, fmt.Errorf("unsupported archive format for %q (panel knows zip / tar / tar.gz / tar.bz2 / tar.xz / 7z / rar)", name)
}
}
// WriteAsTar parses an archive from `data` and re-emits its entries as
// a tar stream into `out`. Used by container-side extract paths so the
// agent can feed Docker's CopyToContainer in a single API call.
func WriteAsTar(data []byte, originalName string, out io.Writer) (int64, int64, error) {
tw := tar.NewWriter(out)
defer tw.Close()
var entries, bytesOut int64
_, _, err := Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
safe := SafeEntryPath(name)
if safe == "" {
return nil
}
// Buffer the entry body so we can compute its length before
// writing the tar header. zip / gzip readers return streaming
// readers; the tar writer needs Size up front.
var bodyBuf bytes.Buffer
var sz int64
if !isDir {
n, err := io.Copy(&bodyBuf, body)
if err != nil {
return fmt.Errorf("entry %q: %w", name, err)
}
sz = n
if sz > EntrySoftBytes {
return fmt.Errorf("entry %q exceeds %d-byte cap", name, int64(EntrySoftBytes))
}
}
hdr := &tar.Header{Name: safe, ModTime: time.Now(), Mode: 0o644}
if mode != 0 {
hdr.Mode = mode & 0o777
}
if isDir {
hdr.Typeflag = tar.TypeDir
hdr.Mode = 0o755
if !strings.HasSuffix(hdr.Name, "/") {
hdr.Name += "/"
}
} else {
hdr.Typeflag = tar.TypeReg
hdr.Size = sz
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if !isDir {
if _, err := tw.Write(bodyBuf.Bytes()); err != nil {
return err
}
}
entries++
bytesOut += sz
return nil
})
if err != nil {
return entries, bytesOut, err
}
return entries, bytesOut, tw.Close()
}
// DetectFormat classifies `data` by magic bytes first (handles renamed
// extensions), then falls back to the filename hint. Returns "" when
// the content is not a recognized archive.
func DetectFormat(data []byte, name string) string {
lower := strings.ToLower(name)
// Magic bytes win when present — handles renamed extensions.
if len(data) >= 4 && data[0] == 0x50 && data[1] == 0x4b && (data[2] == 0x03 || data[2] == 0x05 || data[2] == 0x07) {
return "zip"
}
if len(data) >= 3 && data[0] == 0x1f && data[1] == 0x8b && data[2] == 0x08 {
return "tar.gz"
}
if len(data) >= 3 && data[0] == 'B' && data[1] == 'Z' && data[2] == 'h' {
return "tar.bz2"
}
// 7z signature: 37 7A BC AF 27 1C
if len(data) >= 6 && data[0] == 0x37 && data[1] == 0x7a && data[2] == 0xbc && data[3] == 0xaf && data[4] == 0x27 && data[5] == 0x1c {
return "7z"
}
// RAR 5.0 signature: 52 61 72 21 1A 07 01 00 ("Rar!\x1a\x07\x01\x00")
// RAR 1.54.x: 52 61 72 21 1A 07 00 ("Rar!\x1a\x07\x00")
if len(data) >= 7 && data[0] == 'R' && data[1] == 'a' && data[2] == 'r' && data[3] == '!' && data[4] == 0x1a && data[5] == 0x07 {
return "rar"
}
// xz signature: FD 37 7A 58 5A 00
if len(data) >= 6 && data[0] == 0xfd && data[1] == '7' && data[2] == 'z' && data[3] == 'X' && data[4] == 'Z' && data[5] == 0x00 {
return "tar.xz"
}
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return "tar.gz"
}
if strings.HasSuffix(lower, ".tar.bz2") || strings.HasSuffix(lower, ".tbz2") || strings.HasSuffix(lower, ".tbz") {
return "tar.bz2"
}
if strings.HasSuffix(lower, ".tar.xz") || strings.HasSuffix(lower, ".txz") {
return "tar.xz"
}
if strings.HasSuffix(lower, ".tar") {
return "tar"
}
if strings.HasSuffix(lower, ".zip") {
return "zip"
}
if strings.HasSuffix(lower, ".7z") {
return "7z"
}
if strings.HasSuffix(lower, ".rar") {
return "rar"
}
return ""
}
func walkZip(data []byte, fn EntryFunc) (int64, int64, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return 0, 0, fmt.Errorf("zip open: %w", err)
}
var entries, bytesOut int64
var totalBytes int64
for _, f := range zr.File {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
isDir := f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/")
if isDir {
if err := fn(f.Name, int64(f.Mode()), true, nil); err != nil {
return entries, bytesOut, err
}
entries++
continue
}
rc, err := f.Open()
if err != nil {
return entries, bytesOut, fmt.Errorf("zip entry %q: %w", f.Name, err)
}
// Cap each entry by `EntrySoftBytes` so a malformed/zip-bomb
// entry can't run away with memory. We DO NOT trust
// f.UncompressedSize64 — some zip writers leave it 0 for streamed
// entries; using it for accounting (bytesOut += size) caused the
// per-archive total to be charged the 4 GB soft cap per such
// entry, tripping MaxTotalBytes after just a couple of
// files and aborting the extract mid-stream — silently leaving
// the first N files extracted and the rest dropped (this was the
// root cause of the 7DTD mod-upload "Config/ folders missing"
// bug). Use a counting reader so accounting reflects real bytes.
cr := &countingReader{r: io.LimitReader(rc, EntrySoftBytes)}
if err := fn(f.Name, int64(f.Mode()), false, cr); err != nil {
_ = rc.Close()
return entries, bytesOut, err
}
_ = rc.Close()
entries++
bytesOut += cr.n
totalBytes += cr.n
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
return entries, bytesOut, nil
}
// countingReader wraps an io.Reader and tracks the byte count actually
// consumed. Used by walkZip / walkTar accounting so the per-archive
// total reflects real-world bytes, not zip-header declared sizes (which
// some encoders leave as 0).
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func walkTar(r io.Reader, fn EntryFunc) (int64, int64, error) {
tr := tar.NewReader(r)
var entries, bytesOut, totalBytes int64
for {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
hdr, err := tr.Next()
if err == io.EOF {
return entries, bytesOut, nil
}
if err != nil {
return entries, bytesOut, fmt.Errorf("tar next: %w", err)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := fn(hdr.Name, hdr.Mode, true, nil); err != nil {
return entries, bytesOut, err
}
entries++
case tar.TypeReg, tar.TypeRegA:
if err := fn(hdr.Name, hdr.Mode, false, tr); err != nil {
return entries, bytesOut, err
}
entries++
bytesOut += hdr.Size
totalBytes += hdr.Size
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
}
}
func walk7z(data []byte, fn EntryFunc) (int64, int64, error) {
zr, err := sevenzip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return 0, 0, fmt.Errorf("7z open: %w", err)
}
var entries, bytesOut, totalBytes int64
for _, f := range zr.File {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
isDir := f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/")
if isDir {
if err := fn(f.Name, int64(f.Mode()), true, nil); err != nil {
return entries, bytesOut, err
}
entries++
continue
}
rc, err := f.Open()
if err != nil {
return entries, bytesOut, fmt.Errorf("7z entry %q: %w", f.Name, err)
}
// See countingReader rationale in walkZip — header-declared
// size can be 0 for streamed entries, charging the soft-cap per
// entry would falsely abort the extract.
cr := &countingReader{r: io.LimitReader(rc, EntrySoftBytes)}
if err := fn(f.Name, int64(f.Mode()), false, cr); err != nil {
_ = rc.Close()
return entries, bytesOut, err
}
_ = rc.Close()
entries++
bytesOut += cr.n
totalBytes += cr.n
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
return entries, bytesOut, nil
}
func walkRar(data []byte, fn EntryFunc) (int64, int64, error) {
rr, err := rardecode.NewReader(bytes.NewReader(data))
if err != nil {
return 0, 0, fmt.Errorf("rar open: %w", err)
}
var entries, bytesOut, totalBytes int64
for {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
hdr, err := rr.Next()
if errors.Is(err, io.EOF) {
return entries, bytesOut, nil
}
if err != nil {
return entries, bytesOut, fmt.Errorf("rar next: %w", err)
}
mode := int64(hdr.Mode().Perm())
if hdr.IsDir {
if err := fn(hdr.Name, mode, true, nil); err != nil {
return entries, bytesOut, err
}
entries++
continue
}
// See countingReader rationale in walkZip.
cr := &countingReader{r: io.LimitReader(rr, EntrySoftBytes)}
if err := fn(hdr.Name, mode, false, cr); err != nil {
return entries, bytesOut, err
}
entries++
bytesOut += cr.n
totalBytes += cr.n
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
}
// SafeEntryPath strips leading slashes / drive letters and
// rejects any path that tries to ../ out of the destination. Returns
// "" on rejected entries — caller skips them silently.
func SafeEntryPath(name string) string {
if name == "" {
return ""
}
// Normalize separators + strip leading drive / slash.
clean := strings.ReplaceAll(name, "\\", "/")
for strings.HasPrefix(clean, "/") {
clean = clean[1:]
}
if len(clean) >= 2 && clean[1] == ':' {
clean = clean[2:]
}
clean = path.Clean(clean)
if clean == "." || clean == "" {
return ""
}
if strings.HasPrefix(clean, "../") || clean == ".." || strings.Contains(clean, "/../") {
return ""
}
return clean
}
+180
View File
@@ -0,0 +1,180 @@
package archive
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"io"
"testing"
"github.com/ulikunitz/xz"
)
// buildTar writes a small dir+file tree as a tar stream.
func buildTar(t *testing.T, w io.Writer) {
t.Helper()
tw := tar.NewWriter(w)
if err := tw.WriteHeader(&tar.Header{Name: "factorio/", Typeflag: tar.TypeDir, Mode: 0o755}); err != nil {
t.Fatal(err)
}
body := []byte("#!/bin/sh\necho hi\n")
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)
}
}
func makeTarXz(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
xw, err := xz.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
buildTar(t, xw)
if err := xw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func makeTarGz(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
buildTar(t, gw)
if err := gw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func makeZip(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, err := zw.Create("factorio/bin/x64/factorio")
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte("#!/bin/sh\necho hi\n")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestDetectFormatMagicBytes(t *testing.T) {
// Extensionless names — magic bytes must carry detection (factorio's
// download URL has no file extension).
cases := []struct {
data []byte
want string
}{
{makeTarXz(t), "tar.xz"},
{makeTarGz(t), "tar.gz"},
{makeZip(t), "zip"},
{[]byte("plain text, not an archive"), ""},
}
for _, c := range cases {
if got := DetectFormat(c.data, "https://example.com/get-download/stable/headless/linux64"); got != c.want {
t.Errorf("DetectFormat = %q, want %q", got, c.want)
}
}
}
func TestDetectFormatExtensionFallback(t *testing.T) {
// Content too short for magic — extension hint decides.
if got := DetectFormat([]byte{}, "thing.tar.xz"); got != "tar.xz" {
t.Errorf("extension fallback = %q, want tar.xz", got)
}
if got := DetectFormat([]byte{}, "thing.tgz"); got != "tar.gz" {
t.Errorf("extension fallback = %q, want tar.gz", got)
}
}
func TestWalkAllFormats(t *testing.T) {
for name, data := range map[string][]byte{
"tar.xz": makeTarXz(t),
"tar.gz": makeTarGz(t),
"zip": makeZip(t),
} {
var files []string
var content []byte
entries, _, err := Walk(data, "blob", func(n string, mode int64, isDir bool, body io.Reader) error {
if isDir {
return nil
}
files = append(files, n)
b, err := io.ReadAll(body)
content = b
return err
})
if err != nil {
t.Fatalf("%s: Walk: %v", name, err)
}
if entries == 0 || len(files) != 1 || files[0] != "factorio/bin/x64/factorio" {
t.Fatalf("%s: unexpected entries %d files %v", name, entries, files)
}
if string(content) != "#!/bin/sh\necho hi\n" {
t.Fatalf("%s: content mismatch: %q", name, content)
}
}
}
func TestWriteAsTarRoundTrip(t *testing.T) {
var out bytes.Buffer
entries, bytesOut, err := WriteAsTar(makeTarXz(t), "blob", &out)
if err != nil {
t.Fatal(err)
}
if entries != 2 {
t.Fatalf("entries = %d, want 2", entries)
}
if bytesOut == 0 {
t.Fatal("bytesOut = 0")
}
// Re-walk the emitted tar and check the file mode survived.
tr := tar.NewReader(&out)
var sawFile bool
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if hdr.Typeflag == tar.TypeReg {
sawFile = true
if hdr.Name != "factorio/bin/x64/factorio" {
t.Fatalf("name = %q", hdr.Name)
}
if hdr.Mode&0o111 == 0 {
t.Fatalf("exec bit lost: mode %o", hdr.Mode)
}
}
}
if !sawFile {
t.Fatal("no regular file in re-emitted tar")
}
}
func TestSafeEntryPathRejectsTraversal(t *testing.T) {
for _, bad := range []string{"../evil", "a/../../evil", "..", ""} {
if got := SafeEntryPath(bad); got != "" {
t.Errorf("SafeEntryPath(%q) = %q, want rejection", bad, got)
}
}
if got := SafeEntryPath("/abs/path"); got != "abs/path" {
t.Errorf("leading slash strip = %q", got)
}
}