panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user