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
1 change: 1 addition & 0 deletions pmoves/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ include mk/amd-rdna4.mk
include mk/hf.mk
include mk/provider.mk
include mk/egress.mk
include mk/neo4j-tailnet.mk
include mk/a2ui-deploy.mk
include mk/yt-cookies.mk
include mk/mcp-toolkit.mk
Expand Down
7 changes: 7 additions & 0 deletions pmoves/config/tailscale/neo4j-tailnet-serve.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"TCP": {
"7687": {
"TCPForward": "neo4j:7687"
}
}
}
20 changes: 20 additions & 0 deletions pmoves/configs/topology/docker_matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ overlays:
canonical_path: pmoves/docker-compose.n8n.yml
- name: n8n.postgres
canonical_path: pmoves/docker-compose.n8n.postgres.yml
- name: neo4j-tailnet
canonical_path: pmoves/docker-compose.neo4j-tailnet.yml
- name: open-notebook
canonical_path: pmoves/docker-compose.open-notebook.yml
- name: persona
Expand Down Expand Up @@ -2304,6 +2306,9 @@ unmapped_compose_keys:
- overlay: base
compose_key: pmoves_external
path: pmoves/docker-compose.base.yml
- overlay: base
compose_key: pmoves_graph_front
path: pmoves/docker-compose.base.yml
- overlay: base
compose_key: pmoves_monitoring
path: pmoves/docker-compose.base.yml
Expand Down Expand Up @@ -2721,6 +2726,9 @@ unmapped_compose_keys:
- overlay: main
compose_key: pmoves_external
path: pmoves/docker-compose.yml
- overlay: main
compose_key: pmoves_graph_front
path: pmoves/docker-compose.yml
- overlay: main
compose_key: pmoves_monitoring
path: pmoves/docker-compose.yml
Expand Down Expand Up @@ -2859,6 +2867,18 @@ unmapped_compose_keys:
- overlay: n8n.postgres
compose_key: n8n-db
path: pmoves/docker-compose.n8n.postgres.yml
- overlay: neo4j-tailnet
compose_key: cap_drop
path: pmoves/docker-compose.neo4j-tailnet.yml
- overlay: neo4j-tailnet
compose_key: graphfront-tailnet-state
path: pmoves/docker-compose.neo4j-tailnet.yml
- overlay: neo4j-tailnet
compose_key: neo4j-tailnet
path: pmoves/docker-compose.neo4j-tailnet.yml
- overlay: neo4j-tailnet
compose_key: security_opt
path: pmoves/docker-compose.neo4j-tailnet.yml
- overlay: open-notebook
compose_key: cataclysm
path: pmoves/docker-compose.open-notebook.yml
Expand Down
13 changes: 13 additions & 0 deletions pmoves/docker-compose.base.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion pmoves/docker-compose.core.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 95 additions & 0 deletions pmoves/docker-compose.neo4j-tailnet.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# docker-compose.neo4j-tailnet.yml — tailnet forwarder that fronts Neo4j (#3201)
# ============================================================================
# Operator decision (#3201, option A): Neo4j stays INTERNAL-ONLY (pmoves_app /
# pmoves_bus / pmoves_data / pmoves_graph_front: no egress, no host port). Fleet
# AGInTs reach its bolt port over the tailnet through THIS forwarder, per
# DOCKER_NETWORK_HARDENING Rule 5 ("There is no 'publish without egress.' Front
# it with a gateway").
#
# Usage (an overlay, not in STACK_FILES; same shape as docker-compose.yt-egress.yml):
# make -C pmoves up-neo4j-tailnet # preflights + start the forwarder (--no-deps)
# make -C pmoves neo4j-tailnet-status # tailscale status + serve status
# make -C pmoves down-neo4j-tailnet # stop and remove the forwarder
# up-neo4j-tailnet runs with --no-deps and REFUSES unless the running
# pmoves-neo4j is already on pmoves_graph_front, so it never recreates Neo4j
# implicitly. Recreating Neo4j is its own gated runbook step (see #3201).
#
# How it forwards (verified against the Tailscale source, see #3201):
# * TS_DEST_IP is NOT usable here: containerboot refuses it with TS_USERSPACE
# ("TS_DEST_IP is not supported with TS_USERSPACE", measured on this image).
# * `tailscale serve --tcp` via the CLI only accepts localhost targets, but a
# ServeConfig applied through TS_SERVE_CONFIG dials its TCPForward target
# with the container's own network stack (ipn/ipnlocal/serve.go: SystemDial),
# so `neo4j:7687` resolves via Docker DNS on pmoves_graph_front. Only bolt
# (7687) is forwarded; the 7474 browser UI is not needed by fleet AGInTs.
# * Userspace mode, no TUN, and NO capabilities: measured to start with
# cap_drop ALL + no-new-privileges.
#
# Networks (least privilege):
# * pmoves_graph_front (internal, 172.30.9.0/24): joined ONLY by neo4j and this
# forwarder, so a compromised forwarder reaches Neo4j and nothing else on the
# data tier. It is deliberately NOT on pmoves_data (Postgres, Qdrant, Meili…).
# * pmoves_external (egress-capable): the Tailscale control plane + DERP need
# outbound internet. This is DOCKER_NETWORK_HARDENING's documented
# dual-attach pattern for a service that needs an internal network plus
# internet (agent-zero, archon, hi-rag-gateway-v2, flute-gateway). Rule 1
# keeps DATA services off pmoves_external; this is a gateway, not a data
# store. Neo4j does not share this container's netns (no network_mode).
#
# Key: a DEDICATED, one-off, pre-authorised key scoped to tag:neo4j, in
# NEO4J_TAILNET_AUTHKEY (not the fleet-wide key), used once (TS_AUTH_ONCE) and
# then the node identity lives in the state volume. Delivering it (and its
# secrets-manifest entry) is an OPERATOR step; see #3201.
#
# State volume `graphfront-tailnet-state`: deliberately has no "neo4j" in its
# name, so `make volume-reset SERVICE=neo4j` (which matches ^pmoves_.*neo4j)
# can never wipe the forwarder's tailnet identity along with the graph.

services:
neo4j-tailnet:
image: tailscale/tailscale:stable@sha256:c507f3a2a6ab1cabd8d809b98edeb41edbd5c3fb6ad9632ffd098b4c7d0b4065
container_name: pmoves-neo4j-tailnet
restart: unless-stopped
environment:
# Fail at compose parse time if unset: an empty key leaves the forwarder
# running but never on the tailnet (same guard shape as yt-egress).
- TS_AUTHKEY=${NEO4J_TAILNET_AUTHKEY:?NEO4J_TAILNET_AUTHKEY must be set -- an operator step, see PR 3201}
# Use the key only for the first login; afterwards the identity in the
# state volume is reused and the key is not needed again.
- TS_AUTH_ONCE=true
- TS_USERSPACE=true
- TS_HOSTNAME=${NEO4J_TAILNET_HOSTNAME:-pmoves-neo4j}
- TS_STATE_DIR=/var/lib/tailscale
# Keep Docker DNS: the forward target `neo4j` must resolve on pmoves_graph_front.
- TS_ACCEPT_DNS=false
- TS_EXTRA_ARGS=--advertise-tags=tag:neo4j
- TS_SERVE_CONFIG=/config/serve.json
volumes:
- graphfront-tailnet-state:/var/lib/tailscale
- ./config/tailscale/neo4j-tailnet-serve.json:/config/serve.json:ro
networks:
- pmoves_graph_front
- pmoves_external
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# Ordering for a full-stack bring-up. The make target uses --no-deps, so
# this never recreates Neo4j implicitly.
depends_on:
neo4j:
condition: service_healthy
healthcheck:
test: ["CMD", "tailscale", "status", "--peers=false"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M

volumes:
graphfront-tailnet-state:
27 changes: 26 additions & 1 deletion pmoves/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,15 @@ services:
environment:
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD:?Set NEO4J_PASSWORD in env.tier-data}
- NEO4J_dbms_security_allow__csv__import__from__file__urls=true
# Deny LOAD CSV (http/https) to EVERY address, checked AFTER name resolution:
# defense in depth even on internal-only networks (it also stops Cypher from
# reaching the host gateway, the LAN, or other containers). Verified on the
# pinned 5.26.30 Community image (#3201): strict validation recognises the
# key, and LOAD CSV of an IP literal or a resolvable hostname fails with
# "access to ... is blocked via the configuration property
# internal.dbms.cypher_ip_blocklist" (a plain connection error without it).
# `internal.*` = unsupported by Neo4j; the digest pin keeps it stable.
- NEO4J_internal_dbms_cypher__ip__blocklist=0.0.0.0/0,::/0
- NEO4J_server_config_strict__validation_enabled=false
# APOC extensions with safe procedure allowlist
- NEO4J_apoc_export_file_enabled=true
Expand All @@ -1612,7 +1621,7 @@ services:
- TOPOLOGY_MODE=${TOPOLOGY_MODE:-docked}
- PARENT_SYSTEM=${PARENT_SYSTEM:-PMOVES.AI}
- PARENT_VERSION=${PARENT_VERSION:-1.0.0-hardened}
- PMOVES_NETWORKS=pmoves_app,pmoves_bus,pmoves_data
- PMOVES_NETWORKS=pmoves_app,pmoves_bus,pmoves_data,pmoves_graph_front
ports:
- ${NEO4J_BIND:-0.0.0.0}:${NEO4J_HTTP_PORT:-7474}:7474
- ${NEO4J_BIND:-0.0.0.0}:${NEO4J_BOLT_PORT:-7687}:7687
Expand All @@ -1633,6 +1642,9 @@ services:
pmoves_data:
aliases:
- neo4j
# Internal link to the tailnet forwarder only (#3201). No alias needed:
# compose registers the service name `neo4j` on every network it joins.
pmoves_graph_front:
healthcheck:
test:
- CMD-SHELL
Expand Down Expand Up @@ -6425,6 +6437,19 @@ networks:
config:
- subnet: 172.30.4.0/24
gateway: 172.30.4.1
# Graph front (#3201, option A): the ONLY link between the tailnet forwarder
# (docker-compose.neo4j-tailnet.yml) and neo4j. Internal, and joined by exactly
# those two, so a compromised forwarder reaches Neo4j and nothing else on the
# data tier (least privilege; it is deliberately NOT on pmoves_data).
# Subnet 172.30.9.0/24 is permanent (DOCKER_NETWORK_HARDENING Rule 2).
pmoves_graph_front:
<<: *network-internal-only
name: pmoves_graph_front
ipam:
driver: default
config:
- subnet: 172.30.9.0/24
gateway: 172.30.9.1
# Host-reachable tier. Kong proxy/admin and PostgREST attach here; the
# services still default to 127.0.0.1 host binds for safer standalone
# operation. Being internal:false this is ALSO egress-capable -- see the
Expand Down
4 changes: 3 additions & 1 deletion pmoves/docs/operations/DOCKER_NETWORK_HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

## Network Inventory

Six Docker networks are defined across the compose stack:
Nine Docker networks are defined across the compose stack (this line said "Six" while the table already listed seven; corrected with the two rows added by #3201):

| Network | Driver | `internal` | Subnet | Role |
|---------|--------|-----------|--------|------|
Expand All @@ -20,6 +20,8 @@ Six Docker networks are defined across the compose stack:
| `pmoves_monitoring` | bridge | **yes** | 172.30.5.0/24 | Observability — Prometheus, Grafana, Loki, cAdvisor |
| `pmoves_external` | bridge | **no** | 172.30.6.0/24 | Internet-capable — TensorZero-gateway, Agent Zero, Archon, Hi-RAG |
| `pmoves_public` | bridge | **no** | 172.30.7.0/24 | Host-reachable + egress-capable — Kong, PostgREST, edge-functions |
| `pmoves_db_egress` | bridge | **no** | 172.30.8.0/24 | Dedicated non-internal bridge for the supabase-db tailnet port publish (#2728); created by the Makefile (`external: true` in compose) |
| `pmoves_graph_front` | bridge | **yes** | 172.30.9.0/24 | Graph front (#3201) — joined ONLY by `neo4j` and its tailnet forwarder `neo4j-tailnet`, so the forwarder reaches Neo4j and nothing else on the data tier |

> **`pmoves_public` was missing from this table until 2026-09-03** while being
> live with three attached containers and referenced nine times in compose. An
Expand Down
45 changes: 45 additions & 0 deletions pmoves/mk/neo4j-tailnet.mk
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# mk/neo4j-tailnet.mk — the tailnet forwarder that fronts Neo4j (#3201, option A)
# ===========================================================================
# Neo4j stays internal-only; fleet AGInTs reach bolt (tcp:7687) over the
# tailnet through docker-compose.neo4j-tailnet.yml. Same overlay shape as
# mk/egress.mk. No target here nests make.
#
# up-neo4j-tailnet NEVER recreates Neo4j: it runs with --no-deps, and refuses
# unless the running pmoves-neo4j is already attached to pmoves_graph_front
# (which only a deliberate, gated Neo4j recreate does -- see #3201's runbook).

NEO4J_TAILNET_COMPOSE := docker-compose.neo4j-tailnet.yml

.PHONY: up-neo4j-tailnet neo4j-tailnet-status down-neo4j-tailnet

up-neo4j-tailnet: ## Start the tailnet forwarder that fronts Neo4j (tcp:7687 -> neo4j:7687); never recreates Neo4j
@# Preflight 1 (state + network names only; never addresses): the live
@# pmoves-neo4j must be RUNNING (--no-deps will not start it, and a forwarder
@# in front of a stopped Neo4j fronts nothing) and already on the graph front.
@out=$$(docker inspect --type container -f '{{.State.Running}} {{range $$k, $$v := .NetworkSettings.Networks}}{{$$k}} {{end}}' pmoves-neo4j 2>/dev/null) || { \
echo "[neo4j-tailnet] REFUSING: could not inspect pmoves-neo4j (missing, or daemon/permission error); nothing started." >&2; exit 1; }; \
case "$$out" in \
"true "*) ;; \
*) echo "[neo4j-tailnet] REFUSING: pmoves-neo4j is not running; start it first (#3201 runbook)." >&2; exit 1;; \
esac; \
case " $$out " in \
*" pmoves_graph_front "*) ;; \
*) echo "[neo4j-tailnet] REFUSING: pmoves-neo4j is not attached to pmoves_graph_front." >&2; \
echo " Recreate Neo4j first, as its own gated step (#3201 runbook), then re-run this." >&2; exit 1;; \
esac
@# Preflight 2: `config -q` validates quietly; it trips the overlay's own key
@# guard when the dedicated key was not delivered, and never prints the key.
@$(DC) -f $(NEO4J_TAILNET_COMPOSE) config -q || { \
echo "[neo4j-tailnet] overlay does not validate; if it names the dedicated key, delivering it is an operator step (#3201)" >&2; \
exit 1; }
@# --no-deps: never let depends_on recreate Neo4j from here.
@$(DC) -f $(NEO4J_TAILNET_COMPOSE) up -d --no-deps neo4j-tailnet

neo4j-tailnet-status: ## Show the forwarder's tailnet status and its serve (forward) config
@docker exec pmoves-neo4j-tailnet tailscale status --peers=false || true
@docker exec pmoves-neo4j-tailnet tailscale serve status || true

down-neo4j-tailnet: ## Stop and remove the forwarder (Neo4j itself is untouched)
@$(DC) -f $(NEO4J_TAILNET_COMPOSE) stop neo4j-tailnet
@# A stopped container needs no force; "no such container" is fine.
@docker container rm pmoves-neo4j-tailnet >/dev/null 2>&1 || true
Loading
Loading