Files
panel/agent/internal/updater/direct.go
T
dbledeez a00bd620a1 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>
2026-07-14 21:17:39 -07:00

208 lines
7.4 KiB
Go

package updater
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
archivepkg "github.com/dbledeez/panel/agent/internal/archive"
modulepkg "github.com/dbledeez/panel/pkg/module"
"github.com/dbledeez/panel/pkg/version"
)
// DirectProvider fetches a URL and writes the response body to a target
// path under the instance. Used for vanilla Minecraft jars, custom mod
// packs hosted on any HTTP server, etc.
type DirectProvider struct{}
// Name identifies this provider kind in logs / manifest.
func (DirectProvider) Name() string { return "direct" }
const maxDirectDownloadBytes = 1 << 30 // 1 GiB sanity cap
// Update downloads spec.URL and writes it to spec.TargetPath relative
// to the instance. Optional SHA256 verification is reserved for a future
// `sha256:` field on the spec.
func (DirectProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
if spec.URL == "" {
return fmt.Errorf("direct provider: url is required")
}
if spec.TargetPath == "" {
return fmt.Errorf("direct provider: target_path is required")
}
req, err := http.NewRequestWithContext(ctx, "GET", spec.URL, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("User-Agent", version.UserAgent())
client := &http.Client{Timeout: 5 * time.Minute}
uc.Log(fmt.Sprintf("GET %s", spec.URL))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("http status %d", resp.StatusCode)
}
if resp.ContentLength > 0 {
uc.Log(fmt.Sprintf("Content-Length: %d bytes", resp.ContentLength))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectDownloadBytes+1))
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if int64(len(data)) > maxDirectDownloadBytes {
return fmt.Errorf("response exceeds %d-byte cap", maxDirectDownloadBytes)
}
sum := sha256.Sum256(data)
uc.Log(fmt.Sprintf("downloaded %d bytes (sha256:%s)", len(data), hex.EncodeToString(sum[:])))
// Archive handling. Some direct URLs serve a whole game tree as an
// archive (factorio's headless .tar.xz) — dumping those bytes as a
// single file at TargetPath is never what the module wants. Extract
// when the module opted in (spec.Extract) or when the content is a
// recognized archive AND TargetPath looks like a directory (no file
// extension). A TargetPath with an extension is always written
// verbatim: minecraft server.jar IS a zip by magic bytes, and
// terraria's entrypoint expects its .zip left on disk.
format := archivepkg.DetectFormat(data, spec.URL)
if spec.Extract && format == "" {
return fmt.Errorf("extract requested but content is not a recognized archive (url %s)", spec.URL)
}
if format != "" && (spec.Extract || path.Ext(path.Base(spec.TargetPath)) == "") {
uc.Log(fmt.Sprintf("content is a %s archive — extracting into %s", format, spec.TargetPath))
entries, bytesOut, err := extractToInstance(ctx, uc, spec.TargetPath, data, spec.URL)
if err != nil {
return fmt.Errorf("extract into %s: %w", spec.TargetPath, err)
}
uc.Log(fmt.Sprintf("extracted %d entries (%d bytes) into %s", entries, bytesOut, spec.TargetPath))
return nil
}
if err := writeToInstance(ctx, uc, spec.TargetPath, data); err != nil {
return fmt.Errorf("write target: %w", err)
}
uc.Log(fmt.Sprintf("wrote %s", spec.TargetPath))
return nil
}
// containerTargetAbs resolves a provider target_path to a container-
// absolute path. An absolute target that is equal to or under one of
// the module's declared browseable roots or volume mount points is
// used as-is (multi-root modules: factorio's /game volume sits BESIDE
// its /game-saves default root). Anything else keeps the historical
// behavior of joining under BrowseableRoot — terraria's entrypoint,
// for one, expects /terraria-server.zip to land at
// /game-saves/terraria-server.zip via exactly that join.
func containerTargetAbs(uc *Context, targetPath string) string {
if strings.HasPrefix(targetPath, "/") && uc.Manifest != nil {
cleaned := path.Clean(targetPath)
var roots []string
if d := uc.Manifest.Runtime.Docker; d != nil {
for _, r := range d.BrowseableRoots {
if r.Path != "" {
roots = append(roots, r.Path)
}
}
for _, v := range d.Volumes {
if v.Container != "" {
roots = append(roots, v.Container)
}
}
}
for _, r := range roots {
if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
return cleaned
}
}
}
return path.Join(uc.BrowseableRoot, targetPath)
}
// extractToInstance expands archive `data` into the instance-relative
// directory targetPath. Mirrors writeToInstance's storage preference:
// container-path ops when the instance has a BrowseableRoot and a
// container (re-encode the archive as one tar stream and let Docker
// extract it in place — same pattern as the file manager's FsExtract),
// falling back to plain filesystem extraction under DataPath.
func extractToInstance(ctx context.Context, uc *Context, targetPath string, data []byte, originalName string) (int64, int64, error) {
if targetPath == "" {
return 0, 0, fmt.Errorf("target_path is required")
}
if uc.BrowseableRoot != "" && uc.ContainerID != "" {
destAbs := containerTargetAbs(uc, targetPath)
// Best-effort mkdir; only possible on a running container, and
// the usual target is a volume mount point that already exists.
_, _, _, _ = uc.Runtime.ExecCapture(ctx, uc.ContainerID, []string{"sh", "-c", "mkdir -p -- '" + destAbs + "'"})
pr, pw := io.Pipe()
emitErr := make(chan error, 1)
var entries, bytesOut int64
go func() {
n, b, err := archivepkg.WriteAsTar(data, originalName, pw)
entries, bytesOut = n, b
_ = pw.CloseWithError(err)
emitErr <- err
}()
if err := uc.Runtime.CopyTarToContainer(ctx, uc.ContainerID, destAbs, pr); err != nil {
// Prefer the encoder's error when it carries archive-format
// detail — but NOT when it's just the pipe slamming shut
// because Docker aborted the copy (that would mask the real
// error, e.g. "destination directory does not exist").
if encErr := <-emitErr; encErr != nil && !errors.Is(encErr, io.ErrClosedPipe) {
return entries, bytesOut, encErr
}
return entries, bytesOut, fmt.Errorf("copy to container %s: %w", destAbs, err)
}
if encErr := <-emitErr; encErr != nil {
return entries, bytesOut, encErr
}
return entries, bytesOut, nil
}
if uc.DataPath == "" {
return 0, 0, fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath")
}
destAbs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath))
if err := os.MkdirAll(destAbs, 0o755); err != nil {
return 0, 0, fmt.Errorf("mkdir %s: %w", destAbs, err)
}
return archivepkg.Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
safe := archivepkg.SafeEntryPath(name)
if safe == "" {
return nil
}
out := filepath.Join(destAbs, filepath.FromSlash(safe))
if isDir {
return os.MkdirAll(out, 0o755)
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
perm := os.FileMode(mode & 0o777)
if perm == 0 {
perm = 0o644
}
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, err := io.Copy(f, body); err != nil {
_ = f.Close()
return err
}
return f.Close()
})
}