Harden Kubernetes lab CA profile [skip ci]

This commit is contained in:
2026-08-05 21:17:32 +02:00
parent bf2f02891f
commit 3b9ae901dd
3 changed files with 66 additions and 2 deletions
+5 -2
View File
@@ -187,7 +187,9 @@ digest and by the existing GovOPlaN signature policy. It deploys PostgreSQL,
Redis, single-node Garage and GreenMail on the state VM. The API, WebUI, workers
and scheduler run in K3s from digest-pinned release images. A private lab CA
protects both ingress and S3; backend pods receive only the CA Secret and keep
TLS verification enabled.
TLS verification enabled. The CA profile carries critical `CA:TRUE` and
`keyCertSign,cRLSign` constraints. `deploy` and `update` rotate older lab CAs
that do not satisfy that profile and reissue the ingress/S3 certificate.
The final output identifies two local files below `state_directory`:
@@ -206,7 +208,8 @@ sudo update-ca-certificates
```
Review mappings before adding them to `/etc/hosts`; the lifecycle does not edit
the workstation's trust or resolver configuration.
the workstation's trust or resolver configuration. Reinstall `pki/ca.crt` in
the client trust store after an automatic CA rotation.
### Enroll the first administrator
+34
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from contextlib import redirect_stdout
from dataclasses import replace
import io
import json
from pathlib import Path
import shutil
import stat
import subprocess
import sys
@@ -24,6 +26,7 @@ from govoplan_lab.lifecycle import ( # noqa: E402
LabOperationError,
_assert_domain_owned,
_domain_description,
_ensure_certificates,
_render_kubectl_wrapper,
destroy,
)
@@ -234,6 +237,37 @@ class KubernetesLabTests(unittest.TestCase):
self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode))
@unittest.skipUnless(shutil.which("openssl"), "openssl is required")
def test_generated_lab_ca_passes_strict_chain_validation(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-lab-pki-") as directory:
config = replace(
load_config(REHEARSAL_CONFIG),
state_directory=Path(directory),
)
_ensure_certificates(config, CommandRunner(config))
ca_certificate = config.state_directory / "pki" / "ca.crt"
server_certificate = config.state_directory / "pki" / "server.crt"
result = subprocess.run(
[
"openssl",
"verify",
"-x509_strict",
"-CAfile",
str(ca_certificate),
str(server_certificate),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(
0,
result.returncode,
(result.stdout + result.stderr).decode(errors="replace"),
)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -857,6 +857,7 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None:
ca_valid = (
ca_key.is_file()
and ca_cert.is_file()
and _ca_key_usage_is_valid(runner, ca_cert)
and runner.run(
[
"openssl",
@@ -900,6 +901,10 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None:
str(ca_key),
"-subj",
f"/CN={config.name} private lab CA",
"-addext",
"basicConstraints=critical,CA:TRUE",
"-addext",
"keyUsage=critical,keyCertSign,cRLSign",
"-out",
str(ca_cert),
],
@@ -988,6 +993,28 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None:
write_private(config.state_directory / "hosts", render_hosts(config))
def _ca_key_usage_is_valid(runner: CommandRunner, certificate: Path) -> bool:
result = runner.run(
[
"openssl",
"x509",
"-in",
str(certificate),
"-noout",
"-ext",
"keyUsage",
],
capture=True,
check=False,
)
output = result.stdout.decode("utf-8", errors="replace")
return (
result.returncode == 0
and "Certificate Sign" in output
and "CRL Sign" in output
)
def _deploy_state_services(
config: LabConfig,
runner: CommandRunner,