Date: 2026-06-06 (proposed) · 2026-06-28 (accepted)
Accepted
The early Islandr Docker image ships for demo and evaluation only: it uses the mock WireGuard and nftables adapters and cannot manage real peers or firewall rules, so production deployments require the native binary under systemd (ADR-0011). Making Docker production-capable is v1-core work, targeted for 0.11.0 — pulled forward from the originally planned v2 usability line.
The reason: nft and wg run on the host kernel, not inside the container. To call them from a container, Docker would need --cap-add NET_ADMIN and --network host. With --network host that capability spans all network namespaces visible to the host — a compromised islandr process could manipulate any nftables table and any network interface on the machine. This violates ADR-0011's principle of least privilege.
Other escape hatches are worse: the Docker socket (/var/run/docker.sock) allows spawning privileged containers and is a full host-escape vector. --pid=host + nsenter is equivalent to root.
There is no safe way to call host-privileged tools from a container without a mediating process that holds the privilege on behalf of the container.
In 0.11.0 (v1 line), a Unix socket proxy (islandr-proxy) runs as a systemd service on the host alongside the container. The container mounts only the proxy socket — no capabilities, no host PID namespace, no Docker socket.
Container Host
──────────────────────────────────────────────────────────────
islandr-binary
│
│ JSON over Unix socket:
│ {"op":"nft_reload"} (ruleset path is a server constant)
│ {"op":"wg_set_peer","interface":"wg0","pubkey":"...","allowedIps":"..."}
│
└── /run/islandr/proxy.sock ────► islandr-proxy.service (host)
│
│ validates: op in allowlist
│ validates: path is under /var/lib/islandr/
│ validates: interface == wg0
│
├── sudo nft -f /var/lib/islandr/ruleset.nft
└── sudo wg set wg0 peer <pubkey> allowed-ips <cidr>
| Op | Command | Constraints |
|---|---|---|
wg_set_peer |
sudo wg set wg0 peer <pubkey> allowed-ips <cidr>[ preshared-key <file>] |
interface fixed to wg0; pubkey & preshared key = base64 of exactly 32 bytes; every allowed-ips element validated as a CIDR; the PSK is written to a short-lived 0600 file and passed by path, never on the command line |
wg_remove_peer |
sudo wg set wg0 peer <pubkey> remove |
pubkey validated (base64/32 bytes) |
wg_show |
sudo wg show wg0 dump |
read-only; stdout returned in the dump field |
nft_validate |
sudo nft -c -f /var/lib/islandr/ruleset.nft |
ruleset path is a server-side constant, not a request field |
nft_reload |
sudo nft -f /var/lib/islandr/ruleset.nft |
ruleset path is a server-side constant, not a request field |
Anything outside this list is rejected with an error — the proxy has no shell, no exec, no wildcard.
This table matches the ops actually sent by
ProxyClientand implemented inislandr-proxy/. An earlier draft listednft_flush(nft flush ruleset islandr); the JVM never emits it — it clears rules by reloading a validated ruleset vianft_validate+nft_reload— so it is not a proxy op.wg_set_peercarries an optionalpresharedKey, andnft_validate(dry-runnft -c -f) was added.
docker run \
-v /run/islandr/proxy.sock:/run/islandr/proxy.sock \
-v /var/lib/islandr:/var/lib/islandr \
-p 8080:8080 \
ghcr.io/chriscohnen/islandr:latest
No --cap-add, no --network host, no --privileged.
islandr separates two planes. The configuration plane — GUI, peers, users, groups, ACLs, settings, audit, JSON export/import — runs unconditionally inside the container and persists to its volume. The enforcement plane — pushing the computed ruleset to nft and peers to wg on the host kernel — is the only part that needs privilege, and privilege cannot be bootstrapped from an unprivileged container (the premise of this ADR and of ADR-0011).
So the container always boots, with or without the proxy. The socket-client WgAdapter/NftAdapter probe /run/islandr/proxy.sock at startup and per operation; when it is absent or unreachable, islandr runs in a degraded "enforcement unavailable" state instead of failing. A bare docker run — volume and port, no socket mount — therefore gives the full GUI to explore, evaluate, and build a complete configuration:
docker run -v islandr-data:/var/lib/islandr -p 8080:8080 ghcr.io/chriscohnen/islandr:latest
The GUI surfaces proxy availability honestly: a persistent banner ("Socket-Proxy nicht verfügbar — Konfiguration wird gespeichert, aber nicht durchgesetzt") linking to the install instructions, plus per-change feedback ("gespeichert, noch nicht durchgesetzt"). Changes made while degraded are persisted and marked pending; once a proxy connects, islandr reconciles and applies the pending state. The published image runs the real socket-client adapter in this degraded mode — not the dev/CI mock, which fakes success and would mislead.
From there, two graduation paths, both maximally convenient:
install.sh on the host to add the socket proxy (islandr user, scoped sudoers, wg0, islandr-proxy + systemd units), mount its socket into the same container, and the existing configuration starts enforcing. No rebuild; the GUI banner links exactly this path.GET /api/v1/admin/config/export) and import it into a native islandr install (ADR-0011) that already holds privilege.A privileged sidecar proxy container (--network host + CAP_NET_ADMIN, the app still unprivileged) was considered to approach a pure docker compose up. It is rejected: a compromised sidecar holds raw CAP_NET_ADMIN in the host network namespace, so the allowlist no longer bounds it, whereas a compromised host daemon is still constrained by the scoped sudoers rules. The host daemon trades some deployment convenience for stronger worst-case containment (see the Pugh matrix).
Line-delimited JSON over a Unix domain socket (SOCK_STREAM). Request: {"op":"...", ...fields}. Response: {"ok":true} or {"ok":false,"error":"..."}. Synchronous — the proxy processes one request at a time per connection.
A small Go binary (< 300 lines), statically compiled, shipped in the same release artifacts as the islandr native binary and installed alongside it. The implementation must execute every host command as an argument vector (no shell string), so the validation/allowlist is the only path to nft/wg; this is why a socat + shell handler is unfit for the shipped version — a quoting bug at the sudo call is a command-injection hole. A Python-stdlib single-file script (subprocess with arg lists, json, re/ipaddress) is an acceptable prototype; socat + bash is fine only for a throwaway spike. The proxy runs as the islandr user with the scoped sudoers rules of ADR-0011, and takes its listening socket from systemd socket activation (RuntimeDirectory=islandr, SocketMode=0600, SocketUser/SocketGroup=islandr), which establishes the R-120 ownership and mode without custom code.
Why Go — trust over raw binary size. At a privilege boundary, "small" means small trusted computing base and auditable source, not smallest file. Three options were weighed: a Python stdlib script (most readable source, but the trusted base is the whole CPython interpreter on the host, and a plaintext script at a sudo boundary can be rewritten by a compromised islandr process and inherit its rights); a Rust binary (smallest binary and runtime, but only if it stays std-only — idiomatic JSON via serde/tokio pulls a crate tree that becomes the supply-chain surface to vet); and Go. Go wins because its standard library covers everything the proxy needs (net, os/exec, encoding/json, regexp), so go.mod carries zero third-party dependencies — there is nothing external to audit, only the ~280 lines plus the well-known stdlib. The 2–5 MB runtime is the price for that, and it is a single immutable artifact that can be reproducibly built and signed. Python stays the prototype language only; it is never the shipped boundary. Four rules hold regardless of language: (1) zero third-party dependencies; (2) reproducible build (-trimpath, CGO_ENABLED=0) plus a signed artifact and published checksum, as with the native binary; (3) the binary is root-owned, mode 0755, not writable by islandr, so the privileged process cannot be tampered with from the app; (4) argument-vector execution, never a shell string.
Baseline: Unix socket proxy — host daemon (the decision). +1 better, 0 equal, −1 worse.
| Criterion (weight) | Socket proxy — host daemon (baseline) | Socket proxy — privileged sidecar | --cap-add NET_ADMIN + --network host |
Docker socket mount | --pid=host + nsenter |
systemd only (no Docker) |
|---|---|---|---|---|---|---|
| Container gets no host capabilities (5) | 0 | −1 | −1 | −1 | −1 | +1 |
| Blast radius if container is compromised (4) | 0 | −1 | −1 | −1 | −1 | +1 |
| Supports production Docker deployments (4) | 0 | +1 | +1 | +1 | 0 | −1 |
| Auditable allowed-op surface (3) | 0 | 0 | −1 | −1 | −1 | +1 |
| Deployment complexity (2) | 0 | +1 | +1 | +1 | 0 | +1 |
| Weighted total | 0 | −3 | −6 | −6 | −24 | +10 |
Notes:
--network host + CAP_NET_ADMIN container, so the deployment is docker compose up with no host binary, and the app still stays unprivileged. That shrinks the privileged attack surface to the ~150-line proxy. But on a proxy compromise the attacker holds raw CAP_NET_ADMIN, which the JSON allowlist cannot bound — unlike the host daemon, whose scoped sudoers still constrain a compromised proxy. The −3 reflects trading worst-case containment for deployment convenience.--cap-add NET_ADMIN — with --network host, CAP_NET_ADMIN inside the container spans all network namespaces on the host; a compromised process has root-equivalent control over every nftables table and interface. Scores identically to Docker socket on the table but the failure mode is different (capability escape vs. container spawn escalation).docker.sock allows spawning --privileged containers, which is full host escape. Equally dangerous but via a different path.--pid=host + nsenter — equivalent to root on the host; worst option.systemd only scores −4 on "supports production Docker" which is the requirement that drives this ADR.islandr-proxy as a second deliverable (binary + systemd unit).WgAdapter and NftAdapter in islandr gain a socket-client mode alongside the existing ProcessBuilder mode, plus a degraded "enforcement unavailable" state (probe + pending-config reconcile on connect). The published image runs this real adapter; the mock stays for dev/CI only.docker run, and becomes enforcement-capable once a socket proxy is attached — same image, no rebuild.spec.md and a runtime scenario in arc42 Ch. 6.install.sh: islandr user, scoped sudoers, wg0, proxy binary + systemd units); docker run of the app never bootstraps it. The privileged-sidecar shortcut that would have avoided the host binary was rejected — see Decision and the Pugh matrix.islandr:islandr with mode 0600. If the socket is world-readable, any local process can send commands. Mitigation: systemd RuntimeDirectory=islandr sets ownership automatically.islandr user, it can send proxy commands. Mitigation: same as ADR-0011 R-111 — access as islandr already implies islandr is compromised; blast radius is still bounded to the WireGuard and nftables allowlist.