Files
clawmates/deploy/compose/docker-compose.yml
T
Omar SobhandClaude Opus 5 869c3adcb7
deploy / test (push) Successful in 5m39s
deploy / build (push) Successful in 7m28s
fix(missions): egress from a subnet of their own, so the host can police it
docs/MISSION-EGRESS.md measured that a mission container reaches the entire
tailnet and SSH on its own host, and left the remediation unapplied. Applying
it on 2026-09-18 found why five iptables lines were never going to be enough:
missions egressed from clawmates_edge, the SERVER's network, and the server
needs the tailnet — Beszel on architect, Ollama for the local backend, the
node daemons for exec-test and node-placed terminals. A tailnet drop scoped to
172.23/16 cut the server off from architect:8090 inside a minute.

Missions now egress from clawmates_missions, 172.25.0.0/16, pinned so the
firewall can name it and declared in both compose files with the same shape
edge has. Compose v1 does not create a network no service uses, so on gw-04
it was created by hand with compose's own labels; the server's attach failure
message now says to check for it. core is unchanged: the door and API are
still reached over 172.20.

The policy itself (/usr/local/sbin/clawmates-egress.sh on gw-04, systemd unit
+ drop-ins on docker and tailscaled) lives in mangle/PREROUTING with
--ctstate NEW. Two earlier placements failed measurably: filter/FORWARD loses
to tailscaled re-inserting ts-forward above it on every restart, and
raw/PREROUTING runs before conntrack, so it dropped the server's replies to
tailnet clients and took the API off 100.102.112.85:8088. Verified from the
mission subnet (tailnet, host ssh, link-local blocked; public and core open),
from edge (tailnet open, ssh blocked), and inbound from tank; and proved to
survive restarting both daemons.

Also: deploy/compose/docker-compose.override.yml is tracked now. It holds the
fixes for the five local bring-up gaps and every credential in it is a
${VAR:?} reference, and it had lived on one laptop that lost a volume this
week.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-18 11:06:03 -05:00

174 lines
6.3 KiB
YAML

# Air-gapped single-node topology. The network segmentation IS the security
# model (spec §15):
# edge — host-published entry points only (frontend, server API)
# core — internal: server <-> postgres
# sandbox_net — internal, NO egress: agent sandboxes (joined in P2)
# secrets_net — internal: secret broker + server only (joined in P2)
# Images are preloaded from the signed offline bundle; nothing pulls at
# install time.
name: clawmates
networks:
edge: {}
# Mission containers egress from here, NOT from edge, so the host's egress
# policy (tailnet/private/link-local/ssh dropped for missions, allowed for
# the server) can be scoped by subnet. See mission_runtime.rs EDGE_NETWORK.
missions:
# Pinned so the host firewall can name the subnet. 172.25/16 is free on
# gw-04 and the laptop; docker's auto-assignment is not stable across hosts.
ipam:
config:
- subnet: 172.25.0.0/16
core:
internal: true
sandbox_net:
internal: true
secrets_net:
internal: true
# Server <-> socket proxy only; the raw Docker socket never reaches the
# server container.
engine_net:
internal: true
volumes:
pgdata: {}
# Unix socket + master key shared ONLY between server and broker —
# the unix-socket equivalent of the K8s sidecar topology.
broker_run: {}
broker_key: {}
# Per-mission repo checkouts + generated PDFs live on the host at
# /var/lib/clawmates-missions so the (separately-managed)
# clawmates-runtime container can bind-mount the SAME host path and
# see the same tree the server wrote to. A named docker volume
# would work too but would need volume-name knowledge in the
# runtime's spawn command.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: clawmates
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
volumes:
- pgdata:/var/lib/postgresql/data
networks: [core]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d clawmates"]
interval: 5s
timeout: 3s
retries: 12
# Named volumes mount root-owned, but the broker runs as uid 10001 from
# a scratch image (no shell to chown itself). This one-shot prepares the
# socket + key volumes, then exits. `on-failure` so a transient chown error
# retries instead of wedging boot (broker/server depend on it completing).
volume-init:
image: busybox:1.36
command: ["sh", "-c", "chown -R 10001:10001 /run/clawmates /etc/clawmates-broker"]
user: "0:0"
volumes:
- broker_run:/run/clawmates
- broker_key:/etc/clawmates-broker
restart: on-failure:5
# The secret broker: separate process, separate image; credentials
# never leave it. Reachable only via the shared unix socket volume.
broker:
image: clawmates/broker:${CLAWMATES_VERSION:-latest}
build:
context: ../..
dockerfile: images/broker.Dockerfile
restart: unless-stopped
environment:
CLAWMATES_BROKER_SOCKET: /run/clawmates/broker.sock
CLAWMATES_BROKER_KEY_FILE: /etc/clawmates-broker/broker.key
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
volumes:
- broker_run:/run/clawmates
- broker_key:/etc/clawmates-broker
# `core` stays for postgres access; `edge` grants OUTBOUND internet so the
# broker can act as the §14 door (fetch Gitea/GitHub/GitLab repo lists,
# forward Slack sends, etc.) without leaking any credential back to
# cm-api. The broker still doesn't accept inbound TCP — it only listens
# on the unix socket inside broker_run.
networks: [core, edge]
depends_on:
postgres:
condition: service_healthy
volume-init:
condition: service_completed_successfully
# Allow-listed Docker API (§15 blast-radius cap): the server can
# create/exec/stop/remove sandbox containers and NOTHING else — no
# image builds, no networks, no secrets, no volumes. Proven by
# crates/cm-sandbox/tests/socket_proxy.rs against this exact allowlist.
socket-proxy:
image: tecnativa/docker-socket-proxy:0.3
restart: unless-stopped
environment:
CONTAINERS: 1
POST: 1
EXEC: 1
DELETE: 1
VERSION: 1
# NETWORKS grant (C3): mission_runtime::ensure_container needs
# to attach per-mission runtime containers to the clawmates_edge
# network for provider egress, in addition to creating them on
# clawmates_core.
NETWORKS: 1
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [engine_net]
server:
image: clawmates/server:${CLAWMATES_VERSION:-latest}
build:
context: ../..
dockerfile: images/server.Dockerfile
restart: unless-stopped
# Operator knobs (bootstrap owner, LLM provider/key overrides, OTLP) come
# from .env — only the keys you actually set are injected, so unset
# options stay absent rather than overriding clawmates.toml with "".
env_file:
- path: .env
required: false
environment:
CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
DOCKER_HOST: tcp://socket-proxy:2375
CLAWMATES_MISSIONS_ROOT: /var/lib/clawmates-missions
CLAWMATES_RUNTIME_CONTAINER: ${CLAWMATES_RUNTIME_CONTAINER:-clawmates-runtime}
volumes:
- ./clawmates.toml:/etc/clawmates/clawmates.toml:ro
- broker_run:/run/clawmates
# Per-mission repo checkouts. Bind-mounted host path so the
# separately-managed clawmates-runtime container can see the
# same trees at the same path when it exec's for scans/benches.
- /var/lib/clawmates-missions:/var/lib/clawmates-missions
networks: [edge, core, engine_net]
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
frontend:
image: clawmates/frontend:${CLAWMATES_VERSION:-latest}
build:
context: ../..
dockerfile: images/frontend.Dockerfile
restart: unless-stopped
# AUTH_MODE + CLERK_* (when running Clerk) come from .env; see docs/clerk.md.
env_file:
- path: .env
required: false
environment:
API_ORIGIN: http://server:8080
networks: [edge]
ports:
- "3000:3000"
depends_on:
- server