From 3d029ff747b1ad6ea408280586ce20b6bea7071f Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 18:28:50 +0200 Subject: [PATCH 1/9] feat: add LP1 isolated local proof topology --- .env.example | 4 + .github/workflows/quality-gates.yml | 43 ++++++ Dockerfile | 1 + README.md | 12 ++ compose.yaml | 163 ++++++++++++++++++++++ doc/README.md | 4 + doc/local-proof-topology.md | 123 ++++++++++++++++ docker/lp1-network/Dockerfile | 24 ++++ docker/lp1-network/entrypoint-ca.sh | 59 ++++++++ docker/lp1-network/entrypoint-dns.sh | 24 ++++ package.json | 4 +- release/local-proof-topology.json | 63 +++++++++ scripts/lp1-compose-audit.mjs | 58 ++++++++ scripts/lp1-proof-check.mjs | 99 +++++++++++++ scripts/lp1-proof-smoke.mjs | 126 +++++++++++++++++ src/release/index.mjs | 7 + src/release/local-proof-topology.mjs | 125 +++++++++++++++++ src/release/local-proof-topology.test.mjs | 73 ++++++++++ 18 files changed, 1011 insertions(+), 1 deletion(-) create mode 100644 doc/local-proof-topology.md create mode 100644 docker/lp1-network/Dockerfile create mode 100644 docker/lp1-network/entrypoint-ca.sh create mode 100644 docker/lp1-network/entrypoint-dns.sh create mode 100644 release/local-proof-topology.json create mode 100644 scripts/lp1-compose-audit.mjs create mode 100644 scripts/lp1-proof-check.mjs create mode 100644 scripts/lp1-proof-smoke.mjs create mode 100644 src/release/local-proof-topology.mjs create mode 100644 src/release/local-proof-topology.test.mjs diff --git a/.env.example b/.env.example index 4003083..377e629 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ GULOGULO_VOLUME_PREFIX=gulogulo-local # Set true only after the named volumes have been created by the deployment # operator. External volumes survive container recreation and image upgrades. GULOGULO_VOLUMES_EXTERNAL=false +GULOGULO_LP1_VOLUMES_EXTERNAL=false +GULOGULO_PROOF_NETWORK=gulogulo-local-proof +GULOGULO_PROOF_HTTP_PORT=18080 +LP1_DNS_PORT=5353 HOST=0.0.0.0 PORT=8080 APP_ENV=development diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 19ba55f..659b7d3 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -138,6 +138,7 @@ jobs: test -f src/observability/observability.test.mjs test -f doc/lifecycle-backup-dr.md test -f doc/local-proof-scope.md + test -f doc/local-proof-topology.md test -f src/ops/acme/index.mjs test -f src/ops/acme/index.test.mjs test -f src/ops/abuse/index.mjs @@ -154,7 +155,16 @@ jobs: test -f src/release/local-proof-scope.mjs test -f src/release/local-proof-scope.test.mjs test -f release/local-proof-scope.json + test -f release/local-proof-topology.json test -f scripts/lp0-scope-audit.mjs + test -f scripts/lp1-compose-audit.mjs + test -f scripts/lp1-proof-check.mjs + test -f scripts/lp1-proof-smoke.mjs + test -f docker/lp1-network/Dockerfile + test -f docker/lp1-network/entrypoint-ca.sh + test -f docker/lp1-network/entrypoint-dns.sh + test -f src/release/local-proof-topology.mjs + test -f src/release/local-proof-topology.test.mjs test -f doc/upgrade-and-migration.md grep -Fq 'profiles: ["local"]' compose.yaml grep -Fq 'profiles: ["test"]' compose.yaml @@ -175,6 +185,9 @@ jobs: scripts/m1-fixture-smoke.ps1 \ scripts/container-patch.sh \ scripts/lp0-scope-audit.mjs \ + scripts/lp1-compose-audit.mjs \ + scripts/lp1-proof-check.mjs \ + scripts/lp1-proof-smoke.mjs \ scripts/m10-release-audit.mjs \ .gitignore \ .github/workflows/commit-tests.yml \ @@ -184,6 +197,11 @@ jobs: grep -Fq 'Author: Sythos (https://www.sythos.net)' "$file" done + for file in docker/lp1-network/Dockerfile docker/lp1-network/entrypoint-ca.sh docker/lp1-network/entrypoint-dns.sh; do + grep -Fq 'SPDX-License-Identifier: MIT' "$file" + grep -Fq 'Author: Sythos (https://www.sythos.net)' "$file" + done + while IFS= read -r file; do grep -Fq 'SPDX-License-Identifier: MIT' "$file" grep -Fq 'Author: Sythos (https://www.sythos.net)' "$file" @@ -287,6 +305,9 @@ jobs: test -f package-lock.json node -e "const e=require('./release/v1-release-evidence.template.json'); if (e.spdxLicenseIdentifier !== 'MIT' || e.author !== 'Sythos (https://www.sythos.net)' || e.product !== 'Gulo Gulo') process.exit(1)" node -e "const s=require('./release/local-proof-scope.json'); if (s.spdxLicenseIdentifier !== 'MIT' || s.author !== 'Sythos (https://www.sythos.net)' || s.proofType !== 'local' || s.networkPolicy !== 'offline_runtime' || s.syntheticDataOnly !== true || s.publicDnsRequired !== false || s.publicAcmeEnabled !== false || s.externalPhaseDeferred !== true) process.exit(1)" + node -e "const t=require('./release/local-proof-topology.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP1' || t.networkPolicy !== 'offline_runtime' || t.internalNetwork !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.dockerSocketMounted !== false) process.exit(1)" + bash -n docker/lp1-network/entrypoint-ca.sh + bash -n docker/lp1-network/entrypoint-dns.sh - name: Set up Node.js when package metadata exists if: hashFiles('package.json') != '' @@ -352,6 +373,23 @@ jobs: . test -s "$output" + - name: Validate LP1 network utility image on amd64 and arm64 + if: hashFiles('docker/lp1-network/Dockerfile') != '' + shell: bash + run: | + set -euo pipefail + output="$RUNNER_TEMP/gulogulo-lp1-network-ubuntu-26.04.oci.tar" + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --pull \ + --provenance=false \ + --sbom=false \ + --tag gulogulo:ci-lp1-network-ubuntu-26.04 \ + --file docker/lp1-network/Dockerfile \ + --output "type=oci,dest=$output" \ + . + test -s "$output" + - name: Run Compose test profile if: hashFiles('compose.yaml') != '' || hashFiles('compose.yml') != '' || hashFiles('docker-compose.yml') != '' || hashFiles('docker-compose.yaml') != '' shell: bash @@ -370,6 +408,11 @@ jobs: "${compose[@]}" --profile test build --pull gulogulo-test "${compose[@]}" --profile test run --rm --no-deps gulogulo-test + - name: Run LP1 isolated local proof topology + if: hashFiles('docker/lp1-network/Dockerfile') != '' && hashFiles('scripts/lp1-proof-smoke.mjs') != '' + shell: bash + run: npm run test:lp1:docker + - name: Run disposable PostgreSQL M2 integration contract if: hashFiles('src/integrations/postgres.integration.test.mjs') != '' shell: bash diff --git a/Dockerfile b/Dockerfile index 8a4cb48..40f975c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,6 +77,7 @@ COPY --chown=gulogulo:gulogulo . . RUN set -eux; \ install -m 0755 scripts/container-patch.sh /usr/local/sbin/gulogulo-container-patch; \ + install -d -o gulogulo -g gulogulo /var/lib/gulogulo/lp1; \ if [ ! -f package.json ]; then \ echo 'Gulo Gulo requires package.json with an npm start script.' >&2; \ exit 1; \ diff --git a/README.md b/README.md index 33ea91b..b3c6eb9 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,11 @@ gulogulo/ │ └── gulo-gulo-calendar-mail.png ├── config/ │ └── schema.v1.json +├── docker/ +│ └── lp1-network/ +│ ├── Dockerfile +│ ├── entrypoint-ca.sh +│ └── entrypoint-dns.sh ├── doc/ │ ├── README.md │ ├── api-and-mcp.md @@ -126,6 +131,7 @@ gulogulo/ │ ├── identity-and-postgres.md │ ├── lifecycle-backup-dr.md │ ├── local-proof-scope.md +│ ├── local-proof-topology.md │ ├── mail-core.md │ ├── rbac-admin-mfa.md │ ├── release-readiness.md @@ -137,11 +143,15 @@ gulogulo/ │ ├── m0-smoke.ps1 │ ├── m1-fixture-smoke.ps1 │ ├── lp0-scope-audit.mjs +│ ├── lp1-compose-audit.mjs +│ ├── lp1-proof-check.mjs +│ ├── lp1-proof-smoke.mjs │ ├── m10-release-audit.mjs │ ├── container-patch.sh │ └── runtime, fixture, and patch utilities ├── release/ │ ├── local-proof-scope.json +│ ├── local-proof-topology.json │ └── v1-release-evidence.template.json ├── src/ │ ├── admin/ @@ -157,6 +167,8 @@ gulogulo/ │ │ ├── index.mjs │ │ ├── local-proof-scope.mjs │ │ ├── local-proof-scope.test.mjs +│ │ ├── local-proof-topology.mjs +│ │ ├── local-proof-topology.test.mjs │ │ ├── release-evidence.mjs │ │ └── release-evidence.test.mjs │ ├── ops/ diff --git a/compose.yaml b/compose.yaml index 4ad5fe0..ad061ab 100644 --- a/compose.yaml +++ b/compose.yaml @@ -176,6 +176,146 @@ services: healthcheck: disable: true + # LP1 is a disposable, private proof topology. It deliberately uses a + # separate internal network, loopback-only publication, and named volumes + # that can be switched to externally managed storage by the operator. + gulogulo-proof: + <<: *gulogulo-service + profiles: ["proof"] + ports: + - "127.0.0.1:${GULOGULO_PROOF_HTTP_PORT:-18080}:8080" + environment: + <<: *gulogulo-environment + APP_ENV: local-proof + GULOGULO_ENV: local-proof + GULOGULO_FIXTURE_MODE: "true" + GULOGULO_SERVICE_NAME: gulogulo-lp1-proof + networks: + proof-runtime: + aliases: + - gulogulo-proof + - gulogulo.test + - webmail.localhost + - calendar.localhost + - contacts.localhost + volumes: + - lp1-runtime-state:/var/lib/gulogulo + - lp1-mail-data:/var/lib/gulogulo/mail + - lp1-dav-data:/var/lib/gulogulo/dav + - lp1-backup-data:/var/lib/gulogulo/backups + labels: + com.sythos.gulogulo.milestone: LP1 + com.sythos.gulogulo.proof: local + com.sythos.gulogulo.network-policy: offline_runtime + + local-ca: + build: + context: docker/lp1-network + dockerfile: Dockerfile + profiles: ["proof"] + user: "0:0" + command: ["/usr/local/sbin/gulogulo-lp1-ca"] + environment: + LP1_CA_DIR: /run/gulogulo-ca + volumes: + - lp1-ca-data:/run/gulogulo-ca + networks: + proof-runtime: + aliases: + - local-ca + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + init: true + restart: "no" + healthcheck: + test: + - CMD-SHELL + - test -s /run/gulogulo-ca/ca.crt && test -s /run/gulogulo-ca/gulogulo.test.crt && test -s /run/gulogulo-ca/gulogulo.test.key + interval: 2s + timeout: 2s + start_period: 2s + retries: 15 + labels: + com.sythos.gulogulo.milestone: LP1 + com.sythos.gulogulo.proof: local + com.sythos.gulogulo.network-policy: offline_runtime + + local-dns: + build: + context: docker/lp1-network + dockerfile: Dockerfile + profiles: ["proof"] + command: ["/usr/local/sbin/gulogulo-lp1-dns"] + environment: + LP1_DNS_PORT: ${LP1_DNS_PORT:-5353} + networks: + proof-runtime: + aliases: + - local-dns + read_only: true + tmpfs: + - /run:rw,noexec,nosuid,size=16m + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + init: true + restart: "no" + healthcheck: + test: + - CMD-SHELL + - dnsmasq --test --no-resolv --no-hosts --address=/gulogulo.test/127.0.0.1 + interval: 2s + timeout: 2s + start_period: 2s + retries: 15 + labels: + com.sythos.gulogulo.milestone: LP1 + com.sythos.gulogulo.proof: local + com.sythos.gulogulo.network-policy: offline_runtime + + # This is an on-demand client. It is not started by the proof runtime; the + # LP1 smoke harness runs it twice, before and after an application restart. + gulogulo-proof-check: + build: + context: . + dockerfile: Dockerfile + args: + INSTALL_DEV: "true" + profiles: ["proof-check"] + command: ["node", "scripts/lp1-proof-check.mjs"] + environment: + LP1_CA_DIR: /run/gulogulo-ca + LP1_PROOF_STATE_DIR: /var/lib/gulogulo/lp1 + LP1_DNS_SERVER: local-dns + LP1_DNS_PORT: ${LP1_DNS_PORT:-5353} + LP1_APPLICATION_SERVER: gulogulo-proof + NODE_EXTRA_CA_CERTS: /run/gulogulo-ca/ca.crt + volumes: + - lp1-ca-data:/run/gulogulo-ca:ro + - lp1-proof-state:/var/lib/gulogulo/lp1 + networks: + - proof-runtime + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + init: true + restart: "no" + labels: + com.sythos.gulogulo.milestone: LP1 + com.sythos.gulogulo.proof: local + com.sythos.gulogulo.network-policy: offline_runtime + volumes: runtime-state: name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-runtime-state @@ -189,3 +329,26 @@ volumes: backup-data: name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-backup-data external: ${GULOGULO_VOLUMES_EXTERNAL:-false} + lp1-ca-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp1-ca-data + external: ${GULOGULO_LP1_VOLUMES_EXTERNAL:-false} + lp1-runtime-state: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp1-runtime-state + external: ${GULOGULO_LP1_VOLUMES_EXTERNAL:-false} + lp1-mail-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp1-mail-data + external: ${GULOGULO_LP1_VOLUMES_EXTERNAL:-false} + lp1-dav-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp1-dav-data + external: ${GULOGULO_LP1_VOLUMES_EXTERNAL:-false} + lp1-backup-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp1-backup-data + external: ${GULOGULO_LP1_VOLUMES_EXTERNAL:-false} + lp1-proof-state: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp1-proof-state + external: ${GULOGULO_LP1_VOLUMES_EXTERNAL:-false} + +networks: + proof-runtime: + name: ${GULOGULO_PROOF_NETWORK:-gulogulo-local-proof} + internal: true diff --git a/doc/README.md b/doc/README.md index 1568a16..022c8e6 100644 --- a/doc/README.md +++ b/doc/README.md @@ -57,6 +57,10 @@ normal workflow. - [LP0 local proof scope](local-proof-scope.md) — the reserved local names, synthetic-data rule, offline runtime boundary, disposable service inventory, architecture targets, and the explicit deferral of the external phase. +- [LP1 isolated local topology](local-proof-topology.md) — the private Compose + network, disposable CA/DNS utilities, loopback-only application binding, + external-capable named volumes, restart continuity check, and Docker proof + harness. The project is still intentionally small. The documents describe real behavior only: DAV, administration, lifecycle, backup, observability, ACME, abuse, and diff --git a/doc/local-proof-topology.md b/doc/local-proof-topology.md new file mode 100644 index 0000000..9003e33 --- /dev/null +++ b/doc/local-proof-topology.md @@ -0,0 +1,123 @@ +# LP1 isolated local topology + + + +LP1 turns the LP0 boundary into a disposable Docker Compose topology. It is a +small, private harness for the first local proof, not the production service +stack. The topology is intentionally limited to the application runtime, a +short-lived local certificate authority, and a reserved-name DNS responder. +LDAP, PostgreSQL, Postfix, Dovecot, Rspamd, ClamAV, CalDAV, and CardDAV remain +later LP milestones. + +The machine-readable contract is +[`release/local-proof-topology.json`](../release/local-proof-topology.json). +The static audit is `npm run test:lp1`; the live Docker rehearsal is +`npm run test:lp1:docker`. + +## Services and network + +The `proof` profile creates three long-running services on the named +`gulogulo-local-proof` network: + +| Service | Purpose | Runtime boundary | +|---|---|---| +| `gulogulo-proof` | The existing non-root Gulo Gulo runtime | Published only on `127.0.0.1:18080`; read-only root filesystem; LP1 external-capable named volumes | +| `local-ca` | Disposable seven-day CA and leaf certificate generator | Writes only to `lp1-ca-data`; no host port; synthetic keys are destroyed with the project by default | +| `local-dns` | `dnsmasq` responder on port 5353 | Answers the four reserved names only with `127.0.0.1`; has no upstream resolver and no Internet egress | + +The network is declared `internal: true`. The application also receives Docker +network aliases for `gulogulo.test`, `webmail.localhost`, +`calendar.localhost`, and `contacts.localhost`; the proof client queries the +dedicated `local-dns` responder as an independent check. + +The `proof-check` profile is on-demand. Its Node.js client installs the CA via +`NODE_EXTRA_CA_CERTS`, verifies the CA/leaf chain and SAN, resolves the reserved +name through the local DNS service, checks `/health/ready`, and persists a +small restart marker. The smoke harness runs that client before and after an +application restart, proving that the marker volume survives replacement. + +## Volumes and lifecycle + +LP1 uses separate named volumes: + +- `lp1-ca-data` — disposable CA and leaf material; +- `lp1-runtime-state` — application runtime and patch state; +- `lp1-mail-data` — reserved mailbox path for later milestones; +- `lp1-dav-data` — reserved DAV path for later milestones; +- `lp1-backup-data` — reserved backup path for later milestones; +- `lp1-proof-state` — the synthetic restart marker used by the proof client. + +By default these volumes are project-scoped and removed by the LP1 smoke +harness. Set `GULOGULO_LP1_VOLUMES_EXTERNAL=true` only after an operator has +created and backed up the named volumes independently. The same Compose file +then keeps them across container replacement and future blue/green rehearsal. +The application never receives the Docker socket, host namespaces, or a host +path mount. + +## Run the static contract + +The static path does not need Docker and is safe to run on Windows, Linux, or +macOS: + +```powershell +npm run test:lp1 +``` + +It validates the topology manifest and checks that `compose.yaml` contains the +internal network, proof services, loopback-only binding, labels, named volumes, +and no Docker-socket, host-network, or privileged marker. + +## Run the live local proof + +Docker Desktop or a Docker Engine with Compose v2 is required: + +```powershell +Copy-Item .env.example .env +npm run test:lp1:docker +``` + +The harness creates a unique project, volume prefix, and network name. It then: + +1. validates Compose configuration; +2. builds the application and network utility images with `--pull`; +3. starts the private proof services; +4. waits for application, CA, and DNS health checks; +5. runs the proof client; +6. restarts only the application and runs the proof client again; +7. inspects the network, host binding, labels, mounts, and privilege flags; +8. removes only that project, its networks, and its disposable volumes. + +If a step fails, the harness prints the recent application, CA, and DNS logs +before cleanup. The command never targets a broad Docker project name and never +uses `docker system prune`. + +## Local certificates and trust + +`local-ca` creates: + +- `ca.crt`, a disposable CA certificate; +- `gulogulo.test.crt`, a seven-day leaf with SANs for all four reserved names; +- `gulogulo.test.key`, the matching synthetic private key. + +The proof client does not publish the key or send it anywhere. It validates the +signature, CA flags, SAN, and a Node TLS context using the CA certificate. LP4 +will use the same trust boundary when an HTTPS web/DAV endpoint is added. The +LP1 HTTP health endpoint remains plain HTTP inside the internal network because +there is no reverse proxy in this milestone. + +## Security and non-goals + +- Runtime containers have no Internet egress through the internal network. +- No public DNS or ACME challenge is attempted. +- No real email address, credential, mailbox, contact, or calendar object is + accepted by the topology. +- No external LDAP/PostgreSQL or vendor protocol service is started yet. +- No Docker socket, host network, privileged flag, or arbitrary shell control + is exposed. +- A green LP1 rehearsal proves topology isolation, health, trust material, + reserved-name resolution, and volume continuity only. It is not production + readiness or evidence of a complete mail provider. diff --git a/docker/lp1-network/Dockerfile b/docker/lp1-network/Dockerfile new file mode 100644 index 0000000..a98f69d --- /dev/null +++ b/docker/lp1-network/Dockerfile @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +# Author: Sythos (https://www.sythos.net) + +# Disposable LP1 network utility image. It is deliberately separate from the +# application image: the local CA and DNS boundary must never be mistaken for +# a production certificate or public resolver. +FROM ubuntu:26.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN set -eux; \ + apt-get update; \ + apt-get upgrade -y; \ + apt-get install -y --no-install-recommends ca-certificates dnsmasq openssl; \ + rm -rf /var/lib/apt/lists/* + +COPY entrypoint-ca.sh /usr/local/sbin/gulogulo-lp1-ca +COPY entrypoint-dns.sh /usr/local/sbin/gulogulo-lp1-dns + +RUN chmod 0755 /usr/local/sbin/gulogulo-lp1-ca /usr/local/sbin/gulogulo-lp1-dns + +# Compose selects the entrypoint for each logical utility role. +CMD ["/usr/local/sbin/gulogulo-lp1-dns"] diff --git a/docker/lp1-network/entrypoint-ca.sh b/docker/lp1-network/entrypoint-ca.sh new file mode 100644 index 0000000..cf685e3 --- /dev/null +++ b/docker/lp1-network/entrypoint-ca.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +# Author: Sythos (https://www.sythos.net) + +set -euo pipefail + +ca_dir="${LP1_CA_DIR:-/run/gulogulo-ca}" +mkdir -p "$ca_dir" +chmod 0700 "$ca_dir" + +if [[ ! -s "$ca_dir/ca.key" || ! -s "$ca_dir/ca.crt" ]]; then + rm -f "$ca_dir"/ca.key "$ca_dir"/ca.crt + openssl req -x509 -newkey rsa:3072 -nodes \ + -keyout "$ca_dir/ca.key" \ + -out "$ca_dir/ca.crt" \ + -days 7 \ + -sha256 \ + -subj '/CN=Gulo Gulo LP1 Local CA' \ + -addext 'basicConstraints=critical,CA:TRUE,pathlen:1' \ + -addext 'keyUsage=critical,keyCertSign,cRLSign' +fi + +if [[ ! -s "$ca_dir/gulogulo.test.key" || ! -s "$ca_dir/gulogulo.test.crt" ]]; then + rm -f "$ca_dir"/gulogulo.test.key "$ca_dir"/gulogulo.test.crt "$ca_dir"/gulogulo.test.csr "$ca_dir"/ca.srl + openssl req -new -newkey rsa:2048 -nodes \ + -keyout "$ca_dir/gulogulo.test.key" \ + -out "$ca_dir/gulogulo.test.csr" \ + -subj '/CN=gulogulo.test' + + cat > "$ca_dir/gulogulo.test.ext" <<'EOF' +basicConstraints=critical,CA:false +keyUsage=critical,digitalSignature,keyEncipherment +extendedKeyUsage=serverAuth +subjectAltName=DNS:gulogulo.test,DNS:webmail.localhost,DNS:calendar.localhost,DNS:contacts.localhost +EOF + + openssl x509 -req \ + -in "$ca_dir/gulogulo.test.csr" \ + -CA "$ca_dir/ca.crt" \ + -CAkey "$ca_dir/ca.key" \ + -CAcreateserial \ + -out "$ca_dir/gulogulo.test.crt" \ + -days 7 \ + -sha256 \ + -extfile "$ca_dir/gulogulo.test.ext" + + rm -f "$ca_dir/gulogulo.test.csr" "$ca_dir/gulogulo.test.ext" "$ca_dir/ca.srl" +fi + +chmod 0600 "$ca_dir/ca.key" "$ca_dir/gulogulo.test.key" +chmod 0644 "$ca_dir/ca.crt" "$ca_dir/gulogulo.test.crt" + +echo 'gulogulo-lp1-ca-ready' +trap 'exit 0' TERM INT +while :; do + sleep 3600 & + wait $! +done diff --git a/docker/lp1-network/entrypoint-dns.sh b/docker/lp1-network/entrypoint-dns.sh new file mode 100644 index 0000000..839e155 --- /dev/null +++ b/docker/lp1-network/entrypoint-dns.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +# Author: Sythos (https://www.sythos.net) + +set -euo pipefail + +dns_port="${LP1_DNS_PORT:-5353}" + +exec dnsmasq \ + --no-daemon \ + --keep-in-foreground \ + --no-resolv \ + --no-hosts \ + --listen-address=0.0.0.0 \ + --port="$dns_port" \ + --address=/gulogulo.test/127.0.0.1 \ + --address=/webmail.localhost/127.0.0.1 \ + --address=/calendar.localhost/127.0.0.1 \ + --address=/contacts.localhost/127.0.0.1 \ + --address=/.localhost/127.0.0.1 \ + --log-facility=- \ + --user=nobody \ + --group=nogroup diff --git a/package.json b/package.json index 299951c..ab85950 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build:web": "node web/build.mjs", "typecheck": "tsc --project tsconfig.json --noEmit", "test:web": "npm run build:web && node web/test/web-shell.test.mjs", - "test": "npm run test:web && npm run test:m6 && npm run test:m7 && npm run test:m8 && npm run test:m9 && npm run test:m10 && npm run test:lp0 && node src/runtime/runtime.test.mjs && node src/foundation/config.test.mjs && node src/runtime/observability.test.mjs && node src/integrations/tenant-context.test.mjs && node src/integrations/ldap-client.test.mjs && node src/integrations/postgres-store.test.mjs && node src/mail/mail-core.test.mjs && node src/web/security/security.test.mjs && node src/web/content/email-content.test.mjs && node src/web/content/attachment-policy.test.mjs && node src/web/content/timezone.test.mjs && node src/web/realtime/event-normalizer.test.mjs && node src/web/backup/backup-request.test.mjs && node src/dav/caldav/caldav-contract.test.mjs && node src/dav/carddav/carddav-store.test.mjs && node src/dav/discovery/index.test.mjs", + "test": "npm run test:web && npm run test:m6 && npm run test:m7 && npm run test:m8 && npm run test:m9 && npm run test:m10 && npm run test:lp0 && npm run test:lp1 && node src/runtime/runtime.test.mjs && node src/foundation/config.test.mjs && node src/runtime/observability.test.mjs && node src/integrations/tenant-context.test.mjs && node src/integrations/ldap-client.test.mjs && node src/integrations/postgres-store.test.mjs && node src/mail/mail-core.test.mjs && node src/web/security/security.test.mjs && node src/web/content/email-content.test.mjs && node src/web/content/attachment-policy.test.mjs && node src/web/content/timezone.test.mjs && node src/web/realtime/event-normalizer.test.mjs && node src/web/backup/backup-request.test.mjs && node src/dav/caldav/caldav-contract.test.mjs && node src/dav/carddav/carddav-store.test.mjs && node src/dav/discovery/index.test.mjs", "test:m6": "node src/admin/rbac.test.mjs && node src/admin/delegation.test.mjs && node src/admin/quota.test.mjs && node src/admin/admin-tools.test.mjs && node src/auth/auth.test.mjs", "test:m7": "node src/lifecycle/retention.test.mjs && node src/lifecycle/account-lifecycle.test.mjs && node src/backup/backup-contract.test.mjs && node src/observability/observability.test.mjs", "test:m8": "node src/ops/acme/index.test.mjs && node src/ops/abuse/index.test.mjs", @@ -24,6 +24,8 @@ "test:m10": "node src/release/release-evidence.test.mjs && node scripts/m10-release-audit.mjs", "test:lp0": "node src/release/local-proof-scope.test.mjs && node scripts/lp0-scope-audit.mjs", "test:lp0:audit": "node scripts/lp0-scope-audit.mjs", + "test:lp1": "node src/release/local-proof-topology.test.mjs && node scripts/lp1-compose-audit.mjs", + "test:lp1:docker": "node scripts/lp1-proof-smoke.mjs", "test:m2:postgres": "node src/integrations/postgres.integration.test.mjs" }, "dependencies": { diff --git a/release/local-proof-topology.json b/release/local-proof-topology.json new file mode 100644 index 0000000..beae94d --- /dev/null +++ b/release/local-proof-topology.json @@ -0,0 +1,63 @@ +{ + "$comment": "SPDX-License-Identifier: MIT; SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net); Author: Sythos (https://www.sythos.net)", + "spdxLicenseIdentifier": "MIT", + "spdxFileCopyrightText": "2026 Sythos (https://www.sythos.net)", + "author": "Sythos (https://www.sythos.net)", + "schemaVersion": 1, + "milestone": "LP1", + "proofType": "local", + "networkPolicy": "offline_runtime", + "networkName": "gulogulo-local-proof", + "internalNetwork": true, + "syntheticDataOnly": true, + "publicDnsRequired": false, + "publicAcmeEnabled": false, + "hostNetwork": false, + "dockerSocketMounted": false, + "externalVolumesMode": "named_external_capable", + "hostBindings": [ + "127.0.0.1:18080->8080/tcp" + ], + "localNames": [ + "gulogulo.test", + "webmail.localhost", + "calendar.localhost", + "contacts.localhost" + ], + "services": [ + { + "name": "gulogulo-proof", + "role": "application", + "health": "GET /health/ready" + }, + { + "name": "local-ca", + "role": "disposable certificate authority", + "health": "ca.crt and gulogulo.test.crt exist" + }, + { + "name": "local-dns", + "role": "reserved-name resolver", + "health": "dnsmasq configuration check" + } + ], + "volumes": [ + "lp1-ca-data", + "lp1-runtime-state", + "lp1-mail-data", + "lp1-dav-data", + "lp1-backup-data", + "lp1-proof-state" + ], + "caArtifacts": [ + "ca.crt", + "gulogulo.test.crt", + "gulogulo.test.key" + ], + "requiredLabels": [ + "com.sythos.gulogulo.milestone", + "com.sythos.gulogulo.proof", + "com.sythos.gulogulo.network-policy" + ], + "status": "frozen" +} diff --git a/scripts/lp1-compose-audit.mjs b/scripts/lp1-compose-audit.mjs new file mode 100644 index 0000000..81a5eb3 --- /dev/null +++ b/scripts/lp1-compose-audit.mjs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { createLocalProofTopology } from '../src/release/local-proof-topology.mjs'; + +const composePath = resolve(process.cwd(), 'compose.yaml'); +const manifestPath = resolve(process.cwd(), 'release/local-proof-topology.json'); +const compose = await readFile(composePath, 'utf8'); +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +const topology = createLocalProofTopology(manifest); + +const requiredMarkers = [ + 'gulogulo-proof:', + 'local-ca:', + 'local-dns:', + 'gulogulo-proof-check:', + 'profiles: ["proof"]', + 'internal: true', + '127.0.0.1:${GULOGULO_PROOF_HTTP_PORT:-18080}:8080', + 'lp1-ca-data:', + 'lp1-runtime-state:', + 'lp1-mail-data:', + 'lp1-dav-data:', + 'lp1-backup-data:', + 'lp1-proof-state:', + 'com.sythos.gulogulo.milestone', + 'com.sythos.gulogulo.network-policy', +]; + +for (const marker of requiredMarkers) { + if (!compose.includes(marker)) { + throw new Error(`LP1 Compose marker is missing: ${marker}`); + } +} + +if (/docker\.sock|network_mode:\s*host|privileged:\s*true/.test(compose)) { + throw new Error('LP1 Compose topology contains a forbidden Docker socket, host network, or privileged marker.'); +} + +console.log(JSON.stringify({ + milestone: topology.milestone, + proofType: topology.proofType, + networkPolicy: topology.networkPolicy, + networkName: topology.networkName, + internalNetwork: topology.internalNetwork, + localNames: topology.localNames, + services: topology.services.map(({ name, role }) => ({ name, role })), + hostBindings: topology.hostBindings, + volumes: topology.volumes, + publicDnsRequired: topology.publicDnsRequired, + publicAcmeEnabled: topology.publicAcmeEnabled, + dockerSocketMounted: topology.dockerSocketMounted, + status: topology.status, +}, null, 2)); diff --git a/scripts/lp1-proof-check.mjs b/scripts/lp1-proof-check.mjs new file mode 100644 index 0000000..86b3b0d --- /dev/null +++ b/scripts/lp1-proof-check.mjs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { readFile, rename, writeFile } from 'node:fs/promises'; +import { lookup } from 'node:dns/promises'; +import { Resolver } from 'node:dns/promises'; +import { join, resolve } from 'node:path'; +import { X509Certificate } from 'node:crypto'; +import { createSecureContext } from 'node:tls'; + +const caDir = resolve(process.env.LP1_CA_DIR || '/run/gulogulo-ca'); +const stateDir = resolve(process.env.LP1_PROOF_STATE_DIR || '/var/lib/gulogulo/lp1'); +const dnsService = process.env.LP1_DNS_SERVER || 'local-dns'; +const dnsPort = Number(process.env.LP1_DNS_PORT || 5353); +const applicationService = process.env.LP1_APPLICATION_SERVER || 'gulogulo-proof'; +const markerPath = join(stateDir, 'restart-marker.json'); + +function fail(message) { + const error = new Error(message); + error.code = 'LP1_PROOF_CHECK_FAILED'; + throw error; +} + +function requireCondition(condition, message) { + if (!condition) fail(message); +} + +async function readRequired(path, label) { + try { + return await readFile(path); + } catch (error) { + throw new Error(`LP1 ${label} is unavailable: ${error.message}`, { cause: error }); + } +} + +const caBytes = await readRequired(join(caDir, 'ca.crt'), 'CA certificate'); +const leafBytes = await readRequired(join(caDir, 'gulogulo.test.crt'), 'leaf certificate'); +const leafKeyBytes = await readRequired(join(caDir, 'gulogulo.test.key'), 'leaf key'); +const caCertificate = new X509Certificate(caBytes); +const leafCertificate = new X509Certificate(leafBytes); + +requireCondition(caCertificate.ca === true, 'the local CA certificate is not marked as a CA'); +requireCondition(leafCertificate.ca === false, 'the application certificate must not be a CA'); +requireCondition(leafCertificate.checkHost('gulogulo.test') === 'gulogulo.test', 'the leaf certificate lacks the gulogulo.test SAN'); +requireCondition(leafCertificate.verify(caCertificate.publicKey), 'the leaf certificate is not signed by the local CA'); +requireCondition(leafKeyBytes.length > 0, 'the leaf private key is empty'); +requireCondition(process.env.NODE_EXTRA_CA_CERTS === join(caDir, 'ca.crt'), 'the test client did not install the local CA trust path'); +createSecureContext({ ca: caBytes }); + +const dnsAddress = (await lookup(dnsService)).address; +const resolver = new Resolver(); +resolver.setServers([`${dnsAddress}:${dnsPort}`]); +const answers = await resolver.resolve4('gulogulo.test'); +requireCondition(answers.length > 0 && answers.every((address) => address === '127.0.0.1'), 'reserved DNS names escaped the loopback-only answer policy'); + +const response = await fetch(`http://${applicationService}:8080/health/ready`, { + signal: AbortSignal.timeout(5000), +}); +requireCondition(response.ok, `the application readiness endpoint returned HTTP ${response.status}`); +const readiness = await response.json(); +requireCondition(readiness.status === 'ready', 'the application readiness payload is not ready'); + +let previous = null; +try { + previous = JSON.parse(await readFile(markerPath, 'utf8')); +} catch (error) { + if (error.code !== 'ENOENT') throw error; +} + +if (previous) { + requireCondition(previous.milestone === 'LP1', 'the persisted restart marker belongs to another milestone'); + requireCondition(previous.checkCount >= 1, 'the persisted restart marker is malformed'); +} + +const marker = { + schemaVersion: 1, + milestone: 'LP1', + checkCount: (previous?.checkCount || 0) + 1, + restartObserved: Boolean(previous), + networkPolicy: 'offline_runtime', + syntheticDataOnly: true, + localNames: ['gulogulo.test', 'webmail.localhost', 'calendar.localhost', 'contacts.localhost'], +}; +const temporaryMarker = `${markerPath}.tmp`; +await writeFile(temporaryMarker, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 }); +await rename(temporaryMarker, markerPath); + +console.log(JSON.stringify({ + milestone: marker.milestone, + checkCount: marker.checkCount, + restartObserved: marker.restartObserved, + caSubject: caCertificate.subject, + leafSubject: leafCertificate.subject, + dnsAnswers: answers, + readiness: readiness.status, + networkPolicy: marker.networkPolicy, + syntheticDataOnly: marker.syntheticDataOnly, +}, null, 2)); diff --git a/scripts/lp1-proof-smoke.mjs b/scripts/lp1-proof-smoke.mjs new file mode 100644 index 0000000..d8519af --- /dev/null +++ b/scripts/lp1-proof-smoke.mjs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { spawnSync } from 'node:child_process'; + +const runId = String(process.env.GITHUB_RUN_ID || Date.now()).replace(/[^0-9]/g, '') || 'local'; +const project = `gulogulo-lp1-${runId}`; +const network = `gulogulo-lp1-network-${runId}`; +const volumePrefix = `gulogulo-lp1-${runId}`; +const composeBase = ['compose', '--project-name', project, '--file', 'compose.yaml', '--env-file', '.env.example']; +const environment = { + ...process.env, + GULOGULO_VOLUME_PREFIX: volumePrefix, + GULOGULO_LP1_VOLUMES_EXTERNAL: 'false', + GULOGULO_PROOF_NETWORK: network, + GULOGULO_PROOF_HTTP_PORT: process.env.GULOGULO_PROOF_HTTP_PORT || '18080', + LP1_DNS_PORT: '5353', +}; + +function execute(args, { capture = false, allowFailure = false } = {}) { + const result = spawnSync('docker', args, { + cwd: process.cwd(), + env: environment, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0 && !allowFailure) { + throw new Error(`Docker command failed (${result.status}): docker ${args.join(' ')}`); + } + return result; +} + +function compose(args, options) { + return execute([...composeBase, ...args], options); +} + +function serviceContainer(service) { + const result = compose(['ps', '-q', service], { capture: true }); + return result.stdout.trim().split(/\r?\n/).filter(Boolean)[0] || ''; +} + +function inspectContainer(container) { + const result = execute(['inspect', container], { capture: true }); + return JSON.parse(result.stdout)[0]; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitForHealthy(service, timeoutMs = 180000) { + const deadline = Date.now() + timeoutMs; + let lastState = 'not-created'; + while (Date.now() < deadline) { + const container = serviceContainer(service); + if (container) { + const details = inspectContainer(container); + lastState = `${details.State.Status}/${details.State.Health?.Status || 'no-health'}`; + if (details.State.Status === 'running' && details.State.Health?.Status === 'healthy') return container; + if (details.State.Status === 'exited' && details.State.ExitCode !== 0) { + throw new Error(`${service} exited with code ${details.State.ExitCode}`); + } + } + await sleep(2000); + } + throw new Error(`${service} did not become healthy: ${lastState}`); +} + +function assertSafeContainer(container, service) { + const hostConfig = container.HostConfig || {}; + if (hostConfig.NetworkMode === 'host') throw new Error(`${service} uses host networking.`); + if (hostConfig.Privileged === true) throw new Error(`${service} is privileged.`); + for (const mount of container.Mounts || []) { + if (/docker\.sock/i.test(`${mount.Source || ''} ${mount.Destination || ''}`)) { + throw new Error(`${service} mounts the Docker socket.`); + } + } +} + +let started = false; +try { + compose(['--profile', 'proof', 'config', '--quiet']); + compose(['--profile', 'proof', '--profile', 'proof-check', 'build', '--pull']); + compose(['--profile', 'proof', 'up', '--detach', '--remove-orphans']); + started = true; + + const application = await waitForHealthy('gulogulo-proof'); + await waitForHealthy('local-ca'); + await waitForHealthy('local-dns'); + + compose(['--profile', 'proof', '--profile', 'proof-check', 'run', '--rm', '--no-deps', 'gulogulo-proof-check']); + compose(['restart', 'gulogulo-proof']); + await waitForHealthy('gulogulo-proof'); + compose(['--profile', 'proof', '--profile', 'proof-check', 'run', '--rm', '--no-deps', 'gulogulo-proof-check']); + + const networkDetails = JSON.parse(execute(['network', 'inspect', network], { capture: true }).stdout)[0]; + if (networkDetails.Internal !== true) throw new Error('LP1 runtime network is not marked internal.'); + for (const service of ['gulogulo-proof', 'local-ca', 'local-dns']) { + assertSafeContainer(inspectContainer(serviceContainer(service)), service); + } + const applicationDetails = inspectContainer(application); + const bindings = applicationDetails.NetworkSettings?.Ports?.['8080/tcp'] || []; + if (bindings.some((binding) => binding.HostIp !== '127.0.0.1')) { + throw new Error('LP1 application has a non-loopback host binding.'); + } + + console.log(JSON.stringify({ + milestone: 'LP1', + project, + network, + networkInternal: networkDetails.Internal, + restartAndVolumeContinuity: true, + localCaHealth: true, + localDnsHealth: true, + applicationHealth: true, + hostBindingPolicy: 'loopback_only', + dockerSocketMounted: false, + }, null, 2)); +} catch (error) { + compose(['logs', '--no-color', '--tail', '120', 'gulogulo-proof', 'local-ca', 'local-dns'], { allowFailure: true }); + throw error; +} finally { + if (started) compose(['--profile', 'proof', '--profile', 'proof-check', 'down', '--volumes', '--remove-orphans'], { allowFailure: true }); +} diff --git a/src/release/index.mjs b/src/release/index.mjs index 46a5231..44851ab 100644 --- a/src/release/index.mjs +++ b/src/release/index.mjs @@ -23,3 +23,10 @@ export { PLATFORM_SET, createLocalProofScope, } from './local-proof-scope.mjs'; + +export { + LOCAL_NAMES, + REQUIRED_SERVICES, + REQUIRED_VOLUMES, + createLocalProofTopology, +} from './local-proof-topology.mjs'; diff --git a/src/release/local-proof-topology.mjs b/src/release/local-proof-topology.mjs new file mode 100644 index 0000000..2054652 --- /dev/null +++ b/src/release/local-proof-topology.mjs @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +/** + * LP1 Compose topology contract. + * + * This validates the topology metadata without contacting Docker. The runtime + * smoke script performs the second, live check against a disposable project. + */ + +const LOCAL_NAMES = Object.freeze([ + 'gulogulo.test', + 'webmail.localhost', + 'calendar.localhost', + 'contacts.localhost', +]); + +const REQUIRED_SERVICES = Object.freeze([ + 'gulogulo-proof', + 'local-ca', + 'local-dns', +]); + +const REQUIRED_VOLUMES = Object.freeze([ + 'lp1-ca-data', + 'lp1-runtime-state', + 'lp1-mail-data', + 'lp1-dav-data', + 'lp1-backup-data', + 'lp1-proof-state', +]); + +function topologyError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function assertPlainObject(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw topologyError('LP1_TOPOLOGY_INVALID', `${label} must be a plain object.`); + } +} + +function assertExact(value, expected, code, label) { + if (value !== expected) { + throw topologyError(code, `${label} must be ${expected}.`); + } +} + +function assertExactList(value, expected, code, label) { + if (!Array.isArray(value) || value.length !== expected.length || + value.some((entry, index) => entry !== expected[index])) { + throw topologyError(code, `${label} does not match the frozen LP1 inventory.`); + } +} + +function assertSafeName(value, code, label) { + if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9.-]*$/.test(value) || /\.\.|[\\/]/.test(value)) { + throw topologyError(code, `${label} contains an unsafe name.`); + } +} + +/** + * Validate and freeze the LP1 topology manifest. + */ +export function createLocalProofTopology(input) { + assertPlainObject(input, 'topology'); + assertExact(input.schemaVersion, 1, 'LP1_SCHEMA_INVALID', 'schemaVersion'); + assertExact(input.milestone, 'LP1', 'LP1_MILESTONE_INVALID', 'milestone'); + assertExact(input.proofType, 'local', 'LP1_PROOF_TYPE_INVALID', 'proofType'); + assertExact(input.networkPolicy, 'offline_runtime', 'LP1_NETWORK_POLICY_INVALID', 'networkPolicy'); + assertExact(input.internalNetwork, true, 'LP1_NETWORK_INVALID', 'internalNetwork'); + assertExact(input.syntheticDataOnly, true, 'LP1_DATA_POLICY_INVALID', 'syntheticDataOnly'); + assertExact(input.publicDnsRequired, false, 'LP1_EXTERNAL_BOUNDARY_INVALID', 'publicDnsRequired'); + assertExact(input.publicAcmeEnabled, false, 'LP1_EXTERNAL_BOUNDARY_INVALID', 'publicAcmeEnabled'); + assertExact(input.hostNetwork, false, 'LP1_HOST_NETWORK_INVALID', 'hostNetwork'); + assertExact(input.dockerSocketMounted, false, 'LP1_SOCKET_INVALID', 'dockerSocketMounted'); + assertExact(input.externalVolumesMode, 'named_external_capable', 'LP1_VOLUME_POLICY_INVALID', 'externalVolumesMode'); + assertExact(input.status, 'frozen', 'LP1_STATUS_INVALID', 'status'); + + assertSafeName(input.networkName, 'LP1_NETWORK_NAME_INVALID', 'networkName'); + assertExactList(input.localNames, LOCAL_NAMES, 'LP1_LOCAL_NAMES_INVALID', 'localNames'); + assertExactList(input.volumes, REQUIRED_VOLUMES, 'LP1_VOLUMES_INVALID', 'volumes'); + assertExactList(input.caArtifacts, ['ca.crt', 'gulogulo.test.crt', 'gulogulo.test.key'], 'LP1_CA_ARTIFACTS_INVALID', 'caArtifacts'); + assertExactList(input.requiredLabels, [ + 'com.sythos.gulogulo.milestone', + 'com.sythos.gulogulo.proof', + 'com.sythos.gulogulo.network-policy', + ], 'LP1_LABELS_INVALID', 'requiredLabels'); + + if (!Array.isArray(input.hostBindings) || input.hostBindings.length !== 1 || + !/^127\.0\.0\.1:[0-9]+->8080\/tcp$/.test(input.hostBindings[0])) { + throw topologyError('LP1_HOST_BINDING_INVALID', 'LP1 must publish only the application on loopback.'); + } + + if (!Array.isArray(input.services) || input.services.length !== REQUIRED_SERVICES.length || + input.services.some((service, index) => service?.name !== REQUIRED_SERVICES[index])) { + throw topologyError('LP1_SERVICES_INVALID', 'LP1 services do not match the frozen topology inventory.'); + } + for (const service of input.services) { + assertPlainObject(service, 'service'); + assertSafeName(service.name, 'LP1_SERVICE_NAME_INVALID', 'service.name'); + if (typeof service.role !== 'string' || service.role.length === 0 || typeof service.health !== 'string' || service.health.length === 0) { + throw topologyError('LP1_SERVICE_METADATA_INVALID', 'Every LP1 service requires a role and health description.'); + } + } + + assertExact(input.spdxLicenseIdentifier, 'MIT', 'LP1_LICENSE_INVALID', 'spdxLicenseIdentifier'); + assertExact(input.spdxFileCopyrightText, '2026 Sythos (https://www.sythos.net)', 'LP1_METADATA_INVALID', 'spdxFileCopyrightText'); + assertExact(input.author, 'Sythos (https://www.sythos.net)', 'LP1_METADATA_INVALID', 'author'); + + return Object.freeze({ + ...input, + hostBindings: Object.freeze([...input.hostBindings]), + localNames: Object.freeze([...input.localNames]), + services: Object.freeze(input.services.map((service) => Object.freeze({ ...service }))), + volumes: Object.freeze([...input.volumes]), + caArtifacts: Object.freeze([...input.caArtifacts]), + requiredLabels: Object.freeze([...input.requiredLabels]), + }); +} + +export { LOCAL_NAMES, REQUIRED_SERVICES, REQUIRED_VOLUMES }; diff --git a/src/release/local-proof-topology.test.mjs b/src/release/local-proof-topology.test.mjs new file mode 100644 index 0000000..b4f9624 --- /dev/null +++ b/src/release/local-proof-topology.test.mjs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createLocalProofTopology } from './local-proof-topology.mjs'; + +const VALID_TOPOLOGY = { + spdxLicenseIdentifier: 'MIT', + spdxFileCopyrightText: '2026 Sythos (https://www.sythos.net)', + author: 'Sythos (https://www.sythos.net)', + schemaVersion: 1, + milestone: 'LP1', + proofType: 'local', + networkPolicy: 'offline_runtime', + networkName: 'gulogulo-local-proof', + internalNetwork: true, + syntheticDataOnly: true, + publicDnsRequired: false, + publicAcmeEnabled: false, + hostNetwork: false, + dockerSocketMounted: false, + externalVolumesMode: 'named_external_capable', + hostBindings: ['127.0.0.1:18080->8080/tcp'], + localNames: ['gulogulo.test', 'webmail.localhost', 'calendar.localhost', 'contacts.localhost'], + services: [ + { name: 'gulogulo-proof', role: 'application', health: 'GET /health/ready' }, + { name: 'local-ca', role: 'disposable certificate authority', health: 'ca.crt and gulogulo.test.crt exist' }, + { name: 'local-dns', role: 'reserved-name resolver', health: 'dnsmasq configuration check' }, + ], + volumes: ['lp1-ca-data', 'lp1-runtime-state', 'lp1-mail-data', 'lp1-dav-data', 'lp1-backup-data', 'lp1-proof-state'], + caArtifacts: ['ca.crt', 'gulogulo.test.crt', 'gulogulo.test.key'], + requiredLabels: ['com.sythos.gulogulo.milestone', 'com.sythos.gulogulo.proof', 'com.sythos.gulogulo.network-policy'], + status: 'frozen', +}; + +test('LP1 accepts the frozen internal topology and loopback binding', () => { + const topology = createLocalProofTopology(VALID_TOPOLOGY); + + assert.equal(topology.networkPolicy, 'offline_runtime'); + assert.equal(topology.internalNetwork, true); + assert.equal(topology.hostBindings[0], '127.0.0.1:18080->8080/tcp'); + assert(Object.isFrozen(topology)); + assert(Object.isFrozen(topology.services[0])); +}); + +test('LP1 rejects public network, host network, and Docker socket claims', () => { + assert.throws( + () => createLocalProofTopology({ ...VALID_TOPOLOGY, internalNetwork: false }), + (error) => error.code === 'LP1_NETWORK_INVALID', + ); + assert.throws( + () => createLocalProofTopology({ ...VALID_TOPOLOGY, hostNetwork: true }), + (error) => error.code === 'LP1_HOST_NETWORK_INVALID', + ); + assert.throws( + () => createLocalProofTopology({ ...VALID_TOPOLOGY, dockerSocketMounted: true }), + (error) => error.code === 'LP1_SOCKET_INVALID', + ); +}); + +test('LP1 rejects public host bindings and incomplete volumes', () => { + assert.throws( + () => createLocalProofTopology({ ...VALID_TOPOLOGY, hostBindings: ['0.0.0.0:18080->8080/tcp'] }), + (error) => error.code === 'LP1_HOST_BINDING_INVALID', + ); + assert.throws( + () => createLocalProofTopology({ ...VALID_TOPOLOGY, volumes: VALID_TOPOLOGY.volumes.slice(1) }), + (error) => error.code === 'LP1_VOLUMES_INVALID', + ); +}); From 8c2ae843a8c67160c92a4da8fe616f8dd7362a16 Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 18:34:46 +0200 Subject: [PATCH 2/9] fix: use LP1 utility build context in CI --- .github/workflows/quality-gates.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 659b7d3..d5a9395 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -387,7 +387,7 @@ jobs: --tag gulogulo:ci-lp1-network-ubuntu-26.04 \ --file docker/lp1-network/Dockerfile \ --output "type=oci,dest=$output" \ - . + docker/lp1-network test -s "$output" - name: Run Compose test profile From 65e5630aa57305c0a8532c4b1b1f0e9ed9446ec6 Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 18:44:18 +0200 Subject: [PATCH 3/9] fix: keep LP1 certificate checks non-root --- compose.yaml | 2 +- doc/local-proof-topology.md | 7 +++++++ docker/lp1-network/entrypoint-ca.sh | 10 +++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index ad061ab..af5005f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -213,7 +213,7 @@ services: context: docker/lp1-network dockerfile: Dockerfile profiles: ["proof"] - user: "0:0" + user: "0:10001" command: ["/usr/local/sbin/gulogulo-lp1-ca"] environment: LP1_CA_DIR: /run/gulogulo-ca diff --git a/doc/local-proof-topology.md b/doc/local-proof-topology.md index 9003e33..4dc1b4f 100644 --- a/doc/local-proof-topology.md +++ b/doc/local-proof-topology.md @@ -109,6 +109,13 @@ will use the same trust boundary when an HTTPS web/DAV endpoint is added. The LP1 HTTP health endpoint remains plain HTTP inside the internal network because there is no reverse proxy in this milestone. +The CA service runs with UID 0 but Gulo Gulo's runtime GID `10001`. Its signing +key remains mode `0600` and is not readable by the proof client. The CA +certificate, leaf certificate, and synthetic leaf key are mode `0640` on the +shared volume and the proof client mounts that volume read-only as the +non-root `gulogulo` user. This keeps the check executable without granting the +application or checker access to the CA signing key. + ## Security and non-goals - Runtime containers have no Internet egress through the internal network. diff --git a/docker/lp1-network/entrypoint-ca.sh b/docker/lp1-network/entrypoint-ca.sh index cf685e3..a1765b4 100644 --- a/docker/lp1-network/entrypoint-ca.sh +++ b/docker/lp1-network/entrypoint-ca.sh @@ -7,7 +7,11 @@ set -euo pipefail ca_dir="${LP1_CA_DIR:-/run/gulogulo-ca}" mkdir -p "$ca_dir" -chmod 0700 "$ca_dir" +# The application-side proof client runs as Gulo Gulo's non-root UID/GID +# (10001). Only the public CA/leaf certificates and the synthetic leaf key are +# group-readable; the CA signing key remains root-only. +umask 0007 +chmod 0750 "$ca_dir" if [[ ! -s "$ca_dir/ca.key" || ! -s "$ca_dir/ca.crt" ]]; then rm -f "$ca_dir"/ca.key "$ca_dir"/ca.crt @@ -48,8 +52,8 @@ EOF rm -f "$ca_dir/gulogulo.test.csr" "$ca_dir/gulogulo.test.ext" "$ca_dir/ca.srl" fi -chmod 0600 "$ca_dir/ca.key" "$ca_dir/gulogulo.test.key" -chmod 0644 "$ca_dir/ca.crt" "$ca_dir/gulogulo.test.crt" +chmod 0600 "$ca_dir/ca.key" +chmod 0640 "$ca_dir/ca.crt" "$ca_dir/gulogulo.test.crt" "$ca_dir/gulogulo.test.key" echo 'gulogulo-lp1-ca-ready' trap 'exit 0' TERM INT From 4ee47d99b97e6cd026c00e6b5d771006c8763fff Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 21:29:18 +0200 Subject: [PATCH 4/9] fix: grant LP1 CA ownership handoff capability --- compose.yaml | 2 ++ doc/local-proof-topology.md | 5 +++-- docker/lp1-network/entrypoint-ca.sh | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/compose.yaml b/compose.yaml index af5005f..0f3e365 100644 --- a/compose.yaml +++ b/compose.yaml @@ -230,6 +230,8 @@ services: - no-new-privileges:true cap_drop: - ALL + cap_add: + - CHOWN init: true restart: "no" healthcheck: diff --git a/doc/local-proof-topology.md b/doc/local-proof-topology.md index 4dc1b4f..a2ee685 100644 --- a/doc/local-proof-topology.md +++ b/doc/local-proof-topology.md @@ -109,8 +109,9 @@ will use the same trust boundary when an HTTPS web/DAV endpoint is added. The LP1 HTTP health endpoint remains plain HTTP inside the internal network because there is no reverse proxy in this milestone. -The CA service runs with UID 0 but Gulo Gulo's runtime GID `10001`. Its signing -key remains mode `0600` and is not readable by the proof client. The CA +The CA service runs with UID 0 but Gulo Gulo's runtime GID `10001` and has only +the narrowly scoped `CAP_CHOWN` capability needed for the ownership handoff. +Its signing key remains mode `0600` and is not readable by the proof client. The CA certificate, leaf certificate, and synthetic leaf key are mode `0640` on the shared volume and the proof client mounts that volume read-only as the non-root `gulogulo` user. This keeps the check executable without granting the diff --git a/docker/lp1-network/entrypoint-ca.sh b/docker/lp1-network/entrypoint-ca.sh index a1765b4..e2cd253 100644 --- a/docker/lp1-network/entrypoint-ca.sh +++ b/docker/lp1-network/entrypoint-ca.sh @@ -52,6 +52,10 @@ EOF rm -f "$ca_dir/gulogulo.test.csr" "$ca_dir/gulogulo.test.ext" "$ca_dir/ca.srl" fi +# The CA service is the only writer and is granted CAP_CHOWN by Compose solely +# to hand the verifier the explicitly shared artifacts. The signing key stays +# root-owned and root-readable. +chown 0:10001 "$ca_dir" "$ca_dir/ca.crt" "$ca_dir/gulogulo.test.crt" "$ca_dir/gulogulo.test.key" chmod 0600 "$ca_dir/ca.key" chmod 0640 "$ca_dir/ca.crt" "$ca_dir/gulogulo.test.crt" "$ca_dir/gulogulo.test.key" From c0388b5599e70bb5ad64ca79ec6d020da6da774a Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 21:40:46 +0200 Subject: [PATCH 5/9] fix: make LP1 local DNS probing deterministic --- docker/lp1-network/entrypoint-dns.sh | 1 + scripts/lp1-proof-check.mjs | 23 +++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docker/lp1-network/entrypoint-dns.sh b/docker/lp1-network/entrypoint-dns.sh index 839e155..61f2596 100644 --- a/docker/lp1-network/entrypoint-dns.sh +++ b/docker/lp1-network/entrypoint-dns.sh @@ -13,6 +13,7 @@ exec dnsmasq \ --no-resolv \ --no-hosts \ --listen-address=0.0.0.0 \ + --bind-interfaces \ --port="$dns_port" \ --address=/gulogulo.test/127.0.0.1 \ --address=/webmail.localhost/127.0.0.1 \ diff --git a/scripts/lp1-proof-check.mjs b/scripts/lp1-proof-check.mjs index 86b3b0d..5c04a6c 100644 --- a/scripts/lp1-proof-check.mjs +++ b/scripts/lp1-proof-check.mjs @@ -5,6 +5,7 @@ import { readFile, rename, writeFile } from 'node:fs/promises'; import { lookup } from 'node:dns/promises'; import { Resolver } from 'node:dns/promises'; +import { setTimeout as delay } from 'node:timers/promises'; import { join, resolve } from 'node:path'; import { X509Certificate } from 'node:crypto'; import { createSecureContext } from 'node:tls'; @@ -48,10 +49,28 @@ requireCondition(leafKeyBytes.length > 0, 'the leaf private key is empty'); requireCondition(process.env.NODE_EXTRA_CA_CERTS === join(caDir, 'ca.crt'), 'the test client did not install the local CA trust path'); createSecureContext({ ca: caBytes }); -const dnsAddress = (await lookup(dnsService)).address; +// Docker service discovery can expose both address families when IPv6 is +// enabled. The disposable dnsmasq proof service deliberately binds IPv4 only, +// so select an IPv4 endpoint explicitly instead of relying on resolver order. +const dnsAddress = (await lookup(dnsService, { family: 4 })).address; const resolver = new Resolver(); resolver.setServers([`${dnsAddress}:${dnsPort}`]); -const answers = await resolver.resolve4('gulogulo.test'); +let answers; +let lastDnsError; +for (let attempt = 1; attempt <= 5; attempt += 1) { + try { + answers = await resolver.resolve4('gulogulo.test'); + break; + } catch (error) { + lastDnsError = error; + if (attempt < 5) await delay(attempt * 250); + } +} +if (!answers) { + throw new Error(`LP1 local DNS query failed for gulogulo.test via ${dnsAddress}:${dnsPort}`, { + cause: lastDnsError, + }); +} requireCondition(answers.length > 0 && answers.every((address) => address === '127.0.0.1'), 'reserved DNS names escaped the loopback-only answer policy'); const response = await fetch(`http://${applicationService}:8080/health/ready`, { From b98d2579dde84c4d4e9ee5fb313bd192c8205a67 Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 21:54:50 +0200 Subject: [PATCH 6/9] feat: add dual-stack LP1 local proof --- .env.example | 1 + .github/workflows/quality-gates.yml | 2 +- compose.yaml | 13 +++++ doc/README.md | 8 +-- doc/local-proof-scope.md | 4 +- doc/local-proof-topology.md | 25 ++++---- docker/lp1-network/entrypoint-dns.sh | 6 ++ release/local-proof-topology.json | 7 ++- scripts/lp1-compose-audit.mjs | 10 +++- scripts/lp1-proof-check.mjs | 71 ++++++++++++++--------- scripts/lp1-proof-smoke.mjs | 9 ++- src/release/local-proof-topology.mjs | 8 ++- src/release/local-proof-topology.test.mjs | 8 ++- 13 files changed, 117 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index 377e629..eb36484 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ GULOGULO_VOLUMES_EXTERNAL=false GULOGULO_LP1_VOLUMES_EXTERNAL=false GULOGULO_PROOF_NETWORK=gulogulo-local-proof GULOGULO_PROOF_HTTP_PORT=18080 +GULOGULO_PROOF_APP_HOST=:: LP1_DNS_PORT=5353 HOST=0.0.0.0 PORT=8080 diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index d5a9395..2d8a1fa 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -305,7 +305,7 @@ jobs: test -f package-lock.json node -e "const e=require('./release/v1-release-evidence.template.json'); if (e.spdxLicenseIdentifier !== 'MIT' || e.author !== 'Sythos (https://www.sythos.net)' || e.product !== 'Gulo Gulo') process.exit(1)" node -e "const s=require('./release/local-proof-scope.json'); if (s.spdxLicenseIdentifier !== 'MIT' || s.author !== 'Sythos (https://www.sythos.net)' || s.proofType !== 'local' || s.networkPolicy !== 'offline_runtime' || s.syntheticDataOnly !== true || s.publicDnsRequired !== false || s.publicAcmeEnabled !== false || s.externalPhaseDeferred !== true) process.exit(1)" - node -e "const t=require('./release/local-proof-topology.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP1' || t.networkPolicy !== 'offline_runtime' || t.internalNetwork !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.dockerSocketMounted !== false) process.exit(1)" + node -e "const t=require('./release/local-proof-topology.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP1' || t.networkPolicy !== 'offline_runtime' || t.internalNetwork !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.dockerSocketMounted !== false) process.exit(1)" bash -n docker/lp1-network/entrypoint-ca.sh bash -n docker/lp1-network/entrypoint-dns.sh diff --git a/compose.yaml b/compose.yaml index 0f3e365..4e8d0fd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -184,8 +184,11 @@ services: profiles: ["proof"] ports: - "127.0.0.1:${GULOGULO_PROOF_HTTP_PORT:-18080}:8080" + - "[::1]:${GULOGULO_PROOF_HTTP_PORT:-18080}:8080" environment: <<: *gulogulo-environment + HOST: "${GULOGULO_PROOF_APP_HOST:-::}" + GULOGULO_HOST: "${GULOGULO_PROOF_APP_HOST:-::}" APP_ENV: local-proof GULOGULO_ENV: local-proof GULOGULO_FIXTURE_MODE: "true" @@ -203,6 +206,12 @@ services: - lp1-mail-data:/var/lib/gulogulo/mail - lp1-dav-data:/var/lib/gulogulo/dav - lp1-backup-data:/var/lib/gulogulo/backups + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://[::1]:'+(process.env.PORT||8080)+'/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" labels: com.sythos.gulogulo.milestone: LP1 com.sythos.gulogulo.proof: local @@ -354,3 +363,7 @@ networks: proof-runtime: name: ${GULOGULO_PROOF_NETWORK:-gulogulo-local-proof} internal: true + enable_ipv6: true + ipam: + config: + - subnet: fd42:4755:756c:7031::/64 diff --git a/doc/README.md b/doc/README.md index 022c8e6..be42be5 100644 --- a/doc/README.md +++ b/doc/README.md @@ -57,10 +57,10 @@ normal workflow. - [LP0 local proof scope](local-proof-scope.md) — the reserved local names, synthetic-data rule, offline runtime boundary, disposable service inventory, architecture targets, and the explicit deferral of the external phase. -- [LP1 isolated local topology](local-proof-topology.md) — the private Compose - network, disposable CA/DNS utilities, loopback-only application binding, - external-capable named volumes, restart continuity check, and Docker proof - harness. +- [LP1 isolated local topology](local-proof-topology.md) — the private dual-stack + Compose network, disposable CA/DNS utilities, IPv4/IPv6 loopback-only + application bindings, external-capable named volumes, restart continuity + check, and Docker proof harness. The project is still intentionally small. The documents describe real behavior only: DAV, administration, lifecycle, backup, observability, ACME, abuse, and diff --git a/doc/local-proof-scope.md b/doc/local-proof-scope.md index debc9bf..2aee6f8 100644 --- a/doc/local-proof-scope.md +++ b/doc/local-proof-scope.md @@ -37,8 +37,8 @@ validator and its tests live in `src/release/local-proof-scope.mjs` and replaced with a real public domain during LP1–LP9. - TLS is terminated with a locally trusted CA generated for the proof. Public Let's Encrypt and generic public ACME are explicitly disabled here. -- Host-file or local DNS entries may point these names at loopback or the - private Compose/Kubernetes test network. No public DNS record is created. +- Host-file or local DNS entries may point these names at IPv4/IPv6 loopback or + the private Compose/Kubernetes test network. No public DNS record is created. ### Data and identities diff --git a/doc/local-proof-topology.md b/doc/local-proof-topology.md index a2ee685..d9e2080 100644 --- a/doc/local-proof-topology.md +++ b/doc/local-proof-topology.md @@ -25,20 +25,23 @@ The `proof` profile creates three long-running services on the named | Service | Purpose | Runtime boundary | |---|---|---| -| `gulogulo-proof` | The existing non-root Gulo Gulo runtime | Published only on `127.0.0.1:18080`; read-only root filesystem; LP1 external-capable named volumes | +| `gulogulo-proof` | The existing non-root Gulo Gulo runtime | Published only on IPv4 `127.0.0.1:18080` and IPv6 `[::1]:18080`; read-only root filesystem; LP1 external-capable named volumes | | `local-ca` | Disposable seven-day CA and leaf certificate generator | Writes only to `lp1-ca-data`; no host port; synthetic keys are destroyed with the project by default | -| `local-dns` | `dnsmasq` responder on port 5353 | Answers the four reserved names only with `127.0.0.1`; has no upstream resolver and no Internet egress | +| `local-dns` | `dnsmasq` responder on port 5353 | Listens on IPv4 and IPv6 and answers the four reserved names only with `127.0.0.1` and `::1`; has no upstream resolver and no Internet egress | -The network is declared `internal: true`. The application also receives Docker -network aliases for `gulogulo.test`, `webmail.localhost`, +The network is declared `internal: true` with IPv6 enabled. The application +listens on the dual-stack wildcard address and receives Docker network aliases +for `gulogulo.test`, `webmail.localhost`, `calendar.localhost`, and `contacts.localhost`; the proof client queries the -dedicated `local-dns` responder as an independent check. +both IPv4 and IPv6 endpoints of the dedicated `local-dns` responder as an +independent check. The `proof-check` profile is on-demand. Its Node.js client installs the CA via `NODE_EXTRA_CA_CERTS`, verifies the CA/leaf chain and SAN, resolves the reserved -name through the local DNS service, checks `/health/ready`, and persists a -small restart marker. The smoke harness runs that client before and after an -application restart, proving that the marker volume survives replacement. +name through both local DNS address families, checks `/health/ready` through +both application address families, and persists a small restart marker. The +smoke harness runs that client before and after an application restart, proving +that the marker volume survives replacement. ## Volumes and lifecycle @@ -68,8 +71,8 @@ npm run test:lp1 ``` It validates the topology manifest and checks that `compose.yaml` contains the -internal network, proof services, loopback-only binding, labels, named volumes, -and no Docker-socket, host-network, or privileged marker. +internal dual-stack network, proof services, IPv4/IPv6 loopback-only bindings, +labels, named volumes, and no Docker-socket, host-network, or privileged marker. ## Run the live local proof @@ -127,5 +130,5 @@ application or checker access to the CA signing key. - No Docker socket, host network, privileged flag, or arbitrary shell control is exposed. - A green LP1 rehearsal proves topology isolation, health, trust material, - reserved-name resolution, and volume continuity only. It is not production + dual-stack reserved-name resolution, and volume continuity only. It is not production readiness or evidence of a complete mail provider. diff --git a/docker/lp1-network/entrypoint-dns.sh b/docker/lp1-network/entrypoint-dns.sh index 61f2596..4520525 100644 --- a/docker/lp1-network/entrypoint-dns.sh +++ b/docker/lp1-network/entrypoint-dns.sh @@ -13,13 +13,19 @@ exec dnsmasq \ --no-resolv \ --no-hosts \ --listen-address=0.0.0.0 \ + --listen-address=:: \ --bind-interfaces \ --port="$dns_port" \ --address=/gulogulo.test/127.0.0.1 \ + --address=/gulogulo.test/::1 \ --address=/webmail.localhost/127.0.0.1 \ + --address=/webmail.localhost/::1 \ --address=/calendar.localhost/127.0.0.1 \ + --address=/calendar.localhost/::1 \ --address=/contacts.localhost/127.0.0.1 \ + --address=/contacts.localhost/::1 \ --address=/.localhost/127.0.0.1 \ + --address=/.localhost/::1 \ --log-facility=- \ --user=nobody \ --group=nogroup diff --git a/release/local-proof-topology.json b/release/local-proof-topology.json index beae94d..fe972fc 100644 --- a/release/local-proof-topology.json +++ b/release/local-proof-topology.json @@ -15,8 +15,13 @@ "hostNetwork": false, "dockerSocketMounted": false, "externalVolumesMode": "named_external_capable", + "ipFamilies": [ + "ipv4", + "ipv6" + ], "hostBindings": [ - "127.0.0.1:18080->8080/tcp" + "127.0.0.1:18080->8080/tcp", + "[::1]:18080->8080/tcp" ], "localNames": [ "gulogulo.test", diff --git a/scripts/lp1-compose-audit.mjs b/scripts/lp1-compose-audit.mjs index 81a5eb3..d904cee 100644 --- a/scripts/lp1-compose-audit.mjs +++ b/scripts/lp1-compose-audit.mjs @@ -8,8 +8,11 @@ import { resolve } from 'node:path'; import { createLocalProofTopology } from '../src/release/local-proof-topology.mjs'; const composePath = resolve(process.cwd(), 'compose.yaml'); +const dnsEntrypointPath = resolve(process.cwd(), 'docker/lp1-network/entrypoint-dns.sh'); const manifestPath = resolve(process.cwd(), 'release/local-proof-topology.json'); const compose = await readFile(composePath, 'utf8'); +const dnsEntrypoint = await readFile(dnsEntrypointPath, 'utf8'); +const topologySource = `${compose}\n${dnsEntrypoint}`; const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); const topology = createLocalProofTopology(manifest); @@ -21,6 +24,10 @@ const requiredMarkers = [ 'profiles: ["proof"]', 'internal: true', '127.0.0.1:${GULOGULO_PROOF_HTTP_PORT:-18080}:8080', + '[::1]:${GULOGULO_PROOF_HTTP_PORT:-18080}:8080', + 'enable_ipv6: true', + '--listen-address=::', + '--address=/gulogulo.test/::1', 'lp1-ca-data:', 'lp1-runtime-state:', 'lp1-mail-data:', @@ -32,7 +39,7 @@ const requiredMarkers = [ ]; for (const marker of requiredMarkers) { - if (!compose.includes(marker)) { + if (!topologySource.includes(marker)) { throw new Error(`LP1 Compose marker is missing: ${marker}`); } } @@ -47,6 +54,7 @@ console.log(JSON.stringify({ networkPolicy: topology.networkPolicy, networkName: topology.networkName, internalNetwork: topology.internalNetwork, + ipFamilies: topology.ipFamilies, localNames: topology.localNames, services: topology.services.map(({ name, role }) => ({ name, role })), hostBindings: topology.hostBindings, diff --git a/scripts/lp1-proof-check.mjs b/scripts/lp1-proof-check.mjs index 5c04a6c..c39f1f9 100644 --- a/scripts/lp1-proof-check.mjs +++ b/scripts/lp1-proof-check.mjs @@ -5,7 +5,6 @@ import { readFile, rename, writeFile } from 'node:fs/promises'; import { lookup } from 'node:dns/promises'; import { Resolver } from 'node:dns/promises'; -import { setTimeout as delay } from 'node:timers/promises'; import { join, resolve } from 'node:path'; import { X509Certificate } from 'node:crypto'; import { createSecureContext } from 'node:tls'; @@ -49,36 +48,54 @@ requireCondition(leafKeyBytes.length > 0, 'the leaf private key is empty'); requireCondition(process.env.NODE_EXTRA_CA_CERTS === join(caDir, 'ca.crt'), 'the test client did not install the local CA trust path'); createSecureContext({ ca: caBytes }); -// Docker service discovery can expose both address families when IPv6 is -// enabled. The disposable dnsmasq proof service deliberately binds IPv4 only, -// so select an IPv4 endpoint explicitly instead of relying on resolver order. -const dnsAddress = (await lookup(dnsService, { family: 4 })).address; -const resolver = new Resolver(); -resolver.setServers([`${dnsAddress}:${dnsPort}`]); -let answers; -let lastDnsError; -for (let attempt = 1; attempt <= 5; attempt += 1) { +// Docker service discovery exposes both address families when the proof +// network has IPv6 enabled. Query the disposable dnsmasq service over every +// advertised endpoint and keep the loopback answer policy explicit. +const dnsAddresses = (await lookup(dnsService, { all: true })).map(({ address }) => address); +requireCondition(dnsAddresses.some((address) => !address.includes(':')), 'the local DNS service has no IPv4 endpoint'); +requireCondition(dnsAddresses.some((address) => address.includes(':')), 'the local DNS service has no IPv6 endpoint'); + +const dnsAnswers = { ipv4: new Set(), ipv6: new Set() }; +const dnsErrors = []; +for (const dnsAddress of dnsAddresses) { + const dnsServer = dnsAddress.includes(':') ? `[${dnsAddress}]:${dnsPort}` : `${dnsAddress}:${dnsPort}`; + const resolver = new Resolver(); + resolver.setServers([dnsServer]); try { - answers = await resolver.resolve4('gulogulo.test'); - break; + for (const address of await resolver.resolve4('gulogulo.test')) dnsAnswers.ipv4.add(address); } catch (error) { - lastDnsError = error; - if (attempt < 5) await delay(attempt * 250); + dnsErrors.push(`${dnsServer}/A: ${error.message}`); + } + try { + for (const address of await resolver.resolve6('gulogulo.test')) dnsAnswers.ipv6.add(address); + } catch (error) { + dnsErrors.push(`${dnsServer}/AAAA: ${error.message}`); } } -if (!answers) { - throw new Error(`LP1 local DNS query failed for gulogulo.test via ${dnsAddress}:${dnsPort}`, { - cause: lastDnsError, - }); + +const ipv4Answers = [...dnsAnswers.ipv4]; +const ipv6Answers = [...dnsAnswers.ipv6]; +if (ipv4Answers.length === 0 || ipv6Answers.length === 0) { + throw new Error(`LP1 local DNS dual-stack query failed: ${dnsErrors.join('; ')}`); } -requireCondition(answers.length > 0 && answers.every((address) => address === '127.0.0.1'), 'reserved DNS names escaped the loopback-only answer policy'); +requireCondition(ipv4Answers.every((address) => address === '127.0.0.1'), 'reserved IPv4 DNS names escaped the loopback-only answer policy'); +requireCondition(ipv6Answers.every((address) => address === '::1'), 'reserved IPv6 DNS names escaped the loopback-only answer policy'); + +const applicationAddresses = (await lookup(applicationService, { all: true })).map(({ address, family }) => ({ address, family })); +requireCondition(applicationAddresses.some(({ family }) => family === 4), 'the application service has no IPv4 endpoint'); +requireCondition(applicationAddresses.some(({ family }) => family === 6), 'the application service has no IPv6 endpoint'); -const response = await fetch(`http://${applicationService}:8080/health/ready`, { - signal: AbortSignal.timeout(5000), -}); -requireCondition(response.ok, `the application readiness endpoint returned HTTP ${response.status}`); -const readiness = await response.json(); -requireCondition(readiness.status === 'ready', 'the application readiness payload is not ready'); +let readiness; +for (const { address, family } of applicationAddresses) { + const host = family === 6 ? `[${address}]` : address; + const response = await fetch(`http://${host}:8080/health/ready`, { + signal: AbortSignal.timeout(5000), + }); + requireCondition(response.ok, `the application readiness endpoint returned HTTP ${response.status}`); + const currentReadiness = await response.json(); + requireCondition(currentReadiness.status === 'ready', 'the application readiness payload is not ready'); + readiness = currentReadiness; +} let previous = null; try { @@ -111,7 +128,9 @@ console.log(JSON.stringify({ restartObserved: marker.restartObserved, caSubject: caCertificate.subject, leafSubject: leafCertificate.subject, - dnsAnswers: answers, + ipFamilies: ['ipv4', 'ipv6'], + dnsAnswers: { ipv4: ipv4Answers, ipv6: ipv6Answers }, + applicationAddresses, readiness: readiness.status, networkPolicy: marker.networkPolicy, syntheticDataOnly: marker.syntheticDataOnly, diff --git a/scripts/lp1-proof-smoke.mjs b/scripts/lp1-proof-smoke.mjs index d8519af..87af2a2 100644 --- a/scripts/lp1-proof-smoke.mjs +++ b/scripts/lp1-proof-smoke.mjs @@ -102,8 +102,11 @@ try { } const applicationDetails = inspectContainer(application); const bindings = applicationDetails.NetworkSettings?.Ports?.['8080/tcp'] || []; - if (bindings.some((binding) => binding.HostIp !== '127.0.0.1')) { - throw new Error('LP1 application has a non-loopback host binding.'); + const loopbackBindings = new Map(bindings.map((binding) => [binding.HostIp, binding.HostPort])); + const ipv6LoopbackAliases = new Set(['::1', '0:0:0:0:0:0:0:1']); + if (bindings.length !== 2 || loopbackBindings.get('127.0.0.1') !== environment.GULOGULO_PROOF_HTTP_PORT || + ![...ipv6LoopbackAliases].some((address) => loopbackBindings.get(address) === environment.GULOGULO_PROOF_HTTP_PORT)) { + throw new Error('LP1 application must expose exactly IPv4 and IPv6 loopback bindings.'); } console.log(JSON.stringify({ @@ -115,7 +118,7 @@ try { localCaHealth: true, localDnsHealth: true, applicationHealth: true, - hostBindingPolicy: 'loopback_only', + hostBindingPolicy: 'dual_stack_loopback_only', dockerSocketMounted: false, }, null, 2)); } catch (error) { diff --git a/src/release/local-proof-topology.mjs b/src/release/local-proof-topology.mjs index 2054652..860d09c 100644 --- a/src/release/local-proof-topology.mjs +++ b/src/release/local-proof-topology.mjs @@ -78,6 +78,7 @@ export function createLocalProofTopology(input) { assertExact(input.hostNetwork, false, 'LP1_HOST_NETWORK_INVALID', 'hostNetwork'); assertExact(input.dockerSocketMounted, false, 'LP1_SOCKET_INVALID', 'dockerSocketMounted'); assertExact(input.externalVolumesMode, 'named_external_capable', 'LP1_VOLUME_POLICY_INVALID', 'externalVolumesMode'); + assertExactList(input.ipFamilies, ['ipv4', 'ipv6'], 'LP1_IP_FAMILIES_INVALID', 'ipFamilies'); assertExact(input.status, 'frozen', 'LP1_STATUS_INVALID', 'status'); assertSafeName(input.networkName, 'LP1_NETWORK_NAME_INVALID', 'networkName'); @@ -90,9 +91,10 @@ export function createLocalProofTopology(input) { 'com.sythos.gulogulo.network-policy', ], 'LP1_LABELS_INVALID', 'requiredLabels'); - if (!Array.isArray(input.hostBindings) || input.hostBindings.length !== 1 || - !/^127\.0\.0\.1:[0-9]+->8080\/tcp$/.test(input.hostBindings[0])) { - throw topologyError('LP1_HOST_BINDING_INVALID', 'LP1 must publish only the application on loopback.'); + if (!Array.isArray(input.hostBindings) || input.hostBindings.length !== 2 || + !/^127\.0\.0\.1:[0-9]+->8080\/tcp$/.test(input.hostBindings[0]) || + !/^\[::1\]:[0-9]+->8080\/tcp$/.test(input.hostBindings[1])) { + throw topologyError('LP1_HOST_BINDING_INVALID', 'LP1 must publish the application on IPv4 and IPv6 loopback only.'); } if (!Array.isArray(input.services) || input.services.length !== REQUIRED_SERVICES.length || diff --git a/src/release/local-proof-topology.test.mjs b/src/release/local-proof-topology.test.mjs index b4f9624..64750e5 100644 --- a/src/release/local-proof-topology.test.mjs +++ b/src/release/local-proof-topology.test.mjs @@ -23,7 +23,8 @@ const VALID_TOPOLOGY = { hostNetwork: false, dockerSocketMounted: false, externalVolumesMode: 'named_external_capable', - hostBindings: ['127.0.0.1:18080->8080/tcp'], + ipFamilies: ['ipv4', 'ipv6'], + hostBindings: ['127.0.0.1:18080->8080/tcp', '[::1]:18080->8080/tcp'], localNames: ['gulogulo.test', 'webmail.localhost', 'calendar.localhost', 'contacts.localhost'], services: [ { name: 'gulogulo-proof', role: 'application', health: 'GET /health/ready' }, @@ -36,12 +37,13 @@ const VALID_TOPOLOGY = { status: 'frozen', }; -test('LP1 accepts the frozen internal topology and loopback binding', () => { +test('LP1 accepts the frozen internal dual-stack topology and loopback bindings', () => { const topology = createLocalProofTopology(VALID_TOPOLOGY); assert.equal(topology.networkPolicy, 'offline_runtime'); assert.equal(topology.internalNetwork, true); assert.equal(topology.hostBindings[0], '127.0.0.1:18080->8080/tcp'); + assert.equal(topology.hostBindings[1], '[::1]:18080->8080/tcp'); assert(Object.isFrozen(topology)); assert(Object.isFrozen(topology.services[0])); }); @@ -63,7 +65,7 @@ test('LP1 rejects public network, host network, and Docker socket claims', () => test('LP1 rejects public host bindings and incomplete volumes', () => { assert.throws( - () => createLocalProofTopology({ ...VALID_TOPOLOGY, hostBindings: ['0.0.0.0:18080->8080/tcp'] }), + () => createLocalProofTopology({ ...VALID_TOPOLOGY, hostBindings: ['0.0.0.0:18080->8080/tcp', '[::1]:18080->8080/tcp'] }), (error) => error.code === 'LP1_HOST_BINDING_INVALID', ); assert.throws( From ec10d486b4e2c5c487f83671a84d6a8ba7b49434 Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 22:03:29 +0200 Subject: [PATCH 7/9] test: report LP1 published bindings --- scripts/lp1-proof-smoke.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lp1-proof-smoke.mjs b/scripts/lp1-proof-smoke.mjs index 87af2a2..69cd920 100644 --- a/scripts/lp1-proof-smoke.mjs +++ b/scripts/lp1-proof-smoke.mjs @@ -106,7 +106,7 @@ try { const ipv6LoopbackAliases = new Set(['::1', '0:0:0:0:0:0:0:1']); if (bindings.length !== 2 || loopbackBindings.get('127.0.0.1') !== environment.GULOGULO_PROOF_HTTP_PORT || ![...ipv6LoopbackAliases].some((address) => loopbackBindings.get(address) === environment.GULOGULO_PROOF_HTTP_PORT)) { - throw new Error('LP1 application must expose exactly IPv4 and IPv6 loopback bindings.'); + throw new Error(`LP1 application must expose exactly IPv4 and IPv6 loopback bindings; observed ${JSON.stringify(bindings)}`); } console.log(JSON.stringify({ From 3eec7dff1d72f74cfef0e39e44d5e8a38643316f Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 22:15:26 +0200 Subject: [PATCH 8/9] fix: inspect LP1 loopback bindings across Docker engines --- scripts/lp1-proof-smoke.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/lp1-proof-smoke.mjs b/scripts/lp1-proof-smoke.mjs index 69cd920..80b67f2 100644 --- a/scripts/lp1-proof-smoke.mjs +++ b/scripts/lp1-proof-smoke.mjs @@ -101,12 +101,19 @@ try { assertSafeContainer(inspectContainer(serviceContainer(service)), service); } const applicationDetails = inspectContainer(application); - const bindings = applicationDetails.NetworkSettings?.Ports?.['8080/tcp'] || []; + // Docker Engine versions do not all populate NetworkSettings.Ports for an + // explicitly loopback-bound Compose service. HostConfig.PortBindings is the + // declared binding contract and remains available in that case; keep both + // values in the failure message so a future engine change cannot hide a + // publication regression. + const configuredBindings = applicationDetails.HostConfig?.PortBindings?.['8080/tcp'] || []; + const publishedBindings = applicationDetails.NetworkSettings?.Ports?.['8080/tcp'] || []; + const bindings = configuredBindings.length > 0 ? configuredBindings : publishedBindings; const loopbackBindings = new Map(bindings.map((binding) => [binding.HostIp, binding.HostPort])); const ipv6LoopbackAliases = new Set(['::1', '0:0:0:0:0:0:0:1']); if (bindings.length !== 2 || loopbackBindings.get('127.0.0.1') !== environment.GULOGULO_PROOF_HTTP_PORT || ![...ipv6LoopbackAliases].some((address) => loopbackBindings.get(address) === environment.GULOGULO_PROOF_HTTP_PORT)) { - throw new Error(`LP1 application must expose exactly IPv4 and IPv6 loopback bindings; observed ${JSON.stringify(bindings)}`); + throw new Error(`LP1 application must expose exactly IPv4 and IPv6 loopback bindings; configured=${JSON.stringify(configuredBindings)} published=${JSON.stringify(publishedBindings)}`); } console.log(JSON.stringify({ From b807d5c082b3bd4827b04b26f4bee0d6dadc2ce5 Mon Sep 17 00:00:00 2001 From: Sythos Date: Sun, 23 Aug 2026 22:32:09 +0200 Subject: [PATCH 9/9] docs: record dual-stack network support --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b3c6eb9..9e9c0a9 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ where the item depends on external infrastructure. - [x] health and metrics; - [x] multi-architecture Docker images for Ubuntu 26.04 LTS on amd64 (x86_64) and arm64; +- [x] dual-stack IPv4 and IPv6 network support; - [x] log rotation; - [x] alerts; - [x] Postfix queue visibility;