Skip to content

harden(alloy): reach the Docker API through a read-only proxy (#193) - #358

Merged
Gerrrt merged 2 commits into
mainfrom
gerrrt/docker-socket-proxy-193
Sep 6, 2026
Merged

Gerrrt merged 2 commits into
mainfrom
gerrrt/docker-socket-proxy-193

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes #193.

The socket is no longer mounted into Alloy. :ro on a socket mount is close to
decorative — it applies to the socket file, not the API behind it, and anything
that can talk to that API can POST /containers/create with / bound
read-write, which is root on this host and the age key with it. #188 took Alloy's
capabilities and closed the direct read off /rootfs; this closes the larger
half.

Proven, not assumed

POST /containers/create   ->  403 Forbidden
GET  /containers/json     ->  works

The allowlist was wrong on the first attempt, and only measurement found it

discovery.docker calls /networks to compute the network labels it puts on
every target. With NETWORKS: 0:

Unable to refresh target groups ... error while computing network labels:
403 Forbidden — Request forbidden by administrative rules

Container log collection stopped entirely, and the agent stayed healthy
while cAdvisor carried on unaffected
— loki_source_docker_target_entries_total
sat at 0 and nothing else looked wrong. That is exactly the #62/#63 shape #193
predicted for a too-narrow allowlist, and precisely why it insisted on
before/after numbers rather than a reading of the docs.

With NETWORKS: 1 the flow resumed: 864 entries streamed, 450 lines in two
minutes.

Before and after, on this host

before after
container_last_seen 10 11 (the proxy is itself a container)
cadvisor_version_info 1 1
named containers — unchanged apart from the proxy arriving and two of my own throwaway test containers leaving

One container is deliberately not read_only

The exception #186 asked to have recorded rather than skipped. The image
generates haproxy.cfg from its environment at every start:

can't create /usr/local/etc/haproxy/haproxy.cfg: Read-only file system

and a tmpfs over that directory shadows the template the image ships:

sed: /usr/local/etc/haproxy/haproxy.cfg.template: No such file or directory

Both observed by running it, not predicted. It still drops every capability,
takes no-new-privileges, holds no secret and publishes nothing.

What this buys, stated as #193 asked

SECURITY.md and docs/security.md both say plainly that this moves the
trust boundary rather than removing it. The proxy holds the socket now. What it
buys is that Alloy — network listener, read-only rootfs mount, largest attack
surface in the stack — has no path to POST. It is not "the socket is now safe".

oracle is not covered

Its agent still mounts the socket directly. docker.alloy reads DOCKER_API and
falls back to the socket when unset, so one config file still deploys to every
host and oracle keeps working unchanged until it gets a proxy of its own —
rather than breaking collection on a host this change does not reach.

Notes

  • Image pinned by tag and digest, from Docker Hub so make pin-digests can
    re-resolve it and Dependabot can bump it.
  • Service-count claims move six/seven → eight in README.md, the stack
    README and four compose comments; architecture.md gains the proxy in its
    host and ports tables.
  • I tested this against the live stack by editing the deployment checkout
    directly, then restored it to clean — so the running stack is temporarily
    ahead of main until this merges and converge reconciles them.

make validate passes.

🤖 Generated with Claude Code

Gerrrt and others added 2 commits September 6, 2026 20:56
The socket is no longer mounted into Alloy. `:ro` on a socket mount is close to
decorative — it applies to the socket FILE, not the API behind it, and anything
that can talk to that API can POST /containers/create with `/` bound
read-write, which is root on this host and the age key with it. #188 took
Alloy's capabilities and closed the direct read off /rootfs; this closes the
larger half.

PROVEN, NOT ASSUMED. Through the proxy:

  POST /containers/create   ->  403 Forbidden
  GET  /containers/json     ->  works

THE ALLOWLIST WAS WRONG ON THE FIRST ATTEMPT, AND ONLY MEASUREMENT FOUND IT.
discovery.docker calls /networks to compute the network labels it puts on every
target, so with NETWORKS: 0 the component failed with

  Unable to refresh target groups ... error while computing network labels:
  403 Forbidden — Request forbidden by administrative rules

container log collection stopped entirely, and the agent stayed healthy while
cAdvisor carried on unaffected — loki_source_docker_target_entries_total sat at
0 and nothing else looked wrong. That is the #62/#63 shape #193 predicted for a
too-narrow allowlist, and the reason it insisted on before/after numbers rather
than a reading of the docs. With NETWORKS: 1 the flow resumed: 864 entries
streamed, 450 lines in two minutes.

Before and after on this host:

  container_last_seen        10 -> 11   (the proxy is itself a container)
  cadvisor_version_info       1 ->  1
  named containers            unchanged apart from the proxy arriving and two
                              of my own throwaway test containers leaving

ONE CONTAINER IS DELIBERATELY NOT read_only, the exception #186 asked to have
recorded rather than skipped. The image generates haproxy.cfg from its
environment at every start:

  can't create /usr/local/etc/haproxy/haproxy.cfg: Read-only file system

and a tmpfs over that directory shadows the template the image ships:

  sed: /usr/local/etc/haproxy/haproxy.cfg.template: No such file or directory

Both observed by running it. It still drops every capability, takes
no-new-privileges, holds no secret and publishes nothing.

SECURITY.md and docs/security.md say plainly that this MOVES the trust boundary
rather than removing it: the proxy holds the socket now, and what it buys is
that Alloy — network listener, rootfs mount, largest surface in the stack — has
no path to POST.

oracle still mounts the socket directly. docker.alloy reads DOCKER_API and falls
back to the socket when unset, so one config file still deploys to every host
and oracle keeps working until it gets a proxy of its own.

Service count claims move six/seven -> eight in README.md, the stack README and
four compose comments; architecture.md gains the proxy in its host and ports
tables.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gerrrt
Gerrrt merged commit 911c01e into main Sep 6, 2026
3 checks passed
@Gerrrt
Gerrrt deleted the gerrrt/docker-socket-proxy-193 branch September 6, 2026 21:12
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.

Put the Docker socket behind an allowlisting proxy — Alloy can still POST to it

1 participant