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 " // 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-- 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--steamcmd-. 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 30s–3min)", 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 ` 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 10–30 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 " ". 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, "") }