Skip to content

fix(server): fail closed when TLS is requested but cannot be established - #221

Merged
xerj-org merged 1 commit into
mainfrom
fix/tls-fail-closed
Aug 8, 2026
Merged

fix(server): fail closed when TLS is requested but cannot be established#221
xerj-org merged 1 commit into
mainfrom
fix/tls-fail-closed

Conversation

@xerj-org

@xerj-org xerj-org commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #200.

What

With tls.enabled = true, a failure in the certificate step (ensure_tls_cert) was logged and swallowed: the call site flipped cfg.tls.enabled = false and served plain HTTP on the very ports the operator configured for HTTPS. Every client keeps working — which is exactly why nobody notices — and API keys cross the wire in the clear.

This PR makes startup fail closed: if TLS was requested and cannot be established, the process exits non-zero with the reason and both ways out (fix the cert setup, or disable TLS explicitly / --insecure for dev). The neighbouring PEM-load step (build_tls_config) was already fail-closed; the cert-provisioning step was the one remaining fail-open hole.

Live before/after (old binary from main vs this branch)

Old binary, tls.enabled = true with the auto-generation target obstructed:

ERROR xerj: TLS setup failed (write cert to .../data/xerj.crt: Is a directory (os error 21)) — falling back to plain HTTP

then a cleartext probe of the operator's ES-compat port answers and solicits credentials over plaintext:

$ curl -s http://127.0.0.1:19312/
{"error":{...,"reason":"missing or invalid API key in Authorization header"}}

Fixed binary, same config: exits non-zero before binding anything:

Error: tls.enabled = true but TLS could not be established; refusing to start rather than serve plain HTTP — fix the certificate setup, or disable TLS explicitly (tls.enabled = false, or --insecure for dev)

Caused by:
    0: write cert to .../data/xerj.crt
    1: Is a directory (os error 21)

The Config::validate interaction (reviewer warning from #200)

  • Config::validate (xerj-common/src/config.rs:146-158) rejects tls.enabled = true with empty cert/key paths on every config-file load (config.rs:106). Pure defaults (no config file) never run validate, but have TLS off — and no CLI flag or env var turns TLS on (--insecure only turns it off). So TLS-enabled always reaches ensure_tls_cert with non-empty paths.
  • Both files exist → reuse branch, which cannot error (the 0600 tightening only warns).
  • Either file missing → self-signed auto-generation into <data_dir>/xerj.{crt,key}, whose generation/writes can genuinely fail (unwritable or full data dir, obstructed target, read-only fs). That error was the one being converted into a silent downgrade — it is now fatal.

No production path relied on the downgrade: the shipped xerj.default.toml and the helm chart both default tls.enabled = false (the CI shipped-config boot smoke is unaffected), and a helm deploy with a broken cert mount now crashloops with the reason instead of silently serving cleartext — consistent with #213's secure-by-default direction.

Tests

  • Unit (mod tls_tests): ensure_tls_cert_failure_errors_without_downgrading — an obstructed auto-generation target must error and must not flip tls.enabled off; pins the contract the call site now relies on.
  • Integration (tests/tls_fail_closed.rs): spawns the real binary (CARGO_BIN_EXE_xerj) with tls.enabled = true and the obstructed target; asserts non-zero exit naming TLS and the refusal within a deadline. A regressed (downgrading) binary binds its free ports and keeps serving, so the test fails rather than hangs.

Reference-coding

Both peer servers propagate TLS setup errors at startup; neither has a downgrade branch (approach only, no code copied):

  • meilisearch crates/meilisearch/src/main.rs:198opt_clone.get_ssl_config()?; a cert/key load failure aborts run_http (option.rs:755-797 errors on bad material rather than returning None).
  • quickwit quickwit-serve/src/rest.rs:230-235make_tls_server_config(tls_config, alpn_protocols)? propagates; TLS misconfig aborts the REST server start.

(The xc index's xerj-search slice currently returns 0 hits even for single-token probes — rustls, tls, certificate, verified directly against the value field — so the clones under ~/.xerj-code/corpora/xerj-search were read directly.)

Out of scope

The two "related, same area" observations in #200 — the bind_address = 0.0.0.0 + TLS-off default posture, and gRPC being h2c even with tls.enabled = true — are GA-posture decisions, not part of this fail-closed fix. If closing #200 should not bury them, they are worth re-filing individually.

Verification (run on this branch)

  • cargo build --release -j 32 -p xerj-server — OK
  • cargo test --release -j 32 -p xerj-server --bin xerj26 passed; 0 failed (tls_tests 6/6, incl. the new ensure_tls_cert_failure_errors_without_downgrading)
  • cargo test --release -j 32 -p xerj-server --test tls_fail_closed1 passed; 0 failed (0.10 s — the fixed binary exits immediately)
  • cargo test --release -j 32 -p xerj-server --no-run — OK
  • cargo fmt --all --check — clean

The before/after transcripts above are from real runs of the old main binary vs this branch's binary against the byte-identical failing config (exit codes 0-running-forever vs 1; ports probed with curl: cleartext 404/security_exception vs connection refused). The ES-YAML conformance gate runs in CI; this change touches only the startup error path with TLS off by default, so the suite's server boot (--insecure) is unaffected.

Closes #200.

Motivation
----------
With tls.enabled = true, a failure in the certificate step was logged and
then swallowed: the call site flipped cfg.tls.enabled = false and carried
on, so the listeners came up as cleartext HTTP on the very ports the
operator configured for HTTPS. Every client keeps working — which is
exactly why nobody notices — and API keys cross the wire in the clear.
The only signal was one error! line.

Root cause
----------
xerj-server/src/main.rs:1599-1603 caught the ensure_tls_cert error, logged
"falling back to plain HTTP", and disabled TLS; the bind path then served
plain HTTP. The neighbouring PEM-load step was already fail-closed
(build_tls_config errors abort startup, main.rs:1605-1612); the
cert-provisioning step was the one remaining fail-open hole.

Interaction with Config::validate (the reachable failure shape)
---------------------------------------------------------------
- Config::validate (xerj-common/src/config.rs:146-158) rejects
  tls.enabled = true with empty cert/key paths, and runs on every config
  file load (config.rs:106). A config that never went through validate
  (pure defaults, no file) has TLS off, and no CLI flag or env var turns
  TLS on (--insecure only turns it off). So TLS-enabled always reaches
  ensure_tls_cert with NON-empty paths.
- If both files exist, the reuse branch cannot fail (the 0600 tightening
  only warns). If either file is missing, ensure_tls_cert auto-generates a
  self-signed pair into <data_dir>/xerj.{crt,key} — and generation and the
  two writes CAN genuinely fail (unwritable or full data dir, obstructed
  target path, read-only fs). That error was being converted into a silent
  downgrade.

What changed
------------
- main.rs call site: propagate the error with a context line naming the
  refusal and both ways out (fix the certificate setup, or disable TLS
  explicitly / --insecure for dev). async_main's Err exits the process
  non-zero before any listener binds.
- Unit test ensure_tls_cert_failure_errors_without_downgrading: an
  obstructed auto-generation target (<data_dir>/xerj.crt squatted by a
  directory) must error and must NOT flip tls.enabled off — pins the
  contract the call site now relies on.
- Integration test tests/tls_fail_closed.rs: spawns the real binary
  (CARGO_BIN_EXE_xerj) with tls.enabled = true and the obstructed cert
  target; asserts non-zero exit with "TLS"/"refusing to start" on stderr
  within a deadline. A regressed (downgrading) binary binds its free ports
  and keeps serving, so the test fails instead of hanging.

Reference-coding
----------------
Both peer servers propagate TLS setup errors at startup; neither has a
downgrade branch (approach only, no code copied):
- meilisearch crates/meilisearch/src/main.rs:198 — `opt_clone
  .get_ssl_config()?`; a cert/key load failure aborts run_http
  (option.rs:755-797 errors on bad material rather than returning None).
- quickwit quickwit-serve/src/rest.rs:230-235 —
  `make_tls_server_config(tls_config, alpn_protocols)?` propagates; a TLS
  misconfig aborts the REST server start.
The xc index's xerj-search slice currently returns 0 hits even for
single-token probes ("rustls", "tls", "certificate" — verified directly
against the value field), so the clones under
~/.xerj-code/corpora/xerj-search were read directly.

Verification (all run on this branch)
-------------------------------------
- cargo build --release -j 32 -p xerj-server: OK
- cargo test --release -j 32 -p xerj-server --bin xerj:
  26 passed; 0 failed (tls_tests 6/6, incl. the new one)
- cargo test --release -j 32 -p xerj-server --test tls_fail_closed:
  1 passed; 0 failed
- cargo test --release -j 32 -p xerj-server --no-run: OK
- cargo fmt --all --check: clean
- Live before/after with the same failing config (obstructed
  <data_dir>/xerj.crt): the old main binary logged "TLS setup failed …
  falling back to plain HTTP" and answered cleartext HTTP on the
  configured ports (ES-compat even solicited an API key over plaintext);
  this branch's binary exits 1 with
  "tls.enabled = true but TLS could not be established; refusing to
  start …" + the write-cert cause chain, and binds nothing.
@cla-bot cla-bot Bot added the cla-signed label Aug 8, 2026
@xerj-org
xerj-org merged commit a50e303 into main Aug 8, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TLS fails open: cert failure downgrades to plain HTTP instead of refusing to start

1 participant