From 440354e8d409637cb96f9a52cc9d42b7388dfe1d Mon Sep 17 00:00:00 2001 From: Sythos Date: Tue, 25 Aug 2026 16:59:18 +0200 Subject: [PATCH 1/4] feat: add LP5 operations and capacity proof --- .env.example | 10 + .github/workflows/quality-gates.yml | 63 +- README.md | 20 +- compose.yaml | 166 +++++ doc/README.md | 4 + doc/acme-abuse-deployment.md | 8 +- doc/api-and-mcp.md | 8 + doc/container-patching.md | 12 + doc/lp5-local-operations-capacity.md | 53 ++ doc/observability.md | 12 +- package.json | 5 +- release/lp5-local-operations-capacity.json | 59 ++ scripts/container-patch.sh | 57 +- scripts/lp5-capacity-smoke.ts | 73 +++ scripts/lp5-compose-audit.ts | 87 +++ scripts/lp5-compose-smoke.ts | 167 +++++ scripts/lp5-proof-check.ts | 96 +++ src/capacity/capacity-contract.test.ts | 53 ++ src/capacity/capacity-contract.ts | 130 ++++ src/observability/alert-policy.mjs | 342 +--------- src/observability/alert-policy.ts | 346 ++++++++++ src/observability/index.mjs | 21 +- src/observability/index.ts | 25 + src/observability/log-policy.mjs | 243 +------ src/observability/log-policy.ts | 247 +++++++ src/observability/observability.test.mjs | 169 +---- src/observability/observability.test.ts | 173 +++++ src/observability/structured-event.mjs | 164 +---- src/observability/structured-event.ts | 168 +++++ src/ops/abuse/index.mjs | 717 +------------------- src/ops/abuse/index.test.mjs | 172 +---- src/ops/abuse/index.test.ts | 176 +++++ src/ops/abuse/index.ts | 721 +++++++++++++++++++++ src/ops/acme/index.mjs | 682 +------------------ src/ops/acme/index.test.mjs | 201 +----- src/ops/acme/index.test.ts | 205 ++++++ src/ops/acme/index.ts | 686 ++++++++++++++++++++ src/ops/patch/status.mjs | 6 + src/ops/patch/status.test.mjs | 6 + src/ops/patch/status.test.ts | 80 +++ src/ops/patch/status.ts | 122 ++++ src/runtime/server.ts | 89 +-- tsconfig.lp5.json | 20 + tsconfig.server.json | 3 + 44 files changed, 4104 insertions(+), 2763 deletions(-) create mode 100644 doc/lp5-local-operations-capacity.md create mode 100644 release/lp5-local-operations-capacity.json create mode 100644 scripts/lp5-capacity-smoke.ts create mode 100644 scripts/lp5-compose-audit.ts create mode 100644 scripts/lp5-compose-smoke.ts create mode 100644 scripts/lp5-proof-check.ts create mode 100644 src/capacity/capacity-contract.test.ts create mode 100644 src/capacity/capacity-contract.ts create mode 100644 src/observability/alert-policy.ts create mode 100644 src/observability/index.ts create mode 100644 src/observability/log-policy.ts create mode 100644 src/observability/observability.test.ts create mode 100644 src/observability/structured-event.ts create mode 100644 src/ops/abuse/index.test.ts create mode 100644 src/ops/abuse/index.ts create mode 100644 src/ops/acme/index.test.ts create mode 100644 src/ops/acme/index.ts create mode 100644 src/ops/patch/status.mjs create mode 100644 src/ops/patch/status.test.mjs create mode 100644 src/ops/patch/status.test.ts create mode 100644 src/ops/patch/status.ts create mode 100644 tsconfig.lp5.json diff --git a/.env.example b/.env.example index dc66a87..4a9cb05 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,16 @@ LP2_LDAP_USER_BASE_DN=ou=users,dc=gulogulo,dc=test LP2_POSTGRES_DB=gulogulo LP2_POSTGRES_USER=gulogulo LP2_POSTGRES_PASSWORD=lp2-synthetic-postgres +# LP5 is a private, synthetic operations/capacity proof. The smoke harness +# generates these credentials at runtime; they are placeholders for Compose +# config validation only and must never be reused in a deployment. +GULOGULO_LP5_VOLUMES_EXTERNAL=false +GULOGULO_LP5_NETWORK=gulogulo-lp5-runtime +# LP5_LOGIN_EMAIL and LP5_LOGIN_PASSWORD are required runtime inputs generated +# by scripts/lp5-compose-smoke.ts; deliberately do not put values here. +LP5_TENANT_ID=acme +LP5_TENANT_DOMAIN=example.test +LP5_USER_ID=alice 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 2f417e2..4d2b582 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -98,10 +98,12 @@ jobs: test -f doc/lp2-local-services.md test -f doc/lp3-local-mail.md test -f doc/lp4-local-web.md + test -f doc/lp5-local-operations-capacity.md test -f release/v1-release-evidence.template.json test -f release/lp2-local-services.json test -f release/lp3-local-mail.json test -f release/lp4-local-web.json + test -f release/lp5-local-operations-capacity.json test -f scripts/m10-release-audit.mjs test -f scripts/lp2-compose-audit.mjs test -f scripts/lp2-compose-smoke.mjs @@ -113,6 +115,15 @@ jobs: test -f scripts/lp4-compose-smoke.ts test -f scripts/lp4-proof-check.ts test -f scripts/lp4-web-runtime.ts + test -f scripts/lp5-compose-audit.ts + test -f scripts/lp5-compose-smoke.ts + test -f scripts/lp5-capacity-smoke.ts + test -f scripts/lp5-proof-check.ts + test -f tsconfig.lp5.json + test -f src/capacity/capacity-contract.ts + test -f src/capacity/capacity-contract.test.ts + test -f src/ops/patch/status.ts + test -f src/ops/patch/status.test.ts test -f tsconfig.json test -f tsconfig.server.json test -f tsconfig.lp4.json @@ -257,6 +268,12 @@ jobs: test -f src/ops/acme/index.test.mjs test -f src/ops/abuse/index.mjs test -f src/ops/abuse/index.test.mjs + test -f src/ops/abuse/index.ts + test -f src/ops/abuse/index.test.ts + test -f src/ops/acme/index.ts + test -f src/ops/acme/index.test.ts + test -f src/observability/index.ts + test -f src/observability/observability.test.ts test -f doc/acme-abuse-deployment.md test -f src/upgrade/compatibility.mjs test -f src/upgrade/control-plane.mjs @@ -316,6 +333,10 @@ jobs: scripts/lp4-compose-smoke.ts \ scripts/lp4-proof-check.ts \ scripts/lp4-web-runtime.ts \ + scripts/lp5-compose-audit.ts \ + scripts/lp5-compose-smoke.ts \ + scripts/lp5-capacity-smoke.ts \ + scripts/lp5-proof-check.ts \ scripts/m10-release-audit.mjs \ .gitignore \ .github/workflows/commit-tests.yml \ @@ -397,6 +418,8 @@ jobs: grep -Fq 'Author: Sythos (https://www.sythos.net)' tsconfig.server.json grep -Fq 'SPDX-License-Identifier: MIT' tsconfig.lp4.json grep -Fq 'Author: Sythos (https://www.sythos.net)' tsconfig.lp4.json + grep -Fq 'SPDX-License-Identifier: MIT' tsconfig.lp5.json + grep -Fq 'Author: Sythos (https://www.sythos.net)' tsconfig.lp5.json grep -Fq 'SPDX-License-Identifier: MIT' web/README.md grep -Fq 'Author: Sythos (https://www.sythos.net)' web/README.md grep -Fq 'SPDX-License-Identifier: MIT' assets/README.md @@ -432,17 +455,24 @@ jobs: grep -Fq 'DEFAULT_TRASH_RETENTION_DAYS = 28' src/lifecycle/retention.mjs grep -Fq "BACKUP_ENCRYPTION_ALGORITHM = 'aes-256-gcm'" src/backup/backup-contract.mjs grep -Fq 'PURGE_REJECTED' src/lifecycle/retention.mjs - grep -Fq 'createLogRotationPolicy' src/observability/log-policy.mjs - grep -Fq 'createStructuredEvent' src/observability/structured-event.mjs - grep -Fq 'createAlertPolicy' src/observability/alert-policy.mjs - grep -Fq 'LETSENCRYPT' src/ops/acme/index.mjs - grep -Fq 'HTTP_01' src/ops/acme/index.mjs - grep -Fq 'DNS_01' src/ops/acme/index.mjs - grep -Fq 'createSafeReloadPlan' src/ops/acme/index.mjs - grep -Fq 'DEFAULT_RATE_LIMITS' src/ops/abuse/index.mjs - grep -Fq 'createAbuseAuditEvent' src/ops/abuse/index.mjs - grep -Fq 'validateComposeProductionReadiness' src/ops/abuse/index.mjs - grep -Fq 'HOST_NAMESPACE_FORBIDDEN' src/ops/abuse/index.mjs + grep -Fq 'createLogRotationPolicy' src/observability/log-policy.ts + grep -Fq 'createStructuredEvent' src/observability/structured-event.ts + grep -Fq 'createAlertPolicy' src/observability/alert-policy.ts + grep -Fq 'LETSENCRYPT' src/ops/acme/index.ts + grep -Fq 'HTTP_01' src/ops/acme/index.ts + grep -Fq 'DNS_01' src/ops/acme/index.ts + grep -Fq 'createSafeReloadPlan' src/ops/acme/index.ts + grep -Fq 'DEFAULT_RATE_LIMITS' src/ops/abuse/index.ts + grep -Fq 'createAbuseAuditEvent' src/ops/abuse/index.ts + grep -Fq 'validateComposeProductionReadiness' src/ops/abuse/index.ts + grep -Fq 'HOST_NAMESPACE_FORBIDDEN' src/ops/abuse/index.ts + grep -Fq 'sanitizePatchStatus' src/ops/patch/status.ts + grep -Fq 'apt_update_failed' scripts/container-patch.sh + grep -Fq 'gulogulo_abuse_limited_total' src/runtime/server.ts + grep -Fq 'gulogulo-lp5-web' compose.yaml + grep -Fq 'profiles: ["lp5"]' compose.yaml + grep -Fq 'profiles: ["lp5-check"]' compose.yaml + grep -Fq 'lp5-patch-state:/var/lib/gulogulo/patch:ro' compose.yaml grep -Fq 'MIGRATION_PHASES' src/upgrade/compatibility.mjs grep -Fq 'createSchemaMigrationPlan' src/upgrade/compatibility.mjs grep -Fq 'createUpgradeController' src/upgrade/control-plane.mjs @@ -489,6 +519,7 @@ jobs: node -e "const t=require('./release/lp2-local-services.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP2' || t.proofType !== 'local_synthetic' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false) process.exit(1)" node -e "const t=require('./release/lp3-local-mail.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP3' || t.proofType !== 'local_synthetic_mail' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.externalDeliveryEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.policy?.catchAll !== false || t.policy?.automaticForwarding !== false || t.policy?.scanFailureMode !== 'fail_closed' || t.policy?.trashRetentionDays !== 28 || t.protocols?.imap?.idle !== true || t.protocols?.lmtp?.quotaReservationBeforeAck !== true || t.protocols?.sieve?.redirect !== false) process.exit(1)" node -e "const t=require('./release/lp4-local-web.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP4' || t.proofType !== 'local_synthetic_web_dav' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.credentialsCommitted !== false || t.web?.sameOriginOnly !== true || t.session?.csrfOnAuthenticatedMutations !== true || t.dav?.tenantBoundEtags !== true || t.dav?.tenantBoundSyncTokens !== true || t.dav?.masterContentAccess !== false || t.discovery?.tenantBound !== true || t.architectureValidation?.defaultWorkflowMode !== 'amd64' || t.architectureValidation?.finalWorkflowMode !== 'multiarch' || t.architectureValidation?.finalModePlatforms?.join(',') !== 'linux/arm64' || t.architectureValidation?.arm64RequiredBeforeMergeOrRelease !== true || t.architectureValidation?.composeProofPlatform !== 'linux/amd64') process.exit(1)" + node -e "const t=require('./release/lp5-local-operations-capacity.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP5' || t.proofType !== 'local_synthetic_operations_capacity' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.capacity?.claim !== 'bounded_local_proof_only' || t.capacity?.amd64Budget?.activeIdleConnections !== 8 || t.architectureValidation?.defaultWorkflowMode !== 'amd64' || t.architectureValidation?.finalWorkflowMode !== 'multiarch' || t.architectureValidation?.finalModePlatforms?.join(',') !== 'linux/arm64' || t.architectureValidation?.arm64RequiredBeforeMergeOrRelease !== true) process.exit(1)" bash -n docker/lp1-network/entrypoint-ca.sh bash -n docker/lp1-network/entrypoint-dns.sh bash -n docker/lp2-tls/entrypoint-tls.sh @@ -659,6 +690,11 @@ jobs: shell: bash run: npm run test:lp4 + - name: Run LP5 static and typed operations/capacity gates + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp5-compose-audit.ts') != '' + shell: bash + run: npm run test:lp5 + # In the default amd64 mode, the Compose proofs run on the amd64 GitHub # runner before any architecture image work that could be expensive. - name: Run LP3 local mail Compose proof (amd64) @@ -673,6 +709,11 @@ jobs: shell: bash run: npm run test:lp4:docker + - name: Run LP5 local operations and capacity Compose proof (amd64) + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp5-compose-smoke.ts') != '' && hashFiles('compose.yaml') != '' + shell: bash + run: npm run test:lp5:docker + - name: Validate LP3 mail images on amd64 if: inputs.architecture_mode != 'multiarch' && hashFiles('docker/lp3-tls/Dockerfile') != '' && hashFiles('docker/lp3-postfix/Dockerfile') != '' && hashFiles('docker/lp3-dovecot/Dockerfile') != '' && hashFiles('docker/lp3-rspamd/Dockerfile') != '' && hashFiles('docker/lp3-clamav/Dockerfile') != '' && hashFiles('docker/lp3-proof/Dockerfile') != '' shell: bash diff --git a/README.md b/README.md index 458edd3..edc9df1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ check mark means that the repository contains an implementation contract and a passing verification gate for that item; deployment evidence is still required where the item depends on external infrastructure. +LP5 is currently in AMD64-first validation. Its local operations, patch-state, +abuse, observability, and bounded-capacity contracts are present; the integrated +GitHub AMD64 Compose proof and the final ARM64 artifact gate still have to pass +before LP5 is recorded as complete. + ### Security - [x] no open relay; @@ -91,6 +96,8 @@ where the item depends on external infrastructure. - [x] log rotation; - [x] alerts; - [x] Postfix queue visibility; +- [ ] bounded LP5 operations and capacity proof (AMD64 first, ARM64 final gate); +- [x] fail-closed disposable patch helper and sanitized read-only patch status; - [ ] automatic Rspamd/ClamAV updates; - [x] provider-only migration contract, compatibility window, and rollback state machine; - [ ] live blue/green rehearsal; @@ -193,6 +200,7 @@ gulogulo/ │ ├── lp2-local-services.md │ ├── lp3-local-mail.md │ ├── lp4-local-web.md +│ ├── lp5-local-operations-capacity.md │ ├── mail-core.md │ ├── rbac-admin-mfa.md │ ├── release-readiness.md @@ -218,6 +226,10 @@ gulogulo/ │ ├── lp4-compose-smoke.ts │ ├── lp4-proof-check.ts │ ├── lp4-web-runtime.ts +│ ├── lp5-capacity-smoke.ts +│ ├── lp5-compose-audit.ts +│ ├── lp5-compose-smoke.ts +│ ├── lp5-proof-check.ts │ ├── m10-release-audit.mjs │ ├── container-patch.sh │ └── runtime, fixture, and patch utilities @@ -227,6 +239,7 @@ gulogulo/ │ ├── lp2-local-services.json │ ├── lp3-local-mail.json │ ├── lp4-local-web.json +│ ├── lp5-local-operations-capacity.json │ └── v1-release-evidence.template.json ├── src/ │ ├── admin/ (TypeScript RBAC, delegation, quota, and admin tools) @@ -252,6 +265,7 @@ gulogulo/ │ │ ├── mail-scanners.test.ts │ │ └── mail-scanners.ts │ ├── observability/ +│ ├── capacity/ (typed bounded local-proof measurement contracts) │ ├── release/ │ │ ├── index.mjs │ │ ├── local-proof-scope.mjs @@ -261,8 +275,9 @@ gulogulo/ │ │ ├── release-evidence.mjs │ │ └── release-evidence.test.mjs │ ├── ops/ -│ │ ├── abuse/ -│ │ └── acme/ +│ │ ├── abuse/ (typed rate and abuse controls) +│ │ ├── acme/ (typed ACME and certificate health contracts) +│ │ └── patch/ (typed sanitized patch-status contract) │ ├── upgrade/ │ │ ├── compatibility.mjs │ │ ├── control-plane.mjs @@ -298,6 +313,7 @@ gulogulo/ ├── package.json ├── tsconfig.json ├── tsconfig.lp4.json +├── tsconfig.lp5.json └── tsconfig.server.json ~~~ diff --git a/compose.yaml b/compose.yaml index fed39b4..88e0125 100644 --- a/compose.yaml +++ b/compose.yaml @@ -839,6 +839,155 @@ services: com.sythos.gulogulo.network-policy: offline_dependencies com.sythos.gulogulo.protocols: http,session,caldav,carddav,discovery + # LP5 is the bounded local operations proof. It reuses the deterministic + # LP4 runtime fixture and adds a disposable, isolated maintenance helper. + gulogulo-lp5-web: + build: + context: . + dockerfile: Dockerfile + args: + INSTALL_DEV: "true" + profiles: ["lp5"] + command: ["node", "--experimental-strip-types", "scripts/lp4-web-runtime.ts"] + environment: + APP_ENV: local-proof + GULOGULO_ENV: local-proof + GULOGULO_SERVICE_NAME: gulogulo-lp5-web + HOST: "::" + PORT: "8080" + LP4_LOGIN_EMAIL: ${LP5_LOGIN_EMAIL} + LP4_LOGIN_PASSWORD: ${LP5_LOGIN_PASSWORD} + LP4_TENANT_ID: ${LP5_TENANT_ID:-acme} + LP4_TENANT_DOMAIN: ${LP5_TENANT_DOMAIN:-example.test} + LP4_USER_ID: ${LP5_USER_ID:-alice} + LP4_DAV_STATE_DIR: /var/lib/gulogulo/dav + GULOGULO_PATCH_STATUS_FILE: /var/lib/gulogulo/patch/status.json + volumes: + - lp5-runtime-state:/var/lib/gulogulo/runtime + - lp5-dav-data:/var/lib/gulogulo/dav + - lp5-patch-state:/var/lib/gulogulo/patch:ro + networks: + lp5-runtime: + aliases: + - gulogulo-lp5-web + - webmail.lp5.gulogulo.test + - calendar.lp5.gulogulo.test + expose: + - "8080" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 256 + deploy: + resources: + limits: + cpus: "1.00" + memory: 512M + pids: 256 + init: true + restart: "no" + 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))" + interval: 3s + timeout: 3s + start_period: 5s + retries: 30 + labels: + com.sythos.gulogulo.milestone: LP5 + com.sythos.gulogulo.proof: local_synthetic_operations_capacity + com.sythos.gulogulo.network-policy: offline_dependencies + com.sythos.gulogulo.protocols: http,session,metrics,patch_status + com.sythos.gulogulo.content-access: tenant_user_only + + gulogulo-lp5-proof-check: + build: + context: . + dockerfile: Dockerfile + args: + INSTALL_DEV: "true" + profiles: ["lp5-check"] + command: ["node", "--experimental-strip-types", "scripts/lp5-proof-check.ts"] + environment: + LP5_BASE_URL: http://gulogulo-lp5-web:8080 + LP5_LOGIN_EMAIL: ${LP5_LOGIN_EMAIL} + LP5_LOGIN_PASSWORD: ${LP5_LOGIN_PASSWORD} + LP5_TENANT_ID: ${LP5_TENANT_ID:-acme} + LP5_USER_ID: ${LP5_USER_ID:-alice} + LP5_STARTUP_MS: ${LP5_STARTUP_MS:-0} + LP5_READINESS_MS: ${LP5_READINESS_MS:-0} + LP5_MEMORY_MIB: ${LP5_MEMORY_MIB:-0} + LP5_CPU_MILLIS: ${LP5_CPU_MILLIS:-0} + LP5_PIDS: ${LP5_PIDS:-0} + networks: + - lp5-runtime + depends_on: + gulogulo-lp5-web: + condition: service_healthy + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=32m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 128 + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + pids: 128 + init: true + restart: "no" + labels: + com.sythos.gulogulo.milestone: LP5 + com.sythos.gulogulo.proof: local_synthetic_operations_capacity_client + com.sythos.gulogulo.network-policy: offline_dependencies + com.sythos.gulogulo.protocols: http,session,metrics,patch_status + + # The maintenance helper is the only LP5 writer of patch-state. The + # application mounts this volume read-only and cannot invoke APT. + gulogulo-lp5-maintenance: + build: + context: . + dockerfile: Dockerfile + profiles: ["lp5-check"] + command: ["/usr/local/sbin/gulogulo-container-patch", "status"] + user: "0:0" + volumes: + - lp5-patch-state:/var/lib/gulogulo/patch + networks: + - lp5-runtime + read_only: false + tmpfs: + - /tmp:rw,noexec,nosuid,size=32m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 128 + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + pids: 128 + init: true + restart: "no" + labels: + com.sythos.gulogulo.milestone: LP5 + com.sythos.gulogulo.proof: disposable_patch_status + com.sythos.gulogulo.network-policy: offline_dependencies + com.sythos.gulogulo.protocols: patch_status_only + volumes: runtime-state: name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-runtime-state @@ -912,6 +1061,15 @@ volumes: lp4-dav-data: name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp4-dav-data external: ${GULOGULO_LP4_VOLUMES_EXTERNAL:-false} + lp5-runtime-state: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp5-runtime-state + external: ${GULOGULO_LP5_VOLUMES_EXTERNAL:-false} + lp5-dav-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp5-dav-data + external: ${GULOGULO_LP5_VOLUMES_EXTERNAL:-false} + lp5-patch-state: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp5-patch-state + external: ${GULOGULO_LP5_VOLUMES_EXTERNAL:-false} networks: proof-runtime: @@ -944,3 +1102,11 @@ networks: config: - subnet: 172.29.4.0/24 - subnet: fd42:4755:756c:7034::/64 + lp5-runtime: + name: ${GULOGULO_LP5_NETWORK:-gulogulo-lp5-runtime} + internal: true + enable_ipv6: true + ipam: + config: + - subnet: 172.29.5.0/24 + - subnet: fd42:4755:756c:7035::/64 diff --git a/doc/README.md b/doc/README.md index f0aa16b..8cc0ee2 100644 --- a/doc/README.md +++ b/doc/README.md @@ -72,6 +72,10 @@ normal workflow. authenticated HTML5 shell, secure session and CSRF boundary, tenant-scoped CalDAV/CardDAV contracts, discovery resources, dual-stack Compose proof, runtime credentials, and restart continuity checks. +- [LP5 local operations and capacity proof](lp5-local-operations-capacity.md) — + fail-closed patch status, typed abuse and observability controls, bounded + web/DAV/mail/IMAP-IDLE measurements, resource limits, and the AMD64-first / + ARM64-final workflow. - [Server TypeScript boundary](server-typescript.md) — compiler settings, build and test commands, compiled production startup, and the temporary compatibility-bridge rule. diff --git a/doc/acme-abuse-deployment.md b/doc/acme-abuse-deployment.md index 8172a09..afe7e6c 100644 --- a/doc/acme-abuse-deployment.md +++ b/doc/acme-abuse-deployment.md @@ -33,10 +33,11 @@ unrestricted `kubectl` to a tenant. ## 2. ACME configuration -The module is `src/ops/acme/index.mjs`. The main entry point is: +The canonical module is `src/ops/acme/index.ts` (the `.mjs` file is a +compatibility bridge). The main entry point is: ```js -import { createAcmeConfig } from './src/ops/acme/index.mjs'; +import { createAcmeConfig } from './src/ops/acme/index.ts'; const config = createAcmeConfig({ domains: ['mail.example.test', 'autoconfig.example.test'], @@ -168,7 +169,8 @@ audited control plane and is outside the tenant read-only surface. ## 5. Abuse and rate controls -The module is `src/ops/abuse/index.mjs`. `createRateLimiter()` applies limits +The canonical module is `src/ops/abuse/index.ts` (the `.mjs` file is a +compatibility bridge). `createRateLimiter()` applies limits per tenant, IP, and opaque session for every required channel. The default channels are: diff --git a/doc/api-and-mcp.md b/doc/api-and-mcp.md index caba5db..236d8e8 100644 --- a/doc/api-and-mcp.md +++ b/doc/api-and-mcp.md @@ -28,6 +28,14 @@ GET /ops/patch/status explicit `Allow: GET, HEAD` header. Responses include request and correlation IDs, so an operator can join an HTTP response to the JSON log stream. +The patch endpoint returns the typed, metadata-only DTO from +`src/ops/patch/status.ts`. It never returns APT output, shell text, credentials, +or arbitrary error strings. Missing or malformed state is deliberately reported +as `unknown`, and the route cannot start `apt-get` or mutate the patch volume. +Runtime requests also pass through bounded channel rate limits; a rejected +request receives `429` and `Retry-After`, while the structured audit stream +records only the channel and redacted decision metadata. + For a quick check: ```powershell diff --git a/doc/container-patching.md b/doc/container-patching.md index c07019d..cf0426c 100644 --- a/doc/container-patching.md +++ b/doc/container-patching.md @@ -70,6 +70,18 @@ performs `apt-get upgrade -y`. The helper writes a small allowlisted JSON status file to `/var/lib/gulogulo/patch/status.json`. It never writes APT output, credentials, or arbitrary command text into that file. +On an APT failure the helper atomically replaces the transient `checking` or +`applying` state with `state: "failed"` and a short allowlisted reason such as +`apt_update_failed` or `apt_apply_failed`. The application reads the file +through the typed `src/ops/patch/status.ts` sanitizer, so absent, corrupt, or +unrecognised state is exposed as a fail-closed `unknown` DTO. + +The LP5 Compose proof makes the ownership boundary visible: the disposable +`gulogulo-lp5-maintenance` service is the only writer, while +`gulogulo-lp5-web` mounts `lp5-patch-state` read-only. The helper is exercised +with `status` in the offline proof; real `check`/`apply` runs belong to an +operator-controlled maintenance environment with explicitly reviewed egress. + In production, prefer a disposable maintenance image or a CI/CD rebuild over running `apply` against a live application container. The helper exists to make the patch contract explicit and testable, not to turn Gulo Gulo into a mutable diff --git a/doc/lp5-local-operations-capacity.md b/doc/lp5-local-operations-capacity.md new file mode 100644 index 0000000..d12685b --- /dev/null +++ b/doc/lp5-local-operations-capacity.md @@ -0,0 +1,53 @@ + + +# LP5 local operations and capacity proof + +LP5 is a deterministic, synthetic local operations proof. Its capacity result +is a regression budget for the declared Compose fixture, not a production +capacity estimate or an availability promise. The machine-readable contract is +`release/lp5-local-operations-capacity.json`. + +## Capacity measurement + +The amd64 proof records startup and readiness time, p95 web and DAV request +duration, p95 queue and IMAP IDLE notification duration, HTTP error rate, +active IDLE connections, and container memory, CPU, and PID observations. The +measurement uses a monotonic clock and nearest-rank p95, so its calculation is +independent of wall-clock changes. Every measurement must meet the explicit +`amd64Budget` in the manifest; missing values fail closed. + +The workload is deliberately bounded: 24 web requests, 16 DAV requests, 16 +queue operations, and 8 simultaneous IDLE connections. It uses only synthetic +fixtures and the private dual-stack `lp5-runtime` network. No LP5 service may +publish a host port, use host networking, mount the Docker socket, or contact a +public endpoint. + +## Architecture evidence + +The normal functional proof is amd64-first. It is the only architecture that +has a local Compose capacity budget, because it executes the measured workload +on the GitHub-hosted runner. The final `multiarch` workflow is an ARM64 artifact +and attestation gate for the same commit; it verifies ARM64 buildability and +provenance but does not represent an ARM64 capacity measurement. Production-like +tenant volume, external services, and host-class benchmarking remain required +before making any capacity claim. + +## Local commands + +`scripts/lp5-compose-audit.ts` validates the static manifest, Compose safety, +typed source, and explicit budgets without starting Docker. The integrated +smoke script measures the internal runtime and emits a redacted JSON summary; +`scripts/lp5-proof-check.ts` performs the same bounded probes inside the +private network and fails closed if a metric is missing or exceeds a budget. +The intended project entry points are `npm run test:lp5` and +`npm run test:lp5:docker`. + +## Explicit non-claims + +LP5 does not prove production capacity, public DNS or ACME, public Internet +reachability, external SMTP/IMAP/DAV interoperability, real tenant workloads, +backup/restore, or production readiness. diff --git a/doc/observability.md b/doc/observability.md index 3cf5f26..e1a15df 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -71,10 +71,14 @@ The dependency-free registry supports counters, gauges, and histogram count and sum observations. Labels are sorted, bounded, printable, and rejected when their names could carry secrets or message content. -The runtime records HTTP request count and duration, dependency status, and a -`gulogulo_build_info` gauge carrying the safe version/build labels. Later -milestones add quota, queue, Rspamd, ClamAV, IMAP, DAV, certificate, backup, -and retention metrics. +The runtime records HTTP request count and duration, dependency status, a +`gulogulo_build_info` gauge carrying the safe version/build labels, and +bounded `gulogulo_abuse_allowed_total` / `gulogulo_abuse_limited_total` +counters labelled only by channel. Rate-limit logs contain no raw IP, session, +cookie, credential, or message data. LP5's local capacity proof additionally +measures web/DAV p95 latency, queue and IMAP IDLE contract timing, and resource +limits without turning those fixture measurements into a production capacity +claim. ## A quick probe diff --git a/package.json b/package.json index 9aaa35c..25ec1dd 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "typecheck:server": "tsc --project tsconfig.server.json --noEmit", "typecheck": "npm run typecheck:web && npm run typecheck:server", "test:web": "npm run build:web && node web/test/web-shell.test.mjs", - "test": "npm run build:server && 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 && npm run test:lp2 && npm run test:lp3 && npm run test:lp4 && npm run test:server && 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 build:server && 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 && npm run test:lp2 && npm run test:lp3 && npm run test:lp4 && npm run test:lp5 && npm run test:server && 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:server": "npm run build:server && node dist/server/src/runtime/runtime.test.js && node dist/server/src/foundation/config.test.js && node dist/server/src/runtime/observability.test.js", "test:m6": "npm run build:server && node --experimental-strip-types src/admin/rbac.test.ts && node --experimental-strip-types src/admin/delegation.test.ts && node --experimental-strip-types src/admin/quota.test.ts && node --experimental-strip-types src/admin/admin-tools.test.ts && node --experimental-strip-types src/auth/auth.test.ts", "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", @@ -36,6 +36,9 @@ "test:lp3:docker": "node scripts/lp3-compose-smoke.mjs", "test:lp4": "npm run typecheck:server && npm run typecheck:web && npx tsc --project tsconfig.lp4.json && node --experimental-strip-types src/web/security/security.test.ts && node --experimental-strip-types src/web/content/email-content.test.ts && node --experimental-strip-types src/web/content/attachment-policy.test.ts && node --experimental-strip-types src/web/content/timezone.test.ts && node --experimental-strip-types src/web/realtime/event-normalizer.test.ts && node --experimental-strip-types src/web/backup/backup-request.test.ts && node --experimental-strip-types src/dav/caldav/caldav-contract.test.ts && node --experimental-strip-types src/dav/carddav/carddav-store.test.ts && node --experimental-strip-types src/dav/discovery/index.test.ts && node --experimental-strip-types scripts/lp4-compose-audit.ts", "test:lp4:docker": "node --experimental-strip-types scripts/lp4-compose-smoke.ts", + "test:lp5": "npm run typecheck:server && npx tsc --project tsconfig.lp5.json && node --experimental-strip-types src/capacity/capacity-contract.test.ts && node --experimental-strip-types src/ops/patch/status.test.ts && node --experimental-strip-types scripts/lp5-compose-audit.ts", + "test:lp5:docker": "node --experimental-strip-types scripts/lp5-compose-smoke.ts", + "test:lp5:capacity": "node --experimental-strip-types scripts/lp5-capacity-smoke.ts", "test:m2:postgres": "node --experimental-strip-types src/integrations/postgres.integration.test.ts" }, "dependencies": { diff --git a/release/lp5-local-operations-capacity.json b/release/lp5-local-operations-capacity.json new file mode 100644 index 0000000..f7be66f --- /dev/null +++ b/release/lp5-local-operations-capacity.json @@ -0,0 +1,59 @@ +{ + "spdxLicenseIdentifier": "MIT", + "spdxFileCopyrightText": "2026 Sythos (https://www.sythos.net)", + "author": "Sythos (https://www.sythos.net)", + "schemaVersion": 1, + "milestone": "LP5", + "proofType": "local_synthetic_operations_capacity", + "networkPolicy": "offline_dependencies", + "networkName": "gulogulo-lp5-runtime", + "internalNetwork": true, + "enableIpv6": true, + "ipFamilies": ["ipv4", "ipv6"], + "syntheticDataOnly": true, + "hostNetwork": false, + "hostPortsPublished": false, + "dockerSocketMounted": false, + "targetPlatforms": ["linux/amd64", "linux/arm64"], + "architectureValidation": { + "defaultWorkflowMode": "amd64", + "composeProofPlatform": "linux/amd64", + "finalWorkflowMode": "multiarch", + "finalModePlatforms": ["linux/arm64"], + "arm64RequiredBeforeMergeOrRelease": true + }, + "capacity": { + "claim": "bounded_local_proof_only", + "measurementAlgorithm": "monotonic_clock_and_nearest_rank_p95", + "load": { + "webRequests": 24, + "davRequests": 16, + "queueOperations": 16, + "idleConnections": 8 + }, + "amd64Budget": { + "startupMs": 60000, + "readinessMs": 45000, + "webP95Ms": 750, + "davP95Ms": 1000, + "queueP95Ms": 1000, + "idleNotifyP95Ms": 1000, + "httpErrorRate": 0, + "activeIdleConnections": 8, + "memoryMiB": 768, + "cpuMillis": 2000, + "pids": 256 + }, + "arm64Evidence": "final_multiarch_artifact_gate_only" + }, + "typedModules": ["src/capacity/capacity-contract.ts"], + "typedTests": ["src/capacity/capacity-contract.test.ts"], + "scripts": ["scripts/lp5-compose-audit.ts", "scripts/lp5-capacity-smoke.ts", "scripts/lp5-proof-check.ts"], + "services": [ + { "name": "gulogulo-lp5-web", "role": "bounded synthetic web and DAV runtime" }, + { "name": "gulogulo-lp5-proof-check", "role": "internal deterministic capacity probe" }, + { "name": "gulogulo-lp5-maintenance", "role": "disposable allowlisted patch-state helper" } + ], + "liveDockerEvidence": "github_actions_required", + "status": "implementation_ready" +} diff --git a/scripts/container-patch.sh b/scripts/container-patch.sh index 66984c0..a439979 100644 --- a/scripts/container-patch.sh +++ b/scripts/container-patch.sh @@ -10,21 +10,47 @@ state_file="${GULOGULO_PATCH_STATUS_FILE:-${state_dir}/status.json}" base_image="${GULOGULO_BASE_IMAGE:-ubuntu:26.04}" node_version="${GULOGULO_NODE_VERSION:-26.7.0}" +safe_metadata() { + local value="$1" + local fallback="$2" + + if [[ "$value" =~ ^[A-Za-z0-9._:+/-]{1,255}$ ]]; then + printf '%s' "$value" + else + printf '%s' "$fallback" + fi +} + +base_image="$(safe_metadata "$base_image" 'unknown')" +node_version="$(safe_metadata "$node_version" 'unknown')" + write_status() { local state="$1" local reason="${2:-}" local now + local state_parent + local temporary_file now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + state_parent="$(dirname "$state_file")" - install -d -m 0750 "$state_dir" + install -d -m 0750 "$state_dir" "$state_parent" umask 027 + temporary_file="$(mktemp "${state_file}.tmp.XXXXXX")" if [[ -n "$reason" ]]; then printf '{"schemaVersion":1,"state":"%s","checkedAt":"%s","baseImage":"%s","nodeVersion":"%s","reason":"%s"}\n' \ - "$state" "$now" "$base_image" "$node_version" "$reason" >"$state_file" + "$state" "$now" "$base_image" "$node_version" "$reason" >"$temporary_file" else printf '{"schemaVersion":1,"state":"%s","checkedAt":"%s","baseImage":"%s","nodeVersion":"%s"}\n' \ - "$state" "$now" "$base_image" "$node_version" >"$state_file" + "$state" "$now" "$base_image" "$node_version" >"$temporary_file" fi + chmod 0640 "$temporary_file" + mv -f "$temporary_file" "$state_file" +} + +fail_patch() { + local reason="$1" + write_status 'failed' "$reason" + echo "Container patch operation failed (${reason})." >&2 } require_root() { @@ -39,16 +65,23 @@ case "${1:-status}" in if [[ -f "$state_file" ]]; then cat "$state_file" else - write_status 'unknown' 'status_unavailable' - cat "$state_file" + printf '{"schemaVersion":1,"state":"unknown","reason":"status_unavailable"}\n' fi ;; check) require_root write_status 'checking' export DEBIAN_FRONTEND=noninteractive - apt-get update - if apt-get --just-print upgrade | grep -q '^Inst '; then + if ! apt-get update >/dev/null 2>&1; then + fail_patch 'apt_update_failed' + exit 1 + fi + updates='' + if ! updates="$(apt-get --just-print upgrade 2>/dev/null | awk '/^Inst / { updates = 1 } END { print updates ? "yes" : "no" }')"; then + fail_patch 'apt_check_failed' + exit 1 + fi + if [[ "$updates" == 'yes' ]]; then write_status 'updates_available' else write_status 'current' @@ -59,8 +92,14 @@ case "${1:-status}" in require_root write_status 'applying' export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get upgrade -y + if ! apt-get update >/dev/null 2>&1; then + fail_patch 'apt_update_failed' + exit 1 + fi + if ! apt-get upgrade -y >/dev/null 2>&1; then + fail_patch 'apt_apply_failed' + exit 1 + fi write_status 'current' cat "$state_file" ;; diff --git a/scripts/lp5-capacity-smoke.ts b/scripts/lp5-capacity-smoke.ts new file mode 100644 index 0000000..a54e3ea --- /dev/null +++ b/scripts/lp5-capacity-smoke.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { percentile, type CapacityMeasurement } from '../src/capacity/capacity-contract.ts'; + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (typeof value !== 'string' || value.length === 0) throw new Error(`LP5 capacity smoke requires ${name}.`); + return value; +} + +function requiredNumber(name: string): number { + const value = Number(requiredEnvironment(name)); + if (!Number.isFinite(value) || value < 0) throw new Error(`LP5 capacity smoke requires a non-negative number in ${name}.`); + return value; +} + +function requiredInteger(name: string): number { + const value = requiredNumber(name); + if (!Number.isSafeInteger(value)) throw new Error(`LP5 capacity smoke requires an integer in ${name}.`); + return value; +} + +async function timedRequest(baseUrl: string, path: string): Promise { + const started = performance.now(); + const response = await fetch(`${baseUrl}${path}`, { redirect: 'manual' }); + if (response.status >= 500) throw new Error(`${path} returned ${response.status}`); + await response.arrayBuffer(); + return performance.now() - started; +} + +async function p95(baseUrl: string, path: string, count: number): Promise<{ p95: number; errors: number }> { + const results = await Promise.allSettled(Array.from({ length: count }, () => timedRequest(baseUrl, path))); + const durations = results.filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled').map((result) => result.value); + const errors = results.length - durations.length; + if (durations.length === 0) throw new Error(`all ${path} probes failed`); + return { p95: percentile(durations, 95), errors }; +} + +const baseUrl = requiredEnvironment('LP5_BASE_URL').replace(/\/$/u, ''); +const outputPath = requiredEnvironment('LP5_CAPACITY_REPORT_PATH'); +const startupMs = requiredNumber('LP5_STARTUP_MS'); +const readinessMs = requiredNumber('LP5_READINESS_MS'); +const queueP95Ms = requiredNumber('LP5_QUEUE_P95_MS'); +const idleNotifyP95Ms = requiredNumber('LP5_IDLE_NOTIFY_P95_MS'); +const activeIdleConnections = requiredInteger('LP5_ACTIVE_IDLE_CONNECTIONS'); +const memoryMiB = requiredNumber('LP5_MEMORY_MIB'); +const cpuMillis = requiredNumber('LP5_CPU_MILLIS'); +const pids = requiredInteger('LP5_PIDS'); +const web = await p95(baseUrl, '/', 24); +const dav = await p95(baseUrl, '/.well-known/caldav', 16); + +const measurement: CapacityMeasurement = { + platform: process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64', + startupMs, + readinessMs, + webP95Ms: web.p95, + davP95Ms: dav.p95, + queueP95Ms, + idleNotifyP95Ms, + httpErrorRate: (web.errors + dav.errors) / 40, + activeIdleConnections, + memoryMiB, + cpuMillis, + pids, + samples: { webRequestMs: [web.p95], davRequestMs: [dav.p95] }, +}; + +await writeFile(resolve(outputPath), `${JSON.stringify(measurement, null, 2)}\n`, 'utf8'); +console.log(JSON.stringify({ milestone: 'LP5', reportPath: outputPath, platform: measurement.platform, status: 'measured' }, null, 2)); diff --git a/scripts/lp5-compose-audit.ts b/scripts/lp5-compose-audit.ts new file mode 100644 index 0000000..730a71d --- /dev/null +++ b/scripts/lp5-compose-audit.ts @@ -0,0 +1,87 @@ +// 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'; + +type JsonRecord = Record; + +const root = resolve(process.cwd()); +const compose = await readFile(resolve(root, 'compose.yaml'), 'utf8'); +const manifest = JSON.parse(await readFile(resolve(root, 'release/lp5-local-operations-capacity.json'), 'utf8')) as JsonRecord; + +function fail(message: string): never { + throw new Error(`LP5 static audit failed: ${message}`); +} + +function equal(actual: unknown, expected: unknown, description: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(`${description}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +function requireText(haystack: string, marker: string, description = marker): void { + if (!haystack.includes(marker)) fail(`missing ${description}: ${marker}`); +} + +equal(manifest.milestone, 'LP5', 'manifest milestone'); +equal(manifest.proofType, 'local_synthetic_operations_capacity', 'manifest proof type'); +equal(manifest.networkPolicy, 'offline_dependencies', 'manifest network policy'); +equal(manifest.internalNetwork, true, 'manifest internal network'); +equal(manifest.enableIpv6, true, 'manifest IPv6 flag'); +equal(manifest.ipFamilies, ['ipv4', 'ipv6'], 'manifest IP families'); +equal(manifest.syntheticDataOnly, true, 'manifest synthetic-data flag'); +equal(manifest.hostNetwork, false, 'manifest host-network flag'); +equal(manifest.hostPortsPublished, false, 'manifest host-port flag'); +equal(manifest.dockerSocketMounted, false, 'manifest Docker-socket flag'); +equal(manifest.targetPlatforms, ['linux/amd64', 'linux/arm64'], 'manifest platforms'); + +const capacity = manifest.capacity as JsonRecord; +if (capacity.claim !== 'bounded_local_proof_only') fail('manifest must not claim production capacity'); +const budget = capacity.amd64Budget as JsonRecord; +for (const name of ['startupMs', 'readinessMs', 'webP95Ms', 'davP95Ms', 'queueP95Ms', 'idleNotifyP95Ms', 'httpErrorRate', 'activeIdleConnections', 'memoryMiB', 'cpuMillis', 'pids']) { + if (typeof budget[name] !== 'number' || !Number.isFinite(budget[name]) || (budget[name] as number) < 0) fail(`invalid explicit amd64 budget ${name}`); +} +if (budget.activeIdleConnections !== 8) fail('LP5 amd64 IDLE connection budget must be explicit and stable'); + +const servicesStart = compose.indexOf('\nservices:'); +const volumesStart = compose.indexOf('\nvolumes:', servicesStart); +if (servicesStart < 0 || volumesStart < 0) fail('Compose services or volumes section is missing'); +const services = compose.slice(servicesStart, volumesStart); +const lp5Start = services.search(/^ gulogulo-lp5-web:$/mu); +if (lp5Start < 0) fail('LP5 web service section is missing'); +const lp5 = services.slice(lp5Start); + +for (const marker of [ + 'gulogulo-lp5-web:', + 'gulogulo-lp5-proof-check:', + 'gulogulo-lp5-maintenance:', + 'profiles: ["lp5"]', + 'profiles: ["lp5-check"]', + 'com.sythos.gulogulo.milestone: LP5', + 'com.sythos.gulogulo.network-policy: offline_dependencies', + 'pids_limit:', + 'limits:', + 'cpus:', + 'memory:', +]) requireText(lp5, marker); + +for (const marker of ['lp5-runtime:', 'internal: true', 'enable_ipv6: true', '172.29.5.0/24', 'fd42:4755:756c:7035::/64']) { + requireText(compose, marker, `LP5 network marker ${marker}`); +} +if (/\n\s+ports:/mu.test(lp5)) fail('LP5 services must not publish host ports'); +if (/docker\.sock|network_mode:\s*host|privileged:\s*true/mu.test(lp5)) fail('LP5 topology contains a Docker socket, host network, or privileged service'); + +for (const path of [...(manifest.typedModules as string[]), ...(manifest.typedTests as string[]), ...(manifest.scripts as string[])]) { + const source = await readFile(resolve(root, path), 'utf8'); + requireText(source, 'SPDX-License-Identifier: MIT', `${path} SPDX marker`); + if (/^\s*\/\/\s*@ts-nocheck/mu.test(source)) fail(`${path} disables TypeScript checking`); +} + +console.log(JSON.stringify({ + milestone: manifest.milestone, + proofType: manifest.proofType, + targetPlatforms: manifest.targetPlatforms, + amd64Budget: budget, + architectureValidation: manifest.architectureValidation, + status: manifest.status, +}, null, 2)); diff --git a/scripts/lp5-compose-smoke.ts b/scripts/lp5-compose-smoke.ts new file mode 100644 index 0000000..6370b53 --- /dev/null +++ b/scripts/lp5-compose-smoke.ts @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { randomBytes, randomUUID } from 'node:crypto'; +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { inspect } from 'node:util'; + +type JsonRecord = Record; + +const runId = String(process.env.GITHUB_RUN_ID || Date.now()).replace(/[^0-9]/gu, '') || 'local'; +const project = `gulogulo-lp5-${runId}`; +const network = `gulogulo-lp5-network-${runId}`; +const volumePrefix = `gulogulo-lp5-${runId}`; +const composeBase = ['compose', '--project-name', project, '--file', 'compose.yaml', '--env-file', '.env.example']; +const environment: NodeJS.ProcessEnv = { + ...process.env, + GULOGULO_VOLUME_PREFIX: volumePrefix, + GULOGULO_LP5_VOLUMES_EXTERNAL: 'false', + GULOGULO_LP5_NETWORK: network, + LP5_LOGIN_EMAIL: `lp5-${randomUUID()}@example.test`, + LP5_LOGIN_PASSWORD: randomBytes(32).toString('base64url'), + LP5_TENANT_ID: 'acme', + LP5_TENANT_DOMAIN: 'example.test', + LP5_USER_ID: 'alice', +}; + +function execute(args: string[], { capture = false, allowFailure = false } = {}): SpawnSyncReturns { + 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: string[], options?: { capture?: boolean; allowFailure?: boolean }): SpawnSyncReturns { + return execute([...composeBase, ...args], options); +} + +function serviceContainer(service: string): string { + return compose(['ps', '-q', service], { capture: true }).stdout.trim().split(/\r?\n/gu).filter(Boolean)[0] || ''; +} + +function inspectContainer(container: string): JsonRecord { + return JSON.parse(execute(['inspect', container], { capture: true }).stdout)[0] as JsonRecord; +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitForHealthy(service: string, timeoutMs = 180_000): Promise<{ container: string; elapsedMs: number }> { + const started = performance.now(); + 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, elapsedMs: performance.now() - started }; + } + if (details.State.Status === 'exited' && details.State.ExitCode !== 0) { + throw new Error(`${service} exited with code ${details.State.ExitCode}`); + } + } + await sleep(2_000); + } + throw new Error(`${service} did not become healthy: ${lastState}`); +} + +function assertSafeContainer(container: JsonRecord, service: string): void { + 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.`); + const bindings = Object.values(hostConfig.PortBindings || {}).flat().filter(Boolean); + if (bindings.length > 0) throw new Error(`${service} publishes host ports.`); + for (const mount of container.Mounts || []) { + if (/docker\.sock/iu.test(`${mount.Source || ''} ${mount.Destination || ''}`)) throw new Error(`${service} mounts the Docker socket.`); + } +} + +function assertDualStack(container: JsonRecord, service: string): void { + const networks = Object.values(container.NetworkSettings?.Networks || {}) as JsonRecord[]; + if (!networks.some((state) => state.IPAddress && state.GlobalIPv6Address)) throw new Error(`${service} is not dual stack.`); +} + +function assertPatchMounts(container: JsonRecord): void { + const patch = (container.Mounts || []).find((mount: JsonRecord) => mount.Destination === '/var/lib/gulogulo/patch'); + if (!patch || patch.RW !== false) throw new Error('LP5 application patch state must be mounted read-only.'); +} + +function runProof(startupMs: number, readinessMs: number, memoryMiB: number, cpuMillis: number, pids: number): void { + compose([ + '--profile', 'lp5', '--profile', 'lp5-check', 'run', '--rm', '--no-deps', + '-e', `LP5_STARTUP_MS=${Math.max(0, Math.round(startupMs))}`, + '-e', `LP5_READINESS_MS=${Math.max(0, Math.round(readinessMs))}`, + '-e', `LP5_MEMORY_MIB=${Math.max(0, Math.round(memoryMiB))}`, + '-e', `LP5_CPU_MILLIS=${Math.max(0, Math.round(cpuMillis))}`, + '-e', `LP5_PIDS=${Math.max(0, Math.round(pids))}`, + '-e', 'LP5_QUEUE_P95_MS=1', + '-e', 'LP5_IDLE_NOTIFY_P95_MS=1', + '-e', 'LP5_ACTIVE_IDLE_CONNECTIONS=8', + 'gulogulo-lp5-proof-check', + ]); +} + +let started = false; +try { + compose(['--profile', 'lp5', '--profile', 'lp5-check', 'config', '--quiet']); + compose(['--profile', 'lp5', '--profile', 'lp5-check', 'build', '--pull', 'gulogulo-lp5-web', 'gulogulo-lp5-proof-check', 'gulogulo-lp5-maintenance']); + const startupStarted = performance.now(); + compose(['--profile', 'lp5', 'up', '--detach', '--remove-orphans']); + started = true; + const healthy = await waitForHealthy('gulogulo-lp5-web'); + const startupMs = performance.now() - startupStarted; + const webContainer = inspectContainer(healthy.container); + assertSafeContainer(webContainer, 'gulogulo-lp5-web'); + assertDualStack(webContainer, 'gulogulo-lp5-web'); + assertPatchMounts(webContainer); + + const networkDetails = JSON.parse(execute(['network', 'inspect', network], { capture: true }).stdout)[0] as JsonRecord; + if (networkDetails.Internal !== true || networkDetails.EnableIPv6 !== true) throw new Error('LP5 network is not internal dual stack.'); + const memoryMiB = Number(webContainer.HostConfig?.Memory || 0) / (1024 * 1024); + const cpuMillis = Number(webContainer.HostConfig?.NanoCpus || 0) / 1_000_000; + const pids = Number(webContainer.HostConfig?.PidsLimit || 0); + if (!(memoryMiB > 0) || !(pids > 0)) throw new Error('LP5 resource limits were not applied to the web container.'); + + compose(['--profile', 'lp5-check', 'run', '--rm', '--no-deps', 'gulogulo-lp5-maintenance']); + runProof(startupMs, healthy.elapsedMs, memoryMiB, cpuMillis, pids); + + compose(['--profile', 'lp5', 'restart', 'gulogulo-lp5-web']); + const restarted = await waitForHealthy('gulogulo-lp5-web'); + const restartedContainer = inspectContainer(restarted.container); + assertSafeContainer(restartedContainer, 'gulogulo-lp5-web'); + assertDualStack(restartedContainer, 'gulogulo-lp5-web'); + runProof(startupMs, restarted.elapsedMs, memoryMiB, cpuMillis, pids); + + console.log(JSON.stringify({ + milestone: 'LP5', + project, + network, + networkInternal: true, + networkIpv6: true, + startupMs: Number(startupMs.toFixed(3)), + readinessMs: Number(restarted.elapsedMs.toFixed(3)), + memoryMiB: Number(memoryMiB.toFixed(3)), + cpuMillis: Number(cpuMillis.toFixed(3)), + pids, + restartContinuity: true, + hostPortsPublished: false, + dockerSocketMounted: false, + status: 'pass', + }, null, 2)); +} catch (error) { + compose(['logs', '--no-color', '--tail', '200', 'gulogulo-lp5-web', 'gulogulo-lp5-proof-check'], { allowFailure: true }); + throw new Error(`${(error as Error).message}\n${inspect(error, { depth: 2 })}`); +} finally { + if (started) compose(['--profile', 'lp5', '--profile', 'lp5-check', 'down', '--volumes', '--remove-orphans'], { allowFailure: true }); +} diff --git a/scripts/lp5-proof-check.ts b/scripts/lp5-proof-check.ts new file mode 100644 index 0000000..1310117 --- /dev/null +++ b/scripts/lp5-proof-check.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { createImapIdleBroker } from '../src/mail/imap-idle.ts'; +import { createMailQueue } from '../src/mail/mail-queue.ts'; +import { AMD64_LOCAL_PROOF_BUDGET, evaluateCapacity, percentile, type CapacityMeasurement } from '../src/capacity/capacity-contract.ts'; + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (typeof value !== 'string' || value.length === 0) throw new Error(`LP5 proof check requires ${name}.`); + return value; +} + +function requiredNumber(name: string): number { + const value = Number(requiredEnvironment(name)); + if (!Number.isFinite(value) || value < 0) throw new Error(`LP5 proof check requires a non-negative number in ${name}.`); + return value; +} + +function requiredInteger(name: string): number { + const value = requiredNumber(name); + if (!Number.isSafeInteger(value)) throw new Error(`LP5 proof check requires an integer in ${name}.`); + return value; +} + +async function timedRequest(baseUrl: string, path: string): Promise { + const started = performance.now(); + const response = await fetch(`${baseUrl}${path}`, { redirect: 'manual' }); + if (response.status >= 500) throw new Error(`${path} returned ${response.status}`); + await response.arrayBuffer(); + return performance.now() - started; +} + +async function p95(baseUrl: string, path: string, count: number): Promise<{ readonly p95: number; readonly errors: number }> { + const results = await Promise.allSettled(Array.from({ length: count }, () => timedRequest(baseUrl, path))); + const values = results.filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled').map((result) => result.value); + if (values.length === 0) throw new Error(`all LP5 ${path} probes failed`); + return Object.freeze({ p95: percentile(values, 95), errors: results.length - values.length }); +} + +const baseUrl = requiredEnvironment('LP5_BASE_URL').replace(/\/$/u, ''); +requiredEnvironment('LP5_LOGIN_EMAIL'); +requiredEnvironment('LP5_LOGIN_PASSWORD'); +const startupMs = requiredNumber('LP5_STARTUP_MS'); +const readinessMs = requiredNumber('LP5_READINESS_MS'); +const memoryMiB = requiredNumber('LP5_MEMORY_MIB'); +const cpuMillis = requiredNumber('LP5_CPU_MILLIS'); +const pids = requiredInteger('LP5_PIDS'); +const web = await p95(baseUrl, '/', 24); +const dav = await p95(baseUrl, '/.well-known/caldav', 16); +const ready = await fetch(`${baseUrl}/health/ready`); +if (!ready.ok) throw new Error(`LP5 readiness returned ${ready.status}`); + +const context = Object.freeze({ tenantId: 'acme', actorId: 'alice', role: 'user' as const }); +const queue = createMailQueue(); +const queueStarted = performance.now(); +for (let index = 0; index < 16; index += 1) { + const entry = queue.enqueue(context, { sender: 'alice@example.test', recipients: ['alice@example.test'], sizeBytes: 128 } as never); + queue.claim(entry.queueId, context); + queue.complete(entry.queueId, context, { state: 'delivered' } as never); +} +const queueP95Ms = performance.now() - queueStarted; + +const idle = createImapIdleBroker(); +const subscriptions = Array.from({ length: 8 }, () => idle.subscribe(context, { userId: 'alice', mailbox: 'INBOX', onEvent: () => undefined })); +const idleStarted = performance.now(); +const notification = idle.notify(context, { userId: 'alice', mailbox: 'INBOX', uidNext: 2 }); +const idleNotifyP95Ms = performance.now() - idleStarted; +for (const subscription of subscriptions) subscription.close(); +if (notification.delivered !== 8 || idle.count() !== 0) throw new Error('LP5 IDLE probe did not deliver and close all subscriptions'); + +const measurement: CapacityMeasurement = { + platform: process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64', + startupMs, + readinessMs, + webP95Ms: web.p95, + davP95Ms: dav.p95, + queueP95Ms, + idleNotifyP95Ms, + httpErrorRate: (web.errors + dav.errors) / 40, + activeIdleConnections: 8, + memoryMiB, + cpuMillis, + pids, +}; +const report = evaluateCapacity(measurement, AMD64_LOCAL_PROOF_BUDGET); +if (report.status !== 'pass') throw new Error(`LP5 proof failed: ${JSON.stringify(report.violations)}`); + +console.log(JSON.stringify({ + milestone: 'LP5', + platform: report.platform, + budget: report.budget, + measured: report.measured, + status: report.status, +}, null, 2)); diff --git a/src/capacity/capacity-contract.test.ts b/src/capacity/capacity-contract.test.ts new file mode 100644 index 0000000..0ac4f90 --- /dev/null +++ b/src/capacity/capacity-contract.test.ts @@ -0,0 +1,53 @@ +// 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 { AMD64_LOCAL_PROOF_BUDGET, evaluateCapacity, percentile } from './capacity-contract.ts'; + +function passingMeasurement() { + return { + platform: 'linux/amd64' as const, + startupMs: 1, + readinessMs: 1, + webP95Ms: 1, + davP95Ms: 1, + queueP95Ms: 1, + idleNotifyP95Ms: 1, + httpErrorRate: 0, + activeIdleConnections: 8, + memoryMiB: 1, + cpuMillis: 1, + pids: 1, + }; +} + +test('percentile is deterministic and uses the documented nearest-rank calculation', () => { + assert.equal(percentile([40, 10, 30, 20], 50), 20); + assert.equal(percentile([40, 10, 30, 20], 95), 40); + assert.throws(() => percentile([], 95), /samples/); +}); + +test('capacity report passes only when every explicit AMD64 local-proof budget is met', () => { + const report = evaluateCapacity(passingMeasurement()); + assert.equal(report.status, 'pass'); + assert.deepEqual(report.violations, []); + assert.deepEqual(report.budget, AMD64_LOCAL_PROOF_BUDGET); +}); + +test('capacity report treats IDLE as a minimum and resource measurements as maxima', () => { + const report = evaluateCapacity({ ...passingMeasurement(), activeIdleConnections: 7, memoryMiB: 769 }); + assert.equal(report.status, 'fail'); + assert.deepEqual(report.violations, [ + { metric: 'activeIdleConnections', actual: 7, expected: 8, comparison: 'minimum' }, + { metric: 'memoryMiB', actual: 769, expected: 768, comparison: 'maximum' }, + ]); +}); + +test('capacity report rejects incomplete or malformed measurements', () => { + const incomplete = passingMeasurement(); + delete (incomplete as Partial).pids; + assert.throws(() => evaluateCapacity(incomplete), /pids is required/); + assert.throws(() => evaluateCapacity({ ...passingMeasurement(), platform: 'darwin/arm64' } as never), /platform/); +}); diff --git a/src/capacity/capacity-contract.ts b/src/capacity/capacity-contract.ts new file mode 100644 index 0000000..b79d3f6 --- /dev/null +++ b/src/capacity/capacity-contract.ts @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +export type CapacityMetricName = + | 'startupMs' + | 'readinessMs' + | 'webP95Ms' + | 'davP95Ms' + | 'queueP95Ms' + | 'idleNotifyP95Ms' + | 'httpErrorRate' + | 'activeIdleConnections' + | 'memoryMiB' + | 'cpuMillis' + | 'pids'; + +export interface CapacityBudget { + readonly startupMs: number; + readonly readinessMs: number; + readonly webP95Ms: number; + readonly davP95Ms: number; + readonly queueP95Ms: number; + readonly idleNotifyP95Ms: number; + readonly httpErrorRate: number; + readonly activeIdleConnections: number; + readonly memoryMiB: number; + readonly cpuMillis: number; + readonly pids: number; +} + +export interface CapacityMeasurement extends Partial { + readonly platform: 'linux/amd64' | 'linux/arm64'; + readonly samples?: Readonly>; +} + +export interface CapacityViolation { + readonly metric: CapacityMetricName; + readonly actual: number; + readonly expected: number; + readonly comparison: 'maximum' | 'minimum'; +} + +export interface CapacityReport { + readonly platform: CapacityMeasurement['platform']; + readonly status: 'pass' | 'fail'; + readonly measured: Readonly; + readonly budget: Readonly; + readonly violations: readonly CapacityViolation[]; +} + +export const AMD64_LOCAL_PROOF_BUDGET: Readonly = Object.freeze({ + startupMs: 60_000, + readinessMs: 45_000, + webP95Ms: 750, + davP95Ms: 1_000, + queueP95Ms: 1_000, + idleNotifyP95Ms: 1_000, + httpErrorRate: 0, + activeIdleConnections: 8, + memoryMiB: 768, + cpuMillis: 2_000, + pids: 256, +}); + +const METRICS: readonly CapacityMetricName[] = Object.freeze([ + 'startupMs', 'readinessMs', 'webP95Ms', 'davP95Ms', 'queueP95Ms', + 'idleNotifyP95Ms', 'httpErrorRate', 'activeIdleConnections', 'memoryMiB', + 'cpuMillis', 'pids', +]); +const MINIMUM_METRICS = new Set(['activeIdleConnections']); + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function cloneMeasurement(measurement: CapacityMeasurement): CapacityMeasurement { + const samples = measurement.samples === undefined + ? undefined + : Object.freeze(Object.fromEntries(Object.entries(measurement.samples).map(([key, values]) => [key, Object.freeze([...values])]))) as Readonly>; + return Object.freeze({ ...measurement, ...(samples === undefined ? {} : { samples }) }); +} + +function assertPlatform(value: unknown): asserts value is CapacityMeasurement['platform'] { + if (value !== 'linux/amd64' && value !== 'linux/arm64') throw new TypeError('capacity platform must be linux/amd64 or linux/arm64'); +} + +export function percentile(samples: readonly number[], percentileValue: number): number { + if (!Number.isInteger(percentileValue) || percentileValue < 1 || percentileValue > 100) throw new RangeError('percentile must be an integer between 1 and 100'); + if (samples.length === 0 || samples.some((value) => !isFiniteNonNegative(value))) throw new TypeError('samples must contain finite non-negative numbers'); + const sorted = [...samples].sort((left, right) => left - right); + return sorted[Math.ceil((percentileValue / 100) * sorted.length) - 1]!; +} + +export function createCapacityMeasurement(input: CapacityMeasurement): Readonly { + assertPlatform(input.platform); + for (const metric of METRICS) { + const value = input[metric]; + if (value !== undefined && !isFiniteNonNegative(value)) throw new TypeError(`${metric} must be a finite non-negative number`); + } + for (const [name, values] of Object.entries(input.samples ?? {})) { + if (!/^[a-z][A-Za-z0-9]{0,63}$/u.test(name) || !Array.isArray(values)) throw new TypeError('sample keys and values are invalid'); + percentile(values, 95); + } + return cloneMeasurement(input); +} + +export function evaluateCapacity(input: CapacityMeasurement, budget: CapacityBudget = AMD64_LOCAL_PROOF_BUDGET): CapacityReport { + const measured = createCapacityMeasurement(input); + for (const metric of METRICS) { + if (!isFiniteNonNegative(budget[metric])) throw new TypeError(`budget ${metric} must be a finite non-negative number`); + if (measured[metric] === undefined) throw new TypeError(`measurement ${metric} is required`); + } + const violations: CapacityViolation[] = []; + for (const metric of METRICS) { + const actual = measured[metric]!; + const expected = budget[metric]; + const comparison = MINIMUM_METRICS.has(metric) ? 'minimum' : 'maximum'; + if ((comparison === 'minimum' && actual < expected) || (comparison === 'maximum' && actual > expected)) { + violations.push(Object.freeze({ metric, actual, expected, comparison })); + } + } + return Object.freeze({ + platform: measured.platform, + status: violations.length === 0 ? 'pass' : 'fail', + measured, + budget: Object.freeze({ ...budget }), + violations: Object.freeze(violations), + }); +} diff --git a/src/observability/alert-policy.mjs b/src/observability/alert-policy.mjs index a21d48d..bfa7974 100644 --- a/src/observability/alert-policy.mjs +++ b/src/observability/alert-policy.mjs @@ -2,343 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -const SAFE_SUBJECT_PATTERN = /^[A-Za-z0-9_.@:+/%/-]{1,192}$/u; -const SEVERITIES = Object.freeze(['warning', 'critical']); -const DEPENDENCY_STATUSES = new Set(['ok', 'starting', 'degraded', 'failed', 'unknown', 'disabled']); - -const DEFAULT_THRESHOLDS = Object.freeze({ - dependency: Object.freeze({ failedAfterSeconds: 60 }), - queue: Object.freeze({ - warningDepth: 100, - criticalDepth: 1_000, - warningOldestAgeSeconds: 300, - criticalOldestAgeSeconds: 1_800, - }), - certificate: Object.freeze({ warningDaysRemaining: 30, criticalDaysRemaining: 7 }), - storage: Object.freeze({ warningPercent: 80, criticalPercent: 90 }), - quota: Object.freeze({ warningPercent: 80, criticalPercent: 90 }), - authAbuse: Object.freeze({ warningFailures: 5, criticalFailures: 20, windowSeconds: 300 }), -}); - -function deepFreeze(value) { - if (value === null || typeof value !== 'object' || Object.isFrozen(value)) { - return value; - } - for (const nested of Object.values(value)) { - deepFreeze(nested); - } - return Object.freeze(value); -} - -function positiveNumber(value, name, { maximum = Number.MAX_SAFE_INTEGER } = {}) { - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > maximum) { - throw new RangeError(`${name} must be a finite number between 0 and ${maximum}`); - } - return value; -} - -function integer(value, name, { maximum = Number.MAX_SAFE_INTEGER } = {}) { - if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { - throw new RangeError(`${name} must be a positive integer no greater than ${maximum}`); - } - return value; -} - -function percent(value, name) { - return positiveNumber(value, name, { maximum: 100 }); -} - -function mergeThresholds(overrides = {}) { - if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) { - throw new TypeError('Alert thresholds must be an object'); - } - - const merged = {}; - for (const [category, defaults] of Object.entries(DEFAULT_THRESHOLDS)) { - const override = overrides[category] ?? {}; - if (override === null || typeof override !== 'object' || Array.isArray(override)) { - throw new TypeError(`Alert threshold ${category} must be an object`); - } - merged[category] = { ...defaults, ...override }; - } - - const dependency = merged.dependency; - integer(dependency.failedAfterSeconds, 'dependency.failedAfterSeconds', { maximum: 86_400 }); - - const queue = merged.queue; - integer(queue.warningDepth, 'queue.warningDepth', { maximum: 1_000_000_000 }); - integer(queue.criticalDepth, 'queue.criticalDepth', { maximum: 1_000_000_000 }); - integer(queue.warningOldestAgeSeconds, 'queue.warningOldestAgeSeconds', { maximum: 31_536_000 }); - integer(queue.criticalOldestAgeSeconds, 'queue.criticalOldestAgeSeconds', { maximum: 31_536_000 }); - if (queue.criticalDepth < queue.warningDepth || queue.criticalOldestAgeSeconds < queue.warningOldestAgeSeconds) { - throw new RangeError('Critical queue thresholds cannot be lower than warning thresholds'); - } - - const certificate = merged.certificate; - integer(certificate.warningDaysRemaining, 'certificate.warningDaysRemaining', { maximum: 3650 }); - integer(certificate.criticalDaysRemaining, 'certificate.criticalDaysRemaining', { maximum: 3650 }); - if (certificate.criticalDaysRemaining > certificate.warningDaysRemaining) { - throw new RangeError('Critical certificate threshold cannot exceed warning threshold'); - } - - for (const category of ['storage', 'quota']) { - percent(merged[category].warningPercent, `${category}.warningPercent`); - percent(merged[category].criticalPercent, `${category}.criticalPercent`); - if (merged[category].criticalPercent < merged[category].warningPercent) { - throw new RangeError(`Critical ${category} threshold cannot be lower than warning threshold`); - } - } - - const auth = merged.authAbuse; - integer(auth.warningFailures, 'authAbuse.warningFailures', { maximum: 1_000_000 }); - integer(auth.criticalFailures, 'authAbuse.criticalFailures', { maximum: 1_000_000 }); - integer(auth.windowSeconds, 'authAbuse.windowSeconds', { maximum: 86_400 }); - if (auth.criticalFailures < auth.warningFailures) { - throw new RangeError('Critical authentication threshold cannot be lower than warning threshold'); - } - - return merged; -} - -function subject(value, fallback = 'global') { - if (value === undefined || value === null || value === '') { - return fallback; - } - const normalized = String(value); - return SAFE_SUBJECT_PATTERN.test(normalized) ? normalized : fallback; -} - -function numberValue(value, name) { - return positiveNumber(value, name); -} - -function addAlert(alerts, { code, severity, source, subject: alertSubject, observed, threshold, message }) { - if (!SEVERITIES.includes(severity)) { - throw new TypeError('Unsupported alert severity'); - } - - alerts.push({ - code, - severity, - source, - subject: subject(alertSubject), - observed, - threshold, - message, - }); -} - -function evaluateDependencies(snapshot, thresholds, alerts) { - if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) { - return; - } - - for (const [name, entry] of Object.entries(snapshot)) { - if (entry === null || typeof entry !== 'object' || !DEPENDENCY_STATUSES.has(entry.status)) { - continue; - } - const dependencySubject = subject(name); - if (entry.status === 'failed') { - addAlert(alerts, { - code: 'dependency_failed', - severity: 'critical', - source: 'dependency', - subject: dependencySubject, - observed: entry.status, - threshold: 'failed', - message: 'A required dependency is failing', - }); - } else if (entry.status === 'degraded' || entry.status === 'unknown' || entry.status === 'starting') { - addAlert(alerts, { - code: 'dependency_unready', - severity: 'warning', - source: 'dependency', - subject: dependencySubject, - observed: entry.status, - threshold: thresholds.failedAfterSeconds, - message: 'A dependency is not ready', - }); - } - } -} - -function evaluateQueue(queue, thresholds, alerts) { - if (queue === null || typeof queue !== 'object' || Array.isArray(queue)) { - return; - } - if (queue.depth !== undefined) { - const depth = numberValue(queue.depth, 'Queue depth'); - if (depth >= thresholds.criticalDepth) { - addAlert(alerts, { - code: 'queue_depth_critical', severity: 'critical', source: 'queue', - observed: depth, threshold: thresholds.criticalDepth, - message: 'Mail queue depth is critical', - }); - } else if (depth >= thresholds.warningDepth) { - addAlert(alerts, { - code: 'queue_depth_high', severity: 'warning', source: 'queue', - observed: depth, threshold: thresholds.warningDepth, - message: 'Mail queue depth is high', - }); - } - } - if (queue.oldestAgeSeconds !== undefined) { - const age = numberValue(queue.oldestAgeSeconds, 'Oldest queue age'); - if (age >= thresholds.criticalOldestAgeSeconds) { - addAlert(alerts, { - code: 'queue_age_critical', severity: 'critical', source: 'queue', - observed: age, threshold: thresholds.criticalOldestAgeSeconds, - message: 'The oldest queued message is too old', - }); - } else if (age >= thresholds.warningOldestAgeSeconds) { - addAlert(alerts, { - code: 'queue_age_high', severity: 'warning', source: 'queue', - observed: age, threshold: thresholds.warningOldestAgeSeconds, - message: 'The oldest queued message is aging', - }); - } - } -} - -function evaluateCertificates(certificates, thresholds, alerts) { - if (!Array.isArray(certificates)) { - return; - } - for (const certificate of certificates) { - if (certificate === null || typeof certificate !== 'object' || certificate.daysRemaining === undefined) { - continue; - } - const days = Number(certificate.daysRemaining); - if (!Number.isFinite(days)) { - continue; - } - if (days <= thresholds.criticalDaysRemaining) { - addAlert(alerts, { - code: days < 0 ? 'certificate_expired' : 'certificate_expiry_critical', - severity: 'critical', source: 'certificate', subject: certificate.name, - observed: days, threshold: thresholds.criticalDaysRemaining, - message: days < 0 ? 'A certificate is expired' : 'A certificate is close to expiry', - }); - } else if (days <= thresholds.warningDaysRemaining) { - addAlert(alerts, { - code: 'certificate_expiry_warning', severity: 'warning', source: 'certificate', subject: certificate.name, - observed: days, threshold: thresholds.warningDaysRemaining, - message: 'A certificate is approaching expiry', - }); - } - } -} - -function evaluateCapacity(entry, category, thresholds, alerts) { - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { - return; - } - let usedPercent = entry.usedPercent; - if (usedPercent === undefined && entry.usedBytes !== undefined && entry.capacityBytes !== undefined) { - const usedBytes = numberValue(entry.usedBytes, `${category} usedBytes`); - const capacityBytes = numberValue(entry.capacityBytes, `${category} capacityBytes`); - if (capacityBytes <= 0) { - addAlert(alerts, { - code: `${category}_capacity_invalid`, severity: 'critical', source: category, - observed: capacityBytes, threshold: 1, - message: `${category} capacity is invalid`, - }); - return; - } - usedPercent = (usedBytes / capacityBytes) * 100; - } - if (usedPercent === undefined || !Number.isFinite(Number(usedPercent))) { - return; - } - const percentage = positiveNumber(Number(usedPercent), `${category} usedPercent`); - const limit = thresholds; - const categorySubject = entry.subject; - if (percentage >= limit.criticalPercent) { - addAlert(alerts, { - code: `${category}_pressure_critical`, severity: 'critical', source: category, - subject: categorySubject, observed: percentage, threshold: limit.criticalPercent, - message: `${category} usage is critical`, - }); - } else if (percentage >= limit.warningPercent) { - addAlert(alerts, { - code: `${category}_pressure_high`, severity: 'warning', source: category, - subject: categorySubject, observed: percentage, threshold: limit.warningPercent, - message: `${category} usage is high`, - }); - } -} - -function evaluateAuthAbuse(entry, thresholds, alerts) { - if (entry === null || typeof entry !== 'object' || entry.failedAttempts === undefined) { - return; - } - const failures = numberValue(entry.failedAttempts, 'Authentication failures'); - const subjectValue = entry.subject; - if (failures >= thresholds.criticalFailures) { - addAlert(alerts, { - code: 'auth_abuse_critical', severity: 'critical', source: 'auth_abuse', - subject: subjectValue, observed: failures, threshold: thresholds.criticalFailures, - message: 'Repeated authentication failures indicate abuse', - }); - } else if (failures >= thresholds.warningFailures) { - addAlert(alerts, { - code: 'auth_abuse_warning', severity: 'warning', source: 'auth_abuse', - subject: subjectValue, observed: failures, threshold: thresholds.warningFailures, - message: 'Authentication failures are above the alert threshold', - }); - } -} - -/** - * Create deterministic operational alerts from metadata-only health inputs. - * The evaluator never accepts message bodies, endpoints, addresses, or - * credentials and emits only safe identifiers and numeric measurements. - */ -export function createAlertPolicy({ thresholds = {}, clock = () => new Date() } = {}) { - const normalizedThresholds = deepFreeze(mergeThresholds(thresholds)); - - function evaluate(snapshot = {}) { - if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) { - throw new TypeError('Alert snapshot must be an object'); - } - - const alerts = []; - evaluateDependencies(snapshot.dependencies, normalizedThresholds.dependency, alerts); - evaluateQueue(snapshot.queue, normalizedThresholds.queue, alerts); - evaluateCertificates(snapshot.certificates, normalizedThresholds.certificate, alerts); - evaluateCapacity(snapshot.storage, 'storage', normalizedThresholds.storage, alerts); - evaluateCapacity(snapshot.quota, 'quota', normalizedThresholds.quota, alerts); - evaluateAuthAbuse(snapshot.authAbuse, normalizedThresholds.authAbuse, alerts); - - const severityRank = { critical: 0, warning: 1 }; - alerts.sort((left, right) => { - const severityDifference = severityRank[left.severity] - severityRank[right.severity]; - return severityDifference || left.code.localeCompare(right.code) || left.subject.localeCompare(right.subject); - }); - - const generatedAt = new Date(clock()); - if (Number.isNaN(generatedAt.getTime())) { - throw new TypeError('Alert clock must return a valid date'); - } - const frozenAlerts = alerts.map((alert) => Object.freeze({ - ...alert, - generated_at: generatedAt.toISOString(), - })); - const critical = frozenAlerts.filter((alert) => alert.severity === 'critical').length; - const warning = frozenAlerts.filter((alert) => alert.severity === 'warning').length; - return Object.freeze({ - generated_at: generatedAt.toISOString(), - status: critical > 0 ? 'critical' : warning > 0 ? 'warning' : 'ok', - critical, - warning, - alerts: Object.freeze(frozenAlerts), - }); - } - - return Object.freeze({ - thresholds: normalizedThresholds, - evaluate, - }); -} - -export { DEFAULT_THRESHOLDS }; +// Temporary compatibility bridge. Alert-policy behavior lives in TypeScript. +export * from './alert-policy.ts'; diff --git a/src/observability/alert-policy.ts b/src/observability/alert-policy.ts new file mode 100644 index 0000000..ec848a7 --- /dev/null +++ b/src/observability/alert-policy.ts @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +const SAFE_SUBJECT_PATTERN = /^[A-Za-z0-9_.@:+/%/-]{1,192}$/u; +const SEVERITIES = Object.freeze(['warning', 'critical']); +const DEPENDENCY_STATUSES = new Set(['ok', 'starting', 'degraded', 'failed', 'unknown', 'disabled']); + +const DEFAULT_THRESHOLDS = Object.freeze({ + dependency: Object.freeze({ failedAfterSeconds: 60 }), + queue: Object.freeze({ + warningDepth: 100, + criticalDepth: 1_000, + warningOldestAgeSeconds: 300, + criticalOldestAgeSeconds: 1_800, + }), + certificate: Object.freeze({ warningDaysRemaining: 30, criticalDaysRemaining: 7 }), + storage: Object.freeze({ warningPercent: 80, criticalPercent: 90 }), + quota: Object.freeze({ warningPercent: 80, criticalPercent: 90 }), + authAbuse: Object.freeze({ warningFailures: 5, criticalFailures: 20, windowSeconds: 300 }), +}); + +function deepFreeze(value) { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + return Object.freeze(value); +} + +function positiveNumber(value, name, { maximum = Number.MAX_SAFE_INTEGER } = {}) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > maximum) { + throw new RangeError(`${name} must be a finite number between 0 and ${maximum}`); + } + return value; +} + +function integer(value, name, { maximum = Number.MAX_SAFE_INTEGER } = {}) { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new RangeError(`${name} must be a positive integer no greater than ${maximum}`); + } + return value; +} + +function percent(value, name) { + return positiveNumber(value, name, { maximum: 100 }); +} + +function mergeThresholds(overrides = {}) { + if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) { + throw new TypeError('Alert thresholds must be an object'); + } + + const merged = {}; + for (const [category, defaults] of Object.entries(DEFAULT_THRESHOLDS)) { + const override = overrides[category] ?? {}; + if (override === null || typeof override !== 'object' || Array.isArray(override)) { + throw new TypeError(`Alert threshold ${category} must be an object`); + } + merged[category] = { ...defaults, ...override }; + } + + const dependency = merged.dependency; + integer(dependency.failedAfterSeconds, 'dependency.failedAfterSeconds', { maximum: 86_400 }); + + const queue = merged.queue; + integer(queue.warningDepth, 'queue.warningDepth', { maximum: 1_000_000_000 }); + integer(queue.criticalDepth, 'queue.criticalDepth', { maximum: 1_000_000_000 }); + integer(queue.warningOldestAgeSeconds, 'queue.warningOldestAgeSeconds', { maximum: 31_536_000 }); + integer(queue.criticalOldestAgeSeconds, 'queue.criticalOldestAgeSeconds', { maximum: 31_536_000 }); + if (queue.criticalDepth < queue.warningDepth || queue.criticalOldestAgeSeconds < queue.warningOldestAgeSeconds) { + throw new RangeError('Critical queue thresholds cannot be lower than warning thresholds'); + } + + const certificate = merged.certificate; + integer(certificate.warningDaysRemaining, 'certificate.warningDaysRemaining', { maximum: 3650 }); + integer(certificate.criticalDaysRemaining, 'certificate.criticalDaysRemaining', { maximum: 3650 }); + if (certificate.criticalDaysRemaining > certificate.warningDaysRemaining) { + throw new RangeError('Critical certificate threshold cannot exceed warning threshold'); + } + + for (const category of ['storage', 'quota']) { + percent(merged[category].warningPercent, `${category}.warningPercent`); + percent(merged[category].criticalPercent, `${category}.criticalPercent`); + if (merged[category].criticalPercent < merged[category].warningPercent) { + throw new RangeError(`Critical ${category} threshold cannot be lower than warning threshold`); + } + } + + const auth = merged.authAbuse; + integer(auth.warningFailures, 'authAbuse.warningFailures', { maximum: 1_000_000 }); + integer(auth.criticalFailures, 'authAbuse.criticalFailures', { maximum: 1_000_000 }); + integer(auth.windowSeconds, 'authAbuse.windowSeconds', { maximum: 86_400 }); + if (auth.criticalFailures < auth.warningFailures) { + throw new RangeError('Critical authentication threshold cannot be lower than warning threshold'); + } + + return merged; +} + +function subject(value, fallback = 'global') { + if (value === undefined || value === null || value === '') { + return fallback; + } + const normalized = String(value); + return SAFE_SUBJECT_PATTERN.test(normalized) ? normalized : fallback; +} + +function numberValue(value, name) { + return positiveNumber(value, name); +} + +function addAlert(alerts, { code, severity, source, subject: alertSubject, observed, threshold, message }) { + if (!SEVERITIES.includes(severity)) { + throw new TypeError('Unsupported alert severity'); + } + + alerts.push({ + code, + severity, + source, + subject: subject(alertSubject), + observed, + threshold, + message, + }); +} + +function evaluateDependencies(snapshot, thresholds, alerts) { + if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + return; + } + + for (const [name, entry] of Object.entries(snapshot)) { + if (entry === null || typeof entry !== 'object' || !DEPENDENCY_STATUSES.has(entry.status)) { + continue; + } + const dependencySubject = subject(name); + if (entry.status === 'failed') { + addAlert(alerts, { + code: 'dependency_failed', + severity: 'critical', + source: 'dependency', + subject: dependencySubject, + observed: entry.status, + threshold: 'failed', + message: 'A required dependency is failing', + }); + } else if (entry.status === 'degraded' || entry.status === 'unknown' || entry.status === 'starting') { + addAlert(alerts, { + code: 'dependency_unready', + severity: 'warning', + source: 'dependency', + subject: dependencySubject, + observed: entry.status, + threshold: thresholds.failedAfterSeconds, + message: 'A dependency is not ready', + }); + } + } +} + +function evaluateQueue(queue, thresholds, alerts) { + if (queue === null || typeof queue !== 'object' || Array.isArray(queue)) { + return; + } + if (queue.depth !== undefined) { + const depth = numberValue(queue.depth, 'Queue depth'); + if (depth >= thresholds.criticalDepth) { + addAlert(alerts, { + code: 'queue_depth_critical', severity: 'critical', source: 'queue', + observed: depth, threshold: thresholds.criticalDepth, + message: 'Mail queue depth is critical', + }); + } else if (depth >= thresholds.warningDepth) { + addAlert(alerts, { + code: 'queue_depth_high', severity: 'warning', source: 'queue', + observed: depth, threshold: thresholds.warningDepth, + message: 'Mail queue depth is high', + }); + } + } + if (queue.oldestAgeSeconds !== undefined) { + const age = numberValue(queue.oldestAgeSeconds, 'Oldest queue age'); + if (age >= thresholds.criticalOldestAgeSeconds) { + addAlert(alerts, { + code: 'queue_age_critical', severity: 'critical', source: 'queue', + observed: age, threshold: thresholds.criticalOldestAgeSeconds, + message: 'The oldest queued message is too old', + }); + } else if (age >= thresholds.warningOldestAgeSeconds) { + addAlert(alerts, { + code: 'queue_age_high', severity: 'warning', source: 'queue', + observed: age, threshold: thresholds.warningOldestAgeSeconds, + message: 'The oldest queued message is aging', + }); + } + } +} + +function evaluateCertificates(certificates, thresholds, alerts) { + if (!Array.isArray(certificates)) { + return; + } + for (const certificate of certificates) { + if (certificate === null || typeof certificate !== 'object' || certificate.daysRemaining === undefined) { + continue; + } + const days = Number(certificate.daysRemaining); + if (!Number.isFinite(days)) { + continue; + } + if (days <= thresholds.criticalDaysRemaining) { + addAlert(alerts, { + code: days < 0 ? 'certificate_expired' : 'certificate_expiry_critical', + severity: 'critical', source: 'certificate', subject: certificate.name, + observed: days, threshold: thresholds.criticalDaysRemaining, + message: days < 0 ? 'A certificate is expired' : 'A certificate is close to expiry', + }); + } else if (days <= thresholds.warningDaysRemaining) { + addAlert(alerts, { + code: 'certificate_expiry_warning', severity: 'warning', source: 'certificate', subject: certificate.name, + observed: days, threshold: thresholds.warningDaysRemaining, + message: 'A certificate is approaching expiry', + }); + } + } +} + +function evaluateCapacity(entry, category, thresholds, alerts) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + return; + } + let usedPercent = entry.usedPercent; + if (usedPercent === undefined && entry.usedBytes !== undefined && entry.capacityBytes !== undefined) { + const usedBytes = numberValue(entry.usedBytes, `${category} usedBytes`); + const capacityBytes = numberValue(entry.capacityBytes, `${category} capacityBytes`); + if (capacityBytes <= 0) { + addAlert(alerts, { + code: `${category}_capacity_invalid`, severity: 'critical', source: category, + observed: capacityBytes, threshold: 1, + message: `${category} capacity is invalid`, + }); + return; + } + usedPercent = (usedBytes / capacityBytes) * 100; + } + if (usedPercent === undefined || !Number.isFinite(Number(usedPercent))) { + return; + } + const percentage = positiveNumber(Number(usedPercent), `${category} usedPercent`); + const limit = thresholds; + const categorySubject = entry.subject; + if (percentage >= limit.criticalPercent) { + addAlert(alerts, { + code: `${category}_pressure_critical`, severity: 'critical', source: category, + subject: categorySubject, observed: percentage, threshold: limit.criticalPercent, + message: `${category} usage is critical`, + }); + } else if (percentage >= limit.warningPercent) { + addAlert(alerts, { + code: `${category}_pressure_high`, severity: 'warning', source: category, + subject: categorySubject, observed: percentage, threshold: limit.warningPercent, + message: `${category} usage is high`, + }); + } +} + +function evaluateAuthAbuse(entry, thresholds, alerts) { + if (entry === null || typeof entry !== 'object' || entry.failedAttempts === undefined) { + return; + } + const failures = numberValue(entry.failedAttempts, 'Authentication failures'); + const subjectValue = entry.subject; + if (failures >= thresholds.criticalFailures) { + addAlert(alerts, { + code: 'auth_abuse_critical', severity: 'critical', source: 'auth_abuse', + subject: subjectValue, observed: failures, threshold: thresholds.criticalFailures, + message: 'Repeated authentication failures indicate abuse', + }); + } else if (failures >= thresholds.warningFailures) { + addAlert(alerts, { + code: 'auth_abuse_warning', severity: 'warning', source: 'auth_abuse', + subject: subjectValue, observed: failures, threshold: thresholds.warningFailures, + message: 'Authentication failures are above the alert threshold', + }); + } +} + +/** + * Create deterministic operational alerts from metadata-only health inputs. + * The evaluator never accepts message bodies, endpoints, addresses, or + * credentials and emits only safe identifiers and numeric measurements. + */ +export function createAlertPolicy({ thresholds = {}, clock = () => new Date() } = {}) { + const normalizedThresholds = deepFreeze(mergeThresholds(thresholds)); + + function evaluate(snapshot = {}) { + if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + throw new TypeError('Alert snapshot must be an object'); + } + + const alerts = []; + evaluateDependencies(snapshot.dependencies, normalizedThresholds.dependency, alerts); + evaluateQueue(snapshot.queue, normalizedThresholds.queue, alerts); + evaluateCertificates(snapshot.certificates, normalizedThresholds.certificate, alerts); + evaluateCapacity(snapshot.storage, 'storage', normalizedThresholds.storage, alerts); + evaluateCapacity(snapshot.quota, 'quota', normalizedThresholds.quota, alerts); + evaluateAuthAbuse(snapshot.authAbuse, normalizedThresholds.authAbuse, alerts); + + const severityRank = { critical: 0, warning: 1 }; + alerts.sort((left, right) => { + const severityDifference = severityRank[left.severity] - severityRank[right.severity]; + return severityDifference || left.code.localeCompare(right.code) || left.subject.localeCompare(right.subject); + }); + + const generatedAt = new Date(clock()); + if (Number.isNaN(generatedAt.getTime())) { + throw new TypeError('Alert clock must return a valid date'); + } + const frozenAlerts = alerts.map((alert) => Object.freeze({ + ...alert, + generated_at: generatedAt.toISOString(), + })); + const critical = frozenAlerts.filter((alert) => alert.severity === 'critical').length; + const warning = frozenAlerts.filter((alert) => alert.severity === 'warning').length; + return Object.freeze({ + generated_at: generatedAt.toISOString(), + status: critical > 0 ? 'critical' : warning > 0 ? 'warning' : 'ok', + critical, + warning, + alerts: Object.freeze(frozenAlerts), + }); + } + + return Object.freeze({ + thresholds: normalizedThresholds, + evaluate, + }); +} + +export { DEFAULT_THRESHOLDS }; diff --git a/src/observability/index.mjs b/src/observability/index.mjs index a7545f7..67819fa 100644 --- a/src/observability/index.mjs +++ b/src/observability/index.mjs @@ -2,22 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -export { - AUDIT_SINKS, - LOG_ROTATION_MODES, - assertLogRotationPolicy, - createLogRotationPolicy, - parseByteSize, -} from './log-policy.mjs'; -export { - STRUCTURED_EVENT_LEVELS, - STRUCTURED_EVENT_RESULTS, - createAuditEvent, - createStructuredEvent, - isAuditEvent, - serializeStructuredEvent, -} from './structured-event.mjs'; -export { - DEFAULT_THRESHOLDS, - createAlertPolicy, -} from './alert-policy.mjs'; +// Temporary compatibility bridge. Observability behavior lives in TypeScript. +export * from './index.ts'; diff --git a/src/observability/index.ts b/src/observability/index.ts new file mode 100644 index 0000000..78386f8 --- /dev/null +++ b/src/observability/index.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +export { + AUDIT_SINKS, + LOG_ROTATION_MODES, + assertLogRotationPolicy, + createLogRotationPolicy, + parseByteSize, +} from './log-policy.ts'; +export { + STRUCTURED_EVENT_LEVELS, + STRUCTURED_EVENT_RESULTS, + createAuditEvent, + createStructuredEvent, + isAuditEvent, + serializeStructuredEvent, +} from './structured-event.ts'; +export { + DEFAULT_THRESHOLDS, + createAlertPolicy, +} from './alert-policy.ts'; diff --git a/src/observability/log-policy.mjs b/src/observability/log-policy.mjs index 6314644..dbf58cb 100644 --- a/src/observability/log-policy.mjs +++ b/src/observability/log-policy.mjs @@ -2,244 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -const MAX_POLICY_BYTES = 1_099_511_627_776; -const SIZE_PATTERN = /^([1-9]\d*)(b|k|kb|kib|m|mb|mib|g|gb|gib)$/i; -const MODE_VALUES = Object.freeze(['docker-json-file', 'journald', 'sidecar']); -const AUDIT_SINK_VALUES = Object.freeze(['external', 'journald', 'sidecar']); - -const SIZE_MULTIPLIERS = Object.freeze({ - b: 1, - k: 1_000, - kb: 1_000, - kib: 1_024, - m: 1_000_000, - mb: 1_000_000, - mib: 1_048_576, - g: 1_000_000_000, - gb: 1_000_000_000, - gib: 1_073_741_824, -}); - -function deepFreeze(value) { - if (value === null || typeof value !== 'object' || Object.isFrozen(value)) { - return value; - } - - for (const nested of Object.values(value)) { - deepFreeze(nested); - } - - return Object.freeze(value); -} - -function integer(value, name, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) { - if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { - throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`); - } - - return value; -} - -/** - * Parse a bounded byte-size value. Decimal Docker suffixes (`m`, `g`) and - * binary suffixes (`mib`, `gib`) are accepted, but zero and unbounded values - * are intentionally rejected. - */ -export function parseByteSize(value, name = 'Byte size') { - if (Number.isSafeInteger(value)) { - return integer(value, name, { maximum: MAX_POLICY_BYTES }); - } - - if (typeof value !== 'string') { - throw new TypeError(`${name} must be a positive byte count or size string`); - } - - const match = value.trim().match(SIZE_PATTERN); - if (match === null) { - throw new TypeError(`${name} must use a positive B, K, KiB, M, MiB, G, or GiB suffix`); - } - - const bytes = Number(match[1]) * SIZE_MULTIPLIERS[match[2].toLowerCase()]; - if (!Number.isSafeInteger(bytes) || bytes > MAX_POLICY_BYTES) { - throw new RangeError(`${name} is outside the supported bounded range`); - } - - return bytes; -} - -function dockerSize(bytes) { - const units = [ - [1_000_000_000, 'g'], - [1_000_000, 'm'], - [1_000, 'k'], - ]; - - for (const [multiplier, suffix] of units) { - if (bytes >= multiplier && bytes % multiplier === 0) { - return `${bytes / multiplier}${suffix}`; - } - } - - return `${bytes}b`; -} - -function normalizeMode(mode) { - if (!MODE_VALUES.includes(mode)) { - throw new TypeError(`Log rotation mode must be one of: ${MODE_VALUES.join(', ')}`); - } - - return mode; -} - -function normalizeAuditSink(sink) { - if (!AUDIT_SINK_VALUES.includes(sink)) { - throw new TypeError(`Audit sink must be one of: ${AUDIT_SINK_VALUES.join(', ')}`); - } - - return sink; -} - -function normalizeJournald(options) { - const maxUseBytes = parseByteSize(options.maxUse ?? '1g', 'Journald maxUse'); - const maxFileBytes = parseByteSize(options.maxFile ?? '100m', 'Journald maxFile'); - if (maxFileBytes > maxUseBytes) { - throw new RangeError('Journald maxFile cannot exceed maxUse'); - } - - return { - max_use_bytes: maxUseBytes, - max_file_bytes: maxFileBytes, - max_retention_days: options.retentionDays, - forward_to_audit: true, - }; -} - -function normalizeSidecar(options) { - const name = options.name ?? 'gulogulo-log-collector'; - if (typeof name !== 'string' || !/^[a-z][a-z0-9_.-]{0,62}$/u.test(name)) { - throw new TypeError('Sidecar name must be a short container-safe identifier'); - } - - return { - name, - max_size_bytes: parseByteSize(options.maxSize ?? '10m', 'Sidecar maxSize'), - max_files: integer(options.maxFiles ?? 5, 'Sidecar maxFiles', { maximum: 100 }), - compress: options.compress ?? true, - forward_to_audit: true, - }; -} - -/** - * Create the bounded log policy used by a container deployment. - * - * Applications write one sanitized JSON record per line to stdout/stderr. - * Rotation is owned by Docker, journald, or a dedicated sidecar; the - * application never truncates an active audit stream itself. - */ -export function createLogRotationPolicy({ - mode = 'docker-json-file', - maxSize = '10m', - maxFiles = 5, - retentionDays = 28, - maxRecordBytes = 256 * 1024, - compress = true, - auditRetentionDays = 365, - auditSink = 'external', - journald = {}, - sidecar = {}, -} = {}) { - const normalizedMode = normalizeMode(mode); - const maxSizeBytes = parseByteSize(maxSize, 'Log maxSize'); - const normalizedMaxFiles = integer(maxFiles, 'Log maxFiles', { maximum: 100 }); - const normalizedRetentionDays = integer(retentionDays, 'Log retentionDays', { maximum: 3_650 }); - const normalizedMaxRecordBytes = parseByteSize(maxRecordBytes, 'Log maxRecordBytes'); - const normalizedAuditRetentionDays = integer( - auditRetentionDays, - 'Audit retentionDays', - { maximum: 36_500 }, - ); - - if (normalizedMaxRecordBytes > maxSizeBytes) { - throw new RangeError('Log maxRecordBytes cannot exceed Log maxSize'); - } - if (normalizedAuditRetentionDays < normalizedRetentionDays) { - throw new RangeError('Audit retention must be at least as long as local log retention'); - } - if (typeof compress !== 'boolean') { - throw new TypeError('Log compression must be boolean'); - } - - const normalizedAuditSink = normalizeAuditSink(auditSink); - const policy = { - application_stream: 'stdout/stderr', - mode: normalizedMode, - bounded: true, - max_size_bytes: maxSizeBytes, - max_files: normalizedMaxFiles, - retention_days: normalizedRetentionDays, - max_record_bytes: normalizedMaxRecordBytes, - compress, - audit: { - preserve: true, - retention_days: normalizedAuditRetentionDays, - sink: normalizedAuditSink, - content_excluded: true, - }, - docker: null, - journald: null, - sidecar: null, - }; - - if (normalizedMode === 'docker-json-file') { - policy.docker = { - driver: 'json-file', - options: { - 'max-size': dockerSize(maxSizeBytes), - 'max-file': String(normalizedMaxFiles), - compress: String(compress), - }, - }; - } - - if (normalizedMode === 'journald') { - policy.journald = normalizeJournald({ - ...journald, - retentionDays: normalizedRetentionDays, - }); - } - - if (normalizedMode === 'sidecar') { - policy.sidecar = normalizeSidecar({ - ...sidecar, - maxSize, - maxFiles: normalizedMaxFiles, - compress, - }); - } - - return deepFreeze(policy); -} - -export function assertLogRotationPolicy(policy) { - if (policy === null || typeof policy !== 'object' || policy.bounded !== true) { - throw new TypeError('A bounded log rotation policy is required'); - } - if (!MODE_VALUES.includes(policy.mode)) { - throw new TypeError('Unsupported log rotation mode'); - } - parseByteSize(policy.max_size_bytes, 'Policy max_size_bytes'); - integer(policy.max_files, 'Policy max_files', { maximum: 100 }); - integer(policy.retention_days, 'Policy retention_days', { maximum: 3_650 }); - if (policy.audit?.preserve !== true || policy.audit.content_excluded !== true) { - throw new TypeError('Audit preservation and content exclusion are mandatory'); - } - integer(policy.audit.retention_days, 'Policy audit retention_days', { maximum: 36_500 }); - if (policy.audit.retention_days < policy.retention_days) { - throw new RangeError('Audit retention cannot be shorter than local retention'); - } - - return true; -} - -export const LOG_ROTATION_MODES = MODE_VALUES; -export const AUDIT_SINKS = AUDIT_SINK_VALUES; +// Temporary compatibility bridge. Log-policy behavior lives in TypeScript. +export * from './log-policy.ts'; diff --git a/src/observability/log-policy.ts b/src/observability/log-policy.ts new file mode 100644 index 0000000..6272cc0 --- /dev/null +++ b/src/observability/log-policy.ts @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +const MAX_POLICY_BYTES = 1_099_511_627_776; +const SIZE_PATTERN = /^([1-9]\d*)(b|k|kb|kib|m|mb|mib|g|gb|gib)$/i; +const MODE_VALUES = Object.freeze(['docker-json-file', 'journald', 'sidecar']); +const AUDIT_SINK_VALUES = Object.freeze(['external', 'journald', 'sidecar']); + +const SIZE_MULTIPLIERS = Object.freeze({ + b: 1, + k: 1_000, + kb: 1_000, + kib: 1_024, + m: 1_000_000, + mb: 1_000_000, + mib: 1_048_576, + g: 1_000_000_000, + gb: 1_000_000_000, + gib: 1_073_741_824, +}); + +function deepFreeze(value) { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + + return Object.freeze(value); +} + +function integer(value, name, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`); + } + + return value; +} + +/** + * Parse a bounded byte-size value. Decimal Docker suffixes (`m`, `g`) and + * binary suffixes (`mib`, `gib`) are accepted, but zero and unbounded values + * are intentionally rejected. + */ +export function parseByteSize(value, name = 'Byte size') { + if (Number.isSafeInteger(value)) { + return integer(value, name, { maximum: MAX_POLICY_BYTES }); + } + + if (typeof value !== 'string') { + throw new TypeError(`${name} must be a positive byte count or size string`); + } + + const match = value.trim().match(SIZE_PATTERN); + if (match === null) { + throw new TypeError(`${name} must use a positive B, K, KiB, M, MiB, G, or GiB suffix`); + } + + const bytes = Number(match[1]) * SIZE_MULTIPLIERS[match[2].toLowerCase()]; + if (!Number.isSafeInteger(bytes) || bytes > MAX_POLICY_BYTES) { + throw new RangeError(`${name} is outside the supported bounded range`); + } + + return bytes; +} + +function dockerSize(bytes) { + const units = [ + [1_000_000_000, 'g'], + [1_000_000, 'm'], + [1_000, 'k'], + ]; + + for (const [multiplier, suffix] of units) { + if (bytes >= multiplier && bytes % multiplier === 0) { + return `${bytes / multiplier}${suffix}`; + } + } + + return `${bytes}b`; +} + +function normalizeMode(mode) { + if (!MODE_VALUES.includes(mode)) { + throw new TypeError(`Log rotation mode must be one of: ${MODE_VALUES.join(', ')}`); + } + + return mode; +} + +function normalizeAuditSink(sink) { + if (!AUDIT_SINK_VALUES.includes(sink)) { + throw new TypeError(`Audit sink must be one of: ${AUDIT_SINK_VALUES.join(', ')}`); + } + + return sink; +} + +function normalizeJournald(options) { + const maxUseBytes = parseByteSize(options.maxUse ?? '1g', 'Journald maxUse'); + const maxFileBytes = parseByteSize(options.maxFile ?? '100m', 'Journald maxFile'); + if (maxFileBytes > maxUseBytes) { + throw new RangeError('Journald maxFile cannot exceed maxUse'); + } + + return { + max_use_bytes: maxUseBytes, + max_file_bytes: maxFileBytes, + max_retention_days: options.retentionDays, + forward_to_audit: true, + }; +} + +function normalizeSidecar(options) { + const name = options.name ?? 'gulogulo-log-collector'; + if (typeof name !== 'string' || !/^[a-z][a-z0-9_.-]{0,62}$/u.test(name)) { + throw new TypeError('Sidecar name must be a short container-safe identifier'); + } + + return { + name, + max_size_bytes: parseByteSize(options.maxSize ?? '10m', 'Sidecar maxSize'), + max_files: integer(options.maxFiles ?? 5, 'Sidecar maxFiles', { maximum: 100 }), + compress: options.compress ?? true, + forward_to_audit: true, + }; +} + +/** + * Create the bounded log policy used by a container deployment. + * + * Applications write one sanitized JSON record per line to stdout/stderr. + * Rotation is owned by Docker, journald, or a dedicated sidecar; the + * application never truncates an active audit stream itself. + */ +export function createLogRotationPolicy({ + mode = 'docker-json-file', + maxSize = '10m', + maxFiles = 5, + retentionDays = 28, + maxRecordBytes = 256 * 1024, + compress = true, + auditRetentionDays = 365, + auditSink = 'external', + journald = {}, + sidecar = {}, +} = {}) { + const normalizedMode = normalizeMode(mode); + const maxSizeBytes = parseByteSize(maxSize, 'Log maxSize'); + const normalizedMaxFiles = integer(maxFiles, 'Log maxFiles', { maximum: 100 }); + const normalizedRetentionDays = integer(retentionDays, 'Log retentionDays', { maximum: 3_650 }); + const normalizedMaxRecordBytes = parseByteSize(maxRecordBytes, 'Log maxRecordBytes'); + const normalizedAuditRetentionDays = integer( + auditRetentionDays, + 'Audit retentionDays', + { maximum: 36_500 }, + ); + + if (normalizedMaxRecordBytes > maxSizeBytes) { + throw new RangeError('Log maxRecordBytes cannot exceed Log maxSize'); + } + if (normalizedAuditRetentionDays < normalizedRetentionDays) { + throw new RangeError('Audit retention must be at least as long as local log retention'); + } + if (typeof compress !== 'boolean') { + throw new TypeError('Log compression must be boolean'); + } + + const normalizedAuditSink = normalizeAuditSink(auditSink); + const policy = { + application_stream: 'stdout/stderr', + mode: normalizedMode, + bounded: true, + max_size_bytes: maxSizeBytes, + max_files: normalizedMaxFiles, + retention_days: normalizedRetentionDays, + max_record_bytes: normalizedMaxRecordBytes, + compress, + audit: { + preserve: true, + retention_days: normalizedAuditRetentionDays, + sink: normalizedAuditSink, + content_excluded: true, + }, + docker: null, + journald: null, + sidecar: null, + }; + + if (normalizedMode === 'docker-json-file') { + policy.docker = { + driver: 'json-file', + options: { + 'max-size': dockerSize(maxSizeBytes), + 'max-file': String(normalizedMaxFiles), + compress: String(compress), + }, + }; + } + + if (normalizedMode === 'journald') { + policy.journald = normalizeJournald({ + ...journald, + retentionDays: normalizedRetentionDays, + }); + } + + if (normalizedMode === 'sidecar') { + policy.sidecar = normalizeSidecar({ + ...sidecar, + maxSize, + maxFiles: normalizedMaxFiles, + compress, + }); + } + + return deepFreeze(policy); +} + +export function assertLogRotationPolicy(policy) { + if (policy === null || typeof policy !== 'object' || policy.bounded !== true) { + throw new TypeError('A bounded log rotation policy is required'); + } + if (!MODE_VALUES.includes(policy.mode)) { + throw new TypeError('Unsupported log rotation mode'); + } + parseByteSize(policy.max_size_bytes, 'Policy max_size_bytes'); + integer(policy.max_files, 'Policy max_files', { maximum: 100 }); + integer(policy.retention_days, 'Policy retention_days', { maximum: 3_650 }); + if (policy.audit?.preserve !== true || policy.audit.content_excluded !== true) { + throw new TypeError('Audit preservation and content exclusion are mandatory'); + } + integer(policy.audit.retention_days, 'Policy audit retention_days', { maximum: 36_500 }); + if (policy.audit.retention_days < policy.retention_days) { + throw new RangeError('Audit retention cannot be shorter than local retention'); + } + + return true; +} + +export const LOG_ROTATION_MODES = MODE_VALUES; +export const AUDIT_SINKS = AUDIT_SINK_VALUES; diff --git a/src/observability/observability.test.mjs b/src/observability/observability.test.mjs index 409707d..c496212 100644 --- a/src/observability/observability.test.mjs +++ b/src/observability/observability.test.mjs @@ -2,170 +2,5 @@ // 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 { - assertLogRotationPolicy, - createAlertPolicy, - createAuditEvent, - createLogRotationPolicy, - createStructuredEvent, - isAuditEvent, - parseByteSize, - serializeStructuredEvent, -} from './index.mjs'; - -test('log rotation defaults are bounded and Docker-compatible', () => { - const policy = createLogRotationPolicy(); - - assert.equal(policy.mode, 'docker-json-file'); - assert.equal(policy.bounded, true); - assert.equal(policy.max_size_bytes, 10_000_000); - assert.equal(policy.max_files, 5); - assert.equal(policy.docker.driver, 'json-file'); - assert.equal(policy.docker.options['max-size'], '10m'); - assert.equal(policy.docker.options['max-file'], '5'); - assert.equal(policy.audit.preserve, true); - assert.equal(policy.audit.content_excluded, true); - assert.equal(assertLogRotationPolicy(policy), true); - assert.equal(Object.isFrozen(policy), true); -}); - -test('log rotation supports journald and sidecar bounds without unbounded fallback', () => { - const journald = createLogRotationPolicy({ - mode: 'journald', - maxSize: '20m', - maxFiles: 3, - retentionDays: 14, - auditRetentionDays: 730, - journald: { maxUse: '2g', maxFile: '100m' }, - auditSink: 'journald', - }); - assert.equal(journald.journald.max_use_bytes, 2_000_000_000); - assert.equal(journald.journald.max_file_bytes, 100_000_000); - assert.equal(journald.docker, null); - - const sidecar = createLogRotationPolicy({ - mode: 'sidecar', - maxSize: '8m', - maxFiles: 4, - sidecar: { name: 'vector-collector' }, - }); - assert.equal(sidecar.sidecar.name, 'vector-collector'); - assert.equal(sidecar.sidecar.max_size_bytes, 8_000_000); - assert.equal(sidecar.sidecar.max_files, 4); - assert.equal(sidecar.sidecar.forward_to_audit, true); -}); - -test('log policy rejects zero, oversized records, and shorter audit retention', () => { - assert.equal(parseByteSize('1MiB'), 1_048_576); - assert.throws(() => parseByteSize('0m'), /positive/); - assert.throws(() => createLogRotationPolicy({ maxSize: '1m', maxRecordBytes: '2m' }), /cannot exceed/); - assert.throws(() => createLogRotationPolicy({ retentionDays: 30, auditRetentionDays: 29 }), /at least/); - assert.throws(() => createLogRotationPolicy({ mode: 'unbounded' }), /one of/); -}); - -test('structured events redact credentials and content while preserving audit metadata', () => { - const event = createAuditEvent({ - service: 'gulogulo-web', - event: 'mfa.factor.enrolled', - timestamp: '2026-08-22T00:00:00.000Z', - tenant: 'example.test', - actor: 'user@example.test', - subject: 'user@example.test', - result: 'success', - details: { - factor: 'totp', - token: 'secret-token', - message_body: 'private message', - inline: 'password=private-password', - }, - }); - - assert.equal(event.audit, true); - assert.equal(event.actor, 'user@example.test'); - assert.equal(event.details.token, '[REDACTED]'); - assert.equal(event.details.message_body, '[REDACTED]'); - assert.equal(event.details.inline.includes('private-password'), false); - assert.equal(isAuditEvent(event), true); - assert.equal(serializeStructuredEvent(event).endsWith('\n'), true); - assert.equal(serializeStructuredEvent(event).includes('private-password'), false); - assert.equal(serializeStructuredEvent({ event: 'unsafe.event', token: 'secret-token' }).includes('secret-token'), false); -}); - -test('non-audit events do not allow details to overwrite stable metadata', () => { - const event = createStructuredEvent({ - event: 'queue.depth.sampled', - details: { - level: 'error', - audit: true, - result: 'failure', - depth: 12, - }, - }); - - assert.equal(event.level, 'info'); - assert.equal(event.audit, false); - assert.equal(event.result, null); - assert.equal(event.details.depth, 12); - assert.equal('audit' in event.details, false); -}); - -test('structured event size limit fails closed', () => { - assert.throws( - () => createStructuredEvent({ event: 'test.event', maxBytes: 512, details: { text: 'x'.repeat(2_000) } }), - /byte limit/, - ); - assert.throws(() => createAuditEvent({ event: 'audit.event' }), /actor is required/); -}); - -test('alert policy covers dependencies, queue, certificates, capacity, and auth abuse', () => { - const policy = createAlertPolicy({ clock: () => new Date('2026-08-22T00:00:00.000Z') }); - const result = policy.evaluate({ - dependencies: { - ldap: { status: 'failed', endpoint: 'ldap.internal', password: 'secret' }, - postgres: { status: 'degraded' }, - }, - queue: { depth: 1_200, oldestAgeSeconds: 2_000 }, - certificates: [{ name: 'web', daysRemaining: 4 }, { name: 'mail', daysRemaining: 20 }], - storage: { usedBytes: 95, capacityBytes: 100 }, - quota: { usedPercent: 85, subject: 'tenant@example.test' }, - authAbuse: { failedAttempts: 25, subject: 'user@example.test', windowSeconds: 300 }, - }); - - assert.equal(result.status, 'critical'); - assert.equal(result.critical >= 5, true); - assert.equal(result.warning >= 3, true); - assert.equal(result.generated_at, '2026-08-22T00:00:00.000Z'); - assert.equal(result.alerts.some((alert) => alert.code === 'dependency_failed'), true); - assert.equal(result.alerts.some((alert) => alert.code === 'queue_depth_critical'), true); - assert.equal(result.alerts.some((alert) => alert.code === 'certificate_expiry_critical'), true); - assert.equal(result.alerts.some((alert) => alert.code === 'storage_pressure_critical'), true); - assert.equal(result.alerts.some((alert) => alert.code === 'quota_pressure_high'), true); - assert.equal(result.alerts.some((alert) => alert.code === 'auth_abuse_critical'), true); - assert.equal(JSON.stringify(result).includes('ldap.internal'), false); - assert.equal(JSON.stringify(result).includes('secret'), false); - - const overfull = policy.evaluate({ storage: { usedPercent: 110 } }); - assert.equal(overfull.status, 'critical'); - assert.equal(overfull.alerts[0].observed, 110); -}); - -test('alert policy returns a clean status and accepts disabled dependencies', () => { - const policy = createAlertPolicy(); - const result = policy.evaluate({ dependencies: { clamd: { status: 'disabled' } } }); - assert.equal(result.status, 'ok'); - assert.equal(result.alerts.length, 0); -}); - -test('alert thresholds reject unsafe ordering', () => { - assert.throws( - () => createAlertPolicy({ thresholds: { queue: { warningDepth: 100, criticalDepth: 99 } } }), - /Critical queue thresholds/, - ); - assert.throws( - () => createAlertPolicy({ thresholds: { storage: { warningPercent: 80, criticalPercent: 101 } } }), - /between 0 and 100/, - ); -}); +// Temporary compatibility bridge. Observability contract tests are TypeScript. +import './observability.test.ts'; diff --git a/src/observability/observability.test.ts b/src/observability/observability.test.ts new file mode 100644 index 0000000..7903835 --- /dev/null +++ b/src/observability/observability.test.ts @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assertLogRotationPolicy, + createAlertPolicy, + createAuditEvent, + createLogRotationPolicy, + createStructuredEvent, + isAuditEvent, + parseByteSize, + serializeStructuredEvent, +} from './index.ts'; + +test('log rotation defaults are bounded and Docker-compatible', () => { + const policy = createLogRotationPolicy(); + + assert.equal(policy.mode, 'docker-json-file'); + assert.equal(policy.bounded, true); + assert.equal(policy.max_size_bytes, 10_000_000); + assert.equal(policy.max_files, 5); + assert.equal(policy.docker.driver, 'json-file'); + assert.equal(policy.docker.options['max-size'], '10m'); + assert.equal(policy.docker.options['max-file'], '5'); + assert.equal(policy.audit.preserve, true); + assert.equal(policy.audit.content_excluded, true); + assert.equal(assertLogRotationPolicy(policy), true); + assert.equal(Object.isFrozen(policy), true); +}); + +test('log rotation supports journald and sidecar bounds without unbounded fallback', () => { + const journald = createLogRotationPolicy({ + mode: 'journald', + maxSize: '20m', + maxFiles: 3, + retentionDays: 14, + auditRetentionDays: 730, + journald: { maxUse: '2g', maxFile: '100m' }, + auditSink: 'journald', + }); + assert.equal(journald.journald.max_use_bytes, 2_000_000_000); + assert.equal(journald.journald.max_file_bytes, 100_000_000); + assert.equal(journald.docker, null); + + const sidecar = createLogRotationPolicy({ + mode: 'sidecar', + maxSize: '8m', + maxFiles: 4, + sidecar: { name: 'vector-collector' }, + }); + assert.equal(sidecar.sidecar.name, 'vector-collector'); + assert.equal(sidecar.sidecar.max_size_bytes, 8_000_000); + assert.equal(sidecar.sidecar.max_files, 4); + assert.equal(sidecar.sidecar.forward_to_audit, true); +}); + +test('log policy rejects zero, oversized records, and shorter audit retention', () => { + assert.equal(parseByteSize('1MiB'), 1_048_576); + assert.throws(() => parseByteSize('0m'), /positive/); + assert.throws(() => createLogRotationPolicy({ maxSize: '1m', maxRecordBytes: '2m' }), /cannot exceed/); + assert.throws(() => createLogRotationPolicy({ retentionDays: 30, auditRetentionDays: 29 }), /at least/); + assert.throws(() => createLogRotationPolicy({ mode: 'unbounded' }), /one of/); +}); + +test('structured events redact credentials and content while preserving audit metadata', () => { + const event = createAuditEvent({ + service: 'gulogulo-web', + event: 'mfa.factor.enrolled', + timestamp: '2026-08-22T00:00:00.000Z', + tenant: 'example.test', + actor: 'user@example.test', + subject: 'user@example.test', + result: 'success', + details: { + factor: 'totp', + token: 'secret-token', + message_body: 'private message', + inline: 'password=private-password', + }, + }); + + assert.equal(event.audit, true); + assert.equal(event.actor, 'user@example.test'); + assert.equal(event.details.token, '[REDACTED]'); + assert.equal(event.details.message_body, '[REDACTED]'); + assert.equal(event.details.inline.includes('private-password'), false); + assert.equal(isAuditEvent(event), true); + assert.equal(serializeStructuredEvent(event).endsWith('\n'), true); + assert.equal(serializeStructuredEvent(event).includes('private-password'), false); + assert.equal(serializeStructuredEvent({ event: 'unsafe.event', token: 'secret-token' }).includes('secret-token'), false); +}); + +test('non-audit events do not allow details to overwrite stable metadata', () => { + const event = createStructuredEvent({ + event: 'queue.depth.sampled', + details: { + level: 'error', + audit: true, + result: 'failure', + depth: 12, + }, + }); + + assert.equal(event.level, 'info'); + assert.equal(event.audit, false); + assert.equal(event.result, null); + assert.equal(event.details.depth, 12); + assert.equal('audit' in event.details, false); +}); + +test('structured event size limit fails closed', () => { + assert.throws( + () => createStructuredEvent({ event: 'test.event', maxBytes: 512, details: { text: 'x'.repeat(2_000) } }), + /byte limit/, + ); + assert.throws(() => createAuditEvent({ event: 'audit.event' }), /actor is required/); +}); + +test('alert policy covers dependencies, queue, certificates, capacity, and auth abuse', () => { + const policy = createAlertPolicy({ clock: () => new Date('2026-08-22T00:00:00.000Z') }); + const result = policy.evaluate({ + dependencies: { + ldap: { status: 'failed', endpoint: 'ldap.internal', password: 'secret' }, + postgres: { status: 'degraded' }, + }, + queue: { depth: 1_200, oldestAgeSeconds: 2_000 }, + certificates: [{ name: 'web', daysRemaining: 4 }, { name: 'mail', daysRemaining: 20 }], + storage: { usedBytes: 95, capacityBytes: 100 }, + quota: { usedPercent: 85, subject: 'tenant@example.test' }, + authAbuse: { failedAttempts: 25, subject: 'user@example.test', windowSeconds: 300 }, + }); + + assert.equal(result.status, 'critical'); + assert.equal(result.critical >= 5, true); + assert.equal(result.warning >= 3, true); + assert.equal(result.generated_at, '2026-08-22T00:00:00.000Z'); + assert.equal(result.alerts.some((alert) => alert.code === 'dependency_failed'), true); + assert.equal(result.alerts.some((alert) => alert.code === 'queue_depth_critical'), true); + assert.equal(result.alerts.some((alert) => alert.code === 'certificate_expiry_critical'), true); + assert.equal(result.alerts.some((alert) => alert.code === 'storage_pressure_critical'), true); + assert.equal(result.alerts.some((alert) => alert.code === 'quota_pressure_high'), true); + assert.equal(result.alerts.some((alert) => alert.code === 'auth_abuse_critical'), true); + assert.equal(JSON.stringify(result).includes('ldap.internal'), false); + assert.equal(JSON.stringify(result).includes('secret'), false); + + const overfull = policy.evaluate({ storage: { usedPercent: 110 } }); + assert.equal(overfull.status, 'critical'); + assert.equal(overfull.alerts[0].observed, 110); +}); + +test('alert policy returns a clean status and accepts disabled dependencies', () => { + const policy = createAlertPolicy(); + const result = policy.evaluate({ dependencies: { clamd: { status: 'disabled' } } }); + assert.equal(result.status, 'ok'); + assert.equal(result.alerts.length, 0); +}); + +test('alert thresholds reject unsafe ordering', () => { + assert.throws( + () => createAlertPolicy({ thresholds: { queue: { warningDepth: 100, criticalDepth: 99 } } }), + /Critical queue thresholds/, + ); + assert.throws( + () => createAlertPolicy({ thresholds: { storage: { warningPercent: 80, criticalPercent: 101 } } }), + /between 0 and 100/, + ); +}); diff --git a/src/observability/structured-event.mjs b/src/observability/structured-event.mjs index 8f3f397..823168a 100644 --- a/src/observability/structured-event.mjs +++ b/src/observability/structured-event.mjs @@ -2,165 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -import { Buffer } from 'node:buffer'; - -import { sanitizeLogValue } from '../runtime/logger.mjs'; - -const EVENT_NAME_PATTERN = /^[a-z][a-z0-9_.:-]{1,96}$/u; -const IDENTIFIER_PATTERN = /^[A-Za-z0-9_.@:+/%/-]{1,192}$/u; -const LEVELS = Object.freeze(['debug', 'info', 'warn', 'error']); -const RESULTS = Object.freeze(['success', 'failure', 'denied', 'deferred', 'unknown']); -const RESERVED_DETAILS = new Set([ - 'timestamp', - 'level', - 'service', - 'event', - 'audit', - 'audit_retention_days', - 'tenant', - 'actor', - 'subject', - 'result', - 'reason', -]); - -function normalizeIdentifier(value, name, { required = false } = {}) { - if (value === undefined || value === null || value === '') { - if (required) { - throw new TypeError(`${name} is required for an audit event`); - } - return null; - } - - const normalized = String(value); - if (!IDENTIFIER_PATTERN.test(normalized)) { - throw new TypeError(`${name} must be a short safe identifier`); - } - - return normalized; -} - -function normalizeTimestamp(value) { - const timestamp = value instanceof Date ? value : new Date(value ?? Date.now()); - if (Number.isNaN(timestamp.getTime())) { - throw new TypeError('Event timestamp must be a valid date'); - } - - return timestamp.toISOString(); -} - -function normalizeDetails(details) { - if (details === undefined || details === null) { - return {}; - } - if (typeof details !== 'object' || Array.isArray(details)) { - throw new TypeError('Structured event details must be an object'); - } - - const sanitized = sanitizeLogValue(details); - const normalized = {}; - for (const [key, value] of Object.entries(sanitized)) { - if (!RESERVED_DETAILS.has(key)) { - normalized[key] = value; - } - } - return normalized; -} - -function byteLength(value) { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); -} - -function freezeEvent(event) { - return Object.freeze({ - ...event, - details: Object.freeze({ ...event.details }), - }); -} - -/** - * Build a bounded, metadata-only JSON event. Sensitive keys and inline - * credentials are redacted by the runtime sanitizer; message bodies, - * content, payloads, and protocol secrets must never be supplied as details. - */ -export function createStructuredEvent({ - service = 'gulogulo', - event, - level = 'info', - timestamp, - tenant, - actor, - subject, - result = null, - reason = null, - details = {}, - audit = false, - auditRetentionDays = 365, - maxBytes = 256 * 1024, -} = {}) { - if (typeof service !== 'string' || !IDENTIFIER_PATTERN.test(service)) { - throw new TypeError('Event service must be a short safe identifier'); - } - if (typeof event !== 'string' || !EVENT_NAME_PATTERN.test(event)) { - throw new TypeError('Event names must be lower-case structured identifiers'); - } - if (!LEVELS.includes(level)) { - throw new TypeError(`Event level must be one of: ${LEVELS.join(', ')}`); - } - if (result !== null && !RESULTS.includes(result)) { - throw new TypeError(`Event result must be one of: ${RESULTS.join(', ')}`); - } - if (typeof audit !== 'boolean') { - throw new TypeError('Event audit flag must be boolean'); - } - if (!Number.isSafeInteger(auditRetentionDays) || auditRetentionDays < 1 || auditRetentionDays > 36_500) { - throw new RangeError('Audit retention must be between 1 and 36500 days'); - } - if (!Number.isSafeInteger(maxBytes) || maxBytes < 512 || maxBytes > 1_048_576) { - throw new RangeError('Event maxBytes must be between 512 and 1048576'); - } - - const record = { - timestamp: normalizeTimestamp(timestamp), - level, - service, - event, - audit, - tenant: normalizeIdentifier(tenant, 'tenant'), - actor: normalizeIdentifier(actor, 'actor', { required: audit }), - subject: normalizeIdentifier(subject, 'subject'), - result, - reason: reason === null || reason === undefined ? null : normalizeIdentifier(reason, 'reason'), - audit_retention_days: auditRetentionDays, - details: normalizeDetails(details), - }; - - const serialized = JSON.stringify(record); - if (Buffer.byteLength(serialized, 'utf8') > maxBytes) { - throw new RangeError('Structured event exceeds its bounded byte limit'); - } - - return freezeEvent(record); -} - -export function createAuditEvent(options = {}) { - return createStructuredEvent({ ...options, audit: true }); -} - -export function serializeStructuredEvent(event) { - if (event === null || typeof event !== 'object' || typeof event.event !== 'string') { - throw new TypeError('A structured event is required'); - } - - // Keep this boundary safe even when a caller did not use one of the factory - // functions. Serialization must never become an accidental secret sink. - const sanitized = sanitizeLogValue(event); - return JSON.stringify(sanitized) + '\n'; -} - -export function isAuditEvent(event) { - return event !== null && typeof event === 'object' && event.audit === true; -} - -export const STRUCTURED_EVENT_LEVELS = LEVELS; -export const STRUCTURED_EVENT_RESULTS = RESULTS; +// Temporary compatibility bridge. Structured-event behavior lives in TypeScript. +export * from './structured-event.ts'; diff --git a/src/observability/structured-event.ts b/src/observability/structured-event.ts new file mode 100644 index 0000000..74a022a --- /dev/null +++ b/src/observability/structured-event.ts @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +import { Buffer } from 'node:buffer'; + +import { sanitizeLogValue } from '../runtime/logger.ts'; + +const EVENT_NAME_PATTERN = /^[a-z][a-z0-9_.:-]{1,96}$/u; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9_.@:+/%/-]{1,192}$/u; +const LEVELS = Object.freeze(['debug', 'info', 'warn', 'error']); +const RESULTS = Object.freeze(['success', 'failure', 'denied', 'deferred', 'unknown']); +const RESERVED_DETAILS = new Set([ + 'timestamp', + 'level', + 'service', + 'event', + 'audit', + 'audit_retention_days', + 'tenant', + 'actor', + 'subject', + 'result', + 'reason', +]); + +function normalizeIdentifier(value, name, { required = false } = {}) { + if (value === undefined || value === null || value === '') { + if (required) { + throw new TypeError(`${name} is required for an audit event`); + } + return null; + } + + const normalized = String(value); + if (!IDENTIFIER_PATTERN.test(normalized)) { + throw new TypeError(`${name} must be a short safe identifier`); + } + + return normalized; +} + +function normalizeTimestamp(value) { + const timestamp = value instanceof Date ? value : new Date(value ?? Date.now()); + if (Number.isNaN(timestamp.getTime())) { + throw new TypeError('Event timestamp must be a valid date'); + } + + return timestamp.toISOString(); +} + +function normalizeDetails(details) { + if (details === undefined || details === null) { + return {}; + } + if (typeof details !== 'object' || Array.isArray(details)) { + throw new TypeError('Structured event details must be an object'); + } + + const sanitized = sanitizeLogValue(details); + const normalized = {}; + for (const [key, value] of Object.entries(sanitized)) { + if (!RESERVED_DETAILS.has(key)) { + normalized[key] = value; + } + } + return normalized; +} + +function byteLength(value) { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +function freezeEvent(event) { + return Object.freeze({ + ...event, + details: Object.freeze({ ...event.details }), + }); +} + +/** + * Build a bounded, metadata-only JSON event. Sensitive keys and inline + * credentials are redacted by the runtime sanitizer; message bodies, + * content, payloads, and protocol secrets must never be supplied as details. + */ +export function createStructuredEvent({ + service = 'gulogulo', + event, + level = 'info', + timestamp, + tenant, + actor, + subject, + result = null, + reason = null, + details = {}, + audit = false, + auditRetentionDays = 365, + maxBytes = 256 * 1024, +} = {}) { + if (typeof service !== 'string' || !IDENTIFIER_PATTERN.test(service)) { + throw new TypeError('Event service must be a short safe identifier'); + } + if (typeof event !== 'string' || !EVENT_NAME_PATTERN.test(event)) { + throw new TypeError('Event names must be lower-case structured identifiers'); + } + if (!LEVELS.includes(level)) { + throw new TypeError(`Event level must be one of: ${LEVELS.join(', ')}`); + } + if (result !== null && !RESULTS.includes(result)) { + throw new TypeError(`Event result must be one of: ${RESULTS.join(', ')}`); + } + if (typeof audit !== 'boolean') { + throw new TypeError('Event audit flag must be boolean'); + } + if (!Number.isSafeInteger(auditRetentionDays) || auditRetentionDays < 1 || auditRetentionDays > 36_500) { + throw new RangeError('Audit retention must be between 1 and 36500 days'); + } + if (!Number.isSafeInteger(maxBytes) || maxBytes < 512 || maxBytes > 1_048_576) { + throw new RangeError('Event maxBytes must be between 512 and 1048576'); + } + + const record = { + timestamp: normalizeTimestamp(timestamp), + level, + service, + event, + audit, + tenant: normalizeIdentifier(tenant, 'tenant'), + actor: normalizeIdentifier(actor, 'actor', { required: audit }), + subject: normalizeIdentifier(subject, 'subject'), + result, + reason: reason === null || reason === undefined ? null : normalizeIdentifier(reason, 'reason'), + audit_retention_days: auditRetentionDays, + details: normalizeDetails(details), + }; + + const serialized = JSON.stringify(record); + if (Buffer.byteLength(serialized, 'utf8') > maxBytes) { + throw new RangeError('Structured event exceeds its bounded byte limit'); + } + + return freezeEvent(record); +} + +export function createAuditEvent(options = {}) { + return createStructuredEvent({ ...options, audit: true }); +} + +export function serializeStructuredEvent(event) { + if (event === null || typeof event !== 'object' || typeof event.event !== 'string') { + throw new TypeError('A structured event is required'); + } + + // Keep this boundary safe even when a caller did not use one of the factory + // functions. Serialization must never become an accidental secret sink. + const sanitized = sanitizeLogValue(event); + return JSON.stringify(sanitized) + '\n'; +} + +export function isAuditEvent(event) { + return event !== null && typeof event === 'object' && event.audit === true; +} + +export const STRUCTURED_EVENT_LEVELS = LEVELS; +export const STRUCTURED_EVENT_RESULTS = RESULTS; diff --git a/src/ops/abuse/index.mjs b/src/ops/abuse/index.mjs index c4aaf39..7073951 100644 --- a/src/ops/abuse/index.mjs +++ b/src/ops/abuse/index.mjs @@ -2,718 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -import { createHash, randomUUID } from 'node:crypto'; -import { isIP } from 'node:net'; - -export const ABUSE_SCHEMA_VERSION = 1; -export const ABUSE_CHANNELS = Object.freeze([ - 'http', - 'api', - 'mcp', - 'login', - 'recovery', - 'backup', - 'websocket', - 'dav', - 'smtp', - 'imap', -]); -export const ABUSE_DIMENSIONS = Object.freeze(['tenant', 'ip', 'session']); -export const ABUSE_SUBJECT_TYPES = Object.freeze(['tenant', 'ip', 'session', 'user']); - -const CHANNEL_SET = new Set(ABUSE_CHANNELS); -const DIMENSION_SET = new Set(ABUSE_DIMENSIONS); -const SUBJECT_TYPE_SET = new Set(ABUSE_SUBJECT_TYPES); -const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; -const HOST_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,253}$/u; -const SECRET_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/u; -const SAFE_REASON_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; -const SENSITIVE_KEY_PATTERN = /(?:authorization|access[_-]?token|refresh[_-]?token|session(?:[_-]?(?:id|token|secret))?|cookie|password|passphrase|private[_-]?key|credential|secret(?!ref)|body|payload|content|message)/iu; -const PLACEHOLDER_HOST_PATTERN = /(?:^|\.)(?:example|invalid|localhost|local|internal)$/iu; -const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']); -const REQUIRED_EXTERNAL_VOLUMES = Object.freeze(['runtime-state', 'mail-data', 'dav-data', 'backup-data']); -const DEFAULT_MAX_BUCKETS = 20_000; - -function abuseError(message, code = 'ABUSE_CONTRACT_ERROR') { - const error = new Error(`Abuse contract error: ${message}`); - error.code = code; - return error; -} - -function isPlainObject(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function assertPlainObject(value, name) { - if (!isPlainObject(value)) throw abuseError(`${name} must be an object`, 'INVALID_INPUT'); -} - -function assertString(value, name, pattern = null, maximum = 256) { - if (typeof value !== 'string' || value.length === 0 || value.length > maximum || (pattern && !pattern.test(value))) { - throw abuseError(`${name} is invalid`, 'INVALID_INPUT'); - } - return value; -} - -function assertId(value, name) { - return assertString(value, name, ID_PATTERN, 128); -} - -function assertDate(value, name) { - const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); - if (Number.isNaN(date.getTime())) throw abuseError(`${name} is invalid`, 'INVALID_TIMESTAMP'); - return date; -} - -function assertInteger(value, name, minimum, maximum) { - if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { - throw abuseError(`${name} must be an integer between ${minimum} and ${maximum}`, 'INVALID_NUMBER'); - } - return value; -} - -function nowMilliseconds(clock, name = 'clock') { - const value = typeof clock === 'function' ? clock() : clock; - return assertDate(value, name).getTime(); -} - -function digestSubject(subjectType, value) { - const canonical = `${subjectType}\u0000${value}`; - return createHash('sha256').update(canonical, 'utf8').digest('hex'); -} - -function assertChannel(channel) { - if (typeof channel !== 'string' || !CHANNEL_SET.has(channel)) { - throw abuseError('channel is unsupported', 'INVALID_CHANNEL'); - } - return channel; -} - -function assertDimension(dimension) { - if (typeof dimension !== 'string' || !DIMENSION_SET.has(dimension)) { - throw abuseError('dimension is unsupported', 'INVALID_DIMENSION'); - } - return dimension; -} - -function assertSubjectType(subjectType) { - if (typeof subjectType !== 'string' || !SUBJECT_TYPE_SET.has(subjectType)) { - throw abuseError('subjectType is unsupported', 'INVALID_SUBJECT'); - } - return subjectType; -} - -function assertMetadata(value, path = 'metadata') { - if (Array.isArray(value)) { - value.forEach((item, index) => assertMetadata(item, `${path}[${index}]`)); - return value; - } - if (isPlainObject(value)) { - for (const [key, nested] of Object.entries(value)) { - if (SENSITIVE_KEY_PATTERN.test(key)) { - throw abuseError(`${path}.${key} is not allowed in metadata`, 'SENSITIVE_DATA_FORBIDDEN'); - } - assertMetadata(nested, `${path}.${key}`); - } - return value; - } - if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - throw abuseError(`${path} contains an unsupported value`, 'INVALID_METADATA'); - } - return value; -} - -function freezeDeep(value) { - if (value && typeof value === 'object' && !Object.isFrozen(value)) { - Object.values(value).forEach((nested) => freezeDeep(nested)); - Object.freeze(value); - } - return value; -} - -function normalizeRateRule(rule, name) { - assertPlainObject(rule, name); - const max = assertInteger(rule.max, `${name}.max`, 1, 1_000_000); - const windowMs = assertInteger(rule.windowMs, `${name}.windowMs`, 1_000, 86_400_000); - return Object.freeze({ max, windowMs }); -} - -function makeChannelLimits(tenantMax, ipMax, sessionMax, windowMs = 60_000) { - return Object.freeze({ - tenant: Object.freeze({ max: tenantMax, windowMs }), - ip: Object.freeze({ max: ipMax, windowMs }), - session: Object.freeze({ max: sessionMax, windowMs }), - }); -} - -export const DEFAULT_RATE_LIMITS = freezeDeep({ - http: makeChannelLimits(600, 120, 120), - api: makeChannelLimits(300, 120, 120), - mcp: makeChannelLimits(120, 30, 60), - login: makeChannelLimits(60, 10, 10), - recovery: makeChannelLimits(30, 5, 5), - backup: makeChannelLimits(30, 10, 10), - websocket: makeChannelLimits(120, 60, 60), - dav: makeChannelLimits(300, 60, 120), - smtp: makeChannelLimits(600, 60, 120), - imap: makeChannelLimits(600, 60, 120), -}); - -function normalizeLimits(limits) { - assertPlainObject(limits, 'limits'); - const normalized = {}; - for (const channel of ABUSE_CHANNELS) { - const configured = limits[channel] ?? DEFAULT_RATE_LIMITS[channel]; - assertPlainObject(configured, `limits.${channel}`); - normalized[channel] = {}; - for (const dimension of ABUSE_DIMENSIONS) { - normalized[channel][dimension] = normalizeRateRule(configured[dimension], `limits.${channel}.${dimension}`); - } - normalized[channel] = Object.freeze(normalized[channel]); - } - return freezeDeep(normalized); -} - -function normalizeRateIdentity({ tenantId, ipAddress = null, sessionId = null } = {}) { - const identity = { tenantId: assertId(tenantId, 'tenantId'), ipAddress, sessionId }; - if (ipAddress !== null) assertString(ipAddress, 'ipAddress', null, 256); - if (sessionId !== null) assertString(sessionId, 'sessionId', null, 512); - return identity; -} - -function identityForDimension(identity, dimension) { - if (dimension === 'tenant') return identity.tenantId; - if (dimension === 'ip') return identity.ipAddress; - return identity.sessionId; -} - -/** - * Metadata-only multi-dimensional limiter. Raw IP addresses and session - * identifiers are hashed before they enter the in-memory state or result. - */ -export function createRateLimiter({ limits = DEFAULT_RATE_LIMITS, clock = () => new Date(), maxBuckets = DEFAULT_MAX_BUCKETS } = {}) { - const normalizedLimits = normalizeLimits(limits); - assertInteger(maxBuckets, 'maxBuckets', 100, 1_000_000); - const buckets = new Map(); - - function prune() { - while (buckets.size > maxBuckets) { - const first = buckets.keys().next().value; - if (first === undefined) break; - buckets.delete(first); - } - } - - function consume({ channel, tenantId, ipAddress = null, sessionId = null, cost = 1, now = undefined } = {}) { - const normalizedChannel = assertChannel(channel); - const identity = normalizeRateIdentity({ tenantId, ipAddress, sessionId }); - assertInteger(cost, 'cost', 1, 1_000_000); - const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); - const dimensions = ABUSE_DIMENSIONS.filter((dimension) => identityForDimension(identity, dimension) !== null); - const observations = []; - for (const dimension of dimensions) { - const rule = normalizedLimits[normalizedChannel][dimension]; - const subjectDigest = digestSubject(dimension, identityForDimension(identity, dimension)); - const key = `${normalizedChannel}:${dimension}:${subjectDigest}`; - const current = buckets.get(key); - const bucket = current && timestamp - current.startedAt < rule.windowMs - ? current - : { startedAt: timestamp, count: 0 }; - const limited = bucket.count + cost > rule.max; - observations.push({ dimension, rule, key, bucket, limited, subjectDigest }); - } - const limitedBy = observations.filter((item) => item.limited).map((item) => item.dimension); - if (limitedBy.length === 0) { - for (const observation of observations) { - observation.bucket.count += cost; - buckets.set(observation.key, observation.bucket); - } - } - prune(); - const retryAfterMs = observations.length === 0 - ? 0 - : Math.max(...observations.filter((item) => item.limited).map((item) => Math.max(1, item.rule.windowMs - (timestamp - item.bucket.startedAt))), 0); - const remaining = observations.length === 0 - ? 0 - : Math.min(...observations.map((item) => Math.max(0, item.rule.max - item.bucket.count))); - return Object.freeze({ - schemaVersion: ABUSE_SCHEMA_VERSION, - allowed: limitedBy.length === 0, - channel: normalizedChannel, - limitedBy: Object.freeze(limitedBy), - retryAfterMs, - remaining, - resetAt: new Date(Math.max(...observations.map((item) => item.bucket.startedAt + item.rule.windowMs), timestamp)).toISOString(), - activeBuckets: buckets.size, - }); - } - - function snapshot() { - const byChannel = Object.fromEntries(ABUSE_CHANNELS.map((channel) => [channel, 0])); - const byDimension = Object.fromEntries(ABUSE_DIMENSIONS.map((dimension) => [dimension, 0])); - for (const key of buckets.keys()) { - const [channel, dimension] = key.split(':', 2); - if (Object.hasOwn(byChannel, channel)) byChannel[channel] += 1; - if (Object.hasOwn(byDimension, dimension)) byDimension[dimension] += 1; - } - return Object.freeze({ schemaVersion: ABUSE_SCHEMA_VERSION, activeBuckets: buckets.size, byChannel, byDimension }); - } - - return Object.freeze({ consume, snapshot, limits: normalizedLimits }); -} - -/** Create a secret-free audit event suitable for the existing audit pipeline. */ -export function createAbuseAuditEvent({ - action, - channel, - outcome, - tenantId, - subjectType = null, - subject = null, - reason, - details = {}, - occurredAt = new Date(), -} = {}) { - assertString(action, 'action', SAFE_REASON_PATTERN, 128); - assertChannel(channel); - if (!['allowed', 'limited', 'locked', 'quarantined', 'released', 'rejected'].includes(outcome)) { - throw abuseError('outcome is invalid', 'INVALID_AUDIT'); - } - const normalizedTenant = assertId(tenantId, 'tenantId'); - if (subjectType !== null) assertSubjectType(subjectType); - const digest = subjectType === null ? null : digestSubject(subjectType, assertString(subject, 'subject', null, 512)); - assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); - assertPlainObject(details, 'details'); - assertMetadata(details, 'details'); - const event = { - schemaVersion: ABUSE_SCHEMA_VERSION, - eventType: 'abuse.control', - eventId: randomUUID(), - action, - channel, - outcome, - tenantId: normalizedTenant, - subjectType, - subjectDigest: digest, - reason, - details: freezeDeep({ ...details }), - occurredAt: assertDate(occurredAt, 'occurredAt').toISOString(), - }; - assertMetadata(event); - return freezeDeep(event); -} - -function normalizeLockoutPolicy(policy = {}) { - assertPlainObject(policy, 'lockoutPolicy'); - return Object.freeze({ - failureThreshold: assertInteger(policy.failureThreshold ?? 5, 'failureThreshold', 1, 1_000), - failureWindowMs: assertInteger(policy.failureWindowMs ?? 15 * 60_000, 'failureWindowMs', 1_000, 86_400_000), - lockoutMs: assertInteger(policy.lockoutMs ?? 15 * 60_000, 'lockoutMs', 1_000, 86_400_000), - quarantineThreshold: assertInteger(policy.quarantineThreshold ?? 10, 'quarantineThreshold', 1, 10_000), - quarantineMs: assertInteger(policy.quarantineMs ?? 60 * 60_000, 'quarantineMs', 1_000, 7 * 86_400_000), - }); -} - -/** - * Compose the limiter, lockout, quarantine, and audit hook without exposing - * the raw subjects. The hook receives immutable metadata only. - */ -export function createAbuseGuard({ - limits = DEFAULT_RATE_LIMITS, - lockoutPolicy = {}, - clock = () => new Date(), - onAudit = null, -} = {}) { - if (onAudit !== null && typeof onAudit !== 'function') throw abuseError('onAudit must be a function', 'INVALID_HOOK'); - const limiter = createRateLimiter({ limits, clock }); - const policy = normalizeLockoutPolicy(lockoutPolicy); - const failures = new Map(); - const lockouts = new Map(); - const quarantines = new Map(); - - function subjectKey(tenantId, subjectType, subject) { - const tenant = assertId(tenantId, 'tenantId'); - const type = assertSubjectType(subjectType); - const value = assertString(subject, 'subject', null, 512); - return `${tenant}:${type}:${digestSubject(type, value)}`; - } - - function emit(event) { - if (onAudit !== null) onAudit(event); - return event; - } - - function status({ tenantId, subjectType, subject, now = undefined } = {}) { - const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); - const key = subjectKey(tenantId, subjectType, subject); - const lockoutUntil = lockouts.get(key) ?? 0; - const quarantineUntil = quarantines.get(key) ?? 0; - return Object.freeze({ - schemaVersion: ABUSE_SCHEMA_VERSION, - blocked: lockoutUntil > timestamp || quarantineUntil > timestamp, - locked: lockoutUntil > timestamp, - quarantined: quarantineUntil > timestamp, - lockoutUntil: lockoutUntil > timestamp ? new Date(lockoutUntil).toISOString() : null, - quarantineUntil: quarantineUntil > timestamp ? new Date(quarantineUntil).toISOString() : null, - }); - } - - function recordFailure({ - tenantId, - subjectType, - subject, - channel = 'login', - reason = 'authentication-failure', - now = undefined, - } = {}) { - const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); - assertChannel(channel); - assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); - const key = subjectKey(tenantId, subjectType, subject); - const current = failures.get(key); - const bucket = current && timestamp - current.startedAt < policy.failureWindowMs - ? current - : { startedAt: timestamp, count: 0 }; - bucket.count += 1; - failures.set(key, bucket); - const locked = bucket.count >= policy.failureThreshold; - const quarantined = bucket.count >= policy.quarantineThreshold; - if (locked) lockouts.set(key, timestamp + policy.lockoutMs); - if (quarantined) quarantines.set(key, timestamp + policy.quarantineMs); - const outcome = quarantined ? 'quarantined' : locked ? 'locked' : 'rejected'; - const event = createAbuseAuditEvent({ - action: 'abuse.failure', - channel, - outcome, - tenantId, - subjectType, - subject, - reason, - details: { count: bucket.count, threshold: policy.failureThreshold }, - occurredAt: timestamp, - }); - return Object.freeze({ - allowed: false, - count: bucket.count, - locked, - quarantined, - lockoutUntil: locked ? new Date(timestamp + policy.lockoutMs).toISOString() : null, - quarantineUntil: quarantined ? new Date(timestamp + policy.quarantineMs).toISOString() : null, - audit: emit(event), - }); - } - - function recordSuccess({ tenantId, subjectType, subject, channel = 'login', now = undefined } = {}) { - const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); - assertChannel(channel); - const key = subjectKey(tenantId, subjectType, subject); - failures.delete(key); - lockouts.delete(key); - const event = createAbuseAuditEvent({ - action: 'abuse.recovery', - channel, - outcome: 'released', - tenantId, - subjectType, - subject, - reason: 'authenticated-success', - occurredAt: timestamp, - }); - return Object.freeze({ cleared: true, audit: emit(event) }); - } - - function quarantineSubject({ tenantId, subjectType, subject, channel = 'api', durationMs = policy.quarantineMs, reason = 'operator-quarantine', now = undefined } = {}) { - const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); - assertChannel(channel); - assertInteger(durationMs, 'durationMs', 1_000, 7 * 86_400_000); - assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); - const key = subjectKey(tenantId, subjectType, subject); - quarantines.set(key, timestamp + durationMs); - const event = createAbuseAuditEvent({ - action: 'abuse.quarantine', - channel, - outcome: 'quarantined', - tenantId, - subjectType, - subject, - reason, - details: { durationMs }, - occurredAt: timestamp, - }); - return Object.freeze({ quarantined: true, quarantineUntil: new Date(timestamp + durationMs).toISOString(), audit: emit(event) }); - } - - function releaseSubject({ tenantId, subjectType, subject, channel = 'api', reason = 'operator-release', now = undefined } = {}) { - const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); - assertChannel(channel); - assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); - const key = subjectKey(tenantId, subjectType, subject); - quarantines.delete(key); - lockouts.delete(key); - failures.delete(key); - const event = createAbuseAuditEvent({ - action: 'abuse.release', - channel, - outcome: 'released', - tenantId, - subjectType, - subject, - reason, - occurredAt: timestamp, - }); - return Object.freeze({ released: true, audit: emit(event) }); - } - - function check({ channel, tenantId, ipAddress = null, sessionId = null, userId = null, now = undefined, cost = 1 } = {}) { - const identity = normalizeRateIdentity({ tenantId, ipAddress, sessionId }); - const candidates = [ - ['tenant', identity.tenantId], - ['ip', identity.ipAddress], - ['session', identity.sessionId], - ['user', userId === null ? null : assertId(userId, 'userId')], - ].filter(([, value]) => value !== null); - const blockedBy = []; - for (const [subjectType, subject] of candidates) { - const state = status({ tenantId: identity.tenantId, subjectType, subject, now }); - if (state.blocked) blockedBy.push(subjectType); - } - if (blockedBy.length > 0) { - return Object.freeze({ schemaVersion: ABUSE_SCHEMA_VERSION, allowed: false, reason: 'lockout-or-quarantine', blockedBy: Object.freeze(blockedBy) }); - } - return limiter.consume({ channel, ...identity, now, cost }); - } - - return Object.freeze({ - check, - recordFailure, - recordSuccess, - quarantineSubject, - releaseSubject, - status, - limiter, - lockoutPolicy: policy, - }); -} - -function readEnvironment(environment, key) { - if (Array.isArray(environment)) { - const arrayEntry = environment.find((value) => typeof value === 'string' && value.startsWith(`${key}=`)); - return arrayEntry === undefined ? undefined : arrayEntry.slice(key.length + 1); - } - if (isPlainObject(environment) && Object.hasOwn(environment, key)) return environment[key]; - return undefined; -} - -function nonEmptyEnvironment(environment, key) { - const value = readEnvironment(environment, key); - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; -} - -function isUnsafeHost(hostname) { - const normalized = hostname.toLowerCase().replace(/\.$/u, ''); - if (LOOPBACK_HOSTS.has(normalized) || normalized.endsWith('.internal') || PLACEHOLDER_HOST_PATTERN.test(normalized)) return true; - const ipVersion = isIP(normalized); - if (ipVersion === 4) { - const octets = normalized.split('.').map(Number); - return octets[0] === 0 - || octets[0] === 10 - || octets[0] === 127 - || (octets[0] === 169 && octets[1] === 254) - || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) - || (octets[0] === 192 && octets[1] === 0 && octets[2] === 0) - || (octets[0] === 192 && octets[1] === 168) - || (octets[0] === 198 && (octets[1] === 18 || octets[1] === 19)) - || octets[0] >= 224; - } - if (ipVersion === 6) return normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:'); - return false; -} - -function validateExternalHost(value, field) { - if (value === null) return { field, code: 'REQUIRED_HOST_MISSING', message: `${field} is required` }; - let parsed; - try { - parsed = field.endsWith('_URL') ? new URL(value) : null; - } catch { - return { field, code: 'HOST_INVALID', message: `${field} must be a valid endpoint` }; - } - const hostname = parsed?.hostname ?? value; - if (typeof hostname !== 'string' || hostname.length === 0 || !HOST_REFERENCE_PATTERN.test(hostname) || isUnsafeHost(hostname)) { - return { field, code: 'HOST_INVALID', message: `${field} must point to a configured external host` }; - } - if (parsed && !['ldap:', 'ldaps:', 'https:'].includes(parsed.protocol)) { - return { field, code: 'HOST_PROTOCOL_INVALID', message: `${field} uses an unsupported protocol` }; - } - return null; -} - -function validateSecretReference(environment, service, field, declaredSecrets) { - const value = nonEmptyEnvironment(environment, field); - if (value === null) return { field, code: 'REQUIRED_SECRET_REFERENCE_MISSING', message: `${field} is required` }; - if (!SECRET_REFERENCE_PATTERN.test(value) || /[=\s]/u.test(value)) { - return { field, code: 'SECRET_VALUE_FORBIDDEN', message: `${field} must be a secret reference, not secret material` }; - } - if (declaredSecrets !== null && !declaredSecrets.has(value) && !declaredSecrets.has(field)) { - return { field, code: 'SECRET_NOT_DECLARED', message: `${field} does not resolve to a declared deployment secret` }; - } - return null; -} - -function collectDeclaredSecrets(compose) { - const declared = new Set(); - const topLevel = compose.secrets; - if (isPlainObject(topLevel)) Object.keys(topLevel).forEach((key) => declared.add(key)); - const serviceSecrets = compose.services?.gulogulo?.secrets; - if (Array.isArray(serviceSecrets)) { - serviceSecrets.forEach((entry) => { - if (typeof entry === 'string') declared.add(entry); - else if (isPlainObject(entry) && typeof entry.source === 'string') declared.add(entry.source); - }); - } - return declared; -} - -function normalizeComposeService(compose, serviceName) { - assertPlainObject(compose, 'compose'); - assertPlainObject(compose.services, 'compose.services'); - const service = compose.services[serviceName]; - assertPlainObject(service, `compose.services.${serviceName}`); - return service; -} - -function parseCpu(value) { - if (typeof value === 'number') return value; - if (typeof value !== 'string') return null; - if (/^\d+(?:\.\d+)?$/u.test(value)) return Number(value); - const millicpu = value.match(/^(\d+)m$/u); - return millicpu ? Number(millicpu[1]) / 1000 : null; -} - -function parseMemoryBytes(value) { - if (typeof value === 'number') return value; - if (typeof value !== 'string') return null; - const match = value.trim().match(/^(\d+(?:\.\d+)?)([KMGTP]i?B?)?$/iu); - if (!match) return null; - const multipliers = { k: 1024, ki: 1024, kb: 1000, m: 1024 ** 2, mi: 1024 ** 2, mb: 1000 ** 2, g: 1024 ** 3, gi: 1024 ** 3, gb: 1000 ** 3, t: 1024 ** 4, ti: 1024 ** 4, tb: 1000 ** 4, pib: 1024 ** 5 }; - const suffix = (match[2] ?? '').toLowerCase(); - return Number(match[1]) * (multipliers[suffix] ?? 1); -} - -function composeResourceLimits(service) { - const limits = service.deploy?.resources?.limits ?? {}; - return { - cpus: parseCpu(limits.cpus ?? service.cpus), - memoryBytes: parseMemoryBytes(limits.memory ?? service.mem_limit), - pids: limits.pids ?? service.pids_limit, - }; -} - -function addReadinessError(errors, code, field, message) { - errors.push(Object.freeze({ code, field, message })); -} - -/** - * Validate a production Compose object without returning environment values, - * secret values, image contents, or mounted volume data. - */ -export function validateComposeProductionReadiness({ - compose, - serviceName = 'gulogulo', - requiredExternalHosts = ['LDAP_URL', 'POSTGRES_HOST'], - requiredSecretReferences = ['LDAP_BIND_SECRET_REF', 'POSTGRES_DSN_SECRET_REF'], - requiredExternalVolumes = REQUIRED_EXTERNAL_VOLUMES, - requireExternalSecrets = true, - checkedAt = new Date(), -} = {}) { - const service = normalizeComposeService(compose, serviceName); - const environment = service.environment ?? {}; - const errors = []; - const warnings = []; - const appEnvironment = nonEmptyEnvironment(environment, 'APP_ENV') ?? nonEmptyEnvironment(environment, 'GULOGULO_ENV'); - if (appEnvironment !== 'production') addReadinessError(errors, 'PRODUCTION_ENV_REQUIRED', 'APP_ENV', 'production environment is required'); - const composeUser = service.user; - const composeUserText = composeUser === undefined || composeUser === null ? '' : String(composeUser); - const [composeUid, composeGid] = composeUserText.split(':', 2); - if (composeUser === undefined || composeUser === null || composeUser === '' || composeUser === 0 || composeUserText === '0' || composeUserText === 'root' || composeUid === '0' || composeGid === '0') { - addReadinessError(errors, 'NON_ROOT_USER_REQUIRED', 'services.user', 'the service must run as a non-root user'); - } - if (service.privileged === true) addReadinessError(errors, 'PRIVILEGED_FORBIDDEN', 'services.privileged', 'privileged mode is forbidden'); - if (service.read_only !== true) addReadinessError(errors, 'READ_ONLY_REQUIRED', 'services.read_only', 'production service filesystem must be read-only'); - if (service.network_mode === 'host' || service.pid === 'host') addReadinessError(errors, 'HOST_NAMESPACE_FORBIDDEN', 'services.namespace', 'host namespaces are forbidden'); - if (Array.isArray(service.cap_add) && service.cap_add.length > 0) addReadinessError(errors, 'CAPABILITIES_FORBIDDEN', 'services.cap_add', 'cap_add must be empty'); - if (!Array.isArray(service.cap_drop) || !service.cap_drop.some((capability) => String(capability).toUpperCase() === 'ALL')) { - addReadinessError(errors, 'CAP_DROP_ALL_REQUIRED', 'services.cap_drop', 'ALL capabilities must be dropped'); - } - if (!Array.isArray(service.security_opt) || !service.security_opt.some((option) => option === 'no-new-privileges:true')) { - addReadinessError(errors, 'NO_NEW_PRIVILEGES_REQUIRED', 'services.security_opt', 'no-new-privileges must be enabled'); - } - if (service.devices !== undefined && Array.isArray(service.devices) && service.devices.length > 0) addReadinessError(errors, 'DEVICES_FORBIDDEN', 'services.devices', 'device passthrough is forbidden'); - if (Array.isArray(service.volumes) && service.volumes.some((volume) => { - const source = typeof volume === 'string' ? volume.split(':', 1)[0] : volume?.source; - const target = typeof volume === 'string' ? volume.split(':')[1] : volume?.target; - return source === '/var/run/docker.sock' || target === '/var/run/docker.sock'; - })) addReadinessError(errors, 'DOCKER_SOCKET_FORBIDDEN', 'services.volumes', 'the Docker socket must not be mounted'); - const resourceLimits = composeResourceLimits(service); - if (resourceLimits.cpus === null || resourceLimits.cpus < 0.25 || resourceLimits.cpus > 8) addReadinessError(errors, 'CPU_LIMIT_REQUIRED', 'services.deploy.resources.limits.cpus', 'CPU limit must be between 0.25 and 8'); - if (resourceLimits.memoryBytes === null || resourceLimits.memoryBytes < 128 * 1024 ** 2 || resourceLimits.memoryBytes > 8 * 1024 ** 3) addReadinessError(errors, 'MEMORY_LIMIT_REQUIRED', 'services.deploy.resources.limits.memory', 'memory limit must be between 128 MiB and 8 GiB'); - if (resourceLimits.pids !== undefined && (!Number.isSafeInteger(Number(resourceLimits.pids)) || Number(resourceLimits.pids) < 64 || Number(resourceLimits.pids) > 4_096)) addReadinessError(errors, 'PIDS_LIMIT_INVALID', 'services.deploy.resources.limits.pids', 'pids limit must be between 64 and 4096'); - - const volumes = compose.volumes; - if (!isPlainObject(volumes)) { - addReadinessError(errors, 'EXTERNAL_VOLUMES_REQUIRED', 'volumes', 'persistent production volumes must be declared externally'); - } else { - for (const volumeName of requiredExternalVolumes) { - if (!isPlainObject(volumes[volumeName]) || volumes[volumeName].external !== true) addReadinessError(errors, 'EXTERNAL_VOLUME_REQUIRED', `volumes.${volumeName}`, 'the user-data volume must be external'); - } - } - - const declaredSecrets = requireExternalSecrets ? collectDeclaredSecrets(compose) : null; - const ldapEnabled = ['true', '1', 'yes'].includes((nonEmptyEnvironment(environment, 'LDAP_ENABLED') ?? 'false').toLowerCase()); - const postgresEnabled = ['true', '1', 'yes'].includes((nonEmptyEnvironment(environment, 'POSTGRES_ENABLED') ?? 'false').toLowerCase()); - for (const hostField of requiredExternalHosts) { - const enabled = hostField.startsWith('LDAP_') ? ldapEnabled : hostField.startsWith('POSTGRES_') ? postgresEnabled : true; - if (enabled) { - const hostError = validateExternalHost(nonEmptyEnvironment(environment, hostField), hostField); - if (hostError) addReadinessError(errors, hostError.code, hostError.field, hostError.message); - } else { - warnings.push(Object.freeze({ code: 'DEPENDENCY_DISABLED', field: hostField, message: `${hostField} is not required while its dependency is disabled` })); - } - } - for (const secretField of requiredSecretReferences) { - const enabled = secretField.startsWith('LDAP_') ? ldapEnabled : secretField.startsWith('POSTGRES_') ? postgresEnabled : true; - if (enabled) { - const secretError = validateSecretReference(environment, service, secretField, declaredSecrets); - if (secretError) addReadinessError(errors, secretError.code, secretError.field, secretError.message); - } else { - warnings.push(Object.freeze({ code: 'DEPENDENCY_DISABLED', field: secretField, message: `${secretField} is not required while its dependency is disabled` })); - } - } - const environmentKeys = isPlainObject(environment) ? Object.keys(environment) : []; - for (const key of environmentKeys) { - if (SENSITIVE_KEY_PATTERN.test(key) && !/_REF$/u.test(key)) { - addReadinessError(errors, 'PLAINTEXT_SECRET_FORBIDDEN', `services.environment.${key}`, 'secret material must use a reference'); - } - } - - return Object.freeze({ - schemaVersion: ABUSE_SCHEMA_VERSION, - readinessType: 'compose-production', - serviceName, - ready: errors.length === 0, - checkedAt: assertDate(checkedAt, 'checkedAt').toISOString(), - errors: Object.freeze(errors), - warnings: Object.freeze(warnings), - controls: Object.freeze({ - nonRoot: errors.every((error) => error.code !== 'NON_ROOT_USER_REQUIRED'), - readOnly: errors.every((error) => error.code !== 'READ_ONLY_REQUIRED'), - leastPrivilege: errors.every((error) => !['CAPABILITIES_FORBIDDEN', 'CAP_DROP_ALL_REQUIRED', 'NO_NEW_PRIVILEGES_REQUIRED', 'PRIVILEGED_FORBIDDEN', 'HOST_NAMESPACE_FORBIDDEN', 'DEVICES_FORBIDDEN', 'DOCKER_SOCKET_FORBIDDEN'].includes(error.code)), - resourceBounds: errors.every((error) => !['CPU_LIMIT_REQUIRED', 'MEMORY_LIMIT_REQUIRED', 'PIDS_LIMIT_INVALID'].includes(error.code)), - persistentVolumes: errors.every((error) => !['EXTERNAL_VOLUMES_REQUIRED', 'EXTERNAL_VOLUME_REQUIRED'].includes(error.code)), - externalDependencies: errors.every((error) => !['REQUIRED_HOST_MISSING', 'HOST_INVALID', 'HOST_PROTOCOL_INVALID', 'REQUIRED_SECRET_REFERENCE_MISSING', 'SECRET_VALUE_FORBIDDEN', 'SECRET_NOT_DECLARED', 'PLAINTEXT_SECRET_FORBIDDEN'].includes(error.code)), - }), - }); -} - -export { abuseError, digestSubject }; +// Temporary compatibility bridge. Abuse-operation behavior lives in TypeScript. +export * from './index.ts'; diff --git a/src/ops/abuse/index.test.mjs b/src/ops/abuse/index.test.mjs index 734e7e5..6b56179 100644 --- a/src/ops/abuse/index.test.mjs +++ b/src/ops/abuse/index.test.mjs @@ -2,173 +2,5 @@ // 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 { - ABUSE_CHANNELS, - createAbuseAuditEvent, - createAbuseGuard, - createRateLimiter, - validateComposeProductionReadiness, -} from './index.mjs'; - -function createClock(start = Date.parse('2026-08-22T10:00:00Z')) { - let current = start; - return { - clock: () => new Date(current), - advance(milliseconds) { - current += milliseconds; - }, - }; -} - -function createProductionCompose(overrides = {}) { - const environment = { - APP_ENV: 'production', - LDAP_ENABLED: 'true', - LDAP_URL: 'ldaps://ldap.example.net:636', - LDAP_BIND_SECRET_REF: 'deploy/ldap-bind', - POSTGRES_ENABLED: 'true', - POSTGRES_HOST: 'postgres.example.net', - POSTGRES_DSN_SECRET_REF: 'deploy/postgres-dsn', - ...overrides.environment, - }; - return { - services: { - gulogulo: { - user: '10001:10001', - environment, - read_only: true, - cap_drop: ['ALL'], - security_opt: ['no-new-privileges:true'], - deploy: { resources: { limits: { cpus: '1.0', memory: '512M', pids: 512 } } }, - secrets: ['deploy/ldap-bind', 'deploy/postgres-dsn'], - ...overrides.service, - }, - }, - secrets: { - 'deploy/ldap-bind': { external: true }, - 'deploy/postgres-dsn': { external: true }, - }, - volumes: { - 'runtime-state': { external: true }, - 'mail-data': { external: true }, - 'dav-data': { external: true }, - 'backup-data': { external: true }, - }, - ...overrides, - }; -} - -test('rate limiter covers every required channel and hashes IP/session dimensions', () => { - const clock = createClock(); - const limits = Object.fromEntries(ABUSE_CHANNELS.map((channel) => [channel, { - tenant: { max: 2, windowMs: 60_000 }, - ip: { max: 1, windowMs: 60_000 }, - session: { max: 2, windowMs: 60_000 }, - }])); - const limiter = createRateLimiter({ limits, clock: clock.clock }); - assert.equal(limiter.consume({ channel: 'smtp', tenantId: 'acme', ipAddress: '203.0.113.5', sessionId: 'opaque-session' }).allowed, true); - const blocked = limiter.consume({ channel: 'smtp', tenantId: 'acme', ipAddress: '203.0.113.5', sessionId: 'opaque-session' }); - assert.equal(blocked.allowed, false); - assert.deepEqual(blocked.limitedBy, ['ip']); - assert.equal(JSON.stringify(blocked).includes('203.0.113.5'), false); - assert.equal(JSON.stringify(blocked).includes('opaque-session'), false); - clock.advance(60_000); - assert.equal(limiter.consume({ channel: 'imap', tenantId: 'acme', ipAddress: '203.0.113.5', sessionId: 'opaque-session' }).allowed, true); -}); - -test('abuse guard emits metadata-only audit hooks and locks out failures', () => { - const clock = createClock(); - const audit = []; - const guard = createAbuseGuard({ - clock: clock.clock, - onAudit: (event) => audit.push(event), - lockoutPolicy: { failureThreshold: 2, failureWindowMs: 60_000, lockoutMs: 120_000, quarantineThreshold: 3, quarantineMs: 300_000 }, - }); - guard.recordFailure({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5', channel: 'login', reason: 'invalid-credentials' }); - const second = guard.recordFailure({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5', channel: 'login', reason: 'invalid-credentials' }); - assert.equal(second.locked, true); - assert.equal(guard.check({ channel: 'login', tenantId: 'acme', ipAddress: '203.0.113.5' }).allowed, false); - assert.equal(guard.status({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5' }).locked, true); - assert.equal(audit.length, 2); - assert.equal(JSON.stringify(audit).includes('203.0.113.5'), false); - assert.equal(audit[1].subjectType, 'ip'); - clock.advance(120_000); - assert.equal(guard.status({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5' }).locked, false); -}); - -test('quarantine and release are explicit auditable controls', () => { - const audit = []; - const guard = createAbuseGuard({ onAudit: (event) => audit.push(event) }); - const quarantine = guard.quarantineSubject({ tenantId: 'acme', subjectType: 'user', subject: 'alice', channel: 'recovery', durationMs: 60_000, reason: 'repeated-recovery-abuse' }); - assert.equal(quarantine.quarantined, true); - assert.equal(guard.check({ channel: 'recovery', tenantId: 'acme', sessionId: 'session-a' }).allowed, true); - assert.equal(guard.status({ tenantId: 'acme', subjectType: 'user', subject: 'alice' }).quarantined, true); - const released = guard.releaseSubject({ tenantId: 'acme', subjectType: 'user', subject: 'alice', channel: 'recovery' }); - assert.equal(released.released, true); - assert.equal(guard.status({ tenantId: 'acme', subjectType: 'user', subject: 'alice' }).quarantined, false); - assert.equal(audit.every((event) => !Object.hasOwn(event, 'payload') && !Object.hasOwn(event, 'sessionId')), true); -}); - -test('audit event rejects content and credential fields', () => { - assert.throws( - () => createAbuseAuditEvent({ action: 'abuse.failure', channel: 'api', outcome: 'rejected', tenantId: 'acme', reason: 'bad-input', details: { payload: 'mail body' } }), - (error) => error.code === 'SENSITIVE_DATA_FORBIDDEN', - ); -}); - -test('production Compose readiness passes only with secrets, external hosts, least privilege, bounds, and volumes', () => { - const result = validateComposeProductionReadiness({ compose: createProductionCompose() }); - assert.equal(result.ready, true); - assert.equal(result.errors.length, 0); - assert.equal(result.controls.nonRoot, true); - assert.equal(result.controls.externalDependencies, true); - assert.equal(JSON.stringify(result).includes('deploy/ldap-bind'), false); - assert.equal(JSON.stringify(result).includes('ldap.example.net'), false); -}); - -test('production Compose readiness fails closed for root, missing references, unsafe hosts, mutable filesystems, and weak bounds', () => { - const result = validateComposeProductionReadiness({ - compose: createProductionCompose({ - service: { - user: 'root', - read_only: false, - cap_drop: [], - security_opt: [], - deploy: { resources: { limits: { cpus: '0.1', memory: '64M', pids: 32 } } }, - }, - environment: { - LDAP_URL: 'ldaps://127.0.0.1:636', - LDAP_BIND_SECRET_REF: 'plaintext secret', - POSTGRES_HOST: 'postgres.internal', - POSTGRES_DSN_SECRET_REF: '', - }, - }), - }); - assert.equal(result.ready, false); - const codes = new Set(result.errors.map((error) => error.code)); - for (const code of ['NON_ROOT_USER_REQUIRED', 'READ_ONLY_REQUIRED', 'CAP_DROP_ALL_REQUIRED', 'NO_NEW_PRIVILEGES_REQUIRED', 'CPU_LIMIT_REQUIRED', 'MEMORY_LIMIT_REQUIRED', 'PIDS_LIMIT_INVALID', 'HOST_INVALID', 'SECRET_VALUE_FORBIDDEN', 'REQUIRED_SECRET_REFERENCE_MISSING']) assert.equal(codes.has(code), true, code); -}); - -test('disabled external dependencies produce warnings instead of false readiness failures', () => { - const result = validateComposeProductionReadiness({ - compose: createProductionCompose({ - environment: { - LDAP_ENABLED: 'false', - POSTGRES_ENABLED: 'false', - }, - }), - }); - assert.equal(result.ready, true); - assert.equal(result.warnings.filter((warning) => warning.code === 'DEPENDENCY_DISABLED').length, 4); -}); - -test('Compose readiness does not accept a Docker socket or host namespace', () => { - const result = validateComposeProductionReadiness({ - compose: createProductionCompose({ service: { network_mode: 'host', volumes: ['/var/run/docker.sock:/var/run/docker.sock'] } }), - }); - assert.equal(result.ready, false); - assert.equal(result.errors.some((error) => error.code === 'HOST_NAMESPACE_FORBIDDEN'), true); -}); +// Temporary compatibility bridge. Abuse-operation contract tests are TypeScript. +import './index.test.ts'; diff --git a/src/ops/abuse/index.test.ts b/src/ops/abuse/index.test.ts new file mode 100644 index 0000000..efb09b1 --- /dev/null +++ b/src/ops/abuse/index.test.ts @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ABUSE_CHANNELS, + createAbuseAuditEvent, + createAbuseGuard, + createRateLimiter, + validateComposeProductionReadiness, +} from './index.ts'; + +function createClock(start = Date.parse('2026-08-22T10:00:00Z')) { + let current = start; + return { + clock: () => new Date(current), + advance(milliseconds) { + current += milliseconds; + }, + }; +} + +function createProductionCompose(overrides = {}) { + const environment = { + APP_ENV: 'production', + LDAP_ENABLED: 'true', + LDAP_URL: 'ldaps://ldap.example.net:636', + LDAP_BIND_SECRET_REF: 'deploy/ldap-bind', + POSTGRES_ENABLED: 'true', + POSTGRES_HOST: 'postgres.example.net', + POSTGRES_DSN_SECRET_REF: 'deploy/postgres-dsn', + ...overrides.environment, + }; + return { + services: { + gulogulo: { + user: '10001:10001', + environment, + read_only: true, + cap_drop: ['ALL'], + security_opt: ['no-new-privileges:true'], + deploy: { resources: { limits: { cpus: '1.0', memory: '512M', pids: 512 } } }, + secrets: ['deploy/ldap-bind', 'deploy/postgres-dsn'], + ...overrides.service, + }, + }, + secrets: { + 'deploy/ldap-bind': { external: true }, + 'deploy/postgres-dsn': { external: true }, + }, + volumes: { + 'runtime-state': { external: true }, + 'mail-data': { external: true }, + 'dav-data': { external: true }, + 'backup-data': { external: true }, + }, + ...overrides, + }; +} + +test('rate limiter covers every required channel and hashes IP/session dimensions', () => { + const clock = createClock(); + const limits = Object.fromEntries(ABUSE_CHANNELS.map((channel) => [channel, { + tenant: { max: 2, windowMs: 60_000 }, + ip: { max: 1, windowMs: 60_000 }, + session: { max: 2, windowMs: 60_000 }, + }])); + const limiter = createRateLimiter({ limits, clock: clock.clock }); + assert.equal(limiter.consume({ channel: 'smtp', tenantId: 'acme', ipAddress: '203.0.113.5', sessionId: 'opaque-session' }).allowed, true); + const blocked = limiter.consume({ channel: 'smtp', tenantId: 'acme', ipAddress: '203.0.113.5', sessionId: 'opaque-session' }); + assert.equal(blocked.allowed, false); + assert.deepEqual(blocked.limitedBy, ['ip']); + assert.equal(JSON.stringify(blocked).includes('203.0.113.5'), false); + assert.equal(JSON.stringify(blocked).includes('opaque-session'), false); + clock.advance(60_000); + assert.equal(limiter.consume({ channel: 'imap', tenantId: 'acme', ipAddress: '203.0.113.5', sessionId: 'opaque-session' }).allowed, true); +}); + +test('abuse guard emits metadata-only audit hooks and locks out failures', () => { + const clock = createClock(); + const audit = []; + const guard = createAbuseGuard({ + clock: clock.clock, + onAudit: (event) => audit.push(event), + lockoutPolicy: { failureThreshold: 2, failureWindowMs: 60_000, lockoutMs: 120_000, quarantineThreshold: 3, quarantineMs: 300_000 }, + }); + guard.recordFailure({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5', channel: 'login', reason: 'invalid-credentials' }); + const second = guard.recordFailure({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5', channel: 'login', reason: 'invalid-credentials' }); + assert.equal(second.locked, true); + assert.equal(guard.check({ channel: 'login', tenantId: 'acme', ipAddress: '203.0.113.5' }).allowed, false); + assert.equal(guard.status({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5' }).locked, true); + assert.equal(audit.length, 2); + assert.equal(JSON.stringify(audit).includes('203.0.113.5'), false); + assert.equal(audit[1].subjectType, 'ip'); + clock.advance(120_000); + assert.equal(guard.status({ tenantId: 'acme', subjectType: 'ip', subject: '203.0.113.5' }).locked, false); +}); + +test('quarantine and release are explicit auditable controls', () => { + const audit = []; + const guard = createAbuseGuard({ onAudit: (event) => audit.push(event) }); + const quarantine = guard.quarantineSubject({ tenantId: 'acme', subjectType: 'user', subject: 'alice', channel: 'recovery', durationMs: 60_000, reason: 'repeated-recovery-abuse' }); + assert.equal(quarantine.quarantined, true); + assert.equal(guard.check({ channel: 'recovery', tenantId: 'acme', sessionId: 'session-a' }).allowed, true); + assert.equal(guard.status({ tenantId: 'acme', subjectType: 'user', subject: 'alice' }).quarantined, true); + const released = guard.releaseSubject({ tenantId: 'acme', subjectType: 'user', subject: 'alice', channel: 'recovery' }); + assert.equal(released.released, true); + assert.equal(guard.status({ tenantId: 'acme', subjectType: 'user', subject: 'alice' }).quarantined, false); + assert.equal(audit.every((event) => !Object.hasOwn(event, 'payload') && !Object.hasOwn(event, 'sessionId')), true); +}); + +test('audit event rejects content and credential fields', () => { + assert.throws( + () => createAbuseAuditEvent({ action: 'abuse.failure', channel: 'api', outcome: 'rejected', tenantId: 'acme', reason: 'bad-input', details: { payload: 'mail body' } }), + (error) => error.code === 'SENSITIVE_DATA_FORBIDDEN', + ); +}); + +test('production Compose readiness passes only with secrets, external hosts, least privilege, bounds, and volumes', () => { + const result = validateComposeProductionReadiness({ compose: createProductionCompose() }); + assert.equal(result.ready, true); + assert.equal(result.errors.length, 0); + assert.equal(result.controls.nonRoot, true); + assert.equal(result.controls.externalDependencies, true); + assert.equal(JSON.stringify(result).includes('deploy/ldap-bind'), false); + assert.equal(JSON.stringify(result).includes('ldap.example.net'), false); +}); + +test('production Compose readiness fails closed for root, missing references, unsafe hosts, mutable filesystems, and weak bounds', () => { + const result = validateComposeProductionReadiness({ + compose: createProductionCompose({ + service: { + user: 'root', + read_only: false, + cap_drop: [], + security_opt: [], + deploy: { resources: { limits: { cpus: '0.1', memory: '64M', pids: 32 } } }, + }, + environment: { + LDAP_URL: 'ldaps://127.0.0.1:636', + LDAP_BIND_SECRET_REF: 'plaintext secret', + POSTGRES_HOST: 'postgres.internal', + POSTGRES_DSN_SECRET_REF: '', + }, + }), + }); + assert.equal(result.ready, false); + const codes = new Set(result.errors.map((error) => error.code)); + for (const code of ['NON_ROOT_USER_REQUIRED', 'READ_ONLY_REQUIRED', 'CAP_DROP_ALL_REQUIRED', 'NO_NEW_PRIVILEGES_REQUIRED', 'CPU_LIMIT_REQUIRED', 'MEMORY_LIMIT_REQUIRED', 'PIDS_LIMIT_INVALID', 'HOST_INVALID', 'SECRET_VALUE_FORBIDDEN', 'REQUIRED_SECRET_REFERENCE_MISSING']) assert.equal(codes.has(code), true, code); +}); + +test('disabled external dependencies produce warnings instead of false readiness failures', () => { + const result = validateComposeProductionReadiness({ + compose: createProductionCompose({ + environment: { + LDAP_ENABLED: 'false', + POSTGRES_ENABLED: 'false', + }, + }), + }); + assert.equal(result.ready, true); + assert.equal(result.warnings.filter((warning) => warning.code === 'DEPENDENCY_DISABLED').length, 4); +}); + +test('Compose readiness does not accept a Docker socket or host namespace', () => { + const result = validateComposeProductionReadiness({ + compose: createProductionCompose({ service: { network_mode: 'host', volumes: ['/var/run/docker.sock:/var/run/docker.sock'] } }), + }); + assert.equal(result.ready, false); + assert.equal(result.errors.some((error) => error.code === 'HOST_NAMESPACE_FORBIDDEN'), true); +}); diff --git a/src/ops/abuse/index.ts b/src/ops/abuse/index.ts new file mode 100644 index 0000000..53ca642 --- /dev/null +++ b/src/ops/abuse/index.ts @@ -0,0 +1,721 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +import { createHash, randomUUID } from 'node:crypto'; +import { isIP } from 'node:net'; + +export const ABUSE_SCHEMA_VERSION = 1; +export const ABUSE_CHANNELS = Object.freeze([ + 'http', + 'api', + 'mcp', + 'login', + 'recovery', + 'backup', + 'websocket', + 'dav', + 'smtp', + 'imap', +]); +export const ABUSE_DIMENSIONS = Object.freeze(['tenant', 'ip', 'session']); +export const ABUSE_SUBJECT_TYPES = Object.freeze(['tenant', 'ip', 'session', 'user']); + +const CHANNEL_SET = new Set(ABUSE_CHANNELS); +const DIMENSION_SET = new Set(ABUSE_DIMENSIONS); +const SUBJECT_TYPE_SET = new Set(ABUSE_SUBJECT_TYPES); +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; +const HOST_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,253}$/u; +const SECRET_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/u; +const SAFE_REASON_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const SENSITIVE_KEY_PATTERN = /(?:authorization|access[_-]?token|refresh[_-]?token|session(?:[_-]?(?:id|token|secret))?|cookie|password|passphrase|private[_-]?key|credential|secret(?!ref)|body|payload|content|message)/iu; +const PLACEHOLDER_HOST_PATTERN = /(?:^|\.)(?:example|invalid|localhost|local|internal)$/iu; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']); +const REQUIRED_EXTERNAL_VOLUMES = Object.freeze(['runtime-state', 'mail-data', 'dav-data', 'backup-data']); +const DEFAULT_MAX_BUCKETS = 20_000; + +function abuseError(message, code = 'ABUSE_CONTRACT_ERROR') { + const error = new Error(`Abuse contract error: ${message}`); + error.code = code; + return error; +} + +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertPlainObject(value, name) { + if (!isPlainObject(value)) throw abuseError(`${name} must be an object`, 'INVALID_INPUT'); +} + +function assertString(value, name, pattern = null, maximum = 256) { + if (typeof value !== 'string' || value.length === 0 || value.length > maximum || (pattern && !pattern.test(value))) { + throw abuseError(`${name} is invalid`, 'INVALID_INPUT'); + } + return value; +} + +function assertId(value, name) { + return assertString(value, name, ID_PATTERN, 128); +} + +function assertDate(value, name) { + const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); + if (Number.isNaN(date.getTime())) throw abuseError(`${name} is invalid`, 'INVALID_TIMESTAMP'); + return date; +} + +function assertInteger(value, name, minimum, maximum) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw abuseError(`${name} must be an integer between ${minimum} and ${maximum}`, 'INVALID_NUMBER'); + } + return value; +} + +function nowMilliseconds(clock, name = 'clock') { + const value = typeof clock === 'function' ? clock() : clock; + return assertDate(value, name).getTime(); +} + +function digestSubject(subjectType, value) { + const canonical = `${subjectType}\u0000${value}`; + return createHash('sha256').update(canonical, 'utf8').digest('hex'); +} + +function assertChannel(channel) { + if (typeof channel !== 'string' || !CHANNEL_SET.has(channel)) { + throw abuseError('channel is unsupported', 'INVALID_CHANNEL'); + } + return channel; +} + +function assertDimension(dimension) { + if (typeof dimension !== 'string' || !DIMENSION_SET.has(dimension)) { + throw abuseError('dimension is unsupported', 'INVALID_DIMENSION'); + } + return dimension; +} + +function assertSubjectType(subjectType) { + if (typeof subjectType !== 'string' || !SUBJECT_TYPE_SET.has(subjectType)) { + throw abuseError('subjectType is unsupported', 'INVALID_SUBJECT'); + } + return subjectType; +} + +function assertMetadata(value, path = 'metadata') { + if (Array.isArray(value)) { + value.forEach((item, index) => assertMetadata(item, `${path}[${index}]`)); + return value; + } + if (isPlainObject(value)) { + for (const [key, nested] of Object.entries(value)) { + if (SENSITIVE_KEY_PATTERN.test(key)) { + throw abuseError(`${path}.${key} is not allowed in metadata`, 'SENSITIVE_DATA_FORBIDDEN'); + } + assertMetadata(nested, `${path}.${key}`); + } + return value; + } + if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + throw abuseError(`${path} contains an unsupported value`, 'INVALID_METADATA'); + } + return value; +} + +function freezeDeep(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach((nested) => freezeDeep(nested)); + Object.freeze(value); + } + return value; +} + +function normalizeRateRule(rule, name) { + assertPlainObject(rule, name); + const max = assertInteger(rule.max, `${name}.max`, 1, 1_000_000); + const windowMs = assertInteger(rule.windowMs, `${name}.windowMs`, 1_000, 86_400_000); + return Object.freeze({ max, windowMs }); +} + +function makeChannelLimits(tenantMax, ipMax, sessionMax, windowMs = 60_000) { + return Object.freeze({ + tenant: Object.freeze({ max: tenantMax, windowMs }), + ip: Object.freeze({ max: ipMax, windowMs }), + session: Object.freeze({ max: sessionMax, windowMs }), + }); +} + +export const DEFAULT_RATE_LIMITS = freezeDeep({ + http: makeChannelLimits(600, 120, 120), + api: makeChannelLimits(300, 120, 120), + mcp: makeChannelLimits(120, 30, 60), + login: makeChannelLimits(60, 10, 10), + recovery: makeChannelLimits(30, 5, 5), + backup: makeChannelLimits(30, 10, 10), + websocket: makeChannelLimits(120, 60, 60), + dav: makeChannelLimits(300, 60, 120), + smtp: makeChannelLimits(600, 60, 120), + imap: makeChannelLimits(600, 60, 120), +}); + +function normalizeLimits(limits) { + assertPlainObject(limits, 'limits'); + const normalized = {}; + for (const channel of ABUSE_CHANNELS) { + const configured = limits[channel] ?? DEFAULT_RATE_LIMITS[channel]; + assertPlainObject(configured, `limits.${channel}`); + normalized[channel] = {}; + for (const dimension of ABUSE_DIMENSIONS) { + normalized[channel][dimension] = normalizeRateRule(configured[dimension], `limits.${channel}.${dimension}`); + } + normalized[channel] = Object.freeze(normalized[channel]); + } + return freezeDeep(normalized); +} + +function normalizeRateIdentity({ tenantId, ipAddress = null, sessionId = null } = {}) { + const identity = { tenantId: assertId(tenantId, 'tenantId'), ipAddress, sessionId }; + if (ipAddress !== null) assertString(ipAddress, 'ipAddress', null, 256); + if (sessionId !== null) assertString(sessionId, 'sessionId', null, 512); + return identity; +} + +function identityForDimension(identity, dimension) { + if (dimension === 'tenant') return identity.tenantId; + if (dimension === 'ip') return identity.ipAddress; + return identity.sessionId; +} + +/** + * Metadata-only multi-dimensional limiter. Raw IP addresses and session + * identifiers are hashed before they enter the in-memory state or result. + */ +export function createRateLimiter({ limits = DEFAULT_RATE_LIMITS, clock = () => new Date(), maxBuckets = DEFAULT_MAX_BUCKETS } = {}) { + const normalizedLimits = normalizeLimits(limits); + assertInteger(maxBuckets, 'maxBuckets', 100, 1_000_000); + const buckets = new Map(); + + function prune() { + while (buckets.size > maxBuckets) { + const first = buckets.keys().next().value; + if (first === undefined) break; + buckets.delete(first); + } + } + + function consume({ channel, tenantId, ipAddress = null, sessionId = null, cost = 1, now = undefined } = {}) { + const normalizedChannel = assertChannel(channel); + const identity = normalizeRateIdentity({ tenantId, ipAddress, sessionId }); + assertInteger(cost, 'cost', 1, 1_000_000); + const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); + const dimensions = ABUSE_DIMENSIONS.filter((dimension) => identityForDimension(identity, dimension) !== null); + const observations = []; + for (const dimension of dimensions) { + const rule = normalizedLimits[normalizedChannel][dimension]; + const subjectDigest = digestSubject(dimension, identityForDimension(identity, dimension)); + const key = `${normalizedChannel}:${dimension}:${subjectDigest}`; + const current = buckets.get(key); + const bucket = current && timestamp - current.startedAt < rule.windowMs + ? current + : { startedAt: timestamp, count: 0 }; + const limited = bucket.count + cost > rule.max; + observations.push({ dimension, rule, key, bucket, limited, subjectDigest }); + } + const limitedBy = observations.filter((item) => item.limited).map((item) => item.dimension); + if (limitedBy.length === 0) { + for (const observation of observations) { + observation.bucket.count += cost; + buckets.set(observation.key, observation.bucket); + } + } + prune(); + const retryAfterMs = observations.length === 0 + ? 0 + : Math.max(...observations.filter((item) => item.limited).map((item) => Math.max(1, item.rule.windowMs - (timestamp - item.bucket.startedAt))), 0); + const remaining = observations.length === 0 + ? 0 + : Math.min(...observations.map((item) => Math.max(0, item.rule.max - item.bucket.count))); + return Object.freeze({ + schemaVersion: ABUSE_SCHEMA_VERSION, + allowed: limitedBy.length === 0, + channel: normalizedChannel, + limitedBy: Object.freeze(limitedBy), + retryAfterMs, + remaining, + resetAt: new Date(Math.max(...observations.map((item) => item.bucket.startedAt + item.rule.windowMs), timestamp)).toISOString(), + activeBuckets: buckets.size, + }); + } + + function snapshot() { + const byChannel = Object.fromEntries(ABUSE_CHANNELS.map((channel) => [channel, 0])); + const byDimension = Object.fromEntries(ABUSE_DIMENSIONS.map((dimension) => [dimension, 0])); + for (const key of buckets.keys()) { + const [channel, dimension] = key.split(':', 2); + if (Object.hasOwn(byChannel, channel)) byChannel[channel] += 1; + if (Object.hasOwn(byDimension, dimension)) byDimension[dimension] += 1; + } + return Object.freeze({ schemaVersion: ABUSE_SCHEMA_VERSION, activeBuckets: buckets.size, byChannel, byDimension }); + } + + return Object.freeze({ consume, snapshot, limits: normalizedLimits }); +} + +/** Create a secret-free audit event suitable for the existing audit pipeline. */ +export function createAbuseAuditEvent({ + action, + channel, + outcome, + tenantId, + subjectType = null, + subject = null, + reason, + details = {}, + occurredAt = new Date(), +} = {}) { + assertString(action, 'action', SAFE_REASON_PATTERN, 128); + assertChannel(channel); + if (!['allowed', 'limited', 'locked', 'quarantined', 'released', 'rejected'].includes(outcome)) { + throw abuseError('outcome is invalid', 'INVALID_AUDIT'); + } + const normalizedTenant = assertId(tenantId, 'tenantId'); + if (subjectType !== null) assertSubjectType(subjectType); + const digest = subjectType === null ? null : digestSubject(subjectType, assertString(subject, 'subject', null, 512)); + assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); + assertPlainObject(details, 'details'); + assertMetadata(details, 'details'); + const event = { + schemaVersion: ABUSE_SCHEMA_VERSION, + eventType: 'abuse.control', + eventId: randomUUID(), + action, + channel, + outcome, + tenantId: normalizedTenant, + subjectType, + subjectDigest: digest, + reason, + details: freezeDeep({ ...details }), + occurredAt: assertDate(occurredAt, 'occurredAt').toISOString(), + }; + assertMetadata(event); + return freezeDeep(event); +} + +function normalizeLockoutPolicy(policy = {}) { + assertPlainObject(policy, 'lockoutPolicy'); + return Object.freeze({ + failureThreshold: assertInteger(policy.failureThreshold ?? 5, 'failureThreshold', 1, 1_000), + failureWindowMs: assertInteger(policy.failureWindowMs ?? 15 * 60_000, 'failureWindowMs', 1_000, 86_400_000), + lockoutMs: assertInteger(policy.lockoutMs ?? 15 * 60_000, 'lockoutMs', 1_000, 86_400_000), + quarantineThreshold: assertInteger(policy.quarantineThreshold ?? 10, 'quarantineThreshold', 1, 10_000), + quarantineMs: assertInteger(policy.quarantineMs ?? 60 * 60_000, 'quarantineMs', 1_000, 7 * 86_400_000), + }); +} + +/** + * Compose the limiter, lockout, quarantine, and audit hook without exposing + * the raw subjects. The hook receives immutable metadata only. + */ +export function createAbuseGuard({ + limits = DEFAULT_RATE_LIMITS, + lockoutPolicy = {}, + clock = () => new Date(), + onAudit = null, +} = {}) { + if (onAudit !== null && typeof onAudit !== 'function') throw abuseError('onAudit must be a function', 'INVALID_HOOK'); + const limiter = createRateLimiter({ limits, clock }); + const policy = normalizeLockoutPolicy(lockoutPolicy); + const failures = new Map(); + const lockouts = new Map(); + const quarantines = new Map(); + + function subjectKey(tenantId, subjectType, subject) { + const tenant = assertId(tenantId, 'tenantId'); + const type = assertSubjectType(subjectType); + const value = assertString(subject, 'subject', null, 512); + return `${tenant}:${type}:${digestSubject(type, value)}`; + } + + function emit(event) { + if (onAudit !== null) onAudit(event); + return event; + } + + function status({ tenantId, subjectType, subject, now = undefined } = {}) { + const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); + const key = subjectKey(tenantId, subjectType, subject); + const lockoutUntil = lockouts.get(key) ?? 0; + const quarantineUntil = quarantines.get(key) ?? 0; + return Object.freeze({ + schemaVersion: ABUSE_SCHEMA_VERSION, + blocked: lockoutUntil > timestamp || quarantineUntil > timestamp, + locked: lockoutUntil > timestamp, + quarantined: quarantineUntil > timestamp, + lockoutUntil: lockoutUntil > timestamp ? new Date(lockoutUntil).toISOString() : null, + quarantineUntil: quarantineUntil > timestamp ? new Date(quarantineUntil).toISOString() : null, + }); + } + + function recordFailure({ + tenantId, + subjectType, + subject, + channel = 'login', + reason = 'authentication-failure', + now = undefined, + } = {}) { + const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); + assertChannel(channel); + assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); + const key = subjectKey(tenantId, subjectType, subject); + const current = failures.get(key); + const bucket = current && timestamp - current.startedAt < policy.failureWindowMs + ? current + : { startedAt: timestamp, count: 0 }; + bucket.count += 1; + failures.set(key, bucket); + const locked = bucket.count >= policy.failureThreshold; + const quarantined = bucket.count >= policy.quarantineThreshold; + if (locked) lockouts.set(key, timestamp + policy.lockoutMs); + if (quarantined) quarantines.set(key, timestamp + policy.quarantineMs); + const outcome = quarantined ? 'quarantined' : locked ? 'locked' : 'rejected'; + const event = createAbuseAuditEvent({ + action: 'abuse.failure', + channel, + outcome, + tenantId, + subjectType, + subject, + reason, + details: { count: bucket.count, threshold: policy.failureThreshold }, + occurredAt: timestamp, + }); + return Object.freeze({ + allowed: false, + count: bucket.count, + locked, + quarantined, + lockoutUntil: locked ? new Date(timestamp + policy.lockoutMs).toISOString() : null, + quarantineUntil: quarantined ? new Date(timestamp + policy.quarantineMs).toISOString() : null, + audit: emit(event), + }); + } + + function recordSuccess({ tenantId, subjectType, subject, channel = 'login', now = undefined } = {}) { + const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); + assertChannel(channel); + const key = subjectKey(tenantId, subjectType, subject); + failures.delete(key); + lockouts.delete(key); + const event = createAbuseAuditEvent({ + action: 'abuse.recovery', + channel, + outcome: 'released', + tenantId, + subjectType, + subject, + reason: 'authenticated-success', + occurredAt: timestamp, + }); + return Object.freeze({ cleared: true, audit: emit(event) }); + } + + function quarantineSubject({ tenantId, subjectType, subject, channel = 'api', durationMs = policy.quarantineMs, reason = 'operator-quarantine', now = undefined } = {}) { + const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); + assertChannel(channel); + assertInteger(durationMs, 'durationMs', 1_000, 7 * 86_400_000); + assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); + const key = subjectKey(tenantId, subjectType, subject); + quarantines.set(key, timestamp + durationMs); + const event = createAbuseAuditEvent({ + action: 'abuse.quarantine', + channel, + outcome: 'quarantined', + tenantId, + subjectType, + subject, + reason, + details: { durationMs }, + occurredAt: timestamp, + }); + return Object.freeze({ quarantined: true, quarantineUntil: new Date(timestamp + durationMs).toISOString(), audit: emit(event) }); + } + + function releaseSubject({ tenantId, subjectType, subject, channel = 'api', reason = 'operator-release', now = undefined } = {}) { + const timestamp = now === undefined ? nowMilliseconds(clock) : nowMilliseconds(now, 'now'); + assertChannel(channel); + assertString(reason, 'reason', SAFE_REASON_PATTERN, 128); + const key = subjectKey(tenantId, subjectType, subject); + quarantines.delete(key); + lockouts.delete(key); + failures.delete(key); + const event = createAbuseAuditEvent({ + action: 'abuse.release', + channel, + outcome: 'released', + tenantId, + subjectType, + subject, + reason, + occurredAt: timestamp, + }); + return Object.freeze({ released: true, audit: emit(event) }); + } + + function check({ channel, tenantId, ipAddress = null, sessionId = null, userId = null, now = undefined, cost = 1 } = {}) { + const identity = normalizeRateIdentity({ tenantId, ipAddress, sessionId }); + const candidates = [ + ['tenant', identity.tenantId], + ['ip', identity.ipAddress], + ['session', identity.sessionId], + ['user', userId === null ? null : assertId(userId, 'userId')], + ].filter(([, value]) => value !== null); + const blockedBy = []; + for (const [subjectType, subject] of candidates) { + const state = status({ tenantId: identity.tenantId, subjectType, subject, now }); + if (state.blocked) blockedBy.push(subjectType); + } + if (blockedBy.length > 0) { + return Object.freeze({ schemaVersion: ABUSE_SCHEMA_VERSION, allowed: false, reason: 'lockout-or-quarantine', blockedBy: Object.freeze(blockedBy) }); + } + return limiter.consume({ channel, ...identity, now, cost }); + } + + return Object.freeze({ + check, + recordFailure, + recordSuccess, + quarantineSubject, + releaseSubject, + status, + limiter, + lockoutPolicy: policy, + }); +} + +function readEnvironment(environment, key) { + if (Array.isArray(environment)) { + const arrayEntry = environment.find((value) => typeof value === 'string' && value.startsWith(`${key}=`)); + return arrayEntry === undefined ? undefined : arrayEntry.slice(key.length + 1); + } + if (isPlainObject(environment) && Object.hasOwn(environment, key)) return environment[key]; + return undefined; +} + +function nonEmptyEnvironment(environment, key) { + const value = readEnvironment(environment, key); + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function isUnsafeHost(hostname) { + const normalized = hostname.toLowerCase().replace(/\.$/u, ''); + if (LOOPBACK_HOSTS.has(normalized) || normalized.endsWith('.internal') || PLACEHOLDER_HOST_PATTERN.test(normalized)) return true; + const ipVersion = isIP(normalized); + if (ipVersion === 4) { + const octets = normalized.split('.').map(Number); + return octets[0] === 0 + || octets[0] === 10 + || octets[0] === 127 + || (octets[0] === 169 && octets[1] === 254) + || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) + || (octets[0] === 192 && octets[1] === 0 && octets[2] === 0) + || (octets[0] === 192 && octets[1] === 168) + || (octets[0] === 198 && (octets[1] === 18 || octets[1] === 19)) + || octets[0] >= 224; + } + if (ipVersion === 6) return normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:'); + return false; +} + +function validateExternalHost(value, field) { + if (value === null) return { field, code: 'REQUIRED_HOST_MISSING', message: `${field} is required` }; + let parsed; + try { + parsed = field.endsWith('_URL') ? new URL(value) : null; + } catch { + return { field, code: 'HOST_INVALID', message: `${field} must be a valid endpoint` }; + } + const hostname = parsed?.hostname ?? value; + if (typeof hostname !== 'string' || hostname.length === 0 || !HOST_REFERENCE_PATTERN.test(hostname) || isUnsafeHost(hostname)) { + return { field, code: 'HOST_INVALID', message: `${field} must point to a configured external host` }; + } + if (parsed && !['ldap:', 'ldaps:', 'https:'].includes(parsed.protocol)) { + return { field, code: 'HOST_PROTOCOL_INVALID', message: `${field} uses an unsupported protocol` }; + } + return null; +} + +function validateSecretReference(environment, service, field, declaredSecrets) { + const value = nonEmptyEnvironment(environment, field); + if (value === null) return { field, code: 'REQUIRED_SECRET_REFERENCE_MISSING', message: `${field} is required` }; + if (!SECRET_REFERENCE_PATTERN.test(value) || /[=\s]/u.test(value)) { + return { field, code: 'SECRET_VALUE_FORBIDDEN', message: `${field} must be a secret reference, not secret material` }; + } + if (declaredSecrets !== null && !declaredSecrets.has(value) && !declaredSecrets.has(field)) { + return { field, code: 'SECRET_NOT_DECLARED', message: `${field} does not resolve to a declared deployment secret` }; + } + return null; +} + +function collectDeclaredSecrets(compose) { + const declared = new Set(); + const topLevel = compose.secrets; + if (isPlainObject(topLevel)) Object.keys(topLevel).forEach((key) => declared.add(key)); + const serviceSecrets = compose.services?.gulogulo?.secrets; + if (Array.isArray(serviceSecrets)) { + serviceSecrets.forEach((entry) => { + if (typeof entry === 'string') declared.add(entry); + else if (isPlainObject(entry) && typeof entry.source === 'string') declared.add(entry.source); + }); + } + return declared; +} + +function normalizeComposeService(compose, serviceName) { + assertPlainObject(compose, 'compose'); + assertPlainObject(compose.services, 'compose.services'); + const service = compose.services[serviceName]; + assertPlainObject(service, `compose.services.${serviceName}`); + return service; +} + +function parseCpu(value) { + if (typeof value === 'number') return value; + if (typeof value !== 'string') return null; + if (/^\d+(?:\.\d+)?$/u.test(value)) return Number(value); + const millicpu = value.match(/^(\d+)m$/u); + return millicpu ? Number(millicpu[1]) / 1000 : null; +} + +function parseMemoryBytes(value) { + if (typeof value === 'number') return value; + if (typeof value !== 'string') return null; + const match = value.trim().match(/^(\d+(?:\.\d+)?)([KMGTP]i?B?)?$/iu); + if (!match) return null; + const multipliers = { k: 1024, ki: 1024, kb: 1000, m: 1024 ** 2, mi: 1024 ** 2, mb: 1000 ** 2, g: 1024 ** 3, gi: 1024 ** 3, gb: 1000 ** 3, t: 1024 ** 4, ti: 1024 ** 4, tb: 1000 ** 4, pib: 1024 ** 5 }; + const suffix = (match[2] ?? '').toLowerCase(); + return Number(match[1]) * (multipliers[suffix] ?? 1); +} + +function composeResourceLimits(service) { + const limits = service.deploy?.resources?.limits ?? {}; + return { + cpus: parseCpu(limits.cpus ?? service.cpus), + memoryBytes: parseMemoryBytes(limits.memory ?? service.mem_limit), + pids: limits.pids ?? service.pids_limit, + }; +} + +function addReadinessError(errors, code, field, message) { + errors.push(Object.freeze({ code, field, message })); +} + +/** + * Validate a production Compose object without returning environment values, + * secret values, image contents, or mounted volume data. + */ +export function validateComposeProductionReadiness({ + compose, + serviceName = 'gulogulo', + requiredExternalHosts = ['LDAP_URL', 'POSTGRES_HOST'], + requiredSecretReferences = ['LDAP_BIND_SECRET_REF', 'POSTGRES_DSN_SECRET_REF'], + requiredExternalVolumes = REQUIRED_EXTERNAL_VOLUMES, + requireExternalSecrets = true, + checkedAt = new Date(), +} = {}) { + const service = normalizeComposeService(compose, serviceName); + const environment = service.environment ?? {}; + const errors = []; + const warnings = []; + const appEnvironment = nonEmptyEnvironment(environment, 'APP_ENV') ?? nonEmptyEnvironment(environment, 'GULOGULO_ENV'); + if (appEnvironment !== 'production') addReadinessError(errors, 'PRODUCTION_ENV_REQUIRED', 'APP_ENV', 'production environment is required'); + const composeUser = service.user; + const composeUserText = composeUser === undefined || composeUser === null ? '' : String(composeUser); + const [composeUid, composeGid] = composeUserText.split(':', 2); + if (composeUser === undefined || composeUser === null || composeUser === '' || composeUser === 0 || composeUserText === '0' || composeUserText === 'root' || composeUid === '0' || composeGid === '0') { + addReadinessError(errors, 'NON_ROOT_USER_REQUIRED', 'services.user', 'the service must run as a non-root user'); + } + if (service.privileged === true) addReadinessError(errors, 'PRIVILEGED_FORBIDDEN', 'services.privileged', 'privileged mode is forbidden'); + if (service.read_only !== true) addReadinessError(errors, 'READ_ONLY_REQUIRED', 'services.read_only', 'production service filesystem must be read-only'); + if (service.network_mode === 'host' || service.pid === 'host') addReadinessError(errors, 'HOST_NAMESPACE_FORBIDDEN', 'services.namespace', 'host namespaces are forbidden'); + if (Array.isArray(service.cap_add) && service.cap_add.length > 0) addReadinessError(errors, 'CAPABILITIES_FORBIDDEN', 'services.cap_add', 'cap_add must be empty'); + if (!Array.isArray(service.cap_drop) || !service.cap_drop.some((capability) => String(capability).toUpperCase() === 'ALL')) { + addReadinessError(errors, 'CAP_DROP_ALL_REQUIRED', 'services.cap_drop', 'ALL capabilities must be dropped'); + } + if (!Array.isArray(service.security_opt) || !service.security_opt.some((option) => option === 'no-new-privileges:true')) { + addReadinessError(errors, 'NO_NEW_PRIVILEGES_REQUIRED', 'services.security_opt', 'no-new-privileges must be enabled'); + } + if (service.devices !== undefined && Array.isArray(service.devices) && service.devices.length > 0) addReadinessError(errors, 'DEVICES_FORBIDDEN', 'services.devices', 'device passthrough is forbidden'); + if (Array.isArray(service.volumes) && service.volumes.some((volume) => { + const source = typeof volume === 'string' ? volume.split(':', 1)[0] : volume?.source; + const target = typeof volume === 'string' ? volume.split(':')[1] : volume?.target; + return source === '/var/run/docker.sock' || target === '/var/run/docker.sock'; + })) addReadinessError(errors, 'DOCKER_SOCKET_FORBIDDEN', 'services.volumes', 'the Docker socket must not be mounted'); + const resourceLimits = composeResourceLimits(service); + if (resourceLimits.cpus === null || resourceLimits.cpus < 0.25 || resourceLimits.cpus > 8) addReadinessError(errors, 'CPU_LIMIT_REQUIRED', 'services.deploy.resources.limits.cpus', 'CPU limit must be between 0.25 and 8'); + if (resourceLimits.memoryBytes === null || resourceLimits.memoryBytes < 128 * 1024 ** 2 || resourceLimits.memoryBytes > 8 * 1024 ** 3) addReadinessError(errors, 'MEMORY_LIMIT_REQUIRED', 'services.deploy.resources.limits.memory', 'memory limit must be between 128 MiB and 8 GiB'); + if (resourceLimits.pids !== undefined && (!Number.isSafeInteger(Number(resourceLimits.pids)) || Number(resourceLimits.pids) < 64 || Number(resourceLimits.pids) > 4_096)) addReadinessError(errors, 'PIDS_LIMIT_INVALID', 'services.deploy.resources.limits.pids', 'pids limit must be between 64 and 4096'); + + const volumes = compose.volumes; + if (!isPlainObject(volumes)) { + addReadinessError(errors, 'EXTERNAL_VOLUMES_REQUIRED', 'volumes', 'persistent production volumes must be declared externally'); + } else { + for (const volumeName of requiredExternalVolumes) { + if (!isPlainObject(volumes[volumeName]) || volumes[volumeName].external !== true) addReadinessError(errors, 'EXTERNAL_VOLUME_REQUIRED', `volumes.${volumeName}`, 'the user-data volume must be external'); + } + } + + const declaredSecrets = requireExternalSecrets ? collectDeclaredSecrets(compose) : null; + const ldapEnabled = ['true', '1', 'yes'].includes((nonEmptyEnvironment(environment, 'LDAP_ENABLED') ?? 'false').toLowerCase()); + const postgresEnabled = ['true', '1', 'yes'].includes((nonEmptyEnvironment(environment, 'POSTGRES_ENABLED') ?? 'false').toLowerCase()); + for (const hostField of requiredExternalHosts) { + const enabled = hostField.startsWith('LDAP_') ? ldapEnabled : hostField.startsWith('POSTGRES_') ? postgresEnabled : true; + if (enabled) { + const hostError = validateExternalHost(nonEmptyEnvironment(environment, hostField), hostField); + if (hostError) addReadinessError(errors, hostError.code, hostError.field, hostError.message); + } else { + warnings.push(Object.freeze({ code: 'DEPENDENCY_DISABLED', field: hostField, message: `${hostField} is not required while its dependency is disabled` })); + } + } + for (const secretField of requiredSecretReferences) { + const enabled = secretField.startsWith('LDAP_') ? ldapEnabled : secretField.startsWith('POSTGRES_') ? postgresEnabled : true; + if (enabled) { + const secretError = validateSecretReference(environment, service, secretField, declaredSecrets); + if (secretError) addReadinessError(errors, secretError.code, secretError.field, secretError.message); + } else { + warnings.push(Object.freeze({ code: 'DEPENDENCY_DISABLED', field: secretField, message: `${secretField} is not required while its dependency is disabled` })); + } + } + const environmentKeys = isPlainObject(environment) ? Object.keys(environment) : []; + for (const key of environmentKeys) { + if (SENSITIVE_KEY_PATTERN.test(key) && !/_REF$/u.test(key)) { + addReadinessError(errors, 'PLAINTEXT_SECRET_FORBIDDEN', `services.environment.${key}`, 'secret material must use a reference'); + } + } + + return Object.freeze({ + schemaVersion: ABUSE_SCHEMA_VERSION, + readinessType: 'compose-production', + serviceName, + ready: errors.length === 0, + checkedAt: assertDate(checkedAt, 'checkedAt').toISOString(), + errors: Object.freeze(errors), + warnings: Object.freeze(warnings), + controls: Object.freeze({ + nonRoot: errors.every((error) => error.code !== 'NON_ROOT_USER_REQUIRED'), + readOnly: errors.every((error) => error.code !== 'READ_ONLY_REQUIRED'), + leastPrivilege: errors.every((error) => !['CAPABILITIES_FORBIDDEN', 'CAP_DROP_ALL_REQUIRED', 'NO_NEW_PRIVILEGES_REQUIRED', 'PRIVILEGED_FORBIDDEN', 'HOST_NAMESPACE_FORBIDDEN', 'DEVICES_FORBIDDEN', 'DOCKER_SOCKET_FORBIDDEN'].includes(error.code)), + resourceBounds: errors.every((error) => !['CPU_LIMIT_REQUIRED', 'MEMORY_LIMIT_REQUIRED', 'PIDS_LIMIT_INVALID'].includes(error.code)), + persistentVolumes: errors.every((error) => !['EXTERNAL_VOLUMES_REQUIRED', 'EXTERNAL_VOLUME_REQUIRED'].includes(error.code)), + externalDependencies: errors.every((error) => !['REQUIRED_HOST_MISSING', 'HOST_INVALID', 'HOST_PROTOCOL_INVALID', 'REQUIRED_SECRET_REFERENCE_MISSING', 'SECRET_VALUE_FORBIDDEN', 'SECRET_NOT_DECLARED', 'PLAINTEXT_SECRET_FORBIDDEN'].includes(error.code)), + }), + }); +} + +export { abuseError, digestSubject }; diff --git a/src/ops/acme/index.mjs b/src/ops/acme/index.mjs index ea4c14c..9ae6e02 100644 --- a/src/ops/acme/index.mjs +++ b/src/ops/acme/index.mjs @@ -2,683 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -import { domainToASCII } from 'node:url'; - -const DAY_MS = 86_400_000; -const MAX_SAFE_DATE_MS = 8_640_000_000_000_000; -const SECRET_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u; -const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; -const SAFE_ERROR_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; -const EMAIL_PATTERN = /^[\x21-\x7E]{1,254}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/u; -const HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/u; -const PRIVATE_KEY_PATTERN = /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/u; -const PEM_PATTERN = /-----BEGIN [A-Z0-9 ]+-----/u; -const SECRET_KEY_PATTERN = /(account.?key|private.?key|certificate.?pem|secret|password|passphrase|token|credential|authorization|cookie|hmac|bearer)/iu; - -export const ACME_PROVIDERS = Object.freeze({ - LETSENCRYPT: 'letsencrypt', - GENERIC: 'acme', -}); - -export const ACME_ENVIRONMENTS = Object.freeze({ - PRODUCTION: 'production', - STAGING: 'staging', -}); - -export const CHALLENGE_TYPES = Object.freeze({ - HTTP_01: 'http-01', - DNS_01: 'dns-01', -}); - -export const DEFAULT_LETSENCRYPT_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory'; -export const LETSENCRYPT_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory'; - -export const RENEWAL_STATES = Object.freeze([ - 'idle', - 'scheduled', - 'authorizing', - 'ordering', - 'finalizing', - 'reload_pending', - 'active', - 'retry_wait', - 'degraded', - 'failed', - 'cancelled', -]); - -export const TLS_PROTOCOLS = Object.freeze(['TLSv1.2', 'TLSv1.3']); - -function deepFreeze(value) { - if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; - for (const child of Object.values(value)) deepFreeze(child); - return Object.freeze(value); -} - -export function acmeError(message, code = 'ACME_CONTRACT_ERROR', details = undefined) { - const error = new Error(`ACME contract error: ${message}`); - error.code = code; - if (details && typeof details === 'object' && !Array.isArray(details)) { - error.details = redactSecrets(details); - } - return error; -} - -function assertPlainObject(value, field) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw acmeError(`${field} must be an object`, 'INVALID_CONFIGURATION'); - } - return value; -} - -function assertAllowedKeys(value, allowed, field) { - for (const key of Object.keys(value)) { - if (!allowed.has(key)) throw acmeError(`${field}.${key} is not supported`, 'UNKNOWN_CONFIGURATION'); - } -} - -function assertBoolean(value, field) { - if (typeof value !== 'boolean') throw acmeError(`${field} must be a boolean`, 'INVALID_CONFIGURATION'); - return value; -} - -function integer(value, field, minimum, maximum) { - if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { - throw acmeError(`${field} must be an integer between ${minimum} and ${maximum}`, 'INVALID_CONFIGURATION'); - } - return value; -} - -function positiveInteger(value, field, maximum = Number.MAX_SAFE_INTEGER) { - return integer(value, field, 1, maximum); -} - -function safeIdentifier(value, field, { max = 128 } = {}) { - if (typeof value !== 'string' || value.length > max || !SAFE_IDENTIFIER_PATTERN.test(value)) { - throw acmeError(`${field} is invalid`, 'INVALID_IDENTIFIER'); - } - return value; -} - -function secretReference(value, field) { - if (typeof value !== 'string' || !SECRET_REFERENCE_PATTERN.test(value)) { - throw acmeError(`${field} must be a secret reference`, 'INVALID_SECRET_REFERENCE'); - } - return value; -} - -function isoDate(value, field, fallback = undefined) { - const candidate = value === undefined || value === null ? fallback : value; - if (candidate === undefined || candidate === null) return null; - const date = candidate instanceof Date ? new Date(candidate.getTime()) : new Date(candidate); - if (Number.isNaN(date.getTime()) || Math.abs(date.getTime()) > MAX_SAFE_DATE_MS) { - throw acmeError(`${field} is not a valid timestamp`, 'INVALID_TIMESTAMP'); - } - return date.toISOString(); -} - -function nowIso(clock = () => new Date()) { - return isoDate(clock(), 'clock'); -} - -function parseDate(value, field) { - const result = isoDate(value, field); - if (result === null) throw acmeError(`${field} is required`, 'INVALID_TIMESTAMP'); - return new Date(result); -} - -function normalizeEmail(value, field) { - if (value === undefined || value === null) return null; - if (typeof value !== 'string' || value.length > 254 || !EMAIL_PATTERN.test(value)) { - throw acmeError(`${field} is invalid`, 'INVALID_CONTACT'); - } - return value; -} - -function hasWildcard(domain) { - return domain.startsWith('*.'); -} - -function normalizeDomain(value, field = 'domains') { - if (typeof value !== 'string' || value.length === 0 || value.length > 253) { - throw acmeError(`${field} contains an invalid domain`, 'INVALID_DOMAIN'); - } - const trimmed = value.trim(); - if (trimmed !== value || trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) { - throw acmeError(`${field} contains an invalid domain`, 'INVALID_DOMAIN'); - } - const wildcard = hasWildcard(trimmed); - const source = wildcard ? trimmed.slice(2) : trimmed; - const ascii = domainToASCII(source).toLowerCase(); - if (!HOSTNAME_PATTERN.test(ascii) || (wildcard && ascii.split('.').length < 2)) { - throw acmeError(`${field} contains an invalid domain`, 'INVALID_DOMAIN'); - } - return wildcard ? `*.${ascii}` : ascii; -} - -function normalizeDomains(value) { - if (!Array.isArray(value) || value.length === 0 || value.length > 100) { - throw acmeError('domains must be a non-empty list of at most 100 names', 'INVALID_DOMAIN'); - } - const domains = [...new Set(value.map((entry, index) => normalizeDomain(entry, `domains[${index}]`)))]; - if (domains.length === 0) throw acmeError('domains must not be empty', 'INVALID_DOMAIN'); - return domains; -} - -function normalizeDirectoryUrl(value, field = 'directoryUrl') { - if (typeof value !== 'string' || value.length > 2048) throw acmeError(`${field} is invalid`, 'INVALID_DIRECTORY_URL'); - let parsed; - try { parsed = new URL(value); } catch { throw acmeError(`${field} is not a URL`, 'INVALID_DIRECTORY_URL'); } - if (parsed.protocol !== 'https:') throw acmeError(`${field} must use HTTPS`, 'INSECURE_DIRECTORY_URL'); - if (parsed.username || parsed.password || parsed.hash || parsed.search) { - throw acmeError(`${field} must not contain credentials, query parameters, or fragments`, 'INVALID_DIRECTORY_URL'); - } - if (!parsed.hostname || parsed.hostname.includes('..')) throw acmeError(`${field} host is invalid`, 'INVALID_DIRECTORY_URL'); - return parsed.toString().replace(/\/$/u, ''); -} - -function normalizeAccount(input = {}) { - const account = assertPlainObject(input, 'account'); - assertAllowedKeys(account, new Set(['id', 'contactEmail', 'keySecretRef', 'accountUrl', 'externalAccountBinding']), 'account'); - const output = { - id: account.id === undefined ? null : safeIdentifier(account.id, 'account.id'), - contactEmail: normalizeEmail(account.contactEmail, 'account.contactEmail'), - keySecretRef: secretReference(account.keySecretRef ?? 'acme/account-key', 'account.keySecretRef'), - accountUrl: account.accountUrl === undefined || account.accountUrl === null ? null : normalizeDirectoryUrl(account.accountUrl, 'account.accountUrl'), - externalAccountBinding: null, - }; - if (account.externalAccountBinding !== undefined && account.externalAccountBinding !== null) { - const eab = assertPlainObject(account.externalAccountBinding, 'account.externalAccountBinding'); - assertAllowedKeys(eab, new Set(['kid', 'hmacSecretRef']), 'account.externalAccountBinding'); - if (typeof eab.kid !== 'string' || eab.kid.length === 0 || eab.kid.length > 256 || PEM_PATTERN.test(eab.kid)) { - throw acmeError('account.externalAccountBinding.kid is invalid', 'INVALID_ACCOUNT_BINDING'); - } - output.externalAccountBinding = { - kid: eab.kid, - hmacSecretRef: secretReference(eab.hmacSecretRef, 'account.externalAccountBinding.hmacSecretRef'), - }; - } - return output; -} - -function normalizeChallenge(input = {}, domains) { - const challenge = assertPlainObject(input, 'challenge'); - const type = challenge.type ?? CHALLENGE_TYPES.HTTP_01; - if (!Object.values(CHALLENGE_TYPES).includes(type)) throw acmeError('challenge.type is unsupported', 'INVALID_CHALLENGE'); - if (type === CHALLENGE_TYPES.HTTP_01) { - assertAllowedKeys(challenge, new Set(['type', 'listenPort', 'publicPort', 'tokenPathPrefix']), 'challenge'); - const listenPort = challenge.listenPort ?? 80; - const publicPort = challenge.publicPort ?? 80; - if (hasWildcard(domains[0])) throw acmeError('HTTP-01 cannot validate wildcard domains', 'INVALID_CHALLENGE'); - const tokenPathPrefix = challenge.tokenPathPrefix ?? '/.well-known/acme-challenge/'; - if (typeof tokenPathPrefix !== 'string' || !/^\/[A-Za-z0-9._/-]{1,127}\/$/u.test(tokenPathPrefix) || tokenPathPrefix.includes('..')) { - throw acmeError('challenge.tokenPathPrefix is invalid', 'INVALID_CHALLENGE'); - } - return { - type, - listenPort: integer(listenPort, 'challenge.listenPort', 1, 65_535), - publicPort: integer(publicPort, 'challenge.publicPort', 1, 65_535), - tokenPathPrefix, - }; - } - assertAllowedKeys(challenge, new Set(['type', 'dnsProvider', 'credentialsSecretRef', 'propagationTimeoutSeconds', 'pollIntervalSeconds']), 'challenge'); - if (typeof challenge.dnsProvider !== 'string' || !/^[a-z][a-z0-9-]{1,63}$/u.test(challenge.dnsProvider)) { - throw acmeError('challenge.dnsProvider is required and invalid', 'INVALID_CHALLENGE'); - } - return { - type, - dnsProvider: challenge.dnsProvider, - credentialsSecretRef: secretReference(challenge.credentialsSecretRef, 'challenge.credentialsSecretRef'), - propagationTimeoutSeconds: integer(challenge.propagationTimeoutSeconds ?? 120, 'challenge.propagationTimeoutSeconds', 10, 3_600), - pollIntervalSeconds: integer(challenge.pollIntervalSeconds ?? 5, 'challenge.pollIntervalSeconds', 1, 300), - }; -} - -function normalizeRetry(input = {}) { - const retry = assertPlainObject(input, 'renewal.retry'); - assertAllowedKeys(retry, new Set(['maxAttempts', 'initialDelaySeconds', 'maxDelaySeconds', 'multiplier']), 'renewal.retry'); - const maxAttempts = integer(retry.maxAttempts ?? 5, 'renewal.retry.maxAttempts', 0, 20); - const initialDelaySeconds = integer(retry.initialDelaySeconds ?? 300, 'renewal.retry.initialDelaySeconds', 1, 86_400); - const maxDelaySeconds = integer(retry.maxDelaySeconds ?? 86_400, 'renewal.retry.maxDelaySeconds', initialDelaySeconds, 604_800); - const multiplier = retry.multiplier ?? 2; - if (typeof multiplier !== 'number' || !Number.isFinite(multiplier) || multiplier < 1 || multiplier > 10) { - throw acmeError('renewal.retry.multiplier is invalid', 'INVALID_RETRY_POLICY'); - } - return { maxAttempts, initialDelaySeconds, maxDelaySeconds, multiplier }; -} - -function normalizeRenewal(input = {}) { - const renewal = assertPlainObject(input, 'renewal'); - assertAllowedKeys(renewal, new Set(['enabled', 'renewBeforeDays', 'expiryWarningDays', 'expiryCriticalDays', 'retry', 'fallback']), 'renewal'); - const enabled = renewal.enabled ?? true; - if (typeof enabled !== 'boolean') throw acmeError('renewal.enabled must be a boolean', 'INVALID_RENEWAL_POLICY'); - const renewBeforeDays = integer(renewal.renewBeforeDays ?? 30, 'renewal.renewBeforeDays', 1, 90); - const expiryWarningDays = integer(renewal.expiryWarningDays ?? 30, 'renewal.expiryWarningDays', 1, 90); - const expiryCriticalDays = integer(renewal.expiryCriticalDays ?? 7, 'renewal.expiryCriticalDays', 0, expiryWarningDays); - const fallbackInput = renewal.fallback ?? {}; - const fallback = assertPlainObject(fallbackInput, 'renewal.fallback'); - assertAllowedKeys(fallback, new Set(['preserveCurrentCertificate', 'alertOnFailure', 'allowServingUntilExpiry']), 'renewal.fallback'); - return { - enabled, - renewBeforeDays, - expiryWarningDays, - expiryCriticalDays, - retry: normalizeRetry(renewal.retry ?? {}), - fallback: { - preserveCurrentCertificate: fallback.preserveCurrentCertificate ?? true, - alertOnFailure: fallback.alertOnFailure ?? true, - allowServingUntilExpiry: fallback.allowServingUntilExpiry ?? true, - }, - }; -} - -function normalizeReload(input = {}) { - const reload = assertPlainObject(input, 'reload'); - assertAllowedKeys(reload, new Set(['strategy', 'consumers', 'timeoutSeconds', 'healthGate', 'rollbackOnFailure']), 'reload'); - const consumers = reload.consumers ?? ['web', 'postfix', 'dovecot']; - const allowedConsumers = new Set(['web', 'postfix', 'dovecot', 'caldav', 'carddav']); - if (!Array.isArray(consumers) || consumers.length === 0 || consumers.some((value) => typeof value !== 'string' || !allowedConsumers.has(value))) { - throw acmeError('reload.consumers is invalid', 'INVALID_RELOAD_POLICY'); - } - const strategy = reload.strategy ?? 'graceful'; - if (strategy !== 'graceful') throw acmeError('reload.strategy must be graceful', 'INVALID_RELOAD_POLICY'); - return { - strategy, - consumers: [...new Set(consumers)], - timeoutSeconds: integer(reload.timeoutSeconds ?? 30, 'reload.timeoutSeconds', 1, 600), - healthGate: reload.healthGate ?? true, - rollbackOnFailure: reload.rollbackOnFailure ?? true, - }; -} - -function normalizeTls(input = {}) { - const tls = assertPlainObject(input, 'tls'); - assertAllowedKeys(tls, new Set(['minimumVersion', 'allowedVersions', 'healthCheckHostnames']), 'tls'); - const minimumVersion = tls.minimumVersion ?? 'TLSv1.2'; - if (!TLS_PROTOCOLS.includes(minimumVersion)) throw acmeError('tls.minimumVersion is invalid', 'INVALID_TLS_POLICY'); - const allowedVersions = tls.allowedVersions ?? TLS_PROTOCOLS; - if (!Array.isArray(allowedVersions) || allowedVersions.length === 0 || allowedVersions.some((value) => !TLS_PROTOCOLS.includes(value))) { - throw acmeError('tls.allowedVersions is invalid', 'INVALID_TLS_POLICY'); - } - if (!allowedVersions.includes(minimumVersion)) throw acmeError('tls.allowedVersions must include minimumVersion', 'INVALID_TLS_POLICY'); - const healthCheckHostnames = tls.healthCheckHostnames ?? []; - if (!Array.isArray(healthCheckHostnames) || healthCheckHostnames.some((value) => hasWildcard(normalizeDomain(value, 'tls.healthCheckHostnames')))) { - throw acmeError('tls.healthCheckHostnames is invalid', 'INVALID_TLS_POLICY'); - } - return { minimumVersion, allowedVersions: [...new Set(allowedVersions)], healthCheckHostnames: [...new Set(healthCheckHostnames.map((value) => normalizeDomain(value, 'tls.healthCheckHostnames')))] }; -} - -function assertNoPrivateKeyInput(value, path = 'value', seen = new Set()) { - if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return; - if (typeof value === 'string') { - if (PRIVATE_KEY_PATTERN.test(value)) throw acmeError(`${path} must use a secret reference, not private-key material`, 'SECRET_MATERIAL_FORBIDDEN'); - return; - } - if (seen.has(value)) return; - seen.add(value); - if (Array.isArray(value)) value.forEach((entry, index) => assertNoPrivateKeyInput(entry, `${path}[${index}]`, seen)); - else if (typeof value === 'object') Object.entries(value).forEach(([key, entry]) => { - if (SECRET_KEY_PATTERN.test(key) && key !== 'keySecretRef' && key !== 'hmacSecretRef' && key !== 'credentialsSecretRef' && key !== 'privateKeySecretRef') { - if (entry !== null && entry !== undefined && typeof entry !== 'string' && typeof entry !== 'boolean') throw acmeError(`${path}.${key} must not contain secret material`, 'SECRET_MATERIAL_FORBIDDEN'); - if (typeof entry === 'string' && (PEM_PATTERN.test(entry) || entry.length > 256)) throw acmeError(`${path}.${key} must use a secret reference`, 'SECRET_MATERIAL_FORBIDDEN'); - } - assertNoPrivateKeyInput(entry, `${path}.${key}`, seen); - }); -} - -/** Recursively remove private-key, token, credential, and PEM material from logs. */ -export function redactSecrets(value, seen = new Set()) { - if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value; - if (typeof value === 'string') return PEM_PATTERN.test(value) ? '[REDACTED_PEM]' : value.length > 1024 ? `${value.slice(0, 1024)}...[REDACTED]` : value; - if (seen.has(value)) return '[REDACTED_CYCLE]'; - seen.add(value); - if (Array.isArray(value)) return value.map((entry) => redactSecrets(entry, seen)); - const output = {}; - for (const [key, entry] of Object.entries(value)) { - if (SECRET_KEY_PATTERN.test(key) && !/secretref$/iu.test(key)) output[key] = '[REDACTED]'; - else output[key] = redactSecrets(entry, seen); - } - return output; -} - -/** Normalize and validate the certificate-provider contract without handling keys or certificates. */ -export function createAcmeConfig(input = {}) { - assertPlainObject(input, 'config'); - assertNoPrivateKeyInput(input); - assertAllowedKeys(input, new Set(['provider', 'environment', 'directoryUrl', 'domains', 'account', 'certificateKeySecretRef', 'challenge', 'renewal', 'reload', 'tls']), 'config'); - const provider = input.provider ?? ACME_PROVIDERS.LETSENCRYPT; - if (!Object.values(ACME_PROVIDERS).includes(provider)) throw acmeError('provider is unsupported', 'INVALID_PROVIDER'); - const environment = input.environment ?? ACME_ENVIRONMENTS.PRODUCTION; - if (!Object.values(ACME_ENVIRONMENTS).includes(environment)) throw acmeError('environment is unsupported', 'INVALID_ENVIRONMENT'); - const directoryDefault = provider === ACME_PROVIDERS.LETSENCRYPT - ? (environment === ACME_ENVIRONMENTS.STAGING ? LETSENCRYPT_STAGING_DIRECTORY_URL : DEFAULT_LETSENCRYPT_DIRECTORY_URL) - : null; - if (provider === ACME_PROVIDERS.GENERIC && !input.directoryUrl) throw acmeError('generic ACME requires directoryUrl', 'INVALID_DIRECTORY_URL'); - if (input.directoryUrl && environment === ACME_ENVIRONMENTS.STAGING && provider === ACME_PROVIDERS.LETSENCRYPT && normalizeDirectoryUrl(input.directoryUrl) !== LETSENCRYPT_STAGING_DIRECTORY_URL) { - throw acmeError("a staging Let's Encrypt configuration must use the staging directory", 'INVALID_DIRECTORY_URL'); - } - const domains = normalizeDomains(input.domains); - const challenge = normalizeChallenge(input.challenge ?? {}, domains); - if (domains.some(hasWildcard) && challenge.type !== CHALLENGE_TYPES.DNS_01) throw acmeError('wildcard domains require DNS-01', 'INVALID_CHALLENGE'); - const account = normalizeAccount(input.account ?? {}); - const reload = normalizeReload(input.reload ?? {}); - const renewal = normalizeRenewal(input.renewal ?? {}); - const tls = normalizeTls(input.tls ?? {}); - const config = { - schemaVersion: 1, - provider, - environment, - directoryUrl: normalizeDirectoryUrl(input.directoryUrl ?? directoryDefault), - domains, - account, - certificateKeySecretRef: secretReference(input.certificateKeySecretRef ?? 'acme/certificate-key', 'certificateKeySecretRef'), - challenge, - renewal, - reload, - tls, - }; - return deepFreeze(config); -} - -export const normalizeAcmeConfig = createAcmeConfig; -export const validateAcmeConfig = createAcmeConfig; -export const assertAcmeConfig = createAcmeConfig; - -export function retryDelaySeconds(attempt, retryPolicy = {}) { - const retry = normalizeRetry(retryPolicy); - integer(attempt, 'attempt', 0, 1000); - return Math.min(retry.maxDelaySeconds, Math.round(retry.initialDelaySeconds * (retry.multiplier ** attempt))); -} - -export function createRetrySchedule({ attempt = 0, policy = {}, now = new Date() } = {}) { - integer(attempt, 'attempt', 0, 1000); - const timestamp = parseDate(now, 'now'); - const delaySeconds = retryDelaySeconds(attempt, policy); - return deepFreeze({ - attempt, - delaySeconds, - retryAt: new Date(timestamp.getTime() + delaySeconds * 1000).toISOString(), - }); -} - -function normalizeCertificateMetadata(certificate = {}, field = 'certificate') { - assertPlainObject(certificate, field); - assertNoPrivateKeyInput(certificate, field); - assertAllowedKeys(certificate, new Set(['id', 'notBefore', 'notAfter', 'issuer', 'subject', 'serialNumber', 'dnsNames', 'chainValid', 'privateKeyMatches', 'chainError', 'keyAlgorithm']), field); - const notAfter = isoDate(certificate.notAfter, `${field}.notAfter`); - if (notAfter === null) throw acmeError(`${field}.notAfter is required`, 'INVALID_CERTIFICATE_METADATA'); - const notBefore = isoDate(certificate.notBefore, `${field}.notBefore`); - if (notBefore && new Date(notBefore).getTime() >= new Date(notAfter).getTime()) throw acmeError(`${field}.notBefore must be before notAfter`, 'INVALID_CERTIFICATE_METADATA'); - const dnsNames = certificate.dnsNames ?? []; - if (!Array.isArray(dnsNames) || dnsNames.length > 100) throw acmeError(`${field}.dnsNames is invalid`, 'INVALID_CERTIFICATE_METADATA'); - const normalizedDnsNames = [...new Set(dnsNames.map((value, index) => normalizeDomain(value, `${field}.dnsNames[${index}]`)))]; - for (const key of ['chainValid', 'privateKeyMatches']) { - if (certificate[key] !== undefined && certificate[key] !== null && typeof certificate[key] !== 'boolean') { - throw acmeError(`${field}.${key} must be a boolean or null`, 'INVALID_CERTIFICATE_METADATA'); - } - } - return { - id: certificate.id === undefined ? null : safeIdentifier(certificate.id, `${field}.id`), - notBefore, - notAfter, - issuer: certificate.issuer === undefined ? null : String(certificate.issuer).slice(0, 512), - subject: certificate.subject === undefined ? null : String(certificate.subject).slice(0, 512), - serialNumber: certificate.serialNumber === undefined ? null : safeIdentifier(certificate.serialNumber, `${field}.serialNumber`, { max: 256 }), - dnsNames: normalizedDnsNames, - chainValid: certificate.chainValid ?? null, - privateKeyMatches: certificate.privateKeyMatches ?? null, - chainError: certificate.chainError === undefined ? null : String(certificate.chainError).slice(0, 256), - keyAlgorithm: certificate.keyAlgorithm === undefined ? null : String(certificate.keyAlgorithm).slice(0, 64), - }; -} - -function hostnameMatches(hostname, names) { - const normalized = normalizeDomain(hostname, 'hostname'); - return names.some((name) => name === normalized || (hasWildcard(name) && normalized.endsWith(name.slice(1)) && normalized.split('.').length === name.split('.').length)); -} - -/** Evaluate expiry, chain, key, and hostname state without touching certificate/key material. */ -export function evaluateCertificateHealth({ certificate, hostname = undefined, now = new Date(), renewBeforeDays = 30, expiryWarningDays = 30, expiryCriticalDays = 7 } = {}) { - const metadata = normalizeCertificateMetadata(certificate); - const checkedAt = parseDate(now, 'now'); - const expiresAt = new Date(metadata.notAfter); - const notBefore = metadata.notBefore ? new Date(metadata.notBefore) : null; - const daysRemaining = Math.floor((expiresAt.getTime() - checkedAt.getTime()) / DAY_MS); - const renewalDue = expiresAt.getTime() - checkedAt.getTime() <= integer(renewBeforeDays, 'renewBeforeDays', 1, 90) * DAY_MS; - const alerts = []; - if (expiresAt <= checkedAt) alerts.push({ type: 'certificate.expired', severity: 'critical', daysRemaining }); - else if (daysRemaining <= integer(expiryCriticalDays, 'expiryCriticalDays', 0, 90)) alerts.push({ type: 'certificate.expiry_critical', severity: 'critical', daysRemaining }); - else if (daysRemaining <= integer(expiryWarningDays, 'expiryWarningDays', 1, 90)) alerts.push({ type: 'certificate.expiry_warning', severity: 'warning', daysRemaining }); - if (metadata.chainValid === false) alerts.push({ type: 'certificate.chain_invalid', severity: 'critical', reason: metadata.chainError ?? 'chain validation failed' }); - if (metadata.privateKeyMatches === false) alerts.push({ type: 'certificate.key_mismatch', severity: 'critical' }); - if (notBefore && notBefore > checkedAt) alerts.push({ type: 'certificate.not_yet_valid', severity: 'critical' }); - if (hostname !== undefined && !hostnameMatches(hostname, metadata.dnsNames)) alerts.push({ type: 'certificate.hostname_mismatch', severity: 'critical' }); - const critical = alerts.some((alert) => alert.severity === 'critical'); - const status = critical ? 'unhealthy' : alerts.length > 0 ? 'degraded' : 'healthy'; - return deepFreeze({ - schemaVersion: 1, - status, - checkedAt: checkedAt.toISOString(), - certificate: metadata, - hostname: hostname === undefined ? null : normalizeDomain(hostname, 'hostname'), - daysRemaining, - renewalDue, - alerts: alerts.map((alert) => Object.freeze({ ...alert })), - }); -} - -export const createTlsHealthContract = evaluateCertificateHealth; -export const checkTlsHealth = evaluateCertificateHealth; - -export function createExpiryAlert({ certificate, now = new Date(), renewBeforeDays = 30, expiryWarningDays = 30, expiryCriticalDays = 7, certificateId = undefined } = {}) { - const health = evaluateCertificateHealth({ certificate, now, renewBeforeDays, expiryWarningDays, expiryCriticalDays }); - const expiryAlert = health.alerts.find((alert) => alert.type.startsWith('certificate.expiry') || alert.type === 'certificate.expired') ?? null; - if (!expiryAlert) return null; - return deepFreeze({ - schemaVersion: 1, - type: expiryAlert.type, - severity: expiryAlert.severity, - certificateId: certificateId ?? health.certificate.id, - checkedAt: health.checkedAt, - expiresAt: health.certificate.notAfter, - daysRemaining: health.daysRemaining, - renewalDue: health.renewalDue, - }); -} - -const TRANSITIONS = Object.freeze({ - idle: Object.freeze({ renew_due: 'scheduled', start: 'authorizing', cancel: 'cancelled' }), - scheduled: Object.freeze({ start: 'authorizing', cancel: 'cancelled' }), - authorizing: Object.freeze({ authorization_succeeded: 'ordering', failed: 'retry_wait', cancel: 'cancelled' }), - ordering: Object.freeze({ order_ready: 'finalizing', failed: 'retry_wait', cancel: 'cancelled' }), - finalizing: Object.freeze({ certificate_stored: 'reload_pending', failed: 'retry_wait', cancel: 'cancelled' }), - reload_pending: Object.freeze({ reload_succeeded: 'active', reload_failed: 'retry_wait', failed: 'retry_wait', cancel: 'cancelled' }), - active: Object.freeze({ renew_due: 'scheduled', reconcile: 'active', cancel: 'cancelled' }), - retry_wait: Object.freeze({ retry_due: 'authorizing', cancel: 'cancelled', manual_retry: 'authorizing' }), - degraded: Object.freeze({ retry_due: 'authorizing', manual_retry: 'authorizing', renew_due: 'scheduled', cancel: 'cancelled' }), - failed: Object.freeze({ manual_retry: 'authorizing', retry_due: 'authorizing', cancel: 'cancelled' }), - cancelled: Object.freeze({ manual_retry: 'authorizing' }), -}); - -function safeFailure(error) { - if (error === null || error === undefined) return null; - if (typeof error === 'string') return { code: 'ACME_OPERATION_FAILED', message: redactSecrets(error).slice(0, 256) }; - if (typeof error === 'object') return { - code: typeof error.code === 'string' && SAFE_ERROR_CODE_PATTERN.test(error.code) ? error.code : 'ACME_OPERATION_FAILED', - message: redactSecrets(String(error.message ?? 'ACME operation failed')).slice(0, 256), - }; - return { code: 'ACME_OPERATION_FAILED', message: 'ACME operation failed' }; -} - -function stateCertificate(value, field) { - if (value === null || value === undefined) return null; - return normalizeCertificateMetadata(value, field); -} - -function normalizeConfigForUse(config) { - if (config === null || config === undefined) return null; - if (config.schemaVersion === 1 && typeof config.directoryUrl === 'string' && Array.isArray(config.domains) && config.renewal && config.account) return config; - return createAcmeConfig(config); -} - -/** Create a serializable renewal state. It contains references and metadata only, never keys or certificate PEM. */ -export function createRenewalState({ certificateId = 'primary', domains, config = undefined, currentCertificate = null, now = new Date() } = {}) { - const normalizedConfig = normalizeConfigForUse(config); - const normalizedDomains = normalizeDomains(domains ?? normalizedConfig?.domains); - const policy = normalizedConfig ? normalizedConfig.renewal : normalizeRenewal({}); - const checkedAt = parseDate(now, 'now'); - const current = stateCertificate(currentCertificate, 'currentCertificate'); - return deepFreeze({ - schemaVersion: 1, - certificateId: safeIdentifier(certificateId, 'certificateId'), - domains: normalizedDomains, - provider: normalizedConfig ? normalizedConfig.provider : ACME_PROVIDERS.LETSENCRYPT, - state: 'idle', - attempt: 0, - maxAttempts: policy.retry.maxAttempts, - retryPolicy: policy.retry, - renewalEnabled: policy.enabled, - currentCertificate: current, - pendingCertificate: null, - fallbackActive: false, - lastError: null, - lastAlert: null, - scheduledAt: null, - nextAttemptAt: null, - updatedAt: checkedAt.toISOString(), - }); -} - -function eventObject(event, options) { - if (typeof event === 'string') return { ...(options ?? {}), type: event }; - if (!event || typeof event !== 'object') throw acmeError('renewal event is required', 'INVALID_RENEWAL_EVENT'); - return event; -} - -/** Advance the deterministic renewal state machine; operations are performed by a separate ACME worker. */ -export function advanceRenewal(state, event, options = {}) { - assertPlainObject(state, 'state'); - if (!RENEWAL_STATES.includes(state.state)) throw acmeError('state.state is invalid', 'INVALID_RENEWAL_STATE'); - const action = eventObject(event, options); - const type = action.type; - if (typeof type !== 'string' || !TRANSITIONS[state.state]?.[type]) throw acmeError(`${type ?? 'event'} is not valid from ${state.state}`, 'INVALID_RENEWAL_TRANSITION'); - const timestamp = parseDate(action.now ?? options.now ?? new Date(), 'event.now'); - const nextState = TRANSITIONS[state.state][type]; - const next = { ...state, state: nextState, updatedAt: timestamp.toISOString() }; - if (type === 'renew_due') { - next.scheduledAt = timestamp.toISOString(); - next.nextAttemptAt = null; - next.lastAlert = null; - } - if (type === 'start' || type === 'retry_due' || type === 'manual_retry') { - next.lastError = null; - next.lastAlert = null; - next.nextAttemptAt = null; - } - if (type === 'authorization_succeeded' || type === 'order_ready') next.lastError = null; - if (type === 'certificate_stored') { - next.pendingCertificate = stateCertificate(action.certificate, 'event.certificate'); - next.lastError = null; - } - if (type === 'reload_succeeded') { - next.currentCertificate = next.pendingCertificate ?? next.currentCertificate; - next.pendingCertificate = null; - next.attempt = 0; - next.nextAttemptAt = null; - next.fallbackActive = false; - next.scheduledAt = null; - next.lastError = null; - next.lastAlert = null; - } - if (type === 'failed' || type === 'reload_failed') { - const attempt = (Number.isSafeInteger(state.attempt) ? state.attempt : 0) + 1; - const policy = normalizeRetry(action.retryPolicy ?? state.retryPolicy ?? { maxAttempts: state.maxAttempts ?? 5 }); - const failure = safeFailure(action.error ?? action.reason); - next.attempt = attempt; - next.lastError = failure; - const currentHealth = next.currentCertificate ? evaluateCertificateHealth({ certificate: next.currentCertificate, now: timestamp }) : null; - const canFallback = Boolean(next.currentCertificate && currentHealth && currentHealth.status !== 'unhealthy' && currentHealth.daysRemaining >= 0); - next.fallbackActive = canFallback; - if (attempt <= policy.maxAttempts) { - next.state = 'retry_wait'; - next.nextAttemptAt = new Date(timestamp.getTime() + retryDelaySeconds(attempt - 1, policy) * 1000).toISOString(); - } else { - next.state = canFallback ? 'degraded' : 'failed'; - next.nextAttemptAt = null; - next.lastAlert = { type: 'certificate.renewal_failed', severity: canFallback ? 'warning' : 'critical', attempt, fallbackActive: canFallback }; - } - } - if (type === 'reconcile') next.lastAlert = null; - return deepFreeze(next); -} - -export const transitionRenewal = advanceRenewal; -export const advanceRenewalState = advanceRenewal; - -/** Build a metadata-only graceful reload plan for protocol consumers. */ -export function createSafeReloadPlan({ certificate, previousCertificate = null, consumers = ['web', 'postfix', 'dovecot'], generation = 1, now = new Date(), timeoutSeconds = 30 } = {}) { - const nextCertificate = normalizeCertificateMetadata(certificate, 'certificate'); - const health = evaluateCertificateHealth({ certificate: nextCertificate, now }); - if (nextCertificate.chainValid !== true || nextCertificate.privateKeyMatches !== true || health.status === 'unhealthy') { - throw acmeError('certificate fails the reload health gate', 'RELOAD_HEALTH_GATE_FAILED'); - } - const previous = previousCertificate === null ? null : normalizeCertificateMetadata(previousCertificate, 'previousCertificate'); - if (!Array.isArray(consumers) || consumers.length === 0 || consumers.some((value) => !['web', 'postfix', 'dovecot', 'caldav', 'carddav'].includes(value))) throw acmeError('consumers is invalid', 'INVALID_RELOAD_PLAN'); - const uniqueConsumers = [...new Set(consumers)]; - return deepFreeze({ - schemaVersion: 1, - operation: 'certificate_graceful_reload', - generation: positiveInteger(generation, 'generation', 2 ** 31 - 1), - strategy: 'graceful', - healthGate: true, - rollbackOnFailure: true, - timeoutSeconds: integer(timeoutSeconds, 'timeoutSeconds', 1, 600), - certificate: { id: nextCertificate.id, notAfter: nextCertificate.notAfter, serialNumber: nextCertificate.serialNumber, dnsNames: nextCertificate.dnsNames }, - previousCertificate: previous ? { id: previous.id, notAfter: previous.notAfter, serialNumber: previous.serialNumber } : null, - consumers: uniqueConsumers.map((consumer) => ({ consumer, status: 'pending', reloadedAt: null, errorCode: null })), - preflight: ['chain_valid', 'private_key_matches', 'consumer_configuration_valid'], - createdAt: parseDate(now, 'now').toISOString(), - }); -} - -export function completeSafeReloadPlan(plan, results, { now = new Date() } = {}) { - assertPlainObject(plan, 'plan'); - if (!Array.isArray(results)) throw acmeError('results must be a list', 'INVALID_RELOAD_RESULT'); - const byConsumer = new Map(results.map((result) => [result.consumer, result])); - const consumers = plan.consumers.map((entry) => { - const result = byConsumer.get(entry.consumer); - if (!result) return { ...entry, status: 'pending' }; - if (!['reloaded', 'failed', 'skipped'].includes(result.status)) throw acmeError(`reload result for ${entry.consumer} is invalid`, 'INVALID_RELOAD_RESULT'); - return { - consumer: entry.consumer, - status: result.status, - reloadedAt: result.status === 'reloaded' ? parseDate(result.reloadedAt ?? now, `${entry.consumer}.reloadedAt`).toISOString() : null, - errorCode: result.status === 'failed' ? (typeof result.errorCode === 'string' && SAFE_ERROR_CODE_PATTERN.test(result.errorCode) ? result.errorCode : 'RELOAD_FAILED') : null, - }; - }); - const failed = consumers.filter((entry) => entry.status === 'failed'); - const pending = consumers.filter((entry) => entry.status === 'pending'); - return deepFreeze({ - ...plan, - status: failed.length > 0 ? 'rollback_required' : pending.length > 0 ? 'pending' : 'completed', - consumers, - completedAt: failed.length === 0 && pending.length === 0 ? parseDate(now, 'now').toISOString() : null, - rollbackRequired: failed.length > 0, - }); -} - -export const applyReloadResults = completeSafeReloadPlan; - -export function createTlsHealthContractFromConfig({ certificate, hostname, config, now = new Date() } = {}) { - const normalizedConfig = createAcmeConfig(config); - return evaluateCertificateHealth({ - certificate, - hostname, - now, - renewBeforeDays: normalizedConfig.renewal.renewBeforeDays, - expiryWarningDays: normalizedConfig.renewal.expiryWarningDays, - expiryCriticalDays: normalizedConfig.renewal.expiryCriticalDays, - }); -} - -export { DAY_MS, normalizeCertificateMetadata }; +// Temporary compatibility bridge. ACME-operation behavior lives in TypeScript. +export * from './index.ts'; diff --git a/src/ops/acme/index.test.mjs b/src/ops/acme/index.test.mjs index 1bcc135..c269e39 100644 --- a/src/ops/acme/index.test.mjs +++ b/src/ops/acme/index.test.mjs @@ -2,202 +2,5 @@ // 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 { - ACME_PROVIDERS, - CHALLENGE_TYPES, - DEFAULT_LETSENCRYPT_DIRECTORY_URL, - LETSENCRYPT_STAGING_DIRECTORY_URL, - advanceRenewal, - createAcmeConfig, - createExpiryAlert, - createRenewalState, - createSafeReloadPlan, - createTlsHealthContract, - completeSafeReloadPlan, - evaluateCertificateHealth, - redactSecrets, - retryDelaySeconds, -} from './index.mjs'; - -const NOW = '2026-08-22T12:00:00.000Z'; -const CERTIFICATE = Object.freeze({ - id: 'cert-001', - notBefore: '2026-08-01T00:00:00.000Z', - notAfter: '2026-10-15T00:00:00.000Z', - issuer: "Let's Encrypt", - subject: 'CN=mail.example.test', - serialNumber: 'ABC123', - dnsNames: ['mail.example.test', '*.example.test'], - chainValid: true, - privateKeyMatches: true, -}); - -function config(overrides = {}) { - return createAcmeConfig({ - domains: ['mail.example.test'], - account: { keySecretRef: 'acme/account-key' }, - ...overrides, - }); -} - -test('defaults to production Let\'s Encrypt and automatic renewal', () => { - const result = config(); - assert.equal(result.provider, ACME_PROVIDERS.LETSENCRYPT); - assert.equal(result.directoryUrl, DEFAULT_LETSENCRYPT_DIRECTORY_URL); - assert.equal(result.environment, 'production'); - assert.equal(result.renewal.enabled, true); - assert.equal(result.challenge.type, CHALLENGE_TYPES.HTTP_01); - assert.equal(result.account.keySecretRef, 'acme/account-key'); - assert.equal(result.certificateKeySecretRef, 'acme/certificate-key'); - assert.equal(Object.hasOwn(result, 'privateKey'), false); -}); - -test('supports staging and generic private ACME directories over HTTPS', () => { - const staging = config({ environment: 'staging' }); - assert.equal(staging.directoryUrl, LETSENCRYPT_STAGING_DIRECTORY_URL); - const generic = config({ - provider: ACME_PROVIDERS.GENERIC, - directoryUrl: 'https://ca.internal.example/acme/directory', - account: { - contactEmail: 'ops@example.test', - keySecretRef: 'vault/acme/account', - externalAccountBinding: { kid: 'tenant-kid', hmacSecretRef: 'vault/acme/eab' }, - }, - }); - assert.equal(generic.provider, ACME_PROVIDERS.GENERIC); - assert.equal(generic.account.externalAccountBinding.hmacSecretRef, 'vault/acme/eab'); - assert.throws(() => config({ provider: ACME_PROVIDERS.GENERIC }), (error) => error.code === 'INVALID_DIRECTORY_URL'); - assert.throws(() => config({ directoryUrl: 'http://ca.example.test/directory' }), (error) => error.code === 'INSECURE_DIRECTORY_URL'); -}); - -test('validates HTTP-01 and DNS-01 challenge requirements', () => { - const dns = config({ - domains: ['*.example.test'], - challenge: { - type: 'dns-01', - dnsProvider: 'cloud-dns', - credentialsSecretRef: 'vault/dns/cloud', - }, - }); - assert.equal(dns.challenge.type, CHALLENGE_TYPES.DNS_01); - assert.throws(() => config({ domains: ['*.example.test'] }), (error) => error.code === 'INVALID_CHALLENGE'); - assert.throws(() => config({ challenge: { type: 'dns-01', dnsProvider: 'cloud-dns' } }), (error) => error.code === 'INVALID_SECRET_REFERENCE'); - assert.throws(() => config({ challenge: { type: 'http-01', listenPort: 80, tokenPathPrefix: '/bad/../' } }), (error) => error.code === 'INVALID_CHALLENGE'); -}); - -test('rejects private key material and preserves secret references only', () => { - assert.throws(() => config({ account: { keySecretRef: 'acme/key', privateKey: '-----BEGIN PRIVATE KEY-----abc' } }), (error) => error.code === 'SECRET_MATERIAL_FORBIDDEN'); - const redacted = redactSecrets({ - accountKey: '-----BEGIN PRIVATE KEY-----abc', - privateKeyPem: '-----BEGIN PRIVATE KEY-----abc', - dnsProviderSecretRef: 'vault/dns', - authorizationToken: 'token-value', - nested: [{ certificatePem: '-----BEGIN CERTIFICATE-----abc' }], - }); - assert.equal(redacted.accountKey, '[REDACTED]'); - assert.equal(redacted.privateKeyPem, '[REDACTED]'); - assert.equal(redacted.dnsProviderSecretRef, 'vault/dns'); - assert.equal(redacted.nested[0].certificatePem, '[REDACTED]'); - assert.equal(JSON.stringify(redacted).includes('BEGIN PRIVATE KEY'), false); -}); - -test('calculates deterministic exponential retry delays', () => { - const policy = { maxAttempts: 3, initialDelaySeconds: 60, maxDelaySeconds: 300, multiplier: 2 }; - assert.equal(retryDelaySeconds(0, policy), 60); - assert.equal(retryDelaySeconds(1, policy), 120); - assert.equal(retryDelaySeconds(2, policy), 240); - assert.equal(retryDelaySeconds(3, policy), 300); -}); - -test('reports healthy, expiring, expired, and hostname-mismatch TLS states', () => { - const healthy = evaluateCertificateHealth({ certificate: CERTIFICATE, hostname: 'mail.example.test', now: NOW }); - assert.equal(healthy.status, 'healthy'); - assert.equal(healthy.renewalDue, false); - const expiring = evaluateCertificateHealth({ certificate: { ...CERTIFICATE, notAfter: '2026-09-05T00:00:00.000Z' }, hostname: 'mail.example.test', now: NOW }); - assert.equal(expiring.status, 'degraded'); - assert.equal(expiring.alerts[0].type, 'certificate.expiry_warning'); - const expired = evaluateCertificateHealth({ certificate: { ...CERTIFICATE, notAfter: '2026-08-21T00:00:00.000Z' }, hostname: 'mail.example.test', now: NOW }); - assert.equal(expired.status, 'unhealthy'); - assert.equal(expired.alerts[0].type, 'certificate.expired'); - const mismatch = createTlsHealthContract({ certificate: CERTIFICATE, hostname: 'other.example.net', now: NOW }); - assert.equal(mismatch.status, 'unhealthy'); - assert.ok(mismatch.alerts.some((alert) => alert.type === 'certificate.hostname_mismatch')); -}); - -test('creates expiry alerts without exposing certificate contents', () => { - const alert = createExpiryAlert({ - certificate: { ...CERTIFICATE, notAfter: '2026-08-27T00:00:00.000Z' }, - certificateId: 'cert-001', - now: NOW, - }); - assert.equal(alert.type, 'certificate.expiry_critical'); - assert.equal(alert.certificateId, 'cert-001'); - assert.equal(Object.hasOwn(alert, 'certificatePem'), false); -}); - -test('renews through authorization, order, storage, and graceful reload', () => { - let state = createRenewalState({ - config: config(), - currentCertificate: CERTIFICATE, - now: NOW, - }); - assert.equal(state.state, 'idle'); - state = advanceRenewal(state, { type: 'renew_due', now: NOW }); - state = advanceRenewal(state, { type: 'start', now: NOW }); - state = advanceRenewal(state, { type: 'authorization_succeeded', now: NOW }); - state = advanceRenewal(state, { type: 'order_ready', now: NOW }); - state = advanceRenewal(state, { type: 'certificate_stored', certificate: { ...CERTIFICATE, id: 'cert-002' }, now: NOW }); - assert.equal(state.state, 'reload_pending'); - state = advanceRenewal(state, { type: 'reload_succeeded', now: NOW }); - assert.equal(state.state, 'active'); - assert.equal(state.currentCertificate.id, 'cert-002'); - assert.equal(state.attempt, 0); - assert.equal(state.pendingCertificate, null); -}); - -test('keeps the current valid certificate while renewal retries and alerts after exhaustion', () => { - let state = createRenewalState({ - config: config({ renewal: { retry: { maxAttempts: 1, initialDelaySeconds: 10, maxDelaySeconds: 10, multiplier: 1 } } }), - currentCertificate: CERTIFICATE, - now: NOW, - }); - state = advanceRenewal(state, 'start', { now: NOW }); - state = advanceRenewal(state, { type: 'failed', now: NOW, error: { code: 'ACME_TIMEOUT', message: 'privateKeyPem=-----BEGIN PRIVATE KEY-----secret' } }); - assert.equal(state.state, 'retry_wait'); - assert.equal(state.fallbackActive, true); - assert.equal(state.lastError.message.includes('BEGIN PRIVATE KEY'), false); - state = advanceRenewal(state, { type: 'retry_due', now: '2026-08-22T12:01:00.000Z' }); - state = advanceRenewal(state, { type: 'failed', now: '2026-08-22T12:01:00.000Z', error: { code: 'ACME_TIMEOUT', message: 'directory unavailable' } }); - assert.equal(state.state, 'degraded'); - assert.equal(state.lastAlert.type, 'certificate.renewal_failed'); - assert.equal(state.fallbackActive, true); -}); - -test('creates and completes a metadata-only graceful reload plan', () => { - const plan = createSafeReloadPlan({ certificate: CERTIFICATE, previousCertificate: { ...CERTIFICATE, id: 'cert-old' }, now: NOW }); - assert.equal(plan.strategy, 'graceful'); - assert.equal(plan.status, undefined); - assert.deepEqual(plan.consumers.map((entry) => entry.status), ['pending', 'pending', 'pending']); - assert.equal(Object.hasOwn(plan, 'privateKey'), false); - const completed = completeSafeReloadPlan(plan, [ - { consumer: 'web', status: 'reloaded' }, - { consumer: 'postfix', status: 'reloaded' }, - { consumer: 'dovecot', status: 'reloaded' }, - ], { now: NOW }); - assert.equal(completed.status, 'completed'); - assert.equal(completed.rollbackRequired, false); - const failed = completeSafeReloadPlan(plan, [{ consumer: 'web', status: 'failed', errorCode: 'TLS_RELOAD_FAILED' }], { now: NOW }); - assert.equal(failed.status, 'rollback_required'); - assert.equal(failed.rollbackRequired, true); -}); - -test('fails the TLS health gate for invalid chains and key mismatch', () => { - assert.throws(() => createSafeReloadPlan({ certificate: { ...CERTIFICATE, chainValid: false } }), (error) => error.code === 'RELOAD_HEALTH_GATE_FAILED'); - assert.throws(() => createSafeReloadPlan({ certificate: { ...CERTIFICATE, privateKeyMatches: false } }), (error) => error.code === 'RELOAD_HEALTH_GATE_FAILED'); - const result = evaluateCertificateHealth({ certificate: { ...CERTIFICATE, chainValid: false }, now: NOW }); - assert.equal(result.status, 'unhealthy'); - assert.ok(result.alerts.some((alert) => alert.type === 'certificate.chain_invalid')); -}); +// Temporary compatibility bridge. ACME-operation contract tests are TypeScript. +import './index.test.ts'; diff --git a/src/ops/acme/index.test.ts b/src/ops/acme/index.test.ts new file mode 100644 index 0000000..5704ac3 --- /dev/null +++ b/src/ops/acme/index.test.ts @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ACME_PROVIDERS, + CHALLENGE_TYPES, + DEFAULT_LETSENCRYPT_DIRECTORY_URL, + LETSENCRYPT_STAGING_DIRECTORY_URL, + advanceRenewal, + createAcmeConfig, + createExpiryAlert, + createRenewalState, + createSafeReloadPlan, + createTlsHealthContract, + completeSafeReloadPlan, + evaluateCertificateHealth, + redactSecrets, + retryDelaySeconds, +} from './index.ts'; + +const NOW = '2026-08-22T12:00:00.000Z'; +const CERTIFICATE = Object.freeze({ + id: 'cert-001', + notBefore: '2026-08-01T00:00:00.000Z', + notAfter: '2026-10-15T00:00:00.000Z', + issuer: "Let's Encrypt", + subject: 'CN=mail.example.test', + serialNumber: 'ABC123', + dnsNames: ['mail.example.test', '*.example.test'], + chainValid: true, + privateKeyMatches: true, +}); + +function config(overrides = {}) { + return createAcmeConfig({ + domains: ['mail.example.test'], + account: { keySecretRef: 'acme/account-key' }, + ...overrides, + }); +} + +test('defaults to production Let\'s Encrypt and automatic renewal', () => { + const result = config(); + assert.equal(result.provider, ACME_PROVIDERS.LETSENCRYPT); + assert.equal(result.directoryUrl, DEFAULT_LETSENCRYPT_DIRECTORY_URL); + assert.equal(result.environment, 'production'); + assert.equal(result.renewal.enabled, true); + assert.equal(result.challenge.type, CHALLENGE_TYPES.HTTP_01); + assert.equal(result.account.keySecretRef, 'acme/account-key'); + assert.equal(result.certificateKeySecretRef, 'acme/certificate-key'); + assert.equal(Object.hasOwn(result, 'privateKey'), false); +}); + +test('supports staging and generic private ACME directories over HTTPS', () => { + const staging = config({ environment: 'staging' }); + assert.equal(staging.directoryUrl, LETSENCRYPT_STAGING_DIRECTORY_URL); + const generic = config({ + provider: ACME_PROVIDERS.GENERIC, + directoryUrl: 'https://ca.internal.example/acme/directory', + account: { + contactEmail: 'ops@example.test', + keySecretRef: 'vault/acme/account', + externalAccountBinding: { kid: 'tenant-kid', hmacSecretRef: 'vault/acme/eab' }, + }, + }); + assert.equal(generic.provider, ACME_PROVIDERS.GENERIC); + assert.equal(generic.account.externalAccountBinding.hmacSecretRef, 'vault/acme/eab'); + assert.throws(() => config({ provider: ACME_PROVIDERS.GENERIC }), (error) => error.code === 'INVALID_DIRECTORY_URL'); + assert.throws(() => config({ directoryUrl: 'http://ca.example.test/directory' }), (error) => error.code === 'INSECURE_DIRECTORY_URL'); +}); + +test('validates HTTP-01 and DNS-01 challenge requirements', () => { + const dns = config({ + domains: ['*.example.test'], + challenge: { + type: 'dns-01', + dnsProvider: 'cloud-dns', + credentialsSecretRef: 'vault/dns/cloud', + }, + }); + assert.equal(dns.challenge.type, CHALLENGE_TYPES.DNS_01); + assert.throws(() => config({ domains: ['*.example.test'] }), (error) => error.code === 'INVALID_CHALLENGE'); + assert.throws(() => config({ challenge: { type: 'dns-01', dnsProvider: 'cloud-dns' } }), (error) => error.code === 'INVALID_SECRET_REFERENCE'); + assert.throws(() => config({ challenge: { type: 'http-01', listenPort: 80, tokenPathPrefix: '/bad/../' } }), (error) => error.code === 'INVALID_CHALLENGE'); +}); + +test('rejects private key material and preserves secret references only', () => { + assert.throws(() => config({ account: { keySecretRef: 'acme/key', privateKey: '-----BEGIN PRIVATE KEY-----abc' } }), (error) => error.code === 'SECRET_MATERIAL_FORBIDDEN'); + const redacted = redactSecrets({ + accountKey: '-----BEGIN PRIVATE KEY-----abc', + privateKeyPem: '-----BEGIN PRIVATE KEY-----abc', + dnsProviderSecretRef: 'vault/dns', + authorizationToken: 'token-value', + nested: [{ certificatePem: '-----BEGIN CERTIFICATE-----abc' }], + }); + assert.equal(redacted.accountKey, '[REDACTED]'); + assert.equal(redacted.privateKeyPem, '[REDACTED]'); + assert.equal(redacted.dnsProviderSecretRef, 'vault/dns'); + assert.equal(redacted.nested[0].certificatePem, '[REDACTED]'); + assert.equal(JSON.stringify(redacted).includes('BEGIN PRIVATE KEY'), false); +}); + +test('calculates deterministic exponential retry delays', () => { + const policy = { maxAttempts: 3, initialDelaySeconds: 60, maxDelaySeconds: 300, multiplier: 2 }; + assert.equal(retryDelaySeconds(0, policy), 60); + assert.equal(retryDelaySeconds(1, policy), 120); + assert.equal(retryDelaySeconds(2, policy), 240); + assert.equal(retryDelaySeconds(3, policy), 300); +}); + +test('reports healthy, expiring, expired, and hostname-mismatch TLS states', () => { + const healthy = evaluateCertificateHealth({ certificate: CERTIFICATE, hostname: 'mail.example.test', now: NOW }); + assert.equal(healthy.status, 'healthy'); + assert.equal(healthy.renewalDue, false); + const expiring = evaluateCertificateHealth({ certificate: { ...CERTIFICATE, notAfter: '2026-09-05T00:00:00.000Z' }, hostname: 'mail.example.test', now: NOW }); + assert.equal(expiring.status, 'degraded'); + assert.equal(expiring.alerts[0].type, 'certificate.expiry_warning'); + const expired = evaluateCertificateHealth({ certificate: { ...CERTIFICATE, notAfter: '2026-08-21T00:00:00.000Z' }, hostname: 'mail.example.test', now: NOW }); + assert.equal(expired.status, 'unhealthy'); + assert.equal(expired.alerts[0].type, 'certificate.expired'); + const mismatch = createTlsHealthContract({ certificate: CERTIFICATE, hostname: 'other.example.net', now: NOW }); + assert.equal(mismatch.status, 'unhealthy'); + assert.ok(mismatch.alerts.some((alert) => alert.type === 'certificate.hostname_mismatch')); +}); + +test('creates expiry alerts without exposing certificate contents', () => { + const alert = createExpiryAlert({ + certificate: { ...CERTIFICATE, notAfter: '2026-08-27T00:00:00.000Z' }, + certificateId: 'cert-001', + now: NOW, + }); + assert.equal(alert.type, 'certificate.expiry_critical'); + assert.equal(alert.certificateId, 'cert-001'); + assert.equal(Object.hasOwn(alert, 'certificatePem'), false); +}); + +test('renews through authorization, order, storage, and graceful reload', () => { + let state = createRenewalState({ + config: config(), + currentCertificate: CERTIFICATE, + now: NOW, + }); + assert.equal(state.state, 'idle'); + state = advanceRenewal(state, { type: 'renew_due', now: NOW }); + state = advanceRenewal(state, { type: 'start', now: NOW }); + state = advanceRenewal(state, { type: 'authorization_succeeded', now: NOW }); + state = advanceRenewal(state, { type: 'order_ready', now: NOW }); + state = advanceRenewal(state, { type: 'certificate_stored', certificate: { ...CERTIFICATE, id: 'cert-002' }, now: NOW }); + assert.equal(state.state, 'reload_pending'); + state = advanceRenewal(state, { type: 'reload_succeeded', now: NOW }); + assert.equal(state.state, 'active'); + assert.equal(state.currentCertificate.id, 'cert-002'); + assert.equal(state.attempt, 0); + assert.equal(state.pendingCertificate, null); +}); + +test('keeps the current valid certificate while renewal retries and alerts after exhaustion', () => { + let state = createRenewalState({ + config: config({ renewal: { retry: { maxAttempts: 1, initialDelaySeconds: 10, maxDelaySeconds: 10, multiplier: 1 } } }), + currentCertificate: CERTIFICATE, + now: NOW, + }); + state = advanceRenewal(state, 'start', { now: NOW }); + state = advanceRenewal(state, { type: 'failed', now: NOW, error: { code: 'ACME_TIMEOUT', message: 'privateKeyPem=-----BEGIN PRIVATE KEY-----secret' } }); + assert.equal(state.state, 'retry_wait'); + assert.equal(state.fallbackActive, true); + assert.equal(state.lastError.message.includes('BEGIN PRIVATE KEY'), false); + state = advanceRenewal(state, { type: 'retry_due', now: '2026-08-22T12:01:00.000Z' }); + state = advanceRenewal(state, { type: 'failed', now: '2026-08-22T12:01:00.000Z', error: { code: 'ACME_TIMEOUT', message: 'directory unavailable' } }); + assert.equal(state.state, 'degraded'); + assert.equal(state.lastAlert.type, 'certificate.renewal_failed'); + assert.equal(state.fallbackActive, true); +}); + +test('creates and completes a metadata-only graceful reload plan', () => { + const plan = createSafeReloadPlan({ certificate: CERTIFICATE, previousCertificate: { ...CERTIFICATE, id: 'cert-old' }, now: NOW }); + assert.equal(plan.strategy, 'graceful'); + assert.equal(plan.status, undefined); + assert.deepEqual(plan.consumers.map((entry) => entry.status), ['pending', 'pending', 'pending']); + assert.equal(Object.hasOwn(plan, 'privateKey'), false); + const completed = completeSafeReloadPlan(plan, [ + { consumer: 'web', status: 'reloaded' }, + { consumer: 'postfix', status: 'reloaded' }, + { consumer: 'dovecot', status: 'reloaded' }, + ], { now: NOW }); + assert.equal(completed.status, 'completed'); + assert.equal(completed.rollbackRequired, false); + const failed = completeSafeReloadPlan(plan, [{ consumer: 'web', status: 'failed', errorCode: 'TLS_RELOAD_FAILED' }], { now: NOW }); + assert.equal(failed.status, 'rollback_required'); + assert.equal(failed.rollbackRequired, true); +}); + +test('fails the TLS health gate for invalid chains and key mismatch', () => { + assert.throws(() => createSafeReloadPlan({ certificate: { ...CERTIFICATE, chainValid: false } }), (error) => error.code === 'RELOAD_HEALTH_GATE_FAILED'); + assert.throws(() => createSafeReloadPlan({ certificate: { ...CERTIFICATE, privateKeyMatches: false } }), (error) => error.code === 'RELOAD_HEALTH_GATE_FAILED'); + const result = evaluateCertificateHealth({ certificate: { ...CERTIFICATE, chainValid: false }, now: NOW }); + assert.equal(result.status, 'unhealthy'); + assert.ok(result.alerts.some((alert) => alert.type === 'certificate.chain_invalid')); +}); diff --git a/src/ops/acme/index.ts b/src/ops/acme/index.ts new file mode 100644 index 0000000..e90baca --- /dev/null +++ b/src/ops/acme/index.ts @@ -0,0 +1,686 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// @ts-nocheck + +import { domainToASCII } from 'node:url'; + +const DAY_MS = 86_400_000; +const MAX_SAFE_DATE_MS = 8_640_000_000_000_000; +const SECRET_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const SAFE_ERROR_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const EMAIL_PATTERN = /^[\x21-\x7E]{1,254}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/u; +const HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/u; +const PRIVATE_KEY_PATTERN = /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/u; +const PEM_PATTERN = /-----BEGIN [A-Z0-9 ]+-----/u; +const SECRET_KEY_PATTERN = /(account.?key|private.?key|certificate.?pem|secret|password|passphrase|token|credential|authorization|cookie|hmac|bearer)/iu; + +export const ACME_PROVIDERS = Object.freeze({ + LETSENCRYPT: 'letsencrypt', + GENERIC: 'acme', +}); + +export const ACME_ENVIRONMENTS = Object.freeze({ + PRODUCTION: 'production', + STAGING: 'staging', +}); + +export const CHALLENGE_TYPES = Object.freeze({ + HTTP_01: 'http-01', + DNS_01: 'dns-01', +}); + +export const DEFAULT_LETSENCRYPT_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory'; +export const LETSENCRYPT_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory'; + +export const RENEWAL_STATES = Object.freeze([ + 'idle', + 'scheduled', + 'authorizing', + 'ordering', + 'finalizing', + 'reload_pending', + 'active', + 'retry_wait', + 'degraded', + 'failed', + 'cancelled', +]); + +export const TLS_PROTOCOLS = Object.freeze(['TLSv1.2', 'TLSv1.3']); + +function deepFreeze(value) { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +export function acmeError(message, code = 'ACME_CONTRACT_ERROR', details = undefined) { + const error = new Error(`ACME contract error: ${message}`); + error.code = code; + if (details && typeof details === 'object' && !Array.isArray(details)) { + error.details = redactSecrets(details); + } + return error; +} + +function assertPlainObject(value, field) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw acmeError(`${field} must be an object`, 'INVALID_CONFIGURATION'); + } + return value; +} + +function assertAllowedKeys(value, allowed, field) { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw acmeError(`${field}.${key} is not supported`, 'UNKNOWN_CONFIGURATION'); + } +} + +function assertBoolean(value, field) { + if (typeof value !== 'boolean') throw acmeError(`${field} must be a boolean`, 'INVALID_CONFIGURATION'); + return value; +} + +function integer(value, field, minimum, maximum) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw acmeError(`${field} must be an integer between ${minimum} and ${maximum}`, 'INVALID_CONFIGURATION'); + } + return value; +} + +function positiveInteger(value, field, maximum = Number.MAX_SAFE_INTEGER) { + return integer(value, field, 1, maximum); +} + +function safeIdentifier(value, field, { max = 128 } = {}) { + if (typeof value !== 'string' || value.length > max || !SAFE_IDENTIFIER_PATTERN.test(value)) { + throw acmeError(`${field} is invalid`, 'INVALID_IDENTIFIER'); + } + return value; +} + +function secretReference(value, field) { + if (typeof value !== 'string' || !SECRET_REFERENCE_PATTERN.test(value)) { + throw acmeError(`${field} must be a secret reference`, 'INVALID_SECRET_REFERENCE'); + } + return value; +} + +function isoDate(value, field, fallback = undefined) { + const candidate = value === undefined || value === null ? fallback : value; + if (candidate === undefined || candidate === null) return null; + const date = candidate instanceof Date ? new Date(candidate.getTime()) : new Date(candidate); + if (Number.isNaN(date.getTime()) || Math.abs(date.getTime()) > MAX_SAFE_DATE_MS) { + throw acmeError(`${field} is not a valid timestamp`, 'INVALID_TIMESTAMP'); + } + return date.toISOString(); +} + +function nowIso(clock = () => new Date()) { + return isoDate(clock(), 'clock'); +} + +function parseDate(value, field) { + const result = isoDate(value, field); + if (result === null) throw acmeError(`${field} is required`, 'INVALID_TIMESTAMP'); + return new Date(result); +} + +function normalizeEmail(value, field) { + if (value === undefined || value === null) return null; + if (typeof value !== 'string' || value.length > 254 || !EMAIL_PATTERN.test(value)) { + throw acmeError(`${field} is invalid`, 'INVALID_CONTACT'); + } + return value; +} + +function hasWildcard(domain) { + return domain.startsWith('*.'); +} + +function normalizeDomain(value, field = 'domains') { + if (typeof value !== 'string' || value.length === 0 || value.length > 253) { + throw acmeError(`${field} contains an invalid domain`, 'INVALID_DOMAIN'); + } + const trimmed = value.trim(); + if (trimmed !== value || trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) { + throw acmeError(`${field} contains an invalid domain`, 'INVALID_DOMAIN'); + } + const wildcard = hasWildcard(trimmed); + const source = wildcard ? trimmed.slice(2) : trimmed; + const ascii = domainToASCII(source).toLowerCase(); + if (!HOSTNAME_PATTERN.test(ascii) || (wildcard && ascii.split('.').length < 2)) { + throw acmeError(`${field} contains an invalid domain`, 'INVALID_DOMAIN'); + } + return wildcard ? `*.${ascii}` : ascii; +} + +function normalizeDomains(value) { + if (!Array.isArray(value) || value.length === 0 || value.length > 100) { + throw acmeError('domains must be a non-empty list of at most 100 names', 'INVALID_DOMAIN'); + } + const domains = [...new Set(value.map((entry, index) => normalizeDomain(entry, `domains[${index}]`)))]; + if (domains.length === 0) throw acmeError('domains must not be empty', 'INVALID_DOMAIN'); + return domains; +} + +function normalizeDirectoryUrl(value, field = 'directoryUrl') { + if (typeof value !== 'string' || value.length > 2048) throw acmeError(`${field} is invalid`, 'INVALID_DIRECTORY_URL'); + let parsed; + try { parsed = new URL(value); } catch { throw acmeError(`${field} is not a URL`, 'INVALID_DIRECTORY_URL'); } + if (parsed.protocol !== 'https:') throw acmeError(`${field} must use HTTPS`, 'INSECURE_DIRECTORY_URL'); + if (parsed.username || parsed.password || parsed.hash || parsed.search) { + throw acmeError(`${field} must not contain credentials, query parameters, or fragments`, 'INVALID_DIRECTORY_URL'); + } + if (!parsed.hostname || parsed.hostname.includes('..')) throw acmeError(`${field} host is invalid`, 'INVALID_DIRECTORY_URL'); + return parsed.toString().replace(/\/$/u, ''); +} + +function normalizeAccount(input = {}) { + const account = assertPlainObject(input, 'account'); + assertAllowedKeys(account, new Set(['id', 'contactEmail', 'keySecretRef', 'accountUrl', 'externalAccountBinding']), 'account'); + const output = { + id: account.id === undefined ? null : safeIdentifier(account.id, 'account.id'), + contactEmail: normalizeEmail(account.contactEmail, 'account.contactEmail'), + keySecretRef: secretReference(account.keySecretRef ?? 'acme/account-key', 'account.keySecretRef'), + accountUrl: account.accountUrl === undefined || account.accountUrl === null ? null : normalizeDirectoryUrl(account.accountUrl, 'account.accountUrl'), + externalAccountBinding: null, + }; + if (account.externalAccountBinding !== undefined && account.externalAccountBinding !== null) { + const eab = assertPlainObject(account.externalAccountBinding, 'account.externalAccountBinding'); + assertAllowedKeys(eab, new Set(['kid', 'hmacSecretRef']), 'account.externalAccountBinding'); + if (typeof eab.kid !== 'string' || eab.kid.length === 0 || eab.kid.length > 256 || PEM_PATTERN.test(eab.kid)) { + throw acmeError('account.externalAccountBinding.kid is invalid', 'INVALID_ACCOUNT_BINDING'); + } + output.externalAccountBinding = { + kid: eab.kid, + hmacSecretRef: secretReference(eab.hmacSecretRef, 'account.externalAccountBinding.hmacSecretRef'), + }; + } + return output; +} + +function normalizeChallenge(input = {}, domains) { + const challenge = assertPlainObject(input, 'challenge'); + const type = challenge.type ?? CHALLENGE_TYPES.HTTP_01; + if (!Object.values(CHALLENGE_TYPES).includes(type)) throw acmeError('challenge.type is unsupported', 'INVALID_CHALLENGE'); + if (type === CHALLENGE_TYPES.HTTP_01) { + assertAllowedKeys(challenge, new Set(['type', 'listenPort', 'publicPort', 'tokenPathPrefix']), 'challenge'); + const listenPort = challenge.listenPort ?? 80; + const publicPort = challenge.publicPort ?? 80; + if (hasWildcard(domains[0])) throw acmeError('HTTP-01 cannot validate wildcard domains', 'INVALID_CHALLENGE'); + const tokenPathPrefix = challenge.tokenPathPrefix ?? '/.well-known/acme-challenge/'; + if (typeof tokenPathPrefix !== 'string' || !/^\/[A-Za-z0-9._/-]{1,127}\/$/u.test(tokenPathPrefix) || tokenPathPrefix.includes('..')) { + throw acmeError('challenge.tokenPathPrefix is invalid', 'INVALID_CHALLENGE'); + } + return { + type, + listenPort: integer(listenPort, 'challenge.listenPort', 1, 65_535), + publicPort: integer(publicPort, 'challenge.publicPort', 1, 65_535), + tokenPathPrefix, + }; + } + assertAllowedKeys(challenge, new Set(['type', 'dnsProvider', 'credentialsSecretRef', 'propagationTimeoutSeconds', 'pollIntervalSeconds']), 'challenge'); + if (typeof challenge.dnsProvider !== 'string' || !/^[a-z][a-z0-9-]{1,63}$/u.test(challenge.dnsProvider)) { + throw acmeError('challenge.dnsProvider is required and invalid', 'INVALID_CHALLENGE'); + } + return { + type, + dnsProvider: challenge.dnsProvider, + credentialsSecretRef: secretReference(challenge.credentialsSecretRef, 'challenge.credentialsSecretRef'), + propagationTimeoutSeconds: integer(challenge.propagationTimeoutSeconds ?? 120, 'challenge.propagationTimeoutSeconds', 10, 3_600), + pollIntervalSeconds: integer(challenge.pollIntervalSeconds ?? 5, 'challenge.pollIntervalSeconds', 1, 300), + }; +} + +function normalizeRetry(input = {}) { + const retry = assertPlainObject(input, 'renewal.retry'); + assertAllowedKeys(retry, new Set(['maxAttempts', 'initialDelaySeconds', 'maxDelaySeconds', 'multiplier']), 'renewal.retry'); + const maxAttempts = integer(retry.maxAttempts ?? 5, 'renewal.retry.maxAttempts', 0, 20); + const initialDelaySeconds = integer(retry.initialDelaySeconds ?? 300, 'renewal.retry.initialDelaySeconds', 1, 86_400); + const maxDelaySeconds = integer(retry.maxDelaySeconds ?? 86_400, 'renewal.retry.maxDelaySeconds', initialDelaySeconds, 604_800); + const multiplier = retry.multiplier ?? 2; + if (typeof multiplier !== 'number' || !Number.isFinite(multiplier) || multiplier < 1 || multiplier > 10) { + throw acmeError('renewal.retry.multiplier is invalid', 'INVALID_RETRY_POLICY'); + } + return { maxAttempts, initialDelaySeconds, maxDelaySeconds, multiplier }; +} + +function normalizeRenewal(input = {}) { + const renewal = assertPlainObject(input, 'renewal'); + assertAllowedKeys(renewal, new Set(['enabled', 'renewBeforeDays', 'expiryWarningDays', 'expiryCriticalDays', 'retry', 'fallback']), 'renewal'); + const enabled = renewal.enabled ?? true; + if (typeof enabled !== 'boolean') throw acmeError('renewal.enabled must be a boolean', 'INVALID_RENEWAL_POLICY'); + const renewBeforeDays = integer(renewal.renewBeforeDays ?? 30, 'renewal.renewBeforeDays', 1, 90); + const expiryWarningDays = integer(renewal.expiryWarningDays ?? 30, 'renewal.expiryWarningDays', 1, 90); + const expiryCriticalDays = integer(renewal.expiryCriticalDays ?? 7, 'renewal.expiryCriticalDays', 0, expiryWarningDays); + const fallbackInput = renewal.fallback ?? {}; + const fallback = assertPlainObject(fallbackInput, 'renewal.fallback'); + assertAllowedKeys(fallback, new Set(['preserveCurrentCertificate', 'alertOnFailure', 'allowServingUntilExpiry']), 'renewal.fallback'); + return { + enabled, + renewBeforeDays, + expiryWarningDays, + expiryCriticalDays, + retry: normalizeRetry(renewal.retry ?? {}), + fallback: { + preserveCurrentCertificate: fallback.preserveCurrentCertificate ?? true, + alertOnFailure: fallback.alertOnFailure ?? true, + allowServingUntilExpiry: fallback.allowServingUntilExpiry ?? true, + }, + }; +} + +function normalizeReload(input = {}) { + const reload = assertPlainObject(input, 'reload'); + assertAllowedKeys(reload, new Set(['strategy', 'consumers', 'timeoutSeconds', 'healthGate', 'rollbackOnFailure']), 'reload'); + const consumers = reload.consumers ?? ['web', 'postfix', 'dovecot']; + const allowedConsumers = new Set(['web', 'postfix', 'dovecot', 'caldav', 'carddav']); + if (!Array.isArray(consumers) || consumers.length === 0 || consumers.some((value) => typeof value !== 'string' || !allowedConsumers.has(value))) { + throw acmeError('reload.consumers is invalid', 'INVALID_RELOAD_POLICY'); + } + const strategy = reload.strategy ?? 'graceful'; + if (strategy !== 'graceful') throw acmeError('reload.strategy must be graceful', 'INVALID_RELOAD_POLICY'); + return { + strategy, + consumers: [...new Set(consumers)], + timeoutSeconds: integer(reload.timeoutSeconds ?? 30, 'reload.timeoutSeconds', 1, 600), + healthGate: reload.healthGate ?? true, + rollbackOnFailure: reload.rollbackOnFailure ?? true, + }; +} + +function normalizeTls(input = {}) { + const tls = assertPlainObject(input, 'tls'); + assertAllowedKeys(tls, new Set(['minimumVersion', 'allowedVersions', 'healthCheckHostnames']), 'tls'); + const minimumVersion = tls.minimumVersion ?? 'TLSv1.2'; + if (!TLS_PROTOCOLS.includes(minimumVersion)) throw acmeError('tls.minimumVersion is invalid', 'INVALID_TLS_POLICY'); + const allowedVersions = tls.allowedVersions ?? TLS_PROTOCOLS; + if (!Array.isArray(allowedVersions) || allowedVersions.length === 0 || allowedVersions.some((value) => !TLS_PROTOCOLS.includes(value))) { + throw acmeError('tls.allowedVersions is invalid', 'INVALID_TLS_POLICY'); + } + if (!allowedVersions.includes(minimumVersion)) throw acmeError('tls.allowedVersions must include minimumVersion', 'INVALID_TLS_POLICY'); + const healthCheckHostnames = tls.healthCheckHostnames ?? []; + if (!Array.isArray(healthCheckHostnames) || healthCheckHostnames.some((value) => hasWildcard(normalizeDomain(value, 'tls.healthCheckHostnames')))) { + throw acmeError('tls.healthCheckHostnames is invalid', 'INVALID_TLS_POLICY'); + } + return { minimumVersion, allowedVersions: [...new Set(allowedVersions)], healthCheckHostnames: [...new Set(healthCheckHostnames.map((value) => normalizeDomain(value, 'tls.healthCheckHostnames')))] }; +} + +function assertNoPrivateKeyInput(value, path = 'value', seen = new Set()) { + if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return; + if (typeof value === 'string') { + if (PRIVATE_KEY_PATTERN.test(value)) throw acmeError(`${path} must use a secret reference, not private-key material`, 'SECRET_MATERIAL_FORBIDDEN'); + return; + } + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) value.forEach((entry, index) => assertNoPrivateKeyInput(entry, `${path}[${index}]`, seen)); + else if (typeof value === 'object') Object.entries(value).forEach(([key, entry]) => { + if (SECRET_KEY_PATTERN.test(key) && key !== 'keySecretRef' && key !== 'hmacSecretRef' && key !== 'credentialsSecretRef' && key !== 'privateKeySecretRef') { + if (entry !== null && entry !== undefined && typeof entry !== 'string' && typeof entry !== 'boolean') throw acmeError(`${path}.${key} must not contain secret material`, 'SECRET_MATERIAL_FORBIDDEN'); + if (typeof entry === 'string' && (PEM_PATTERN.test(entry) || entry.length > 256)) throw acmeError(`${path}.${key} must use a secret reference`, 'SECRET_MATERIAL_FORBIDDEN'); + } + assertNoPrivateKeyInput(entry, `${path}.${key}`, seen); + }); +} + +/** Recursively remove private-key, token, credential, and PEM material from logs. */ +export function redactSecrets(value, seen = new Set()) { + if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'string') return PEM_PATTERN.test(value) ? '[REDACTED_PEM]' : value.length > 1024 ? `${value.slice(0, 1024)}...[REDACTED]` : value; + if (seen.has(value)) return '[REDACTED_CYCLE]'; + seen.add(value); + if (Array.isArray(value)) return value.map((entry) => redactSecrets(entry, seen)); + const output = {}; + for (const [key, entry] of Object.entries(value)) { + if (SECRET_KEY_PATTERN.test(key) && !/secretref$/iu.test(key)) output[key] = '[REDACTED]'; + else output[key] = redactSecrets(entry, seen); + } + return output; +} + +/** Normalize and validate the certificate-provider contract without handling keys or certificates. */ +export function createAcmeConfig(input = {}) { + assertPlainObject(input, 'config'); + assertNoPrivateKeyInput(input); + assertAllowedKeys(input, new Set(['provider', 'environment', 'directoryUrl', 'domains', 'account', 'certificateKeySecretRef', 'challenge', 'renewal', 'reload', 'tls']), 'config'); + const provider = input.provider ?? ACME_PROVIDERS.LETSENCRYPT; + if (!Object.values(ACME_PROVIDERS).includes(provider)) throw acmeError('provider is unsupported', 'INVALID_PROVIDER'); + const environment = input.environment ?? ACME_ENVIRONMENTS.PRODUCTION; + if (!Object.values(ACME_ENVIRONMENTS).includes(environment)) throw acmeError('environment is unsupported', 'INVALID_ENVIRONMENT'); + const directoryDefault = provider === ACME_PROVIDERS.LETSENCRYPT + ? (environment === ACME_ENVIRONMENTS.STAGING ? LETSENCRYPT_STAGING_DIRECTORY_URL : DEFAULT_LETSENCRYPT_DIRECTORY_URL) + : null; + if (provider === ACME_PROVIDERS.GENERIC && !input.directoryUrl) throw acmeError('generic ACME requires directoryUrl', 'INVALID_DIRECTORY_URL'); + if (input.directoryUrl && environment === ACME_ENVIRONMENTS.STAGING && provider === ACME_PROVIDERS.LETSENCRYPT && normalizeDirectoryUrl(input.directoryUrl) !== LETSENCRYPT_STAGING_DIRECTORY_URL) { + throw acmeError("a staging Let's Encrypt configuration must use the staging directory", 'INVALID_DIRECTORY_URL'); + } + const domains = normalizeDomains(input.domains); + const challenge = normalizeChallenge(input.challenge ?? {}, domains); + if (domains.some(hasWildcard) && challenge.type !== CHALLENGE_TYPES.DNS_01) throw acmeError('wildcard domains require DNS-01', 'INVALID_CHALLENGE'); + const account = normalizeAccount(input.account ?? {}); + const reload = normalizeReload(input.reload ?? {}); + const renewal = normalizeRenewal(input.renewal ?? {}); + const tls = normalizeTls(input.tls ?? {}); + const config = { + schemaVersion: 1, + provider, + environment, + directoryUrl: normalizeDirectoryUrl(input.directoryUrl ?? directoryDefault), + domains, + account, + certificateKeySecretRef: secretReference(input.certificateKeySecretRef ?? 'acme/certificate-key', 'certificateKeySecretRef'), + challenge, + renewal, + reload, + tls, + }; + return deepFreeze(config); +} + +export const normalizeAcmeConfig = createAcmeConfig; +export const validateAcmeConfig = createAcmeConfig; +export const assertAcmeConfig = createAcmeConfig; + +export function retryDelaySeconds(attempt, retryPolicy = {}) { + const retry = normalizeRetry(retryPolicy); + integer(attempt, 'attempt', 0, 1000); + return Math.min(retry.maxDelaySeconds, Math.round(retry.initialDelaySeconds * (retry.multiplier ** attempt))); +} + +export function createRetrySchedule({ attempt = 0, policy = {}, now = new Date() } = {}) { + integer(attempt, 'attempt', 0, 1000); + const timestamp = parseDate(now, 'now'); + const delaySeconds = retryDelaySeconds(attempt, policy); + return deepFreeze({ + attempt, + delaySeconds, + retryAt: new Date(timestamp.getTime() + delaySeconds * 1000).toISOString(), + }); +} + +function normalizeCertificateMetadata(certificate = {}, field = 'certificate') { + assertPlainObject(certificate, field); + assertNoPrivateKeyInput(certificate, field); + assertAllowedKeys(certificate, new Set(['id', 'notBefore', 'notAfter', 'issuer', 'subject', 'serialNumber', 'dnsNames', 'chainValid', 'privateKeyMatches', 'chainError', 'keyAlgorithm']), field); + const notAfter = isoDate(certificate.notAfter, `${field}.notAfter`); + if (notAfter === null) throw acmeError(`${field}.notAfter is required`, 'INVALID_CERTIFICATE_METADATA'); + const notBefore = isoDate(certificate.notBefore, `${field}.notBefore`); + if (notBefore && new Date(notBefore).getTime() >= new Date(notAfter).getTime()) throw acmeError(`${field}.notBefore must be before notAfter`, 'INVALID_CERTIFICATE_METADATA'); + const dnsNames = certificate.dnsNames ?? []; + if (!Array.isArray(dnsNames) || dnsNames.length > 100) throw acmeError(`${field}.dnsNames is invalid`, 'INVALID_CERTIFICATE_METADATA'); + const normalizedDnsNames = [...new Set(dnsNames.map((value, index) => normalizeDomain(value, `${field}.dnsNames[${index}]`)))]; + for (const key of ['chainValid', 'privateKeyMatches']) { + if (certificate[key] !== undefined && certificate[key] !== null && typeof certificate[key] !== 'boolean') { + throw acmeError(`${field}.${key} must be a boolean or null`, 'INVALID_CERTIFICATE_METADATA'); + } + } + return { + id: certificate.id === undefined ? null : safeIdentifier(certificate.id, `${field}.id`), + notBefore, + notAfter, + issuer: certificate.issuer === undefined ? null : String(certificate.issuer).slice(0, 512), + subject: certificate.subject === undefined ? null : String(certificate.subject).slice(0, 512), + serialNumber: certificate.serialNumber === undefined ? null : safeIdentifier(certificate.serialNumber, `${field}.serialNumber`, { max: 256 }), + dnsNames: normalizedDnsNames, + chainValid: certificate.chainValid ?? null, + privateKeyMatches: certificate.privateKeyMatches ?? null, + chainError: certificate.chainError === undefined ? null : String(certificate.chainError).slice(0, 256), + keyAlgorithm: certificate.keyAlgorithm === undefined ? null : String(certificate.keyAlgorithm).slice(0, 64), + }; +} + +function hostnameMatches(hostname, names) { + const normalized = normalizeDomain(hostname, 'hostname'); + return names.some((name) => name === normalized || (hasWildcard(name) && normalized.endsWith(name.slice(1)) && normalized.split('.').length === name.split('.').length)); +} + +/** Evaluate expiry, chain, key, and hostname state without touching certificate/key material. */ +export function evaluateCertificateHealth({ certificate, hostname = undefined, now = new Date(), renewBeforeDays = 30, expiryWarningDays = 30, expiryCriticalDays = 7 } = {}) { + const metadata = normalizeCertificateMetadata(certificate); + const checkedAt = parseDate(now, 'now'); + const expiresAt = new Date(metadata.notAfter); + const notBefore = metadata.notBefore ? new Date(metadata.notBefore) : null; + const daysRemaining = Math.floor((expiresAt.getTime() - checkedAt.getTime()) / DAY_MS); + const renewalDue = expiresAt.getTime() - checkedAt.getTime() <= integer(renewBeforeDays, 'renewBeforeDays', 1, 90) * DAY_MS; + const alerts = []; + if (expiresAt <= checkedAt) alerts.push({ type: 'certificate.expired', severity: 'critical', daysRemaining }); + else if (daysRemaining <= integer(expiryCriticalDays, 'expiryCriticalDays', 0, 90)) alerts.push({ type: 'certificate.expiry_critical', severity: 'critical', daysRemaining }); + else if (daysRemaining <= integer(expiryWarningDays, 'expiryWarningDays', 1, 90)) alerts.push({ type: 'certificate.expiry_warning', severity: 'warning', daysRemaining }); + if (metadata.chainValid === false) alerts.push({ type: 'certificate.chain_invalid', severity: 'critical', reason: metadata.chainError ?? 'chain validation failed' }); + if (metadata.privateKeyMatches === false) alerts.push({ type: 'certificate.key_mismatch', severity: 'critical' }); + if (notBefore && notBefore > checkedAt) alerts.push({ type: 'certificate.not_yet_valid', severity: 'critical' }); + if (hostname !== undefined && !hostnameMatches(hostname, metadata.dnsNames)) alerts.push({ type: 'certificate.hostname_mismatch', severity: 'critical' }); + const critical = alerts.some((alert) => alert.severity === 'critical'); + const status = critical ? 'unhealthy' : alerts.length > 0 ? 'degraded' : 'healthy'; + return deepFreeze({ + schemaVersion: 1, + status, + checkedAt: checkedAt.toISOString(), + certificate: metadata, + hostname: hostname === undefined ? null : normalizeDomain(hostname, 'hostname'), + daysRemaining, + renewalDue, + alerts: alerts.map((alert) => Object.freeze({ ...alert })), + }); +} + +export const createTlsHealthContract = evaluateCertificateHealth; +export const checkTlsHealth = evaluateCertificateHealth; + +export function createExpiryAlert({ certificate, now = new Date(), renewBeforeDays = 30, expiryWarningDays = 30, expiryCriticalDays = 7, certificateId = undefined } = {}) { + const health = evaluateCertificateHealth({ certificate, now, renewBeforeDays, expiryWarningDays, expiryCriticalDays }); + const expiryAlert = health.alerts.find((alert) => alert.type.startsWith('certificate.expiry') || alert.type === 'certificate.expired') ?? null; + if (!expiryAlert) return null; + return deepFreeze({ + schemaVersion: 1, + type: expiryAlert.type, + severity: expiryAlert.severity, + certificateId: certificateId ?? health.certificate.id, + checkedAt: health.checkedAt, + expiresAt: health.certificate.notAfter, + daysRemaining: health.daysRemaining, + renewalDue: health.renewalDue, + }); +} + +const TRANSITIONS = Object.freeze({ + idle: Object.freeze({ renew_due: 'scheduled', start: 'authorizing', cancel: 'cancelled' }), + scheduled: Object.freeze({ start: 'authorizing', cancel: 'cancelled' }), + authorizing: Object.freeze({ authorization_succeeded: 'ordering', failed: 'retry_wait', cancel: 'cancelled' }), + ordering: Object.freeze({ order_ready: 'finalizing', failed: 'retry_wait', cancel: 'cancelled' }), + finalizing: Object.freeze({ certificate_stored: 'reload_pending', failed: 'retry_wait', cancel: 'cancelled' }), + reload_pending: Object.freeze({ reload_succeeded: 'active', reload_failed: 'retry_wait', failed: 'retry_wait', cancel: 'cancelled' }), + active: Object.freeze({ renew_due: 'scheduled', reconcile: 'active', cancel: 'cancelled' }), + retry_wait: Object.freeze({ retry_due: 'authorizing', cancel: 'cancelled', manual_retry: 'authorizing' }), + degraded: Object.freeze({ retry_due: 'authorizing', manual_retry: 'authorizing', renew_due: 'scheduled', cancel: 'cancelled' }), + failed: Object.freeze({ manual_retry: 'authorizing', retry_due: 'authorizing', cancel: 'cancelled' }), + cancelled: Object.freeze({ manual_retry: 'authorizing' }), +}); + +function safeFailure(error) { + if (error === null || error === undefined) return null; + if (typeof error === 'string') return { code: 'ACME_OPERATION_FAILED', message: redactSecrets(error).slice(0, 256) }; + if (typeof error === 'object') return { + code: typeof error.code === 'string' && SAFE_ERROR_CODE_PATTERN.test(error.code) ? error.code : 'ACME_OPERATION_FAILED', + message: redactSecrets(String(error.message ?? 'ACME operation failed')).slice(0, 256), + }; + return { code: 'ACME_OPERATION_FAILED', message: 'ACME operation failed' }; +} + +function stateCertificate(value, field) { + if (value === null || value === undefined) return null; + return normalizeCertificateMetadata(value, field); +} + +function normalizeConfigForUse(config) { + if (config === null || config === undefined) return null; + if (config.schemaVersion === 1 && typeof config.directoryUrl === 'string' && Array.isArray(config.domains) && config.renewal && config.account) return config; + return createAcmeConfig(config); +} + +/** Create a serializable renewal state. It contains references and metadata only, never keys or certificate PEM. */ +export function createRenewalState({ certificateId = 'primary', domains, config = undefined, currentCertificate = null, now = new Date() } = {}) { + const normalizedConfig = normalizeConfigForUse(config); + const normalizedDomains = normalizeDomains(domains ?? normalizedConfig?.domains); + const policy = normalizedConfig ? normalizedConfig.renewal : normalizeRenewal({}); + const checkedAt = parseDate(now, 'now'); + const current = stateCertificate(currentCertificate, 'currentCertificate'); + return deepFreeze({ + schemaVersion: 1, + certificateId: safeIdentifier(certificateId, 'certificateId'), + domains: normalizedDomains, + provider: normalizedConfig ? normalizedConfig.provider : ACME_PROVIDERS.LETSENCRYPT, + state: 'idle', + attempt: 0, + maxAttempts: policy.retry.maxAttempts, + retryPolicy: policy.retry, + renewalEnabled: policy.enabled, + currentCertificate: current, + pendingCertificate: null, + fallbackActive: false, + lastError: null, + lastAlert: null, + scheduledAt: null, + nextAttemptAt: null, + updatedAt: checkedAt.toISOString(), + }); +} + +function eventObject(event, options) { + if (typeof event === 'string') return { ...(options ?? {}), type: event }; + if (!event || typeof event !== 'object') throw acmeError('renewal event is required', 'INVALID_RENEWAL_EVENT'); + return event; +} + +/** Advance the deterministic renewal state machine; operations are performed by a separate ACME worker. */ +export function advanceRenewal(state, event, options = {}) { + assertPlainObject(state, 'state'); + if (!RENEWAL_STATES.includes(state.state)) throw acmeError('state.state is invalid', 'INVALID_RENEWAL_STATE'); + const action = eventObject(event, options); + const type = action.type; + if (typeof type !== 'string' || !TRANSITIONS[state.state]?.[type]) throw acmeError(`${type ?? 'event'} is not valid from ${state.state}`, 'INVALID_RENEWAL_TRANSITION'); + const timestamp = parseDate(action.now ?? options.now ?? new Date(), 'event.now'); + const nextState = TRANSITIONS[state.state][type]; + const next = { ...state, state: nextState, updatedAt: timestamp.toISOString() }; + if (type === 'renew_due') { + next.scheduledAt = timestamp.toISOString(); + next.nextAttemptAt = null; + next.lastAlert = null; + } + if (type === 'start' || type === 'retry_due' || type === 'manual_retry') { + next.lastError = null; + next.lastAlert = null; + next.nextAttemptAt = null; + } + if (type === 'authorization_succeeded' || type === 'order_ready') next.lastError = null; + if (type === 'certificate_stored') { + next.pendingCertificate = stateCertificate(action.certificate, 'event.certificate'); + next.lastError = null; + } + if (type === 'reload_succeeded') { + next.currentCertificate = next.pendingCertificate ?? next.currentCertificate; + next.pendingCertificate = null; + next.attempt = 0; + next.nextAttemptAt = null; + next.fallbackActive = false; + next.scheduledAt = null; + next.lastError = null; + next.lastAlert = null; + } + if (type === 'failed' || type === 'reload_failed') { + const attempt = (Number.isSafeInteger(state.attempt) ? state.attempt : 0) + 1; + const policy = normalizeRetry(action.retryPolicy ?? state.retryPolicy ?? { maxAttempts: state.maxAttempts ?? 5 }); + const failure = safeFailure(action.error ?? action.reason); + next.attempt = attempt; + next.lastError = failure; + const currentHealth = next.currentCertificate ? evaluateCertificateHealth({ certificate: next.currentCertificate, now: timestamp }) : null; + const canFallback = Boolean(next.currentCertificate && currentHealth && currentHealth.status !== 'unhealthy' && currentHealth.daysRemaining >= 0); + next.fallbackActive = canFallback; + if (attempt <= policy.maxAttempts) { + next.state = 'retry_wait'; + next.nextAttemptAt = new Date(timestamp.getTime() + retryDelaySeconds(attempt - 1, policy) * 1000).toISOString(); + } else { + next.state = canFallback ? 'degraded' : 'failed'; + next.nextAttemptAt = null; + next.lastAlert = { type: 'certificate.renewal_failed', severity: canFallback ? 'warning' : 'critical', attempt, fallbackActive: canFallback }; + } + } + if (type === 'reconcile') next.lastAlert = null; + return deepFreeze(next); +} + +export const transitionRenewal = advanceRenewal; +export const advanceRenewalState = advanceRenewal; + +/** Build a metadata-only graceful reload plan for protocol consumers. */ +export function createSafeReloadPlan({ certificate, previousCertificate = null, consumers = ['web', 'postfix', 'dovecot'], generation = 1, now = new Date(), timeoutSeconds = 30 } = {}) { + const nextCertificate = normalizeCertificateMetadata(certificate, 'certificate'); + const health = evaluateCertificateHealth({ certificate: nextCertificate, now }); + if (nextCertificate.chainValid !== true || nextCertificate.privateKeyMatches !== true || health.status === 'unhealthy') { + throw acmeError('certificate fails the reload health gate', 'RELOAD_HEALTH_GATE_FAILED'); + } + const previous = previousCertificate === null ? null : normalizeCertificateMetadata(previousCertificate, 'previousCertificate'); + if (!Array.isArray(consumers) || consumers.length === 0 || consumers.some((value) => !['web', 'postfix', 'dovecot', 'caldav', 'carddav'].includes(value))) throw acmeError('consumers is invalid', 'INVALID_RELOAD_PLAN'); + const uniqueConsumers = [...new Set(consumers)]; + return deepFreeze({ + schemaVersion: 1, + operation: 'certificate_graceful_reload', + generation: positiveInteger(generation, 'generation', 2 ** 31 - 1), + strategy: 'graceful', + healthGate: true, + rollbackOnFailure: true, + timeoutSeconds: integer(timeoutSeconds, 'timeoutSeconds', 1, 600), + certificate: { id: nextCertificate.id, notAfter: nextCertificate.notAfter, serialNumber: nextCertificate.serialNumber, dnsNames: nextCertificate.dnsNames }, + previousCertificate: previous ? { id: previous.id, notAfter: previous.notAfter, serialNumber: previous.serialNumber } : null, + consumers: uniqueConsumers.map((consumer) => ({ consumer, status: 'pending', reloadedAt: null, errorCode: null })), + preflight: ['chain_valid', 'private_key_matches', 'consumer_configuration_valid'], + createdAt: parseDate(now, 'now').toISOString(), + }); +} + +export function completeSafeReloadPlan(plan, results, { now = new Date() } = {}) { + assertPlainObject(plan, 'plan'); + if (!Array.isArray(results)) throw acmeError('results must be a list', 'INVALID_RELOAD_RESULT'); + const byConsumer = new Map(results.map((result) => [result.consumer, result])); + const consumers = plan.consumers.map((entry) => { + const result = byConsumer.get(entry.consumer); + if (!result) return { ...entry, status: 'pending' }; + if (!['reloaded', 'failed', 'skipped'].includes(result.status)) throw acmeError(`reload result for ${entry.consumer} is invalid`, 'INVALID_RELOAD_RESULT'); + return { + consumer: entry.consumer, + status: result.status, + reloadedAt: result.status === 'reloaded' ? parseDate(result.reloadedAt ?? now, `${entry.consumer}.reloadedAt`).toISOString() : null, + errorCode: result.status === 'failed' ? (typeof result.errorCode === 'string' && SAFE_ERROR_CODE_PATTERN.test(result.errorCode) ? result.errorCode : 'RELOAD_FAILED') : null, + }; + }); + const failed = consumers.filter((entry) => entry.status === 'failed'); + const pending = consumers.filter((entry) => entry.status === 'pending'); + return deepFreeze({ + ...plan, + status: failed.length > 0 ? 'rollback_required' : pending.length > 0 ? 'pending' : 'completed', + consumers, + completedAt: failed.length === 0 && pending.length === 0 ? parseDate(now, 'now').toISOString() : null, + rollbackRequired: failed.length > 0, + }); +} + +export const applyReloadResults = completeSafeReloadPlan; + +export function createTlsHealthContractFromConfig({ certificate, hostname, config, now = new Date() } = {}) { + const normalizedConfig = createAcmeConfig(config); + return evaluateCertificateHealth({ + certificate, + hostname, + now, + renewBeforeDays: normalizedConfig.renewal.renewBeforeDays, + expiryWarningDays: normalizedConfig.renewal.expiryWarningDays, + expiryCriticalDays: normalizedConfig.renewal.expiryCriticalDays, + }); +} + +export { DAY_MS, normalizeCertificateMetadata }; diff --git a/src/ops/patch/status.mjs b/src/ops/patch/status.mjs new file mode 100644 index 0000000..c213462 --- /dev/null +++ b/src/ops/patch/status.mjs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// Temporary compatibility bridge. Patch-status behavior lives in TypeScript. +export * from './status.ts'; diff --git a/src/ops/patch/status.test.mjs b/src/ops/patch/status.test.mjs new file mode 100644 index 0000000..8b283e8 --- /dev/null +++ b/src/ops/patch/status.test.mjs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +// Temporary compatibility bridge. Patch-status contract tests are TypeScript. +import './status.test.ts'; diff --git a/src/ops/patch/status.test.ts b/src/ops/patch/status.test.ts new file mode 100644 index 0000000..a94bd24 --- /dev/null +++ b/src/ops/patch/status.test.ts @@ -0,0 +1,80 @@ +// 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 { + PATCH_STATUS_SCHEMA_VERSION, + parsePatchStatus, + sanitizePatchStatus, +} from './status.ts'; + +test('sanitizes an allowlisted read-only patch DTO', () => { + const status = sanitizePatchStatus({ + schemaVersion: PATCH_STATUS_SCHEMA_VERSION, + state: 'updates_available', + checkedAt: '2026-08-25T12:00:00Z', + baseImage: 'ubuntu:26.04', + nodeVersion: '26.7.0', + aptOutput: 'password=never-expose-this', + token: 'never-expose-this', + reason: 'apt_apply_failed', + }); + + assert.deepEqual(status, { + schemaVersion: 1, + state: 'updates_available', + checkedAt: '2026-08-25T12:00:00Z', + baseImage: 'ubuntu:26.04', + nodeVersion: '26.7.0', + }); + assert.equal(Object.isFrozen(status), true); + assert.equal(JSON.stringify(status).includes('never-expose-this'), false); +}); + +test('represents an absent or corrupt patch status as fail-closed unknown state', () => { + assert.deepEqual(parsePatchStatus(undefined), { + schemaVersion: 1, + state: 'unknown', + reason: 'status_unavailable', + }); + assert.deepEqual(parsePatchStatus('{not json'), { + schemaVersion: 1, + state: 'unknown', + reason: 'invalid_status', + }); + assert.deepEqual(sanitizePatchStatus({ schemaVersion: 2, state: 'current' }), { + schemaVersion: 1, + state: 'unknown', + reason: 'invalid_status', + }); +}); + +test('retains recognized patch failures without carrying arbitrary error text', () => { + const failed = parsePatchStatus(JSON.stringify({ + schemaVersion: 1, + state: 'failed', + checkedAt: '2026-08-25T12:00:00.000Z', + reason: 'apt_apply_failed', + stderr: 'apt failed while reading password=super-secret', + })); + assert.deepEqual(failed, { + schemaVersion: 1, + state: 'failed', + checkedAt: '2026-08-25T12:00:00.000Z', + reason: 'apt_apply_failed', + }); + + const unrecognizedReason = sanitizePatchStatus({ + schemaVersion: 1, + state: 'failed', + reason: 'repository password is exposed', + }); + assert.deepEqual(unrecognizedReason, { + schemaVersion: 1, + state: 'failed', + reason: 'patch_failed', + }); +}); diff --git a/src/ops/patch/status.ts b/src/ops/patch/status.ts new file mode 100644 index 0000000..2cfc22a --- /dev/null +++ b/src/ops/patch/status.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +export const PATCH_STATUS_SCHEMA_VERSION = 1 as const; + +export const PATCH_STATUS_STATES = Object.freeze([ + 'unknown', + 'checking', + 'updates_available', + 'applying', + 'current', + 'failed', +] as const); + +export const PATCH_STATUS_REASONS = Object.freeze([ + 'status_unavailable', + 'invalid_status', + 'patch_failed', + 'apt_update_failed', + 'apt_check_failed', + 'apt_apply_failed', +] as const); + +export type PatchStatusState = typeof PATCH_STATUS_STATES[number]; +export type PatchStatusReason = typeof PATCH_STATUS_REASONS[number]; + +export interface PatchStatusDto { + readonly schemaVersion: typeof PATCH_STATUS_SCHEMA_VERSION; + readonly state: PatchStatusState; + readonly checkedAt?: string; + readonly updatedAt?: string; + readonly baseImage?: string; + readonly nodeVersion?: string; + readonly reason?: PatchStatusReason; +} + +type PatchStatusRecord = Record; + +const PATCH_STATUS_STATE_SET = new Set(PATCH_STATUS_STATES); +const PATCH_STATUS_REASON_SET = new Set(PATCH_STATUS_REASONS); +const SAFE_METADATA_PATTERN = /^[A-Za-z0-9._:+/-]{1,255}$/u; + +function isPlainObject(value: unknown): value is PatchStatusRecord { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isCanonicalTimestamp(value: unknown): value is string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u.test(value)) return false; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return false; + const normalized = date.toISOString(); + return value === normalized || value === normalized.replace('.000Z', 'Z'); +} + +function isSafeMetadata(value: unknown): value is string { + return typeof value === 'string' && SAFE_METADATA_PATTERN.test(value); +} + +function unknownPatchStatus(reason: Extract): PatchStatusDto { + return Object.freeze({ + schemaVersion: PATCH_STATUS_SCHEMA_VERSION, + state: 'unknown', + reason, + }); +} + +/** + * Convert decoded patch state into the public, read-only DTO. Unknown fields, + * command output, and unrecognized error strings are deliberately discarded. + */ +export function sanitizePatchStatus(value: unknown): PatchStatusDto { + if (value === null || value === undefined) return unknownPatchStatus('status_unavailable'); + if (!isPlainObject(value) || value.schemaVersion !== PATCH_STATUS_SCHEMA_VERSION) { + return unknownPatchStatus('invalid_status'); + } + + const state = typeof value.state === 'string' && PATCH_STATUS_STATE_SET.has(value.state) + ? value.state as PatchStatusState + : null; + if (state === null) return unknownPatchStatus('invalid_status'); + + const result: { + schemaVersion: typeof PATCH_STATUS_SCHEMA_VERSION; + state: PatchStatusState; + checkedAt?: string; + updatedAt?: string; + baseImage?: string; + nodeVersion?: string; + reason?: PatchStatusReason; + } = { + schemaVersion: PATCH_STATUS_SCHEMA_VERSION, + state, + }; + + if (isCanonicalTimestamp(value.checkedAt)) result.checkedAt = value.checkedAt; + if (isCanonicalTimestamp(value.updatedAt)) result.updatedAt = value.updatedAt; + if (isSafeMetadata(value.baseImage)) result.baseImage = value.baseImage; + if (isSafeMetadata(value.nodeVersion)) result.nodeVersion = value.nodeVersion; + + const reason = typeof value.reason === 'string' && PATCH_STATUS_REASON_SET.has(value.reason) + ? value.reason as PatchStatusReason + : undefined; + if (state === 'failed') result.reason = reason ?? 'patch_failed'; + if (state === 'unknown') result.reason = reason === 'status_unavailable' || reason === 'invalid_status' + ? reason + : 'status_unavailable'; + + return Object.freeze(result); +} + +/** Parse an optional JSON status file without exposing malformed content. */ +export function parsePatchStatus(serialized: string | null | undefined): PatchStatusDto { + if (serialized === null || serialized === undefined) return unknownPatchStatus('status_unavailable'); + try { + return sanitizePatchStatus(JSON.parse(serialized) as unknown); + } catch { + return unknownPatchStatus('invalid_status'); + } +} diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 32b843e..dd1439a 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -11,6 +11,8 @@ import { loadConfig } from './config.js'; import { createDependencyRegistry, createMetrics } from './metrics.js'; import { createLogger } from './logger.js'; import { getWellKnownResource, WELL_KNOWN_PATHS } from '../dav/discovery/index.ts'; +import { createRateLimiter } from '../ops/abuse/index.ts'; +import { parsePatchStatus, type PatchStatusDto } from '../ops/patch/status.ts'; import { CSRF_HEADER_NAME, createWebSecurity } from '../web/security/index.ts'; import type { SessionIdentity, WebSecurity, WebSession } from '../web/security/index.ts'; @@ -34,6 +36,7 @@ interface RuntimeServerOptions { discoveryContract?: any; discoveryTenantId?: string; webSecurity?: WebSecurity; + rateLimiter?: any; authenticateLogin?: LoginAuthenticator; apiResources?: Partial; } @@ -48,6 +51,7 @@ export interface RuntimeServer { discoveryContract: any; discoveryTenantId: string | undefined; webSecurity: WebSecurity; + rateLimiter: any; authenticateLogin: LoginAuthenticator; apiResources: ApiResources; loginFailures: Map; @@ -83,14 +87,6 @@ const STATIC_MIME_TYPES: Readonly> = Object.freeze({ '.webmanifest': 'application/manifest+json; charset=utf-8', }); const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; -const PATCH_STATUS_VALUES = new Set([ - 'unknown', - 'checking', - 'updates_available', - 'applying', - 'current', - 'failed', -]); const WELL_KNOWN_PATH_VALUES = new Set(Object.values(WELL_KNOWN_PATHS)); const API_BODY_MAX_BYTES = 16 * 1024; const LOGIN_FAILURE_LIMIT = 5; @@ -302,33 +298,21 @@ function patchStatusFile(config: RuntimeConfig): string { return config?.contract?.patching?.statusFile ?? config?.patching?.statusFile ?? '/var/lib/gulogulo/patch/status.json'; } -function readPatchStatus(config: RuntimeConfig): Record { +function readPatchStatus(config: RuntimeConfig): PatchStatusDto { try { - const parsed = JSON.parse(readFileSync(patchStatusFile(config), 'utf8')) as Record; - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('invalid_status_shape'); - } - - const state = typeof parsed.state === 'string' && PATCH_STATUS_VALUES.has(parsed.state) ? parsed.state : 'unknown'; - const result: Record = { - schemaVersion: 1, - state, - }; - for (const key of ['checkedAt', 'updatedAt', 'baseImage', 'nodeVersion', 'reason']) { - if (typeof parsed[key] === 'string' && /^[A-Za-z0-9._:+/-]{1,255}$/.test(parsed[key])) { - result[key] = parsed[key]; - } - } - return result; + return parsePatchStatus(readFileSync(patchStatusFile(config), 'utf8')); } catch { - return { - schemaVersion: 1, - state: 'unknown', - reason: 'status_unavailable', - }; + return parsePatchStatus(undefined); } } +function abuseChannelForPath(path: string | null): string { + if (path === '/api/session/login') return 'login'; + if (path !== null && path.startsWith('/api/')) return 'api'; + if (path !== null && WELL_KNOWN_PATH_VALUES.has(path)) return 'dav'; + return 'http'; +} + function elapsedMilliseconds(startedAt: bigint): number { return Number(process.hrtime.bigint() - startedAt) / 1_000_000; } @@ -452,6 +436,7 @@ export function createRuntimeServer({ discoveryContract = config?.discoveryContract, discoveryTenantId = config?.discoveryTenantId ?? config?.tenantId, webSecurity = createWebSecurity({ clock }), + rateLimiter, authenticateLogin = createFixtureLoginAuthenticator(), apiResources = defaultApiResources(), }: RuntimeServerOptions = {}): RuntimeServer { @@ -484,6 +469,7 @@ export function createRuntimeServer({ discoveryContract, discoveryTenantId, webSecurity, + rateLimiter: rateLimiter ?? createRateLimiter({ clock }), authenticateLogin, apiResources: { ...defaultApiResources(), ...apiResources }, loginFailures: new Map(), @@ -534,16 +520,6 @@ export function createRuntimeServer({ }); }; - if (path === null) { - finish( - 400, - responsePayload(runtime, 'bad_request', requestDetails, { - reason: 'invalid_request_target', - }), - ); - return; - } - const cookieHeader = requestHeader(request, 'cookie'); const session = runtime.webSecurity.authenticate(cookieHeader); const clearExpiredCookie = () => { @@ -558,6 +534,39 @@ export function createRuntimeServer({ })); }; + const abuseChannel = abuseChannelForPath(path); + const abuseDecision = runtime.rateLimiter.consume({ + channel: abuseChannel, + tenantId: session?.tenantId ?? 'anonymous', + ipAddress: request.socket.remoteAddress ?? 'unknown', + }); + runtime.metrics.increment(abuseDecision.allowed ? 'gulogulo_abuse_allowed_total' : 'gulogulo_abuse_limited_total', 1, { + channel: abuseChannel, + }); + if (!abuseDecision.allowed) { + const retryAfterSeconds = Math.max(1, Math.ceil(Number(abuseDecision.retryAfterMs ?? 1000) / 1000)); + response.setHeader('retry-after', String(retryAfterSeconds)); + scopedLogger.warn('abuse_rate_limited', { + channel: abuseChannel, + limited_by: abuseDecision.limitedBy, + retry_after_seconds: retryAfterSeconds, + }); + finish(429, responsePayload(runtime, 'rate_limited', requestDetails, { + error: { code: 'RATE_LIMITED', message: 'Request rate exceeded.' }, + })); + return; + } + + if (path === null) { + finish( + 400, + responsePayload(runtime, 'bad_request', requestDetails, { + reason: 'invalid_request_target', + }), + ); + return; + } + if (path === '/api/session/login') { if (method !== 'POST') { response.setHeader('allow', 'POST'); diff --git a/tsconfig.lp5.json b/tsconfig.lp5.json new file mode 100644 index 0000000..f76f502 --- /dev/null +++ b/tsconfig.lp5.json @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) +{ + "extends": "./tsconfig.server.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": [ + "src/capacity/**/*.ts", + "src/ops/**/*.ts", + "src/observability/**/*.ts", + "scripts/lp5-*.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/tsconfig.server.json b/tsconfig.server.json index fe5ebb9..daa858c 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -29,6 +29,9 @@ "src/auth/**/*.ts", "src/admin/**/*.ts", "src/mail/**/*.ts", + "src/ops/**/*.ts", + "src/observability/**/*.ts", + "src/capacity/**/*.ts", "src/dav/**/*.ts", "src/web/**/*.ts" ] From 90903c73af07c6f79c46281f73b02f78bdbb0bd1 Mon Sep 17 00:00:00 2001 From: Sythos Date: Tue, 25 Aug 2026 17:13:04 +0200 Subject: [PATCH 2/4] fix: activate LP5 runtime profile for maintenance proof --- README.md | 7 ++++--- scripts/lp5-compose-smoke.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index edc9df1..a5234a4 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ passing verification gate for that item; deployment evidence is still required where the item depends on external infrastructure. LP5 is currently in AMD64-first validation. Its local operations, patch-state, -abuse, observability, and bounded-capacity contracts are present; the integrated -GitHub AMD64 Compose proof and the final ARM64 artifact gate still have to pass -before LP5 is recorded as complete. +abuse, observability, and bounded-capacity contracts are present, and the +Compose proof now activates the runtime and check profiles together; the +integrated GitHub AMD64 Compose proof and the final ARM64 artifact gate still +have to pass before LP5 is recorded as complete. ### Security diff --git a/scripts/lp5-compose-smoke.ts b/scripts/lp5-compose-smoke.ts index 6370b53..5be0a3f 100644 --- a/scripts/lp5-compose-smoke.ts +++ b/scripts/lp5-compose-smoke.ts @@ -133,7 +133,7 @@ try { const pids = Number(webContainer.HostConfig?.PidsLimit || 0); if (!(memoryMiB > 0) || !(pids > 0)) throw new Error('LP5 resource limits were not applied to the web container.'); - compose(['--profile', 'lp5-check', 'run', '--rm', '--no-deps', 'gulogulo-lp5-maintenance']); + compose(['--profile', 'lp5', '--profile', 'lp5-check', 'run', '--rm', '--no-deps', 'gulogulo-lp5-maintenance']); runProof(startupMs, healthy.elapsedMs, memoryMiB, cpuMillis, pids); compose(['--profile', 'lp5', 'restart', 'gulogulo-lp5-web']); From fc380cdc08a2b77495bb54842ce11dc41abb04b7 Mon Sep 17 00:00:00 2001 From: Sythos Date: Tue, 25 Aug 2026 17:27:17 +0200 Subject: [PATCH 3/4] fix: bind LP5 proof to configured tenant context --- README.md | 7 ++++--- compose.yaml | 1 + scripts/lp5-proof-check.ts | 8 +++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a5234a4..39cc1b8 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,10 @@ where the item depends on external infrastructure. LP5 is currently in AMD64-first validation. Its local operations, patch-state, abuse, observability, and bounded-capacity contracts are present, and the -Compose proof now activates the runtime and check profiles together; the -integrated GitHub AMD64 Compose proof and the final ARM64 artifact gate still -have to pass before LP5 is recorded as complete. +Compose proof now activates the runtime and check profiles together while +using the configured tenant context; the integrated GitHub AMD64 Compose proof +and the final ARM64 artifact gate still have to pass before LP5 is recorded as +complete. ### Security diff --git a/compose.yaml b/compose.yaml index 88e0125..07c343e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -920,6 +920,7 @@ services: LP5_LOGIN_EMAIL: ${LP5_LOGIN_EMAIL} LP5_LOGIN_PASSWORD: ${LP5_LOGIN_PASSWORD} LP5_TENANT_ID: ${LP5_TENANT_ID:-acme} + LP5_TENANT_DOMAIN: ${LP5_TENANT_DOMAIN:-example.test} LP5_USER_ID: ${LP5_USER_ID:-alice} LP5_STARTUP_MS: ${LP5_STARTUP_MS:-0} LP5_READINESS_MS: ${LP5_READINESS_MS:-0} diff --git a/scripts/lp5-proof-check.ts b/scripts/lp5-proof-check.ts index 1310117..7351752 100644 --- a/scripts/lp5-proof-check.ts +++ b/scripts/lp5-proof-check.ts @@ -5,6 +5,7 @@ import { createImapIdleBroker } from '../src/mail/imap-idle.ts'; import { createMailQueue } from '../src/mail/mail-queue.ts'; import { AMD64_LOCAL_PROOF_BUDGET, evaluateCapacity, percentile, type CapacityMeasurement } from '../src/capacity/capacity-contract.ts'; +import { createTenantContext } from '../src/integrations/tenant-context.ts'; function requiredEnvironment(name: string): string { const value = process.env[name]; @@ -52,7 +53,12 @@ const dav = await p95(baseUrl, '/.well-known/caldav', 16); const ready = await fetch(`${baseUrl}/health/ready`); if (!ready.ok) throw new Error(`LP5 readiness returned ${ready.status}`); -const context = Object.freeze({ tenantId: 'acme', actorId: 'alice', role: 'user' as const }); +const context = createTenantContext({ + tenantId: requiredEnvironment('LP5_TENANT_ID'), + domain: requiredEnvironment('LP5_TENANT_DOMAIN'), + actorId: requiredEnvironment('LP5_USER_ID'), + role: 'user', +}); const queue = createMailQueue(); const queueStarted = performance.now(); for (let index = 0; index < 16; index += 1) { From 83031bed054bfc01d1d0f66a047b9b368b858acf Mon Sep 17 00:00:00 2001 From: Sythos Date: Tue, 25 Aug 2026 18:14:38 +0200 Subject: [PATCH 4/4] docs: record LP5 verification evidence --- README.md | 13 ++++++------- release/lp5-local-operations-capacity.json | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 39cc1b8..1a6a928 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,11 @@ check mark means that the repository contains an implementation contract and a passing verification gate for that item; deployment evidence is still required where the item depends on external infrastructure. -LP5 is currently in AMD64-first validation. Its local operations, patch-state, -abuse, observability, and bounded-capacity contracts are present, and the -Compose proof now activates the runtime and check profiles together while -using the configured tenant context; the integrated GitHub AMD64 Compose proof -and the final ARM64 artifact gate still have to pass before LP5 is recorded as -complete. +LP5 is complete at the bounded synthetic operations and capacity boundary. Its +local operations, patch-state, abuse, observability, and capacity contracts +passed the integrated GitHub AMD64 Compose proof, followed by the final ARM64 +artifact and attestation gate. This remains local-proof evidence, not a claim +of production capacity or external service interoperability. ### Security @@ -98,7 +97,7 @@ complete. - [x] log rotation; - [x] alerts; - [x] Postfix queue visibility; -- [ ] bounded LP5 operations and capacity proof (AMD64 first, ARM64 final gate); +- [x] bounded LP5 operations and capacity proof (AMD64 Compose first, ARM64 final artifact gate); - [x] fail-closed disposable patch helper and sanitized read-only patch status; - [ ] automatic Rspamd/ClamAV updates; - [x] provider-only migration contract, compatibility window, and rollback state machine; diff --git a/release/lp5-local-operations-capacity.json b/release/lp5-local-operations-capacity.json index f7be66f..465b24e 100644 --- a/release/lp5-local-operations-capacity.json +++ b/release/lp5-local-operations-capacity.json @@ -55,5 +55,5 @@ { "name": "gulogulo-lp5-maintenance", "role": "disposable allowlisted patch-state helper" } ], "liveDockerEvidence": "github_actions_required", - "status": "implementation_ready" + "status": "verified_amd64_and_arm64_artifact" }