CrowdControl.Provider.Docker (crowd_control v0.1.1)

Copy Markdown View Source

One container per sandbox, running sandboxd, reached on a loopback-published port.

Requires the optional :req dependency and an image containing the sandboxd release. Unlike CrowdControl.Backend.Docker, which works with any image that has sh and tail, this provider needs our agent inside the container.

Network posture, and why it is not isolation

This provider does not block egress, and it must not be described as though it does. That is a measured constraint of the Docker bridge driver, not an oversight:

On one container, Internal: true and a published port are mutually exclusive. Publishing requires at least one non-internal endpoint, and attaching one restores full internet egress.

Confirmed six independent ways (internal-only; NetworkMode: "none"; two internal networks; Internal combined with each of the four gateway_mode_ipv4 values; publish-then-disconnect). Worse, the failure is silent: POST /containers/create answers 201 with "Warnings": [] and HostConfig.PortBindings echoes the request verbatim, while NetworkSettings.Ports quietly reads {"8080/tcp": null}.

So :egress is required and has no default, exactly as CrowdControl.Backend.Docker requires an explicit :network_mode. A silent default here would hand model-driven code general outbound access in the one scenario SECURITY.md warns about:

  • egress: :allow — a private per-sandbox bridge. Full outbound access. Correct when the sandbox is supposed to reach an API, and honest about it.
  • egress: :no_nat — the same bridge with com.docker.network.bridge.enable_ip_masquerade=false. The internet becomes unreachable because return traffic has no SNAT, but the Docker host, every container on every other Docker network, and Docker's embedded DNS all stay reachable. It is "no NAT", not "dropped". On a network whose router knows a path back to the container subnet, egress is not guaranteed to fail at all.

For a strong, structural egress block and a reachable agent, use CrowdControl.Provider.Compose: an internal-only sandbox plus a dual-homed forwarder is the only shape that delivers both, and it needs a second container to do it.

Options

  • :image — image containing the sandboxd release (required)
  • :egress:allow or :no_nat (required; see above)
  • :agent_port — the port sandboxd listens on inside the container, default 8080
  • :capture_path — default /var/log/cc/out.jsonl
  • :ready_timeout — how long acquire/1 waits for GET /v1/health, default 30_000
  • :docker_host, :timeout — as CrowdControl.Backend.Docker.API
  • :cpus, :memory, :cap_drop, :security_opt, :pids_limit, :user, :readonly_rootfs, :tmpfs — hardening, shared verbatim with CrowdControl.Backend.Docker through CrowdControl.Backend.Docker.HostConfig
  • :agent_env — extra environment for the agent process itself, not the CLI. The CLI's env goes through CrowdControl.Backend.exec/4, in a request body.
  • :req_adapter — test seam, threaded into the endpoint's Req options

The published port is never persisted

Every stop/start/restart allocates a new ephemeral host port, and while a container is stopped NetworkSettings.Ports is {} rather than reporting the old one. reconnect/1 therefore always re-reads it. This is measured behaviour, not caution.

Summary

Functions

Read the host port Docker assigned to the agent port.

Types

t()

@type t() :: %CrowdControl.Provider.Docker{
  agent_port: pos_integer(),
  capture_path: String.t(),
  config: keyword(),
  container_id: String.t() | nil,
  image: String.t() | nil,
  network_name: String.t() | nil,
  owner: String.t() | nil,
  session_key: String.t() | nil
}

Functions

host_port_from_inspect(container_json, agent_port)

@spec host_port_from_inspect(map(), pos_integer()) ::
  {:ok, pos_integer()} | {:error, term()}

Read the host port Docker assigned to the agent port.

NetworkSettings.Ports is the only source of truth. Four failure shapes were all observed against a live daemon and all of them mean "there is no usable port", so each is an error rather than something to work around:

  • the key is present with a null value — the binding was discarded because the network is internal;
  • HostPort is ""gateway_mode_ipv4: "routed" reports a binding it did not make;
  • Ports is {} — the container is not running;
  • the key is absent — ExposedPorts was never set.

Public and @doc false-adjacent so the parsing is testable without a daemon.