My own corner of monopam
README.md

ocaml-docker #

The Docker Engine API in pure OCaml: I/O-free request and response types, an Eio client, an Eio server and a content-aware proxy.

Tools that talk to Docker usually shell out to the docker CLI or hand-parse JSON from the daemon's socket, and a gateway that wants to refuse privileged containers has to understand request bodies, not only paths. The docker library maps the Engine API's JSON bodies to OCaml values with no I/O dependency, so the same definitions serve the client, the server and the conformance tests. The server's containers come from a pluggable backend (Docker.Runner.S); the only backend so far is an in-memory simulator. The server answers these endpoints:

  • System. GET /version, /_ping (capabilities carried in headers), /info, /events, and /system/df.
  • Containers. List, create, inspect, remove, rename and prune, the lifecycle operations (start, stop, kill, restart, pause, unpause, wait), and changes and top.
  • Exec. POST /containers/{id}/exec creates an exec instance, /exec/{id}/start runs it, /exec/{id}/resize resizes its terminal and /exec/{id}/json inspects it.
  • Images, networks and volumes. List, create, inspect, remove and prune, plus image tag and history and POST /build.

Every endpoint is also served under a /v<api> version prefix. A failed request becomes a typed Docker.Error, carrying the HTTP status and the daemon's message.

Streaming and hijacked routes #

Some endpoints stream rather than return one JSON body, and attach and exec take over the raw connection. The server writes these as they happen rather than buffering them:

  • Streaming responses. GET /containers/{id}/logs, /stats and /events write a continuing body. logs and non-TTY attach use Docker's stdcopy multiplexing (Docker.Stdcopy): each frame carries an 8-byte header that marks it as stdout or stderr, so the two streams stay apart on one socket. Byte 0 is the stream (0 stdin, 1 stdout, 2 stderr), bytes 1 to 3 are zero, and bytes 4 to 7 are the payload length as a big-endian 32-bit integer; the payload follows, and the next header comes after it.
  • Hijacked connections. POST /containers/{id}/attach and POST /exec/{id}/start answer 101 UPGRADED, after which the socket is no longer HTTP: stdin flows from the client to the process and stdout and stderr flow back. The server hands the raw connection to the route through the Respond.hijack function.

The proxy passes these through without interpreting them. An upgrade request keeps its Upgrade headers and its bytes are copied in both directions, so docker exec -it or docker attach works through the proxy and can still be recorded with a tap.

The in-memory simulator (Docker.Runner.Memory) tracks container metadata and a simulated lifecycle (created -> running -> exited) but runs no process. dockr serve therefore requires an explicit --fake flag and refuses to start without it, and docker ps shows the state as Up (simulated). A backend that runs real processes would be another implementation of Docker.Runner.S.

Install #

Requires OCaml >= 5.1.

opam install docker docker-client docker-server

If opam cannot find the packages, they may not yet be released in the public opam-repository. Add the overlay repository, then install the packages you need:

$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install docker docker-client docker-server

The packages are:

Package Module What it is
docker Docker I/O-free Engine API types and their JSON codecs, plus the Docker.Runner backend interface and the in-memory simulator
docker-client Client Eio client over a Unix socket or TCP
docker-server Server Eio server and the Proxy and Authz gateway
dockr The dockr CLI: client commands, serve and proxy

Usage #

The I/O-free docker library decodes an Engine API body into a typed value with of_string. It ignores unknown members, so a newer daemon stays readable:

# let body =
    {|{"Version":"29.2.1","ApiVersion":"1.53","Os":"linux","Arch":"arm64"}|}
val body : string =
  "{\"Version\":\"29.2.1\",\"ApiVersion\":\"1.53\",\"Os\":\"linux\",\"Arch\":\"arm64\"}"
# let summary = function
    | Ok (v : Docker.Version.t) ->
        Printf.sprintf "%s (API %s) on %s/%s" v.version v.api_version v.os v.arch
    | Error e -> "error: " ^ e
val summary : (Docker.Version.t, string) result -> string = <fun>
# summary (Docker.Version.of_string body)
- : string = "29.2.1 (API 1.53) on linux/arm64"

The dockr client and the dockr serve simulator drive the same types over a socket:

# Talk to a real Docker daemon
dockr version
dockr ping
dockr info
dockr version -H /var/run/docker.sock

# Run our own daemon (simulator), then point a real client at it
dockr serve --fake -H /tmp/dockr.sock &
curl --unix-socket /tmp/dockr.sock http://localhost/version
DOCKER_HOST=unix:///tmp/dockr.sock docker version

Proxy #

Server.Proxy is a Docker API gateway that sits between a client and a real daemon. It parses each request head, applies a policy, then copies the body and the response through unchanged. Path-only proxies such as docker-socket-proxy and CetusGuard see only the URL. This proxy also reads the headers and, when it can, the JSON body, so it can refuse a request that sets HostConfig.Privileged, rewrite a request body or filter a response. A privileged container gets every Linux capability, access to all host devices and no seccomp or AppArmor confinement, so its root is in effect root on the host.

A policy is a function from a request to Allow, Deny of int * string or Rewrite of string. The built-in policies are:

  • allow_all and read_only: pass everything, or refuse anything that mutates.
  • no_privileged: deny POST /containers/create when Privileged: true.
  • unprivilege: rewrite such a request, setting the flag to false, instead of rejecting it.
  • rego engine: delegate the decision to an OPA Rego policy. Rego is the policy language of the Open Policy Agent (OPA): a policy is a set of rules over the request's JSON that evaluate to allow or deny.

The proxy can also write an audit line per request, feed a Prometheus metrics collector, rewrite responses, tap a stream, terminate TLS and keep upstream connections alive in a pool. The policies filter requests; they do not isolate containers. no_privileged blocks only HostConfig.Privileged, so a create that asks for host bind-mounts or host networking still goes through unless a Rego policy refuses it.

# Read-only gateway in front of the real daemon
dockr proxy --listen /tmp/guard.sock --upstream /var/run/docker.sock \
           --policy read-only
DOCKER_HOST=unix:///tmp/guard.sock docker ps      # works
DOCKER_HOST=unix:///tmp/guard.sock docker run ... # denied

# Decide with a Rego/OPA policy instead of a named one
dockr proxy --listen /tmp/guard.sock --rego-file policy.rego

# Terminate TLS for a remote daemon
dockr proxy --listen 0.0.0.0:2376 --tls-cert server.pem --tls-key key.pem

The same policies can also run inside the daemon. With --authz, dockr proxy speaks Docker's authorisation plugin protocol (Plugin.Activate, AuthZPlugin.AuthZReq and AuthZPlugin.AuthZRes) on --listen, so one policy can serve as a standalone gateway or as an in-daemon plugin (Server.Authz). The plugin protocol can only allow or deny, so a Rewrite decision counts as an allow there.

API #

The interface files are the reference: lib/*.mli for the types and codecs (version.mli, container.mli, exec.mli and so on), and lib/server/proxy.mli for the gateway. Each codec maps one Engine API object and documents its JSON field names.

Tests #

  • test/: unit tests for the codecs (decoding, round trips, tolerance of unknown members, error capture).

  • test/client/: the client against a real Docker daemon, skipped if there is none.

  • test/server/: the server driven by the client and by the real docker CLI pointed at its socket (skipped if docker is absent), plus the proxy policies, response rewriting, the TLS listener and the AuthZ plugin protocol.

  • test/interop/dockerd/: replays responses captured from a real Docker daemon and checks that the codecs decode them. The traces under traces/ hold /version, /_ping, the versioned /v1.53/version and a 404 error body. The daemon is contacted only to refresh them:

    REGEN=1 dune build @traces
    

Together they check conformance from both sides: the traces check the client against a real server offline, and dockr serve lets a real client (the docker CLI, curl) drive the server.

License #

ISC, see LICENSE.md.