Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,13 @@ jobs:
- name: Check the syslog sender allowlist
run: ./scripts/check_syslog_senders.sh

# The Docker socket proxies' HAProxy config: Alloy's three /containers
# paths answer, and archive, export, top and the rest are refused by the
# proxy itself (Tecnativa/docker-socket-proxy#182). It also fails when an
# image bump changes upstream's template under the committed copy.
- name: Check the socket proxy allowlist
run: ./scripts/check_socket_proxy.sh

# Same shape for the reverse proxy's config: `caddy validate` and
# `caddy fmt --diff` in the pinned image, per stack, so a Caddyfile that
# would fail at container start fails here instead (#129). Stacks with
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@ check-loki-rules: ## Validate Loki (LogQL) rules and panel queries, and behaviou
check-syslog-senders: ## Send spoofed and real syslog through a scratch Alloy and Loki; only named senders are stored
./scripts/check_syslog_senders.sh

.PHONY: check-socket-proxy
check-socket-proxy: ## Boot the pinned socket proxy on its config; Alloy's paths answer, archive/export/top are refused
./scripts/check_socket_proxy.sh

.PHONY: patch-state
patch-state: ## Collect this host's package patch state into the textfile dir
@# Needs no root: apt-check runs unprivileged, /var/run/reboot-required is a
Expand Down
24 changes: 24 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ docstring gives: it is a record, not a claim about now.
- **Checked.** The pinned trivy on `postgres:18.6`: 25 findings without the
skip, 0 with it. The next scan closes #995.

- **The Docker socket proxies stop serving `archive`, `export`, `top` and
`changes`**
([Tecnativa/docker-socket-proxy#182](https://github.com/Tecnativa/docker-socket-proxy/issues/182)).
- **What was open:** with `CONTAINERS=1`, the image's template allows the
whole `/containers` prefix. On `prometheus` all four GETs answered 200
through the estate's proxy, served by the daemon as root. A HEAD on
`archive` was enough to show that: it returns a stat header and no file.
- **What the clients use,** from the proxy's request log since its 10-07
start: `/containers/json`, `/containers/{id}/json`,
`/containers/{id}/logs`, `/networks`, `/version`, `/info`, `/_ping` and
`/events`. Nothing else.
- **The fix:** the six compose proxies and `deploy-agent.sh`'s
`alloy-socket-proxy` run
[`stacks/observability/docker-socket-proxy/haproxy.cfg`](../stacks/observability/docker-socket-proxy/haproxy.cfg).
It is upstream's v0.5.0 template with the prefix rule replaced by those
three paths. The image pin is unchanged.
- **The proof:** `check_socket_proxy.sh` runs it in CI. On upstream's
template the same check fails: every refused path reaches the daemon,
`export` streams 43 MB, and `%2Farchive` and `json/../archive` get there
too, because the daemon decodes and redirects.
- **Deploying it:** `make up` on each Docker host, and `deploy-agent.sh` for
`oracle` and `trinity`. `reload-config.sh` now restarts the proxy, so a
later change to `haproxy.cfg` alone also lands.

- **`garuda` reports to the lab, is backed up, and passed §9 apart from its
desktop check, which was not run**
([#921](https://github.com/Gerrrt/HomeLab/issues/921),
Expand Down
24 changes: 24 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,30 @@ this closes on.
`docker.alloy` still falls back to the socket when `DOCKER_API` is unset, so
an agent deployed before this keeps working until its next `deploy-agent.sh`
run.

**"Refuses everything else" was not true of `/containers` until
2026-10-10.** The image's rule for `CONTAINERS=1` is a prefix match on
`/containers`, so it also allowed four GETs that `POST=0` does not touch:
`archive`, which returns any file from any container, `export`, which returns
a whole container filesystem, `top` and `changes`
([Tecnativa/docker-socket-proxy#182](https://github.com/Tecnativa/docker-socket-proxy/issues/182),
unfixed upstream). The daemon serves them as root, so a client trusted only
to list containers could read through the proxy what `cap_drop` keeps it from
reading through `/rootfs`. All four answered 200 on `prometheus`.
- **Every proxy now runs the repository's HAProxy config,**
[`stacks/observability/docker-socket-proxy/haproxy.cfg`](../stacks/observability/docker-socket-proxy/haproxy.cfg).
It is upstream's template with that one rule replaced by the three
`/containers` paths the clients send, read from three days of the proxy's
own request log. That covers the six compose stacks and `deploy-agent.sh`'s
`alloy-socket-proxy`.
- **`check_socket_proxy.sh` boots the pinned image on it in CI.** Alloy's
paths must answer, and `archive`, `export`, `top`, `changes`, `stats`,
`attach` and the `..` and encoded-slash spellings must be refused by the
proxy itself. It also fails when an image bump changes upstream's template
under the committed copy.
- **`check_compose_health.py` fails a proxy that does not mount the config or
is not started on it,** because either one alone falls back to the image's
template without any visible change.
- Alloy holds no capabilities. It runs as uid 0 with `cap_drop: [ALL]` and
`no-new-privileges`, so root inside it is subject to file permissions like any
other user, and joins only the group that owns `/var/log/syslog` so the auth
Expand Down
1 change: 1 addition & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ and only its fixtures run in CI, through `self-tests.sh`.
| `check_rule_tests.py` | `check-rules` | Every Prometheus alert has a promtool test that names it |
| `check_loki_rules.sh` | `check-loki-rules` | The LogQL rules and dashboard queries, against a real Loki boot |
| `check_syslog_senders.sh` | `check-syslog-senders` | The syslog listener stores only the senders `syslog.alloy` names, and a message cannot choose its own `host` (#844) |
| `check_socket_proxy.sh` | `check-socket-proxy` | The Docker socket proxies allow what Alloy reads and refuse `archive`, `export` and `top`, and upstream's template is unchanged under the committed config (Tecnativa/docker-socket-proxy#182) |
| `check_compose_health.py` | `check-compose-health` | Every `depends_on: service_healthy` can actually be satisfied |
| `check_caddyfile.sh` | — | Every stack's Caddyfile, validated by the pinned Caddy |
| `check_image_pins.py` | `check-image-pins` | Every image the repository runs comes from a `compose.yaml`, and an image that mounts another stack's config runs that stack's exact pin |
Expand Down
106 changes: 106 additions & 0 deletions scripts/check_compose_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,68 @@ def socket_mount_problems(services: dict) -> list[str]:
return problems


# Where every socket proxy reads its HAProxy config from, and how it is told to
# (Tecnativa/docker-socket-proxy#182). The image's own template allows the
# whole /containers prefix whenever CONTAINERS=1, which includes the GET
# endpoints that copy files out of a container. The repository's copy allows
# three paths instead. That copy only applies if haproxy is pointed at it, so a
# proxy that drops either line below runs the image's template again, still
# looks healthy, and still serves every request Alloy makes.
SOCKET_PROXY_CONFIG_DIR = REPO / "stacks/observability/docker-socket-proxy"
SOCKET_PROXY_CONFIG_TARGET = "/usr/local/etc/socket-proxy"
SOCKET_PROXY_COMMAND = ["haproxy", "-f", f"{SOCKET_PROXY_CONFIG_TARGET}/haproxy.cfg"]


def socket_proxy_config_problems(compose_path: pathlib.Path, services: dict) -> list[str]:
"""A socket proxy that is not running the repository's HAProxy config.

Both halves are needed. The mount alone changes nothing, because the image
starts haproxy on the /tmp copy of its own template. The command alone
fails at start, which at least is loud. The mount is a directory, not the
file, so a `git checkout` that replaces the file is seen at once instead of
leaving the container on the old inode (check_mounted_config.py's #355).
Read-only, because nothing in the proxy should be able to rewrite its own
rules.

Keyed on the image, like socket_mount_problems, so a renamed proxy is still
checked.
"""
problems = []
for name, svc in services.items():
svc = svc or {}
if not str(svc.get("image", "")).startswith(SOCKET_PROXY_IMAGE):
continue
at_target = False
for volume in svc.get("volumes") or []:
if not isinstance(volume, str):
continue
parts = volume.split(":")
if len(parts) < 2 or parts[1].rstrip("/") != SOCKET_PROXY_CONFIG_TARGET:
continue
at_target = True
source = (compose_path.resolve().parent / parts[0]).resolve()
if source != SOCKET_PROXY_CONFIG_DIR.resolve():
problems.append(
f"{name} mounts {parts[0]} at {SOCKET_PROXY_CONFIG_TARGET}, but the "
f"socket proxies' config is {SOCKET_PROXY_CONFIG_DIR.relative_to(REPO)}"
)
elif "ro" not in (parts[2].split(",") if len(parts) > 2 else []):
problems.append(f"{name} mounts its HAProxy config read-write; add :ro")
if not at_target:
problems.append(
f"{name} does not mount {SOCKET_PROXY_CONFIG_DIR.relative_to(REPO)} at "
f"{SOCKET_PROXY_CONFIG_TARGET}, so it runs the image's own template, "
f"where CONTAINERS=1 also allows /containers/{{id}}/archive and /export "
f"(Tecnativa/docker-socket-proxy#182)"
)
if svc.get("command") != SOCKET_PROXY_COMMAND:
problems.append(
f"{name} has command {svc.get('command')!r}, not {SOCKET_PROXY_COMMAND!r}. "
f"Without it haproxy reads the image's template, whatever is mounted"
)
return problems


# Every long-running service carries `no-new-privileges` (#845). The estate
# put it on every service in #186, the sensitive tier on every one of its own,
# and then three stacks on VLAN 30 — the segment that exists to hold attackers —
Expand Down Expand Up @@ -1091,6 +1153,49 @@ def check(name: str, expected: object, got: object) -> None:
len(socket_mount_problems({"alloy": {"volumes": ["/:/rootfs:ro"], "tmpfs": ["/run:size=64k"]}})),
)

# 22a-22e. The proxy runs the repository's HAProxy config, not the image's
# (Tecnativa/docker-socket-proxy#182). Both the directory mount,
# read-only, and the command are required.
here = REPO / "stacks/lab/compose.yaml"
good = {
"image": proxy,
"volumes": [
"/var/run/docker.sock:/var/run/docker.sock:ro",
f"../observability/docker-socket-proxy:{SOCKET_PROXY_CONFIG_TARGET}:ro",
],
"command": list(SOCKET_PROXY_COMMAND),
}
check("a proxy on the repository's config passes", [], socket_proxy_config_problems(here, {"p": good}))
check(
"a proxy without the mount runs the image's template",
1,
len(socket_proxy_config_problems(here, {"p": {**good, "volumes": good["volumes"][:1]}})),
)
check(
"a proxy without the command ignores the mount",
1,
len(socket_proxy_config_problems(here, {"p": {k: v for k, v in good.items() if k != "command"}})),
)
check(
"a read-write config mount fails",
1,
len(
socket_proxy_config_problems(
here,
{"p": {**good, "volumes": [good["volumes"][0], good["volumes"][1].removesuffix(":ro")]}},
)
),
)
check(
"the config from anywhere else fails",
1,
len(
socket_proxy_config_problems(
here, {"p": {**good, "volumes": [good["volumes"][0], f"./proxy:{SOCKET_PROXY_CONFIG_TARGET}:ro"]}}
)
),
)

# 23-28. The no-new-privileges guard (#845). Keyed on the restart policy,
# so a profile is no way past it and a one-shot tool is not caught.
nnp = ["no-new-privileges:true"]
Expand Down Expand Up @@ -1220,6 +1325,7 @@ def main() -> int:

problems += bind_source_problems(path, services)
problems += socket_mount_problems(services)
problems += socket_proxy_config_problems(path, services)
problems += privilege_problems(services, path.resolve().parent.name)
problems += postgres_user_problems(services)

Expand Down
Loading
Loading