Skip to content

feat(docker): migrate socket proxy to wollomatic, pin allowlist to GET /images/json - #89

Merged
tyler-rich merged 1 commit into
devfrom
claude/docker-proxy-wollomatic-migration-f03eif
Jul 25, 2026
Merged

feat(docker): migrate socket proxy to wollomatic, pin allowlist to GET /images/json#89
tyler-rich merged 1 commit into
devfrom
claude/docker-proxy-wollomatic-migration-f03eif

Conversation

@tyler-rich

@tyler-rich tyler-rich commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Closes #63. Completes the half of M23/SC-7 that was deliberately deferred out of the 2026-07-13 supply-chain batch.

Summary

The docker-env sidecar — the only container in the stack that mounts /var/run/docker.sock — moves from tecnativa/docker-socket-proxy:v0.4.2 (HAProxy on Alpine, USER root, coarse env-var toggles) to wollomatic/socket-proxy:1.12.3 (from-scratch Go binary, USER 65534, per-method regex allowlisting), pinned by digest.

The point of the migration is the allowlist. Scrye makes exactly one request of this proxy — GET /images/json (backend/app/core/docker_proxy.py; list_images() is the only caller, and the core/egress.py guard in front of it is DNS-only). The old config permitted far more than that, because tecnativa's rules are prefix matches:

Previously Source Now
GET /containers… — incl. /containers/{id}/json (every container's env vars and command line), /logs, /top, /stats, /export (container filesystem tarball), /archive (arbitrary file read out of any container) CONTAINERS=1 403
GET /images… — incl. /images/{n}/json, /history, /search, /images/{n}/get (image tarball export) IMAGES=1 403 except the listing
GET /info INFO=1 403
GET /events, /_ping, /version image defaults 403
GET /images/json IMAGES=1 allowed — the only thing allowed
any non-GET method POST=0 405
any source on scrye_net no source allowlist exists only the scrye container

The whole allowlist is now one flag: -allowGET=(/v1\.[0-9]{1,2})?/images/json. Upstream anchors patterns itself (regexp.Compile("^"+regex+"$")) and matches against the URL path only, and answers 405 for any method with no -allow* entry — so omitting the other five method flags is what makes the proxy read-only, replacing POST=0.

Hardening deltas

  • /run tmpfs retired. INF-5 existed only because HAProxy needed a writable pid/stats path under read_only: true. A static Go binary needs none, so the sidecar is now read-only with no writable path at all.
  • Runs as uid 65534 in a from-scratch image (no shell, no libc, no package manager) instead of root on haproxy:alpine.
  • Healthcheck moved off the proxied API (wget …/info) onto the bundled /healthcheck binary and its separate 127.0.0.1:55555 listener — which is why /info no longer has to be exposed just to have a liveness probe.
  • Added -watchdoginterval=3600 -stoponwatchdog so a socket broken by a Docker engine update is recovered by restart: unless-stopped.
  • Memory cap 128M → 64M. cap_drop: ALL, no-new-privileges, digest pin, no host port, capped logging, CPU limit unchanged.

⚠️ One operational difference

tecnativa ran as root in-container and could read the socket regardless of ownership. wollomatic ships USER 65534:65534, and /var/run/docker.sock is root:docker mode 0660 — so the container's GID must be the host's docker group. The service is now user: "65534:${DOCKER_GID:-999}" and operators enabling docker-env must set:

export DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)"

The 999 default is a Debian/Ubuntu convention, not a guarantee; a wrong GID surfaces as a socket permission error at proxy start. This is a deployment prerequisite only — no application behavior, API contract, schema, or client code changed, and SCRYE_DOCKER_PROXY_URL / port 2375 are unchanged.

Verification

No Docker daemon was reachable in the environment where this was prepared, and Docker Hub's blob CDN (production.cloudfront.docker.com) is egress-blocked, so the published image could not be pulled or booted. The request-handling path was therefore exercised directly instead: upstream's handlehttprequest.go and bindmount.go at tag 1.12.3 were compiled verbatim (the module is stdlib-only) against a minimal internal/config carrying upstream's AllowList types and compileRegexp unmodified, put in front of a stub Docker API on a real unix socket, and configured with the -allowGET pattern read out of docker-compose.yml. Against that harness:

  • the real docker_proxy.list_images() enumerated images successfully (untagged dropped as expected);
  • all 11 previously-permitted sensitive paths returned 403;
  • HEAD/POST/PUT/DELETE/PATCH returned 405;
  • the /v1.NN form and a query string were accepted;
  • the stub socket's access log confirmed only the 4 allowed requests ever reached it, while all 16 blocked ones stopped at the proxy.

docker compose --profile docker-env config renders the service with the regex and DOCKER_GID interpolation intact. The egress guard's loopback refusal was also confirmed intact along the way.

Still to do on a Docker-capable host: one docker compose --profile docker-env up to confirm the published image boots under the full hardened option set and that DOCKER_GID is right for that host.

Tests

backend/tests/test_compose_hardening.py gains seven regression tests that keep the allowlist tied to the client rather than to a hardcoded string: the -allowGET pattern is compiled the way upstream compiles it and checked against the path list_images() is observed to request, checked to reject every previously-allowed sensitive path, and checked for method flags, source restriction, digest pin, unprivileged uid, and the absence of a re-introduced writable /run. Each was verified to fail against a deliberately widened Compose config before being committed.

Full backend suite (561 passed, 3 skipped), ruff, black, ESLint, Prettier, and the frontend Vitest suite (54 passed) are green locally.

Docs

README § Optional sidecars, § Configuration (the DOCKER_GID note), and § Security model updated; CHANGELOG.md Unreleased entry with the action-required note; docs/reviews/STATUS.md moves M23/SC-7 from § Deferred to § Resolved.

See docs/ARCHIVE.md § Deviations (2026-07-24) for the full before/after allowlist mapping.

…T /images/json

Replaces the tecnativa/docker-socket-proxy sidecar with
wollomatic/socket-proxy:1.12.3 (digest-pinned) on the docker-env profile — the
only container in the stack that mounts /var/run/docker.sock.

The point of the migration is the allowlist. The backend makes exactly one
request of this proxy, GET /images/json. tecnativa's coarse env-var toggles
permitted far more than that: IMAGES=1 and CONTAINERS=1 are prefix rules, so
between them they opened the whole /images and /containers GET surface —
including /containers/{id}/json (every container's environment variables and
command line), /containers/{id}/archive (arbitrary file read out of any
container), /containers/{id}/export, and /images/{name}/get — plus /info and the
image's default /events, /_ping and /version, to any client on the network.

The new config is a single anchored regex, -allowGET=(/v1\.[0-9]{1,2})?/images/json.
Omitting the other five method flags is what makes the proxy read-only (any
method without an entry gets 405), replacing POST=0, and -allowfrom=scrye limits
connections to the app container.

Hardening: the image is from-scratch and runs as uid 65534 (tecnativa ran as
root on haproxy:alpine), so the /run tmpfs added for HAProxy's pid/stats path
under read_only is retired — the sidecar now needs no writable path at all.
Memory cap 128M -> 64M. The healthcheck moves off the proxied API onto the
bundled healthcheck binary and its separate listener, which is why /info no
longer has to be exposed just to have a liveness probe. Adds a socket watchdog
so a Docker engine update can't leave the proxy wedged.

Operators enabling docker-env must now set DOCKER_GID to the host's docker group
id: the proxy runs unprivileged and needs that group to read the socket. No
application, API, schema, or client change — SCRYE_DOCKER_PROXY_URL and port
2375 are unchanged.

Adds seven regression tests that keep the allowlist tied to the client: the
-allowGET pattern is compiled the way upstream compiles it and checked against
the path list_images() is observed to request, and against every previously
permitted sensitive path.

Closes #63. See docs/ARCHIVE.md § Deviations (2026-07-24) for the full
before/after allowlist mapping and how this was verified without a Docker daemon.
@tyler-rich
tyler-rich merged commit d5cc07f into dev Jul 25, 2026
4 checks passed
tyler-rich added a commit that referenced this pull request Jul 26, 2026
…and Compose comment (#97)

The non-200 detail in docker_proxy.list_images() advised checking that the proxy
is "read-only with IMAGES=1" — a tecnativa env var that has not existed since the
wollomatic migration (#89), printed in exactly the 403 case the README teaches
operators to diagnose. It now names the real causes: a non-allowlisted path
(-allowGET permits only /images/json and /v1.NN/images/json), a non-GET method,
or the -allowfrom source check rejecting a non-scrye client, with the note that
the proxy's log distinguishes them, and a pointer to the README rather than a
restatement of it.

The docker-compose.yml DOCKER_GID comment called 999 "the Debian/Ubuntu default";
it now matches the README's corrected wording (a placeholder, not a safe default;
989 on the host this was verified against) and describes the wrong-GID failure as
the crash loop it actually is. The stat -c '%g' derivation remains the instruction
and the ${DOCKER_GID:-999} value is unchanged.

Adds a regression test driving the 403 path through httpx.MockTransport. No
existing test asserted on the old string.

See docs/ARCHIVE.md § Deviations for the dated entry retiring both follow-ups.
tyler-rich added a commit that referenced this pull request Jul 26, 2026
…work) (#101)

Read-only audit producing findings and proposals only; no code, config, or
existing document is changed.

- Part 1: true status of all five open issues against the code on dev — #63
  and #83 are fully resolved (#89, #87) and only open because closing keywords
  never fire on a dev-targeted PR; #98 and #52 are live waiver trackers; #75
  needs a release.
- Part 2: full recursive docs/ inventory with a KEEP/ARCHIVE/DELETE
  disposition per file, the inbound-reference blast radius for each proposed
  removal, and a proposed docs/history/ structure.
- Part 3: consolidated list of deferred work still outstanding in ARCHIVE.md
  section 14, separating items with a live tracking reference from the three
  recorded in prose only.
- Part 4: ARCHIVE.md navigability (including 14 deviation entries misfiled
  under the Build performance heading), ROADMAP items now done, the
  Unreleased changelog backlog, 33 prunable merged branches, and proposed
  CLAUDE.md wording for two operational lessons.
@tyler-rich
tyler-rich deleted the claude/docker-proxy-wollomatic-migration-f03eif branch July 31, 2026 04:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant