name: rootguard # RootGuard 1.0.0 public evaluation stack. # Every release image is pinned to its verified multi-architecture manifest. # Copy .env.release.example to .env and replace all placeholder secrets. services: # The one, narrow, auditable bridge letting cosign's own attestation # checks (see core's/updater's ROOTGUARD_ATTESTATION_PROXY_URL below) # reach GHCR/Sigstore from the fully internet-isolated `control` # network - found live, cutting 1.0.0-rc.2: Core/the Updater never had # any route to the internet at all, so `RequireAttestation`'s cosign # calls could never succeed once wired into the real deploy/update # path. See rootguard-attestation-proxy/README.md for the full # rationale and its hardcoded, 4-host allowlist (the 4th host, # api.github.com, was added for Core's GitHub Releases self-update # discovery - found in review that it ran on this same isolated # network but was never actually routed through this proxy). attestation-proxy: # Bootstrap pin: this component's very first published image, pushed # by hand (real, public, multi-arch manifest - no real release has # cut it yet) so `docker compose pull` and the "Clean install" CI # check have something real to fetch before release-alpha.yml's own # update-alpha-pins has ever run for it. Gets superseded by that # step's own real digest the moment a real release actually # promotes this component, same as every other component's own # first-ever pin. image: "${ROOTGUARD_ATTESTATION_PROXY_IMAGE:-ghcr.io/foxly-it/rootguard-attestation-proxy:1.0.12@sha256:0a8e65d95e62a07149989a98d9db71b68e5ff39da32b10b54bc53aeee80a6aaf}" container_name: rootguard-attestation-proxy restart: unless-stopped read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] networks: - control - egress healthcheck: test: ["CMD", "/rootguard-attestation-proxy", "healthcheck"] interval: 15s timeout: 3s retries: 5 # The only container holding the real Docker socket - Core and the # Updater reach it over `control` instead (see their DOCKER_HOST/ # ROOTGUARD_DOCKER_PROXY_URL below), never mounting the socket # themselves. See docs/threat-model.md, actor 1 ("Docker socket # holders"), and rootguard-docker-proxy/README.md for the full # rationale - an allow-listed, request-body-validated subset of the # Docker Engine API, not a caller-authentication boundary between Core # and the Updater (they share the same allowlist). Unlike # attestation-proxy, this only ever dials the local socket, never the # internet, so it stays off `egress` entirely. docker-proxy: # Bootstrap pin: this component just joined release-alpha.yml's own # publish matrix (#666) - before this, nothing automatically refreshed # this pin, which went stale twice (1.0.2, then again 1.0.4; see # docs/release-history.md for both). Gets superseded by # update-alpha-pins' own real digest the moment the next real release # promotes it, same as every other component's own first-ever pin. image: "${ROOTGUARD_DOCKER_PROXY_IMAGE:-ghcr.io/foxly-it/rootguard-docker-proxy:1.0.12@sha256:a584efc34eb93dfff71d4a4258946124f499572044c7ea46499f7fe3f5724f74}" container_name: rootguard-docker-proxy restart: unless-stopped read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] volumes: # Defaults to the rootful daemon's real socket. Rootless Docker's own # socket lives at a per-user path instead (typically # /run/user//docker.sock) - install.sh detects a rootless # daemon and sets ROOTGUARD_DOCKER_SOCKET_PATH accordingly; a manual # deploy sets it directly. See docs/rootless-docker.md for the full # rootless-Docker findings (a real client-source-IP risk with the # default networking driver, resolved by using `pasta` instead). - ${ROOTGUARD_DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock networks: - control healthcheck: test: ["CMD", "/rootguard-docker-proxy", "healthcheck"] interval: 15s timeout: 3s retries: 5 core: image: "${ROOTGUARD_CORE_IMAGE:-ghcr.io/foxly-it/rootguard-core:1.0.12@sha256:03873550b521e99656ab7a16684f2a234c91d788407b5c4ff0c39099cad8938d}" container_name: rootguard-core restart: unless-stopped depends_on: updater: condition: service_healthy attestation-proxy: condition: service_healthy docker-proxy: condition: service_healthy environment: PORT: "8081" # See rootguard-core/internal/stack/attestation.go's # runAttestationCommand doc comment for the full rationale - scoped # to cosign's own subprocess only, never Core's ambient environment. ROOTGUARD_ATTESTATION_PROXY_URL: http://attestation-proxy:8888 # Redirects every `docker`/`docker compose` call dockercli.Run shells # out to (rootguard-core/internal/dockercli) at the real socket # docker-proxy itself holds - the Docker CLI already honors # DOCKER_HOST from the environment, so this needs no code change. # ROOTGUARD_DOCKER_PROXY_URL is this same endpoint, http:// instead # of tcp://, for CheckDockerProxyReachable's own /healthz check at # startup (rootguard-core/internal/stack/dockerproxy.go) - unset on # an installation whose compose.release.yaml predates this change, # which keeps mounting the real socket directly and keeps working # exactly as before (see that file's doc comment). DOCKER_HOST: tcp://docker-proxy:2375 ROOTGUARD_DOCKER_PROXY_URL: http://docker-proxy:2375 ROOTGUARD_API_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_CORE_CONTAINER: rootguard-core # Test-only escape hatch - see cmd/rootguard/main.go's own doc # comment on ROOTGUARD_SKIP_ATTESTATION for what it disables and # why. Never set outside an E2E fixture; defaults to enforcing. ROOTGUARD_SKIP_ATTESTATION: "${ROOTGUARD_SKIP_ATTESTATION:-false}" ROOTGUARD_INSTALLATION_DIR: /var/lib/rootguard/installation ROOTGUARD_UNBOUND_IMAGE: "${ROOTGUARD_UNBOUND_IMAGE:-ghcr.io/foxly-it/rootguard-unbound:1.0.12@sha256:d1f257426769c54f6a89fcef0a40f4dfb2ab98ea47874d3074dad6c86c7a51e7}" ROOTGUARD_ADGUARD_IMAGE: "${ROOTGUARD_ADGUARD_IMAGE:-adguard/adguardhome:v0.107.80@sha256:e2e1b48c832f7705be915873f95de0d5114de573736441d7f2923a8a6293027c}" ROOTGUARD_ADGUARD_BETA_IMAGE: "${ROOTGUARD_ADGUARD_BETA_IMAGE:-adguard/adguardhome:beta@sha256:2b77703b27730d5c0c7045fcd6c98834169cd5c69af5f86a43947425f2d367fd}" ROOTGUARD_ADGUARD_UPDATE_IMAGE: "${ROOTGUARD_ADGUARD_UPDATE_IMAGE:-adguard/adguardhome:v0.107.80@sha256:e2e1b48c832f7705be915873f95de0d5114de573736441d7f2923a8a6293027c}" # Digest-pinned like every other RootGuard-built component now - # found in review: this was unset here entirely before, so Core # silently fell back to its own code default of a mutable :latest # tag with no digest pin and no attestation ever checked, unlike # core/webapp/unbound/the updater. ROOTGUARD_BLOCKPAGE_IMAGE: "${ROOTGUARD_BLOCKPAGE_IMAGE:-ghcr.io/foxly-it/rootguard-blockpage:1.0.12@sha256:d1b39a97f940598b60b0dd0b7a1efa2150fc6020d09bff1615f26c59dfa3d447}" # Found in review: Blockpage had no ServiceSpec in either update # manager at all, so an installation could only ever get a # Blockpage fix via reinstall or a manual compose refresh - unlike # every other RootGuard-built component. Same digest-pinned, # refreshed-every-release treatment as ROOTGUARD_UNBOUND_UPDATE_IMAGE # right below (live GitHub Releases discovery is the primary # source; this pin is only the fallback for when that lookup fails). ROOTGUARD_BLOCKPAGE_UPDATE_IMAGE: "${ROOTGUARD_BLOCKPAGE_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-blockpage:1.0.12@sha256:d1b39a97f940598b60b0dd0b7a1efa2150fc6020d09bff1615f26c59dfa3d447}" ROOTGUARD_UNBOUND_UPDATE_IMAGE: "${ROOTGUARD_UNBOUND_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-unbound:1.0.12@sha256:d1f257426769c54f6a89fcef0a40f4dfb2ab98ea47874d3074dad6c86c7a51e7}" ROOTGUARD_UPDATE_DIR: /var/lib/rootguard/updates ROOTGUARD_CONTROL_PLANE_UPDATER_URL: http://updater:8082 ROOTGUARD_CONTROL_PLANE_UPDATER_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" # Found in review: this comment used to claim the value below stays # an unpinned :latest fallback, but it's digest-pinned right here # like every other value above and refreshed by the same # update-alpha-pins step (it iterates every managed component # generically, this one included) - stale ever since it was first # digest-pinned. Live GitHub Releases discovery # (ROOTGUARD_SELF_UPDATE_DIR below) is still the primary source; # this pin is only the fallback for when that lookup fails. ROOTGUARD_UPDATER_UPDATE_IMAGE: "${ROOTGUARD_UPDATER_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-updater:1.0.12@sha256:c4cd325070ba9a7442e6b06705c621edcf581cd8a8683076c510c7b87c3c8317}" # Same fallback rationale as ROOTGUARD_UPDATER_UPDATE_IMAGE above - # live GitHub Releases discovery is the primary source # (rootguard#481: the attestation proxy joined self-update # management, no longer static/manually-pinned only). ROOTGUARD_ATTESTATION_PROXY_UPDATE_IMAGE: "${ROOTGUARD_ATTESTATION_PROXY_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-attestation-proxy:1.0.12@sha256:0a8e65d95e62a07149989a98d9db71b68e5ff39da32b10b54bc53aeee80a6aaf}" ROOTGUARD_SELF_UPDATE_DIR: /var/lib/rootguard/updater-self-update # Core replaces the updater's own container the same way it already # replaces AdGuard/Unbound - via `docker compose ... up -d --no-deps # updater` against this same control-plane project. The mounted file # below is intentionally exposed as compose.yaml (not # compose.release.yaml) regardless of this stack's real filename, # since Core's generic update manager always looks for a file # literally named compose.yaml in whichever directory it's pointed at. ROOTGUARD_COMPOSE_FILE: /opt/rootguard/compose.yaml ROOTGUARD_COMPOSE_PROJECT: rootguard # Core never reads these five - they exist only so `docker compose -f # compose.yaml ... up -d --no-deps updater` (and, one hop further, # `... up -d --no-deps core webapp`) can interpolate the whole # compose file, which also references them for the webapp service. # Same reason the updater service carries them. Found in review: # only the two `${VAR:?...}` ones were carried here - the three # `${VAR:-default}` ones below were missed, so a self-update- # triggered `up -d --no-deps webapp` silently re-created it with # ROOTGUARD_WEB_BIND=0.0.0.0/ROOTGUARD_WEB_PORT=8080/ # ROOTGUARD_ADMIN_USER=admin regardless of whatever an operator had # actually set in .env (e.g. binding to 127.0.0.1 behind a reverse # proxy, see docs/https-reverse-proxy.md) - it fails loudly instead # for the two required ones, silently for these three optional ones. ROOTGUARD_ADMIN_PASSWORD: "${ROOTGUARD_ADMIN_PASSWORD:?set ROOTGUARD_ADMIN_PASSWORD in .env}" ROOTGUARD_RECOVERY_TOKEN: "${ROOTGUARD_RECOVERY_TOKEN:?set ROOTGUARD_RECOVERY_TOKEN in .env}" ROOTGUARD_ADMIN_USER: "${ROOTGUARD_ADMIN_USER:-admin}" ROOTGUARD_WEB_BIND: "${ROOTGUARD_WEB_BIND:-0.0.0.0}" ROOTGUARD_WEB_PORT: "${ROOTGUARD_WEB_PORT:-8080}" # Propagates the real host checkout directory into `docker compose -f # /opt/rootguard/compose.yaml ...` re-invocations (self-update and the # control-plane update both do this from inside a container). Without # this, `${PWD}` below re-resolves against the wrong process's cwd and # silently mounts an empty directory instead of the real file - see the # matching comment on the volumes line. PWD: "${PWD}" UNBOUND_CONFIG_DIR: /var/lib/rootguard/unbound UNBOUND_CONTAINER_CONFIG_DIR: /etc/unbound/unbound.d UNBOUND_CONTAINER_NAME: rootguard-unbound ADGUARD_INSTALLER_URL: http://rootguard-adguard:3000 ADGUARD_API_URL: http://rootguard-adguard:80 ADGUARD_DATA_DIR: /var/lib/rootguard/adguard ADGUARD_UPSTREAM: 172.29.53.2:5335 ADGUARD_DNS_ADDRESS: rootguard-adguard:53 ROOTGUARD_BLOCKPAGE_AUTH_DIR: /var/lib/rootguard/adguard-auth volumes: - rootguard-data:/var/lib/rootguard - unbound-config:/var/lib/rootguard/unbound - adguard-auth:/var/lib/rootguard/adguard-auth # ${PWD}, not a relative "./compose.release.yaml": see the matching # PWD/${PWD} comments on this file's core service, above - the same # self-referential-mount hazard applies here. - ${PWD}/compose.release.yaml:/opt/rootguard/compose.yaml:ro networks: - control healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8081/api/health"] interval: 15s timeout: 5s retries: 5 webapp: image: "${ROOTGUARD_WEBAPP_IMAGE:-ghcr.io/foxly-it/rootguard-webapp:1.0.12@sha256:efbf103319426f06f84018d97b22803afb47b6a40f6d13a0f2cf0e1f030eeb7c}" container_name: rootguard-webapp restart: unless-stopped depends_on: core: condition: service_healthy environment: PORT: "8080" ROOTGUARD_CORE_URL: http://core:8081 ROOTGUARD_API_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_ADMIN_USER: "${ROOTGUARD_ADMIN_USER:-admin}" ROOTGUARD_ADMIN_PASSWORD: "${ROOTGUARD_ADMIN_PASSWORD:?set ROOTGUARD_ADMIN_PASSWORD in .env}" ROOTGUARD_RECOVERY_TOKEN: "${ROOTGUARD_RECOVERY_TOKEN:?set ROOTGUARD_RECOVERY_TOKEN in .env}" ROOTGUARD_SESSION_FILE: /var/lib/rootguard-sessions/sessions.json volumes: - rootguard-sessions:/var/lib/rootguard-sessions ports: - "${ROOTGUARD_WEB_BIND:-0.0.0.0}:${ROOTGUARD_WEB_PORT:-8080}:8080/tcp" networks: - control - edge # Found in review: missing here despite compose.yaml (dev) and # compose.integration.yaml both having it - the one public entrypoint # had no health state at all in the file this release actually ships. # The distroless runtime image has neither wget nor a shell - unlike # core/updater's docker:29-cli base, there's nothing here for a # "CMD wget ..." style check to exec. rootguard-webapp itself # implements -healthcheck for exactly this (see cmd/rootguard-webapp). healthcheck: test: ["CMD", "/app/rootguard-webapp", "-healthcheck"] interval: 15s timeout: 5s retries: 5 updater: image: "${ROOTGUARD_UPDATER_IMAGE:-ghcr.io/foxly-it/rootguard-updater:1.0.12@sha256:c4cd325070ba9a7442e6b06705c621edcf581cd8a8683076c510c7b87c3c8317}" container_name: rootguard-updater restart: unless-stopped depends_on: attestation-proxy: condition: service_healthy docker-proxy: condition: service_healthy environment: # See rootguard-updater/attestation.go's verifyAttestation doc # comment for the full rationale - scoped to cosign's own # subprocess only, never the Updater's ambient environment. ROOTGUARD_ATTESTATION_PROXY_URL: http://attestation-proxy:8888 # See core's identical comment above - same redirection, same # backward-compat behavior when unset. DOCKER_HOST: tcp://docker-proxy:2375 ROOTGUARD_DOCKER_PROXY_URL: http://docker-proxy:2375 ROOTGUARD_UPDATER_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_UPDATER_DATA_DIR: /var/lib/rootguard/control-plane-updater ROOTGUARD_COMPOSE_FILE: /opt/rootguard/compose.release.yaml ROOTGUARD_COMPOSE_PROJECT: rootguard ROOTGUARD_CORE_UPDATE_IMAGE: "${ROOTGUARD_CORE_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-core:1.0.12@sha256:03873550b521e99656ab7a16684f2a234c91d788407b5c4ff0c39099cad8938d}" ROOTGUARD_WEBAPP_UPDATE_IMAGE: "${ROOTGUARD_WEBAPP_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-webapp:1.0.12@sha256:efbf103319426f06f84018d97b22803afb47b6a40f6d13a0f2cf0e1f030eeb7c}" ROOTGUARD_API_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_ADMIN_USER: "${ROOTGUARD_ADMIN_USER:-admin}" ROOTGUARD_ADMIN_PASSWORD: "${ROOTGUARD_ADMIN_PASSWORD:?set ROOTGUARD_ADMIN_PASSWORD in .env}" ROOTGUARD_RECOVERY_TOKEN: "${ROOTGUARD_RECOVERY_TOKEN:?set ROOTGUARD_RECOVERY_TOKEN in .env}" ROOTGUARD_WEB_BIND: "${ROOTGUARD_WEB_BIND:-0.0.0.0}" ROOTGUARD_WEB_PORT: "${ROOTGUARD_WEB_PORT:-8080}" ROOTGUARD_CORE_IMAGE: "${ROOTGUARD_CORE_IMAGE:-ghcr.io/foxly-it/rootguard-core:1.0.12@sha256:03873550b521e99656ab7a16684f2a234c91d788407b5c4ff0c39099cad8938d}" ROOTGUARD_WEBAPP_IMAGE: "${ROOTGUARD_WEBAPP_IMAGE:-ghcr.io/foxly-it/rootguard-webapp:1.0.12@sha256:efbf103319426f06f84018d97b22803afb47b6a40f6d13a0f2cf0e1f030eeb7c}" ROOTGUARD_SESSION_DIR: /var/lib/rootguard-sessions # See the matching PWD/${PWD} comments on the core service above - the # same self-referential-mount hazard applies here now that Core can # recreate this very service (self-update). PWD: "${PWD}" volumes: - rootguard-data:/var/lib/rootguard - rootguard-sessions:/var/lib/rootguard-sessions - ${PWD}/compose.release.yaml:/opt/rootguard/compose.release.yaml:ro networks: - control healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8082/health"] interval: 15s timeout: 5s retries: 5 networks: edge: control: internal: true # Not internal - real internet access, but only # rootguard-attestation-proxy sits here. Core/the Updater stay on # `control` only; they never join `egress` directly. egress: volumes: unbound-config: name: rootguard-unbound-config rootguard-data: name: rootguard-data rootguard-sessions: name: rootguard-sessions adguard-auth: name: rootguard-adguard-auth