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
+992
View File
@@ -0,0 +1,992 @@
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
}
// 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)
}
return &DockerRuntime{cli: cli}, nil
}
// 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.
if spec.NetworkMode == "host" {
bindings = nil
exposed = nil
}
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(spec.NetworkMode),
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, "")
}