Files
dbledeez 295eb22826 panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:26:41 -07:00

1046 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runtime
import (
"archive/tar"
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/moby/go-archive"
"github.com/docker/go-connections/nat"
)
// DockerRuntime launches instances as Docker containers on the local daemon.
// Container IDs (the string returned by Create) are Docker's full container IDs.
type DockerRuntime struct {
cli *client.Client
// hostNetUnsupported is true when the daemon is Docker Desktop
// (Windows/macOS), where NetworkMode=host binds inside the hidden
// Linux VM and is NOT reachable from the host LAN. On such daemons we
// fall back to bridge networking + published ports so the panel's
// allocated ports actually reach players. Detected once at startup;
// overridable via PANEL_FORCE_BRIDGE=1 / PANEL_FORCE_HOST_NET=1.
hostNetUnsupported bool
}
// NewDocker connects to the local Docker daemon using environment-configured
// settings (DOCKER_HOST, DOCKER_CERT_PATH, …) and negotiates an API version.
func NewDocker() (*DockerRuntime, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("docker client: %w", err)
}
r := &DockerRuntime{cli: cli}
r.hostNetUnsupported = detectHostNetUnsupported(cli)
return r, nil
}
// applyHostNetPolicy decides the effective network mode + port plumbing for a
// container. Native Linux keeps host mode and drops the (meaningless) port
// bindings; a Docker-Desktop daemon converts host mode to bridge and KEEPS the
// published bindings so the panel-allocated ports reach the host LAN. Non-host
// modes pass through unchanged. Extracted for testability.
func applyHostNetPolicy(r *DockerRuntime, requested string, exposed nat.PortSet, bindings nat.PortMap) (string, nat.PortSet, nat.PortMap) {
if requested != "host" {
return requested, exposed, bindings
}
if r.hostNetUnsupported {
return "", exposed, bindings // bridge + published ports
}
return "host", nil, nil // native host mode, no bindings
}
// detectHostNetUnsupported returns true when NetworkMode=host won't reach the
// host LAN — i.e. Docker Desktop's Linux VM on Windows/macOS. Explicit env
// overrides win over autodetection so operators can force either behavior.
func detectHostNetUnsupported(cli *client.Client) bool {
if os.Getenv("PANEL_FORCE_BRIDGE") == "1" {
return true
}
if os.Getenv("PANEL_FORCE_HOST_NET") == "1" {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
info, err := cli.Info(ctx)
if err != nil {
return false // can't tell → assume native Linux (host works)
}
// Docker Desktop reports OperatingSystem "Docker Desktop" and, on
// Windows hosts, OSType "linux" while the actual client OS is windows.
// The tell-tale is the "Docker Desktop" marker or a non-Linux daemon.
if strings.Contains(info.OperatingSystem, "Docker Desktop") {
return true
}
if info.OSType != "" && info.OSType != "linux" {
return true
}
return false
}
// Close releases daemon resources.
func (r *DockerRuntime) Close() error {
if r.cli == nil {
return nil
}
return r.cli.Close()
}
// Name identifies the runtime kind.
func (r *DockerRuntime) Name() string { return "docker" }
// Ping verifies we can reach the Docker daemon.
func (r *DockerRuntime) Ping(ctx context.Context) error {
_, err := r.cli.Ping(ctx)
return err
}
// Create pulls the image (if missing locally) and creates the container.
// The container is NOT started — call Start afterward.
func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string, error) {
if spec.InstanceID == "" {
return "", errors.New("spec.InstanceID is required")
}
if spec.Image == "" {
return "", errors.New("spec.Image is required")
}
if err := r.acquireImage(ctx, spec.Image, spec.BuildContext, spec.LogSink); err != nil {
return "", err
}
exposed, bindings, err := buildPortBindings(spec.Ports)
if err != nil {
return "", err
}
// Docker rejects PortBindings + ExposedPorts when NetworkMode=host —
// the container shares the host's network namespace so publish-port
// plumbing is meaningless there.
var netMode string
netMode, exposed, bindings = applyHostNetPolicy(r, spec.NetworkMode, exposed, bindings)
if spec.NetworkMode == "host" && netMode != "host" && len(bindings) > 0 && spec.LogSink != nil {
spec.LogSink("Docker Desktop detected: publishing ports via bridge instead of host networking (host mode is not LAN-reachable on Docker Desktop)")
}
envSlice := make([]string, 0, len(spec.Env))
for k, v := range spec.Env {
envSlice = append(envSlice, k+"="+v)
}
mounts := make([]mount.Mount, 0, len(spec.Volumes))
for _, v := range spec.Volumes {
m := mount.Mount{
Target: v.ContainerPath,
ReadOnly: v.ReadOnly,
}
switch v.Type {
case "volume":
if v.VolumeName == "" {
return "", fmt.Errorf("volume mount %q: Name required", v.ContainerPath)
}
if err := r.ensureVolume(ctx, v.VolumeName, spec.InstanceID); err != nil {
return "", err
}
m.Type = mount.TypeVolume
m.Source = v.VolumeName
default: // "" or "bind"
if v.HostPath == "" {
return "", fmt.Errorf("bind mount %q: HostPath required", v.ContainerPath)
}
// Docker CLI auto-creates missing bind sources, but the Go API
// does not — it errors with "bind source path does not exist"
// and refuses to start the container. Modules declaring subdir
// binds like `$DATA_PATH/logs` rely on the source existing.
// Two cases:
// - Path exists as a file (panel-rendered config like
// serverconfig.xml.rendered): leave it alone, it's a
// file bind.
// - Path missing or already a directory: mkdir -p so docker
// bind-mount has something to mount.
if st, err := os.Stat(v.HostPath); err == nil && !st.IsDir() {
// existing file → file bind, no mkdir
} else if err := os.MkdirAll(v.HostPath, 0o755); err != nil {
return "", fmt.Errorf("bind mount %q: mkdir source %q: %w", v.ContainerPath, v.HostPath, err)
}
m.Type = mount.TypeBind
m.Source = v.HostPath
}
mounts = append(mounts, m)
}
restart := spec.RestartPolicy
if restart == "" {
restart = "unless-stopped"
}
hostCfg := &container.HostConfig{
Mounts: mounts,
PortBindings: bindings,
NetworkMode: container.NetworkMode(netMode),
SecurityOpt: spec.SecurityOpts,
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyMode(restart),
},
Resources: container.Resources{
CPUShares: int64(spec.Resources.CPUShares),
Memory: int64(spec.Resources.MemoryBytes),
NanoCPUs: spec.Resources.NanoCPUs,
PidsLimit: pidsPtr(spec.Resources.PidsLimit),
},
}
cfg := &container.Config{
Image: spec.Image,
Env: envSlice,
Entrypoint: spec.Entrypoint,
Cmd: spec.Command,
User: spec.User,
WorkingDir: spec.WorkingDir,
ExposedPorts: exposed,
Tty: false,
// OpenStdin + StdinOnce=false = stdin stays open across reconnects.
// The stdio RCON adapter attaches to stdin later via ContainerAttach.
OpenStdin: spec.OpenStdin,
StdinOnce: false,
Labels: map[string]string{
"panel.instance_id": spec.InstanceID,
"panel.role": roleLabel(spec.IsSidecar),
},
}
name := "panel-" + spec.InstanceID
resp, err := r.cli.ContainerCreate(ctx, cfg, hostCfg, &network.NetworkingConfig{}, nil, name)
if err != nil {
return "", fmt.Errorf("container create: %w", err)
}
return resp.ID, nil
}
// Start starts a previously-created container.
func (r *DockerRuntime) Start(ctx context.Context, id string) error {
if err := r.cli.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
return fmt.Errorf("container start: %w", err)
}
return nil
}
// Stop stops the container, waiting up to grace before SIGKILL.
func (r *DockerRuntime) Stop(ctx context.Context, id string, grace time.Duration) error {
secs := int(grace.Seconds())
if secs < 0 {
secs = 0
}
opts := container.StopOptions{Timeout: &secs}
if err := r.cli.ContainerStop(ctx, id, opts); err != nil {
return fmt.Errorf("container stop: %w", err)
}
return nil
}
// Restart restarts the container. Same StopOptions semantics as Stop —
// engine waits up to grace seconds before SIGKILL, then the container is
// started fresh. The container's RestartPolicy is irrelevant here;
// ContainerRestart always brings it back regardless of policy.
func (r *DockerRuntime) Restart(ctx context.Context, id string, grace time.Duration) error {
secs := int(grace.Seconds())
if secs < 0 {
secs = 0
}
opts := container.StopOptions{Timeout: &secs}
if err := r.cli.ContainerRestart(ctx, id, opts); err != nil {
return fmt.Errorf("container restart: %w", err)
}
return nil
}
// Remove removes the container. Force=true kills if still running.
func (r *DockerRuntime) Remove(ctx context.Context, id string) error {
err := r.cli.ContainerRemove(ctx, id, container.RemoveOptions{
Force: true,
RemoveVolumes: false,
})
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("container remove: %w", err)
}
return nil
}
// StreamLogs follows stdout+stderr and invokes handler for each line.
// Blocks until ctx is cancelled, the container exits, or the connection errors.
func (r *DockerRuntime) StreamLogs(ctx context.Context, id string, handler LogHandler) error {
rc, err := r.cli.ContainerLogs(ctx, id, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
// Timestamps:true prefixes each line with docker's own RFC3339Nano
// time. We parse that prefix below and pass it as the line's `at`
// instead of time.Now(). Critical for the readiness gate: the
// docker log file persists across container restarts, so a Tail:400
// replay can include the PREVIOUS run's "Server has completed
// startup" line. Without docker's real timestamp on each line, the
// frontend can't tell stale replays from current-run lines and
// the pill flips to "running" the instant the buffer is fetched.
Timestamps: true,
// Start with a little backlog so the console isn't blank if someone
// opens the modal mid-run. 400 lines is ~5s of chatter at the busiest
// and is trivial to stream.
Tail: "400",
})
if err != nil {
return fmt.Errorf("container logs: %w", err)
}
defer rc.Close()
outPr, outPw := io.Pipe()
errPr, errPw := io.Pipe()
defer outPw.Close()
defer errPw.Close()
done := make(chan error, 3)
// Demux multiplexed docker log stream into separate stdout/stderr pipes.
go func() {
_, err := stdcopy.StdCopy(outPw, errPw, rc)
_ = outPw.Close()
_ = errPw.Close()
done <- err
}()
scan := func(r io.Reader, stream LogStream) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
// Split on BOTH \n and \r so terminal-style progress updates
// (SteamCMD "Update state ... progress: X%" uses \r to overwrite
// the same line) show up as they happen instead of buffering
// until the next real newline minutes later.
sc.Split(scanLogLine)
for sc.Scan() {
raw := stripANSI(sc.Text())
if raw == "" {
continue
}
// Strip docker's RFC3339Nano timestamp prefix and use it as
// the line's authoritative time. Format is exact:
// "2026-04-29T17:46:53.123456789Z <line content>"
// On parse failure we fall back to time.Now() so a malformed
// line still gets streamed (better wrong timestamp than
// dropped log line).
line, ts := splitDockerTimestamp(raw)
handler(stream, line, ts)
}
done <- sc.Err()
}
go scan(outPr, LogStreamStdout)
go scan(errPr, LogStreamStderr)
for i := 0; i < 3; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
return err
}
}
}
return nil
}
// ListVolumesByInstance returns every volume labeled panel.instance_id=id.
// Used by the delete-with-purge path to clean up the instance's storage.
func (r *DockerRuntime) ListVolumesByInstance(ctx context.Context, instanceID string) ([]string, error) {
lst, err := r.cli.VolumeList(ctx, volume.ListOptions{})
if err != nil {
return nil, fmt.Errorf("volume list: %w", err)
}
var names []string
for _, v := range lst.Volumes {
if v == nil {
continue
}
if v.Labels["panel.instance_id"] == instanceID {
names = append(names, v.Name)
}
}
return names, nil
}
// RemoveVolume removes a Docker volume by name. force=true removes even
// if in-use references exist (shouldn't happen for us since we remove
// containers first).
func (r *DockerRuntime) RemoveVolume(ctx context.Context, name string, force bool) error {
if err := r.cli.VolumeRemove(ctx, name, force); err != nil {
return fmt.Errorf("volume remove %q: %w", name, err)
}
return nil
}
// ListContainersHoldingInstanceVolumes returns container IDs for any
// container (running OR exited) whose mount list references one of
// the panel volumes labeled for the given instance.
//
// Used by the delete path to sweep stranded sidecars — exited steamcmd
// or backup containers that the agent crashed before removing.
// They keep volumes "in use" for `docker volume rm` purposes even when
// they're not running, blocking instance delete-with-purge forever.
//
// We list ALL containers (including exited) and filter by mount name
// rather than relying on the container's own panel label, because
// updater sidecars don't carry the instance label — they're spawned
// by the alpine helper which doesn't know about panel.* labels.
func (r *DockerRuntime) ListContainersHoldingInstanceVolumes(ctx context.Context, instanceID string) ([]string, error) {
// Build the set of volume names this instance owns.
vols, err := r.ListVolumesByInstance(ctx, instanceID)
if err != nil {
return nil, err
}
if len(vols) == 0 {
return nil, nil
}
wanted := make(map[string]bool, len(vols))
for _, v := range vols {
wanted[v] = true
}
// Cheap heuristic: panel-<id>- prefix on the container name catches
// most sidecars (panel-X-steamcmd-N, panel-X-fs, panel-X-backup).
// We still cross-check via mounts so renamed/old containers don't
// slip through.
prefix := "panel-" + instanceID + "-"
cs, err := r.cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("container list: %w", err)
}
out := make([]string, 0, len(cs))
for _, c := range cs {
// Match by mount.
matched := false
for _, m := range c.Mounts {
if m.Type == mount.TypeVolume && wanted[m.Name] {
matched = true
break
}
}
// Or by name prefix.
if !matched {
for _, n := range c.Names {
name := strings.TrimPrefix(n, "/")
if strings.HasPrefix(name, prefix) {
matched = true
break
}
}
}
if matched {
out = append(out, c.ID)
}
}
return out, nil
}
// roleLabel maps the sidecar flag to the "panel.role" label value.
func roleLabel(isSidecar bool) string {
if isSidecar {
return "sidecar"
}
return "instance"
}
// ListMainInstanceNames returns the instance IDs of every main
// game-server container (panel.role=instance), excluding the transient
// fs / steamcmd / backup / etc. helper sidecars. Used by Rehydrate to
// detect orphans: a main container with no matching sidecar metadata,
// which would otherwise be silently invisible to the agent.
//
// Filtering is by the "panel.role" label, set at create time, not by
// name-suffix heuristics — so new sidecar kinds can't be misclassified
// as instances. Containers predating the label (no panel.role) fall
// back to a name check: anything matching a known sidecar suffix is
// excluded, everything else under the panel- prefix is treated as a
// main instance (the conservative choice — better to surface a stray
// helper as a candidate orphan than to hide a real instance).
func (r *DockerRuntime) ListMainInstanceNames(ctx context.Context) ([]string, error) {
cs, err := r.cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("container list: %w", err)
}
out := make([]string, 0, len(cs))
for _, c := range cs {
// Prefer the explicit label.
if role, ok := c.Labels["panel.role"]; ok {
if role != "instance" {
continue
}
if id := c.Labels["panel.instance_id"]; id != "" {
out = append(out, id)
}
continue
}
// Legacy fallback for containers created before panel.role existed.
for _, n := range c.Names {
name := strings.TrimPrefix(n, "/")
id, ok := strings.CutPrefix(name, "panel-")
if !ok || legacyIsSidecarName(id) {
continue
}
out = append(out, id)
}
}
return out, nil
}
// legacyIsSidecarName classifies pre-"panel.role" containers by their
// reserved trailing role. Instance ids themselves can contain hyphens
// (e.g. "scorched-earth"), so we match known suffixes rather than
// splitting on "-".
func legacyIsSidecarName(afterPrefix string) bool {
for _, suf := range []string{"-fs", "-backup", "-restore", "-wipe", "-basemods", "-warmseed"} {
if strings.HasSuffix(afterPrefix, suf) {
return true
}
}
// steamcmd sidecars are panel-<id>-steamcmd-<n>.
if strings.Contains(afterPrefix, "-steamcmd-") || strings.HasSuffix(afterPrefix, "-steamcmd") {
return true
}
return false
}
// ForceRemoveContainer is `docker rm -f` — kills a running container
// and removes it. Used by the orphan-sidecar sweep on delete; the
// sidecar's exit-code state doesn't matter since we're nuking it.
func (r *DockerRuntime) ForceRemoveContainer(ctx context.Context, containerID string) error {
return r.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
Force: true,
RemoveVolumes: false, // we'll remove volumes separately
})
}
// ensureVolume creates the named volume if it doesn't exist. Labels it so
// operators can identify panel-owned volumes (`docker volume ls -f
// label=panel.instance_id=ark-test`).
func (r *DockerRuntime) ensureVolume(ctx context.Context, name, instanceID string) error {
_, err := r.cli.VolumeInspect(ctx, name)
if err == nil {
return nil
}
if !client.IsErrNotFound(err) {
return fmt.Errorf("inspect volume %q: %w", name, err)
}
_, err = r.cli.VolumeCreate(ctx, volume.CreateOptions{
Name: name,
Driver: "local",
Labels: map[string]string{
"panel.instance_id": instanceID,
},
})
if err != nil {
return fmt.Errorf("create volume %q: %w", name, err)
}
return nil
}
// InspectByName looks up a container by its Docker name (leading "/"
// stripped by the API) and returns its state. Used by the Agent on cold
// start to check whether a sidecar-metadata file still points at a live
// container.
func (r *DockerRuntime) InspectByName(ctx context.Context, name string) (RuntimeInstanceState, error) {
c, err := r.cli.ContainerInspect(ctx, name)
if err != nil {
return RuntimeInstanceState{}, err
}
st := RuntimeInstanceState{ContainerID: c.ID}
if c.State != nil {
st.Status = c.State.Status
st.ExitCode = int32(c.State.ExitCode)
}
return st, nil
}
// ContainerMounts returns the mount points of a container by name. Works
// on stopped containers. Used to resolve a 7DTD instance's /cluster bind
// to its host path for the cluster player-save snapshotter.
func (r *DockerRuntime) ContainerMounts(ctx context.Context, name string) ([]MountInfo, error) {
c, err := r.cli.ContainerInspect(ctx, name)
if err != nil {
return nil, err
}
out := make([]MountInfo, 0, len(c.Mounts))
for _, m := range c.Mounts {
out = append(out, MountInfo{
Type: string(m.Type),
Source: m.Source,
Destination: m.Destination,
Name: m.Name,
})
}
return out, nil
}
// ContainerExists reports definitive existence vs. an indeterminate
// query failure. A not-found error from the daemon means the container
// is gone (exists=false, err=nil); any other error (daemon unreachable,
// connection dropped mid-call during agent shutdown, ctx cancelled)
// leaves existence UNKNOWN and is returned verbatim so callers don't
// mistake "couldn't ask" for "doesn't exist".
func (r *DockerRuntime) ContainerExists(ctx context.Context, name string) (bool, error) {
_, err := r.cli.ContainerInspect(ctx, name)
if err == nil {
return true, nil
}
if client.IsErrNotFound(err) {
return false, nil
}
return false, err
}
// StatsStream opens Docker's ContainerStats (streaming) and returns the
// raw JSON reader. Each line is a stats frame — caller parses + throttles.
func (r *DockerRuntime) StatsStream(ctx context.Context, id string) (io.ReadCloser, error) {
resp, err := r.cli.ContainerStats(ctx, id, true)
if err != nil {
return nil, fmt.Errorf("container stats: %w", err)
}
return resp.Body, nil
}
// Wait blocks until the container exits and returns its exit code.
func (r *DockerRuntime) Wait(ctx context.Context, id string) (int, error) {
statusCh, errCh := r.cli.ContainerWait(ctx, id, container.WaitConditionNotRunning)
select {
case <-ctx.Done():
return -1, ctx.Err()
case err := <-errCh:
if err != nil {
return -1, fmt.Errorf("container wait: %w", err)
}
return -1, nil
case s := <-statusCh:
if s.Error != nil {
return int(s.StatusCode), fmt.Errorf("wait: %s", s.Error.Message)
}
return int(s.StatusCode), nil
}
}
// ---- Container-path file ops (used by the panel's file manager) ----
// ExecCapture runs cmd inside the given running container and returns its
// captured stdout + stderr + exit code.
func (r *DockerRuntime) ExecCapture(ctx context.Context, id string, cmd []string) ([]byte, []byte, int, error) {
resp, err := r.cli.ContainerExecCreate(ctx, id, container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return nil, nil, -1, fmt.Errorf("exec create: %w", err)
}
att, err := r.cli.ContainerExecAttach(ctx, resp.ID, container.ExecStartOptions{})
if err != nil {
return nil, nil, -1, fmt.Errorf("exec attach: %w", err)
}
defer att.Close()
// StdCopy reads the hijacked exec stream, which is NOT bound by ctx — a
// container caught mid-restart can leave this Read blocked forever, leaking
// the underlying docker connection. Unbounded, that once cascaded into every
// runtime op hanging (config file-reads 504'd; a log stream stopped
// re-attaching). Run StdCopy off a goroutine and enforce ctx ourselves: on
// timeout, Close() the attach to unblock the Read, then drain the goroutine
// so the buffers are race-free to read.
var stdout, stderr bytes.Buffer
copyDone := make(chan error, 1)
go func() { _, e := stdcopy.StdCopy(&stdout, &stderr, att.Reader); copyDone <- e }()
select {
case <-ctx.Done():
att.Close() // unblock the goroutine's Read (defer Close is a safe no-op double-close)
<-copyDone
return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec copy: %w", ctx.Err())
case err := <-copyDone:
if err != nil && !errors.Is(err, io.EOF) {
return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec copy: %w", err)
}
}
insp, err := r.cli.ContainerExecInspect(ctx, resp.ID)
if err != nil {
return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec inspect: %w", err)
}
return stdout.Bytes(), stderr.Bytes(), insp.ExitCode, nil
}
// CopyFileFromContainer returns the raw bytes of the file at containerPath.
// Works on both running and stopped containers. Follows symlinks (Docker's
// archive API tars a symlink as a symlink without dereferencing — needed
// for cases like the 7DTD module's /game/serverconfig.xml -> /game-saves/…).
func (r *DockerRuntime) CopyFileFromContainer(ctx context.Context, id, containerPath string) ([]byte, error) {
return r.copyFileFromContainerHop(ctx, id, containerPath, 0)
}
// maxCopyFileSymlinkHops bounds symlink resolution depth so a malicious or
// accidental symlink loop can't pin an agent goroutine. 8 is way more than
// any legitimate chain we've seen; bump it if a real use-case demands.
const maxCopyFileSymlinkHops = 8
func (r *DockerRuntime) copyFileFromContainerHop(ctx context.Context, id, containerPath string, depth int) ([]byte, error) {
if depth >= maxCopyFileSymlinkHops {
return nil, fmt.Errorf("symlink chain too deep (>%d hops) at %s", maxCopyFileSymlinkHops, containerPath)
}
rc, _, err := r.cli.CopyFromContainer(ctx, id, containerPath)
if err != nil {
return nil, fmt.Errorf("copy from container: %w", err)
}
defer rc.Close()
tr := tar.NewReader(rc)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil, fmt.Errorf("file not found in archive")
}
if err != nil {
return nil, fmt.Errorf("tar next: %w", err)
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeRegA:
return io.ReadAll(tr)
case tar.TypeDir:
return nil, fmt.Errorf("path is a directory")
case tar.TypeSymlink, tar.TypeLink:
// Resolve the target. Docker hands back the symlink itself —
// it never dereferences. Linkname can be absolute or relative;
// relative is resolved against the link's containing dir, not
// our cwd, mirroring how the kernel resolves it inside the FS.
target := hdr.Linkname
if !path.IsAbs(target) {
target = path.Join(path.Dir(containerPath), target)
}
return r.copyFileFromContainerHop(ctx, id, target, depth+1)
}
}
}
// AttachStdio attaches to the container's multiplexed stdin/stdout/stderr
// stream and returns a single ReadWriteCloser. Writes go to stdin; reads
// are the raw multiplexed frame stream (our stdio RCON adapter drains and
// discards the reader — responses reach the panel via docker logs instead,
// so we don't need to demux here).
func (r *DockerRuntime) AttachStdio(ctx context.Context, id string) (io.ReadWriteCloser, error) {
resp, err := r.cli.ContainerAttach(ctx, id, container.AttachOptions{
Stream: true,
Stdin: true,
Stdout: true,
Stderr: true,
})
if err != nil {
return nil, fmt.Errorf("container attach: %w", err)
}
return &hijackedStream{resp: resp}, nil
}
// hijackedStream adapts Docker's HijackedResponse (a bidirectional net.Conn
// plus a buffered reader) to io.ReadWriteCloser so the stdio adapter can
// treat it uniformly.
type hijackedStream struct {
resp types.HijackedResponse
mu sync.Mutex
}
func (h *hijackedStream) Write(p []byte) (int, error) {
return h.resp.Conn.Write(p)
}
func (h *hijackedStream) Read(p []byte) (int, error) {
return h.resp.Reader.Read(p)
}
func (h *hijackedStream) Close() error {
h.mu.Lock()
defer h.mu.Unlock()
h.resp.Close()
return nil
}
// CopyFileToContainer writes content to containerPath inside the container.
// Parent dir must exist (Docker's archive API doesn't create them).
func (r *DockerRuntime) CopyFileToContainer(ctx context.Context, id, containerPath string, content []byte) error {
dir := path.Dir(containerPath)
base := path.Base(containerPath)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tw.WriteHeader(&tar.Header{
Name: base,
Mode: 0o644,
Size: int64(len(content)),
}); err != nil {
return fmt.Errorf("tar header: %w", err)
}
if _, err := tw.Write(content); err != nil {
return fmt.Errorf("tar body: %w", err)
}
if err := tw.Close(); err != nil {
return fmt.Errorf("tar close: %w", err)
}
return r.cli.CopyToContainer(ctx, id, dir, &buf, container.CopyToContainerOptions{AllowOverwriteDirWithFile: false})
}
// CopyTarToContainer writes a raw tar stream onto dstDir. Caller is
// responsible for the tar formatting (one Header+body per entry, then
// Close()). Docker extracts in place. Used by the file manager's
// extract endpoint so we can ship a whole archive's contents in one
// API call instead of N per-file CopyFileToContainer round-trips.
func (r *DockerRuntime) CopyTarToContainer(ctx context.Context, id, dstDir string, tarStream io.Reader) error {
return r.cli.CopyToContainer(ctx, id, dstDir, tarStream, container.CopyToContainerOptions{AllowOverwriteDirWithFile: true})
}
// CopyDirFromContainer returns a tar stream rooted at containerPath.
// Caller must Close() the reader. Used by the file manager's compress
// endpoint to walk a directory's contents without per-file API calls.
func (r *DockerRuntime) CopyDirFromContainer(ctx context.Context, id, containerPath string) (io.ReadCloser, error) {
rc, _, err := r.cli.CopyFromContainer(ctx, id, containerPath)
if err != nil {
return nil, fmt.Errorf("copy from container: %w", err)
}
return rc, nil
}
// acquireImage ensures the requested image is present locally. Fast path:
// already on disk → return. Otherwise:
//
// - `panel-*` images are NEVER published to a registry. We auto-build
// from buildContext/Dockerfile when buildContext is set. This is the
// "drop in a new agent and any module just works" behavior — first
// instance create on a fresh agent triggers the local build of the
// module's runtime image. Subsequent creates hit the inspect-success
// fast path.
//
// - Non-panel images (alpine, acekorneya/asa-linux-server, etc.) are
// pulled from the registry as usual.
//
// If the image is `panel-*` and buildContext is empty (no Dockerfile in
// the module dir), we surface the same "build it on the agent host"
// message as before — the operator's module is broken, not the agent.
func (r *DockerRuntime) acquireImage(ctx context.Context, ref, buildContext string, sink func(string)) error {
emit := func(line string) {
if sink != nil {
sink(line)
}
}
if _, err := r.cli.ImageInspect(ctx, ref); err == nil {
return nil
} else if !client.IsErrNotFound(err) {
return fmt.Errorf("image inspect: %w", err)
}
isPanel := strings.HasPrefix(ref, "panel-") && !strings.Contains(ref, "/")
if isPanel {
if buildContext == "" {
return fmt.Errorf("module image %q not built on this agent and no build context provided", ref)
}
dockerfile := filepath.Join(buildContext, "Dockerfile")
if _, err := os.Stat(dockerfile); err != nil {
return fmt.Errorf("module image %q missing on this agent and no Dockerfile at %s", ref, dockerfile)
}
emit(fmt.Sprintf("[image] %s missing on this agent — building from %s/Dockerfile (first-run; takes 30s3min)", ref, buildContext))
return r.buildLocalImage(ctx, ref, buildContext, emit)
}
// Public image — pull from registry.
emit(fmt.Sprintf("[image] pulling %s from registry…", ref))
rc, err := r.cli.ImagePull(ctx, ref, image.PullOptions{})
if err != nil {
return fmt.Errorf("image pull: %w", err)
}
defer rc.Close()
streamDockerJSON(rc, emit)
emit(fmt.Sprintf("[image] %s ready", ref))
return nil
}
// buildLocalImage runs `docker build -t <ref> <ctxDir>` via the Docker
// daemon's API. Build output is parsed (NDJSON) and forwarded to `emit`
// so the controller's Console tab gets live progress while the operator
// waits for first-time builds.
func (r *DockerRuntime) buildLocalImage(ctx context.Context, ref, ctxDir string, emit func(string)) error {
tarball, err := archive.TarWithOptions(ctxDir, &archive.TarOptions{})
if err != nil {
return fmt.Errorf("tar build context: %w", err)
}
defer tarball.Close()
resp, err := r.cli.ImageBuild(ctx, tarball, build.ImageBuildOptions{
Tags: []string{ref},
Dockerfile: "Dockerfile",
Remove: true,
ForceRemove: true,
PullParent: false,
})
if err != nil {
return fmt.Errorf("image build start: %w", err)
}
defer resp.Body.Close()
if buildErr := streamDockerJSON(resp.Body, emit); buildErr != nil {
return fmt.Errorf("image build: %w", buildErr)
}
if _, err := r.cli.ImageInspect(ctx, ref); err != nil {
return fmt.Errorf("image build %q produced no tag: %w", ref, err)
}
emit(fmt.Sprintf("[image] %s built and tagged", ref))
return nil
}
// streamDockerJSON parses Docker's streaming JSON output (one object per
// line, fields like {"stream":"...","status":"...","error":"...","errorDetail":{...}})
// and forwards meaningful lines to emit. Returns a non-nil error iff the
// daemon reported an error in the stream.
func streamDockerJSON(rc io.Reader, emit func(string)) error {
dec := json.NewDecoder(rc)
for {
var msg struct {
Stream string `json:"stream"`
Status string `json:"status"`
Progress string `json:"progress"`
ID string `json:"id"`
Error string `json:"error"`
ErrorDetail json.RawMessage `json:"errorDetail"`
}
if err := dec.Decode(&msg); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
// Non-JSON tail / partial trailer — stop parsing but don't
// fail the build; the post-build ImageInspect will catch
// any tag-missing case.
return nil
}
if msg.Error != "" {
return errors.New(msg.Error)
}
// Build → "stream" lines (Step 1/12 : FROM debian:slim, etc).
if s := strings.TrimRight(msg.Stream, "\r\n"); s != "" {
emit(s)
}
// Pull → "status" lines, sometimes paired with progress for
// each layer. Skip the high-frequency "Downloading" updates
// (would spam the console at 1030 Hz) — keep "Pulling /
// Pulled" markers and final messages.
if msg.Status != "" {
st := msg.Status
if strings.HasPrefix(st, "Downloading") || strings.HasPrefix(st, "Extracting") || strings.HasPrefix(st, "Verifying") {
continue
}
if msg.ID != "" {
emit(fmt.Sprintf("%s: %s", msg.ID, st))
} else {
emit(st)
}
}
}
}
// buildPortBindings translates PortSpec to Docker's nat.PortSet / PortMap.
func buildPortBindings(ports []PortSpec) (nat.PortSet, nat.PortMap, error) {
exposed := nat.PortSet{}
bindings := nat.PortMap{}
for _, p := range ports {
proto := strings.ToLower(p.Proto)
if proto != "tcp" && proto != "udp" {
return nil, nil, fmt.Errorf("port %d/%s: proto must be tcp or udp", p.ContainerPort, p.Proto)
}
natPort, err := nat.NewPort(proto, strconv.Itoa(int(p.ContainerPort)))
if err != nil {
return nil, nil, fmt.Errorf("invalid port %d/%s: %w", p.ContainerPort, proto, err)
}
exposed[natPort] = struct{}{}
hostPort := p.HostPort
if hostPort == 0 {
hostPort = p.ContainerPort
}
bindings[natPort] = []nat.PortBinding{{
HostIP: p.HostIP,
HostPort: strconv.Itoa(int(hostPort)),
}}
}
return exposed, bindings, nil
}
func pidsPtr(v int64) *int64 {
if v <= 0 {
return nil
}
return &v
}
// splitDockerTimestamp pulls docker's RFC3339Nano timestamp prefix off a
// log line and returns the remainder + parsed time. Docker emits each
// line as "<rfc3339nano-z> <line>". A space immediately follows the Z;
// anything else (including missing/garbled prefix) falls back to
// time.Now() so we never drop the line.
func splitDockerTimestamp(s string) (string, time.Time) {
// Minimum valid prefix: "2026-01-01T00:00:00Z " — 21 chars. With nano
// precision it's longer; the space after Z is what we look for.
sp := strings.IndexByte(s, ' ')
if sp < 20 || sp > 35 {
return s, time.Now()
}
t, err := time.Parse(time.RFC3339Nano, s[:sp])
if err != nil {
return s, time.Now()
}
return s[sp+1:], t
}
// scanLogLine is a bufio.SplitFunc that treats \n, \r, and \r\n as line
// terminators. The stdlib default (bufio.ScanLines) only splits on \n and
// will buffer everything between \r-overwrites until it sees a real newline
// — that's how SteamCMD's "Update state downloading progress: X%" bars
// used to sit silent for minutes before dumping in one chunk.
func scanLogLine(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
// Swallow a trailing \n after \r so "\r\n" yields one line not two.
advance = i + 1
if data[i] == '\r' && i+1 < len(data) && data[i+1] == '\n' {
advance++
}
return advance, bytes.TrimRight(data[:i], "\r\n"), nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
}
// ansiRE matches ANSI CSI sequences (colour, cursor movement, erase-line, …).
// vinanrra's LGSM wrapper for 7DTD emits these liberally; they're noise in a
// browser console and garble line-collapsing logic on the UI side.
var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
func stripANSI(s string) string {
if !strings.ContainsRune(s, 0x1b) {
return s
}
return ansiRE.ReplaceAllString(s, "")
}