When TLS was the intended posture and the cert could not be read or generated,
the bootstrap printed a WARNING and then bound plaintext HTTP on the port meant
for TLS - a silent downgrade of the management plane (TLS clients got
"Invalid http version: \x16\x03\x01..." while the service reported itself
healthy). The os.path.exists() gate also collapsed EACCES into ENOENT, so a
present-but-unreadable cert was reported "missing" and generation tried to write
over it. The config/ssl mkdir/chmod/migrate ran at import time, so a root-run
importer could plant root-owned certs and lock the service user out - the trigger
behind the reported case.
Adapted from #637 by @SpyrosPsarras, with follow-up hardening from an adversarial
review:
- Fail closed: the inline TLS setup in main() is now _resolve_ssl_context(), which
returns None only for a reverse proxy or the explicit PEGAPROX_ALLOW_PLAINTEXT=1
opt-in, and otherwise raises SystemExit. Both plaintext bind sites (gevent + the
Flask dev-server fallback) read that single context, so there is no path left
where TLS is intended yet cleartext binds.
- Unreadable != missing: readability is probed by opening the file; only a true
ENOENT leads to generation, and a present-but-unreachable cert is never
overwritten. The error names the path, dir owner/mode and the process uid/gid.
- Import-time side effects in constants.py now run only when config/ is owned by
the current euid; update.sh repairs ownership on upgrade.
- (MK) readable != loadable: _unloadable() actually load_cert_chain()s the pair in
the resolver, so a corrupt/mismatched cert gets the same actionable fail-closed
message instead of a raw ssl.SSLError crash downstream. Uses the byte-identical
call the real bind uses, so it can never reject a cert the server would accept.
- (MK) a half-present pair (one file there, the other ENOENT) fails closed instead
of regenerating over the surviving half.
- (MK) systemd StartLimitBurst so a permanently-broken cert lands in `failed`, not
an endless 5s crash-loop.
Tests: tests/test_ssl_bootstrap.py drives the real _resolve_ssl_context; the
fixture now uses a real throwaway pair so the loadability path is exercised, plus
corrupt-cert and half-pair cases. 18/19 pass here; the one gap is the
generation-SUCCESS case, blocked by this box being a broken-pyOpenSSL env (it too
fails closed) - it and a real systemd/TLS-bind E2E are owed on a proper host.
Closes#633.