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), andchangesandtop. - Exec.
POST /containers/{id}/execcreates an exec instance,/exec/{id}/startruns it,/exec/{id}/resizeresizes its terminal and/exec/{id}/jsoninspects it. - Images, networks and volumes. List, create, inspect, remove and
prune, plus imagetagandhistoryandPOST /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,/statsand/eventswrite a continuing body.logsand non-TTYattachuse Docker'sstdcopymultiplexing (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}/attachandPOST /exec/{id}/startanswer101 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 theRespond.hijackfunction.
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_allandread_only: pass everything, or refuse anything that mutates.no_privileged: denyPOST /containers/createwhenPrivileged: true.unprivilege: rewrite such a request, setting the flag tofalse, 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 realdockerCLI pointed at its socket (skipped ifdockeris 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 undertraces/hold/version,/_ping, the versioned/v1.53/versionand 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.