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), 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 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,/statsand/eventskeep writing to the response body.logsandattachwithout a TTY use Docker'sstdcopyframing (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}/attachandPOST /exec/{id}/startanswer101 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 throughRespond.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_allpasses every request, andread_onlyrefuses any request that changes state.no_privilegeddeniesPOST /containers/createwhen it setsPrivileged: true.unprivilegerewrites such a request with the flag set tofalse.rego enginehands 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 realdockerCLI pointed at its socket, skipping the latter whendockeris 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 intraces/cover/version,/_ping, the versioned/v1.53/versionand 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.