diff --git a/pmoves/Makefile b/pmoves/Makefile index 2684fcd5e5..546250ece8 100644 --- a/pmoves/Makefile +++ b/pmoves/Makefile @@ -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 diff --git a/pmoves/config/tailscale/neo4j-tailnet-serve.json b/pmoves/config/tailscale/neo4j-tailnet-serve.json new file mode 100644 index 0000000000..9ec3da0f3d --- /dev/null +++ b/pmoves/config/tailscale/neo4j-tailnet-serve.json @@ -0,0 +1,7 @@ +{ + "TCP": { + "7687": { + "TCPForward": "neo4j:7687" + } + } +} diff --git a/pmoves/configs/topology/docker_matrix.yaml b/pmoves/configs/topology/docker_matrix.yaml index 6c54227283..154801dd67 100644 --- a/pmoves/configs/topology/docker_matrix.yaml +++ b/pmoves/configs/topology/docker_matrix.yaml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/pmoves/docker-compose.base.yml b/pmoves/docker-compose.base.yml index 5396493178..d4c5a8ae88 100644 --- a/pmoves/docker-compose.base.yml +++ b/pmoves/docker-compose.base.yml @@ -579,6 +579,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 diff --git a/pmoves/docker-compose.core.yml b/pmoves/docker-compose.core.yml index f486a45ceb..eab5e03b90 100644 --- a/pmoves/docker-compose.core.yml +++ b/pmoves/docker-compose.core.yml @@ -1155,6 +1155,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 @@ -1166,7 +1175,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 @@ -1187,6 +1196,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 diff --git a/pmoves/docker-compose.neo4j-tailnet.yml b/pmoves/docker-compose.neo4j-tailnet.yml new file mode 100644 index 0000000000..23f00884de --- /dev/null +++ b/pmoves/docker-compose.neo4j-tailnet.yml @@ -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: diff --git a/pmoves/docker-compose.yml b/pmoves/docker-compose.yml index 829595f853..49cdb68999 100644 --- a/pmoves/docker-compose.yml +++ b/pmoves/docker-compose.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/pmoves/docs/operations/DOCKER_NETWORK_HARDENING.md b/pmoves/docs/operations/DOCKER_NETWORK_HARDENING.md index 387026af60..ea8b431901 100644 --- a/pmoves/docs/operations/DOCKER_NETWORK_HARDENING.md +++ b/pmoves/docs/operations/DOCKER_NETWORK_HARDENING.md @@ -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 | |---------|--------|-----------|--------|------| @@ -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 diff --git a/pmoves/mk/neo4j-tailnet.mk b/pmoves/mk/neo4j-tailnet.mk new file mode 100644 index 0000000000..1873c5f7aa --- /dev/null +++ b/pmoves/mk/neo4j-tailnet.mk @@ -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 diff --git a/pmoves/tests/test_neo4j_tailnet_forwarder.py b/pmoves/tests/test_neo4j_tailnet_forwarder.py new file mode 100644 index 0000000000..7ae32d9815 --- /dev/null +++ b/pmoves/tests/test_neo4j_tailnet_forwarder.py @@ -0,0 +1,153 @@ +"""The tailnet forwarder that fronts Neo4j (#3201, operator option A). + +Neo4j stays internal-only: no egress, no host port. Fleet AGInTs reach bolt over +the tailnet through `neo4j-tailnet`, per DOCKER_NETWORK_HARDENING Rule 5 +("gateway-front it"). These assertions pin the design so it cannot drift: + * userspace Tailscale (TS_DEST_IP is refused in userspace; measured), forwarding + via TS_SERVE_CONFIG, which tailscaled dials with the container's own stack; + * ONLY tcp:7687 -> neo4j:7687 (the 7474 browser UI is not forwarded); + * least privilege: pmoves_graph_front (internal; neo4j + this forwarder only) + + pmoves_external (control plane) -- NOT pmoves_data; no shared netns, no + published ports, no capabilities, an image pinned by digest; + * the overlay is NOT in STACK_FILES, so nodes without a key are unaffected. +Structural only: no docker, no env files. (The auth-key line is deliberately not +asserted here -- see #3201; its `:?` guard makes compose itself refuse to start +without the key.) +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +PMOVES = Path(__file__).resolve().parents[1] +OVERLAY = PMOVES / "docker-compose.neo4j-tailnet.yml" +SERVE = PMOVES / "config" / "tailscale" / "neo4j-tailnet-serve.json" + +SVC = yaml.safe_load(OVERLAY.read_text())["services"]["neo4j-tailnet"] +ENV = dict(e.split("=", 1) for e in SVC["environment"]) + + +def test_image_is_pinned_by_digest(): + assert re.fullmatch(r"tailscale/tailscale:[\w.-]+@sha256:[0-9a-f]{64}", SVC["image"]), SVC["image"] + + +def test_userspace_and_serve_config_not_dest_ip(): + assert ENV["TS_USERSPACE"] == "true" + assert "TS_DEST_IP" not in ENV, "TS_DEST_IP is refused with TS_USERSPACE (measured)" + assert ENV["TS_SERVE_CONFIG"] == "/config/serve.json" + mounts = [v for v in SVC["volumes"] if v.endswith(":/config/serve.json:ro")] + assert mounts == ["./config/tailscale/neo4j-tailnet-serve.json:/config/serve.json:ro"] + + +def test_only_bolt_is_forwarded_to_neo4j(): + cfg = json.loads(SERVE.read_text()) + assert set(cfg) == {"TCP"} + assert cfg["TCP"] == {"7687": {"TCPForward": "neo4j:7687"}} + + +def test_it_advertises_tag_neo4j(): + assert "--advertise-tags=tag:neo4j" in ENV["TS_EXTRA_ARGS"].split() + + +def test_least_privilege_networks_no_netns_share_no_ports_no_caps(): + assert set(SVC["networks"]) == {"pmoves_graph_front", "pmoves_external"} + assert "pmoves_data" not in SVC["networks"], "the forwarder must not reach the whole data tier" + assert "network_mode" not in SVC + assert "ports" not in SVC, "the forwarder publishes nothing on the host" + assert SVC.get("cap_drop") == ["ALL"] + assert "cap_add" not in SVC + assert "no-new-privileges:true" in SVC.get("security_opt", []) + + +def test_the_overlay_is_not_in_the_default_stack(): + mk = (PMOVES / "Makefile").read_text() + m = re.search(r"^STACK_FILES \?= \\\n((?:\t-f \S+(?: \\)?\n)+)", mk, re.M) + assert m and "docker-compose.neo4j-tailnet.yml" not in m.group(1) + assert "include mk/neo4j-tailnet.mk" in mk + + +def test_the_make_targets_never_nest_make(): + text = (PMOVES / "mk" / "neo4j-tailnet.mk").read_text() + recipes = [ln for ln in text.splitlines() if ln.startswith("\t")] + assert recipes, "no recipe lines found -- parser is broken" + assert not [ln for ln in recipes if "$(MAKE)" in ln], "a recipe nests make (it runs even under -n)" + for target in ("up-neo4j-tailnet", "neo4j-tailnet-status", "down-neo4j-tailnet"): + assert re.search(rf"^{target}:", text, re.M), target + + + +# --- #3201 review P2: the forwarder must never recreate Neo4j implicitly ----- + +def _recipe(target: str) -> str: + text = (PMOVES / "mk" / "neo4j-tailnet.mk").read_text() + start = text.index(f"\n{target}:") + end = text.find("\n\n", start + 1) + return text[start:end if end != -1 else len(text)] + + +def test_up_uses_no_deps_and_a_graph_front_preflight(): + r = _recipe("up-neo4j-tailnet") + assert "up -d --no-deps neo4j-tailnet" in r + assert "pmoves_graph_front" in r and "REFUSING" in r + assert r.index("pmoves_graph_front") < r.index("up -d --no-deps"), "preflight must precede the up" + + +def test_the_state_volume_cannot_be_wiped_by_volume_reset_neo4j(): + vols = yaml.safe_load(OVERLAY.read_text())["volumes"] + for name in vols: + full = f"pmoves_{name}" + assert not re.search(r"(^pmoves_.*neo4j|neo4j$)", full), full + + +def _docker_guard(): + name = "pmoves_tests_destructive_docker_guard" + if name in sys.modules: + return sys.modules[name] + import importlib.util + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name("_destructive_docker_guard.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +INSPECT_BEHAVIOUR = r""" +if [ "$1" = "inspect" ]; then + [ -n "$FAKE_NEO4J_INSPECT" ] && echo "$FAKE_NEO4J_INSPECT" + exit "${FAKE_INSPECT_RC:-0}" +fi +""" + + +@pytest.mark.skipif(shutil.which("make") is None, reason="make not installed") +@pytest.mark.parametrize("inspect,rc,refusal", [ + ("true pmoves_app pmoves_bus pmoves_data ", 0, "not attached to pmoves_graph_front"), # before the gated recreate + ("true pmoves_app pmoves_bus pmoves_data pmoves_graph_front ", 0, None), # after it + ("false pmoves_app pmoves_bus pmoves_data pmoves_graph_front ", 0, "not running"), # recreated, but stopped + ("", 1, "could not inspect"), # missing / daemon / permission +]) +def test_up_refuses_until_neo4j_is_running_on_the_graph_front(tmp_path, inspect, rc, refusal): + stub = _docker_guard().build_stub_env(tmp_path / "bin", stub_make=False, + behaviours={"docker": INSPECT_BEHAVIOUR}) + env = dict(stub) + env["FAKE_NEO4J_INSPECT"] = inspect + env["FAKE_INSPECT_RC"] = str(rc) + proc = subprocess.run(["make", "-s", "-C", str(PMOVES), "up-neo4j-tailnet"], + capture_output=True, text=True, timeout=120, env=env) + calls = [row for row in stub.calls() if row and row[0] == "docker"] + ups = [row for row in calls if "up" in row] + if refusal: + out = proc.stdout + proc.stderr + assert proc.returncode != 0 and "REFUSING" in out and refusal in out, out + assert ups == [], ups + else: + assert ups, calls + assert all("--no-deps" in row for row in ups), ups diff --git a/pmoves/tests/test_published_ports_need_a_non_internal_network.py b/pmoves/tests/test_published_ports_need_a_non_internal_network.py new file mode 100644 index 0000000000..4af50d604e --- /dev/null +++ b/pmoves/tests/test_published_ports_need_a_non_internal_network.py @@ -0,0 +1,216 @@ +"""A service that publishes ports must be on at least one NON-internal network. + +Docker publishes NOTHING for a container attached only to `internal: true` +networks: HostConfig.PortBindings is honoured nowhere and NetworkSettings.Ports +stays empty, with no error. Phase 1b of the Neo4j migration (#3193/#3196) hit +exactly that: the compose neo4j came up healthy on pmoves_app/bus/data, all +internal, and its tailnet bind silently did not exist. The render and dry-run +gates could not see it; they read the REQUESTED bindings. + +Measured on Knuckles 2026-09-27 against running containers: 15 of 15 running +services this test lists had requested bindings and ZERO effective ones; +cipher-api, minio and presign (each also on a non-internal network) published. + +The stack is the Makefile's default STACK_FILES, parsed from the YAML directly +(no docker, no env files), merged by service name as compose does for these keys: +ports append; networks union; network_mode wins. A service with no `networks:` +key is on the project default network, which is not internal. Network keys are +resolved to their real `name:`. + +A network declared `external: true` (pmoves_external, pmoves_db_egress) is not +created by compose, so compose never says whether it is internal: that is fixed +where the Makefile/mk CREATE it (`docker network create ...`). Its internal flag +is therefore read from those create sites, and EXTERNAL_NON_INTERNAL pins the two +that must stay non-internal -- the ones every published service relies on. + +Scope, stated rather than implied: only the default STACK_FILES are scanned; +other overlays (per-node, vps, elder-melchor, …) are not, and compose's +`!reset`/`!override` merge tags are not modelled (yaml.safe_load would refuse +them; none of the scanned files uses them today). + +KNOWN_VIOLATORS are the services that already break the rule on main. They are +recorded, not silently passed: each is xfail(strict=True), so FIXING one turns +its case into an XPASS failure until it is removed from the set. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +PMOVES = Path(__file__).resolve().parents[1] + +# Measured 2026-09-27 on origin/main f9d8a8228. +# neo4j is here BY DESIGN (#3201, operator option A): it stays internal-only +# (app/bus/data; no egress, no host port) and is reached over the tailnet through +# the neo4j-tailnet forwarder (docker-compose.neo4j-tailnet.yml), per +# DOCKER_NETWORK_HARDENING Rule 5 ("gateway-front it"). Its `ports:` stay only to +# carry ${NEO4J_BIND}; on internal-only networks they are inert. +KNOWN_VIOLATORS = frozenset({ + "neo4j", + "a2ui-nats-bridge", "a2ui-renderer", "consciousness-service", "gateway-agent", + "gpu-orchestrator", "grayjay-plugin-host", "grayjay-server", "hf-research-agent", + "invidious-companion-proxy", "langextract", "llama-throughput-lab", "meilisearch", + "nats_event_bus", "notebook-mcp", "notebook-sync", "nvidia-nim", + "p7-room-orchestrator", "pdf-ingest", "pinokio_bridge", "qdrant", "retrieval-eval", + "session-context-worker", "supabase-pooler", "supabase-realtime", "supabase-storage", + "supabase-studio", "supaserch", "tensorzero-clickhouse", "tokenism-simulator", + "tokenism-ui", "voice-relay", "voice-sampler", "watch-folder-router", "wealth-mcp", +}) + + +def _stack_files() -> list[str]: + mk = (PMOVES / "Makefile").read_text() + m = re.search(r"^STACK_FILES \?= \\\n((?:\t-f \S+(?: \\)?\n)+)", mk, re.M) + assert m, "could not find the STACK_FILES block in the Makefile -- parser is broken" + return re.findall(r"-f (\S+)", m.group(1)) + + +# Must stay non-internal: created outside compose (external: true), and the only +# route by which a service on internal networks can publish a port. +EXTERNAL_NON_INTERNAL = frozenset({"pmoves_external", "pmoves_db_egress"}) + +_CREATE = re.compile(r"docker network create\b([^\n|;&]*)") + + +def _join_continuations(text: str) -> str: + """Join backslash-continued lines, as make and the shell both do, so a + `docker network create` whose --internal sits on a continuation line is + read as one command.""" + return re.sub(r"\\\n[ \t]*", " ", text) + + +def _create_sites(texts: list[str] | None = None) -> dict[str, list[str]]: + """{real network name: [the flag text of each `docker network create` for it]}.""" + if texts is None: + texts = [f.read_text() for f in [PMOVES / "Makefile", *sorted((PMOVES / "mk").glob("*.mk"))]] + sites: dict[str, list[str]] = {} + for text in texts: + for m in _CREATE.finditer(_join_continuations(text)): + words = m.group(1).split() + names = [w for w in words if not w.startswith("-") and not w.startswith(">") + and not w[0].isdigit() and w not in ("bridge",)] + if names: + sites.setdefault(names[-1].strip('"'), []).append(m.group(1)) + return sites + + +CREATE_SITES = _create_sites() + + +def _stack() -> tuple[dict, dict]: + services: dict[str, dict] = {} + networks: dict[str, bool] = {} # keyed by the compose KEY services reference + for f in _stack_files(): + doc = yaml.safe_load((PMOVES / f).read_text()) or {} + for key, spec in (doc.get("networks") or {}).items(): + spec = spec or {} + real = spec.get("name") or key + if spec.get("external"): + sites = CREATE_SITES.get(real) + assert sites, f"external network {real!r} has no `docker network create` site to check" + networks[key] = any("--internal" in s for s in sites) + else: + networks[key] = bool(spec.get("internal")) + for name, sv in (doc.get("services") or {}).items(): + sv = sv or {} + cur = services.setdefault(name, {"ports": [], "networks": set(), "network_mode": None}) + cur["ports"] += sv.get("ports") or [] + nw = sv.get("networks") + if isinstance(nw, (dict, list)): + cur["networks"] |= set(nw) + if sv.get("network_mode"): + cur["network_mode"] = sv["network_mode"] + return services, networks + + +SERVICES, NETWORKS = _stack() +PUBLISHED = sorted(n for n, s in SERVICES.items() if s["ports"] and not s["network_mode"]) + + +def _all_internal(name: str) -> tuple[bool, list[str]]: + nets = sorted(SERVICES[name]["networks"]) or ["default"] + return all(NETWORKS.get(n, False) for n in nets if n != "default") and "default" not in nets, nets + + +def test_the_parser_sees_the_fleet(): + assert len(_stack_files()) >= 6 + assert {"pmoves_app", "pmoves_bus", "pmoves_data"} <= {n for n, i in NETWORKS.items() if i} + assert NETWORKS.get("pmoves_external") is False, "pmoves_external must exist and be non-internal" + assert "neo4j" in PUBLISHED and "cipher-api" in PUBLISHED + + +@pytest.mark.parametrize( + "service", + [pytest.param(s, marks=pytest.mark.xfail(strict=True, reason="known violator on main (see KNOWN_VIOLATORS)")) + if s in KNOWN_VIOLATORS else s for s in PUBLISHED], +) +def test_a_published_service_is_on_a_non_internal_network(service): + internal_only, nets = _all_internal(service) + assert not internal_only, ( + f"{service} publishes {SERVICES[service]['ports']} but every network it joins " + f"({', '.join(nets)}) is internal: Docker will publish NOTHING. Add a non-internal " + f"network (e.g. pmoves_external, as cipher-api does)." + ) + + +def test_known_violators_is_not_stale(): + """Every listed name must still exist and still publish; a rename or removal + must shrink the list, not leave a dead entry that no case exercises.""" + stale = sorted(KNOWN_VIOLATORS - set(PUBLISHED)) + assert not stale, f"KNOWN_VIOLATORS names services that no longer publish in the stack: {stale}" + + +@pytest.mark.parametrize("name", sorted(EXTERNAL_NON_INTERNAL)) +def test_external_networks_are_created_without_internal(name): + """P2-A (#3201 review): every Makefile/mk site that CREATES this network must + omit --internal, or every service relying on it silently loses its ports.""" + sites = CREATE_SITES.get(name) + assert sites, f"no `docker network create ... {name}` site found -- parser or Makefile changed" + bad = [s.strip() for s in sites if "--internal" in s] + assert not bad, f"{name} is created with --internal at: {bad}" + + +def test_positive_control_an_internal_create_site_is_caught(): + fake = "docker network create --driver bridge --internal pmoves_external" + m = _CREATE.search(fake) + assert m and "--internal" in m.group(1) + + + +# --- #3201 option A: neo4j stays internal-only; the forwarder fronts it ------ + +NEO4J_NETWORKS = {"pmoves_app", "pmoves_bus", "pmoves_data", "pmoves_graph_front"} + + +def test_neo4j_joins_only_internal_networks_and_no_new_ones(): + """DOCKER_NETWORK_HARDENING Rule 1: a data service never joins an egress-capable + network (pmoves_external). Option A keeps neo4j on exactly app/bus/data plus the + graph front to its tailnet forwarder -- every one of them internal.""" + nets = SERVICES["neo4j"]["networks"] + assert nets == NEO4J_NETWORKS, f"neo4j networks changed: {sorted(nets)}" + assert all(NETWORKS[n] for n in nets), "every neo4j network must be internal" + + +def test_neo4j_does_not_share_another_containers_netns(): + assert SERVICES["neo4j"]["network_mode"] is None + + +def test_the_graph_front_is_internal_and_neo4j_is_its_only_stack_member(): + """Least privilege: in the default stack only neo4j joins pmoves_graph_front; + the forwarder joins it from its own overlay (docker-compose.neo4j-tailnet.yml).""" + assert NETWORKS.get("pmoves_graph_front") is True + members = sorted(n for n, s in SERVICES.items() if "pmoves_graph_front" in s["networks"]) + assert members == ["neo4j"], members + + + +def test_a_continuation_line_internal_flag_is_caught(): + """P3 (#3201 review): --internal on a backslash-continued line must count.""" + text = ("net:\n\t@docker network create --driver bridge \\\n" + "\t\t--internal --subnet 10.0.0.0/24 \\\n\t\tpmoves_external\n") + sites = _create_sites([text]) + assert sites.get("pmoves_external"), sites + assert any("--internal" in x for x in sites["pmoves_external"]), sites