Skip to content

fix(security): harden trust boundaries with CI forward fix - #677

Merged
dviejokfs merged 33 commits into
gotempsh:mainfrom
bherila:codex/replace-pr-676
Aug 16, 2026
Merged

dviejokfs merged 33 commits into
gotempsh:mainfrom
bherila:codex/replace-pr-676

Conversation

@bherila

@bherila bherila commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This replacement PR carries forward the security-hardening bundle from #676 and includes the forward fixes required by the current upstream test suite.

Forward fixes

  • Updates PostgreSQL upgrade fixtures to the supported managed images: gotempsh/postgres-walg:17-bookworm and gotempsh/postgres-walg:18-bookworm.
  • Makes WAL-G environment probing work when no live service container exists.
  • Restores the multinode enrollment E2E after the HTTPS fail-closed hardening: the control plane now serves a private TLS listener, the worker trusts a generated one-day test certificate, and enrollment/replay checks use https://control-plane.temps.test.
  • The multinode healthcheck verifies the certificate chain, SNI hostname, and TLS handshake before worker enrollment begins.

The original #676 security-hardening commits remain attributed to their original contributor. The focused CI compatibility commits are layered on top so the complete branch can be reviewed and merged together.

Evidence

Shell and Compose contracts

bash -n tools/dev-cluster/role-control-plane.sh tools/dev-cluster/role-worker.sh
shellcheck tools/dev-cluster/role-control-plane.sh tools/dev-cluster/role-worker.sh
docker compose -f tools/dev-cluster/docker-compose.yml -p temps-dev config --quiet
docker compose -f tools/e2e-multinode-cluster/docker-compose.yml -p temps-e2e-mn config --quiet
git diff --check

All commands exited 0 with no diagnostics.

Scenario TypeScript

cd apps/temps-e2e
bun install --frozen-lockfile
bun run typecheck
$ tsc --noEmit

Exit status: 0.

Generated certificate trust and hostname verification

An ephemeral container generated the same ECDSA certificate as the topology, installed its public certificate into the Debian trust store, started an OpenSSL TLS server, and verified the connection with:

echo | openssl s_client \
  -connect 127.0.0.1:443 \
  -servername control-plane.temps.test \
  -CAfile /etc/ssl/certs/ca-certificates.crt \
  -verify_hostname control-plane.temps.test \
  -verify_return_error
HTTPS_TRUST_OK

The dedicated DinD image also built successfully with OpenSSL installed.

Full multinode runtime

The local ARM Docker VM exhausted memory (rustc received SIGKILL while compiling temps-entities) before the control plane could start. This path builds the full workspace inside Docker. GitHub CI uses the already-built Linux artifact, so the dedicated multinode job on this PR is the required full registration/deployment runtime proof.

Review

  • Security review: approved; the plaintext staging key lives only in /run, is mode 0600, is never shared with the worker, and is removed after encrypted database import.
  • Test review: approved; worker registration and consumed-token replay now both exercise the trusted HTTPS path.
  • Public product documentation is unchanged because this is an isolated CI/dev topology. Inline topology and scenario documentation has been updated.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@bherila
bherila force-pushed the codex/replace-pr-676 branch from dec8f09 to 23785e6 Compare August 15, 2026 17:14
bherila and others added 3 commits August 15, 2026 10:34
gotempsh/postgres-walg:17-bookworm is in the allowlist but was never
published (only 18-bookworm exists on Docker Hub), so the upgrade
guard test failed pulling it. Use timescale/timescaledb-ha:pg17/pg18,
which are both allowlisted and real, matching the pattern already
used in postgres_upgrade.rs test fixtures.
postgres:18 was never in ALLOWED_POSTGRES_DOCKER_IMAGES (added by an
earlier commit on this branch), so test_upgrade_postgres_image_parameter_update
and test_can_update_postgres_docker_image failed creating their initial
service. Use timescale/timescaledb-ha:pg18, which is allowlisted and
distinct from the gotempsh/postgres-walg:18-bookworm target both tests
update to, preserving the "same major version, different variant" intent.
@bherila

bherila commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Investigated the `Scenario E2E / Scenario (multinode mTLS)` failure on this run — looks like DinD networking flakiness rather than a regression from this branch.

Control-plane logs show a completely clean boot: setup completes, migrations apply, and `temps serve` starts listening on `0.0.0.0:80` within ~13s of container start, with no errors or panics afterward (Console API, proxy server, all subsystems report ready).

`worker-1` never gets past a bare TCP `Connection refused (os error 111)` trying to reach `10.52.0.10:80` (the control-plane's own address), across 90 retries × several full retry cycles, for the entire ~10 minute test. Since the control-plane is provably listening on all interfaces the whole time, this is a network-level failure between the two outer DinD containers, not an application issue — I checked the full diff for anything touching server bind addresses or internal-proxy routing to `/api/internal/nodes/register` and found nothing that would explain a network-layer refusal.

I don't have admin rights on this repo to re-run just this job. Could someone with access kick off a re-run of this check when convenient?

bherila and others added 5 commits August 15, 2026 13:08
… exists

compute_desired_enable_archiving() called walg_env_exists_in_container(),
which requires a live container since Docker's archive-download API
operates on a container's filesystem, not a volume directly. On any
recreate-from-scratch path (force_recreate, node failover, or a
container removed externally while the data volume persists), the
probe failed closed and the recreated container baked archive_mode=off
even though walg.env still existed on the volume -- contradicting the
function's own documented guarantee that it survives container
recreates, restarts, and node failovers.

Fall back to a throwaway, never-started container that only mounts the
named volume when no live container exists. The probe container never
starts, so nothing in its image ever executes; it reuses the service's
own already-vetted image rather than introducing a new one.
The new production_https redirect default (no external_url configured
-> treat every host as production, force HTTPS) applied to every plain
HTTP request through the proxy, including requests whose Host never
matched a project domain. That incorrectly caught internal
cluster-management API calls such as node registration/heartbeat,
which run over plaintext HTTP by design before any TLS material
exists — the multinode E2E cluster never configures external_url, so
every join attempt got redirected off a listener with no TLS
configured and the worker could never register.

Extract the gating logic into should_apply_production_https_default()
and require a resolved ctx.environment (actual project/environment
traffic) before applying the production assumption, matching the
feature's stated intent (CDN-terminated project environments) rather
than all proxy traffic.
@bherila

bherila commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Traced the multinode mTLS failure — not DinD flakiness as I first guessed (thanks for the correction, David). Root cause: crates/temps-proxy/src/proxy.rs's new production_https default (no external_url configured -> assume production, force HTTPS) wasn't scoped to resolved project traffic. It also caught requests whose Host never matched a project domain at all — including /api/internal/nodes/register, which by design must work over plaintext HTTP before any TLS material exists. Since the multinode cluster never configured external_url, every join attempt got the "production" redirect applied against a listener with no TLS configured.

Pushed a fix (02a2d8ce, now merged with your harness fix as 8d5ee798): extracted the gating into should_apply_production_https_default() and require ctx.environment.is_some() — i.e. only apply the production-HTTPS assumption to traffic that actually resolved to a project/environment, matching the feature's stated intent (CDN-terminated environments), not admin/console/internal-cluster traffic. Added unit tests covering the unresolved-host case plus the existing gates (global disable, TLS already active, ACME challenge, per-env override).

This also fixes a more general case beyond this test: any fresh self-hosted install that hasn't configured external_url yet would hit the same redirect-to-nowhere on its own admin/internal traffic — not just this E2E scenario.

Complementary to your harness change (real external_url/TLS cert + TEMPS_DISABLE_HTTPS_REDIRECT for the scenario) — both landed together with no conflicts.

@bherila

bherila commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Update: the mTLS registration fix worked — this run got much further than before (worker joined, negotiated mTLS, project created, deployment kicked off) before failing at a new, later step:

`worker application to GET http://production.e2e-msuwub0r-opnng5-mn.temps.local did not converge within 60s (last: {"code":1,"stdout":"","stderr":"wget: server returned error: HTTP/1.1 403 Forbidden\n"})`

This is a different area than the redirect logic we just fixed — TEMPS_DISABLE_HTTPS_REDIRECT=true is set for this cluster, so the redirect path is globally short-circuited regardless of my change, and this 403 is coming from somewhere else in the request path (possibly IP access control or attack-mode challenge gating, still unconfirmed). Since the scenario never got this far before, this looks like a previously-latent issue only now exposed. Haven't dug further since I know you're actively iterating on this scenario — let me know if you want me to chase this one down too or if you've already got it.

This was referenced Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants