My own corner of monopam
README.md

ocaml-docker #

The Docker Engine API in OCaml. It has request and response types that do no I/O, an Eio client, an Eio server, and a proxy that reads request bodies.

A program that talks to Docker usually runs the docker CLI or parses the JSON from the daemon's socket by hand. A gateway that should refuse privileged containers must read the request body as well as the path. The docker library turns the Engine API's JSON bodies into OCaml values and depends on no I/O library, so the client, the server and the conformance tests all share one set of definitions. The server gets its containers from a backend that implements Docker.Runner.S. The only backend today 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 also answers under a /v<api> version prefix. A failed request returns a Docker.Error with the HTTP status and the daemon's message.

Streaming and hijacked routes #

A few endpoints stream their output instead of returning one JSON body, and attach and exec take over the raw connection. The server writes their output as it is produced and does not buffer it:

  • Streaming responses. GET /containers/{id}/logs, /stats and /events keep writing to the response body. logs and attach without a TTY use Docker's stdcopy framing (Docker.Stdcopy) to keep stdout and stderr apart on one socket. Each frame starts with an 8-byte header. Byte 0 names the stream (0 stdin, 1 stdout, 2 stderr), bytes 1 to 3 are zero, and bytes 4 to 7 hold the payload length as a big-endian 32-bit integer. The payload follows the header, and the next header follows the payload.
  • Hijacked connections. POST /containers/{id}/attach and POST /exec/{id}/start answer 101 UPGRADED. From then on the socket carries raw bytes instead of HTTP, with stdin going from the client to the process and stdout and stderr coming back. The route gets the raw connection from the server through Respond.hijack.

The proxy forwards these without reading them. It keeps the Upgrade headers of an upgrade request and copies bytes both ways, so docker exec -it and docker attach work through it, and a tap can still record the traffic.

The in-memory simulator, Docker.Runner.Memory, keeps container metadata and moves containers through created -> running -> exited, but it starts no process. For that reason dockr serve refuses to start unless it is given --fake, and docker ps shows the state as Up (simulated). A backend that runs real processes would be a second implementation of Docker.Runner.S.

Install #

Requires OCaml >= 5.1.

opam install docker docker-client docker-server

The packages may not be in the public opam-repository yet. If opam cannot find them, add the overlay repository first:

$ 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 docker library decodes an Engine API body into a typed value with of_string. Unknown members are ignored, so bodies from a newer daemon still decode:

# 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 use 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 sits between a client and a real Docker daemon. It parses the head of each request, asks a policy what to do, and then copies the body and the response through. Proxies such as docker-socket-proxy and CetusGuard decide on the URL alone. This one also reads the headers and, where it can, the JSON body. It can therefore refuse a request that sets HostConfig.Privileged, rewrite a request body or filter a response. The privileged flag matters because a privileged container gets every Linux capability and every host device, with no seccomp or AppArmor profile, and its root user is then effectively root on the host.

A policy is a function that maps a request to Allow, Deny of int * string or Rewrite of string. The library provides these:

  • allow_all passes every request, and read_only refuses any request that changes state.
  • no_privileged denies POST /containers/create when it sets Privileged: true.
  • unprivilege rewrites such a request with the flag set to false.
  • rego engine hands the decision to a Rego policy. Rego is the policy language of the Open Policy Agent (OPA), in which a policy is a set of rules over the request's JSON that allow or deny it.

The proxy can write an audit line for each request and export Prometheus metrics. It can rewrite responses, tap a stream, terminate TLS and keep a pool of open connections to the daemon. A policy only filters requests and gives no isolation between containers. no_privileged looks at HostConfig.Privileged and nothing else, so a create request that asks for host bind mounts or host networking still passes 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

A policy 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, and the daemon consults it as a plugin (Server.Authz). That protocol has only allow and deny, so there a Rewrite decision is treated as an allow.

API #

The interface files document the API. The types and codecs are in lib/*.mli (version.mli, container.mli, exec.mli and the others), and the gateway is in lib/server/proxy.mli. Each codec covers one Engine API object and lists its JSON field names.

Tests #

  • test/ has unit tests for the codecs: decoding, round trips, unknown members and error bodies.

  • test/client/ runs the client against a real Docker daemon, and skips when there is none.

  • test/server/ drives the server with the client and with the real docker CLI pointed at its socket, skipping the latter when docker is not installed. It also tests the proxy policies, response rewriting, the TLS listener and the AuthZ plugin protocol.

  • test/interop/dockerd/ replays responses recorded from a real Docker daemon and checks that the codecs decode them. The recordings in traces/ cover /version, /_ping, the versioned /v1.53/version and a 404 error body. Only refreshing them needs a daemon:

    REGEN=1 dune build @traces
    

Conformance is checked from both ends. The recordings check the codecs against a real server without a network, and dockr serve lets real clients such as the docker CLI and curl drive our server.

License #

ISC, see LICENSE.md.