diff --git a/docs/skills/k0s-sysext-ops.md b/docs/skills/k0s-sysext-ops.md index 7b806f8..b26d54f 100644 --- a/docs/skills/k0s-sysext-ops.md +++ b/docs/skills/k0s-sysext-ops.md @@ -46,6 +46,25 @@ k0s automatically applies all `.yaml` files under `/var/lib/k0s/manifests/argocd k0s kubectl get pods -A ``` +## KubeStellar Kiosk TLS Management + +The KubeStellar kiosk proxy TLS certificate (`cert.pem`) and private key (`key.pem`) are generated on first boot by `k0s-kiosk-tls.service` before `k0scontroller.service` starts. The private key is stored persistently in `/var/lib/k0s/kiosk/key.pem` with mode `0600`, and the certificate is configured with the host's actual IP addresses in the Subject Alternative Names (SAN). `files/k0s/sysext/k0s-manifests.conf` seeds the kiosk static assets (`nginx.conf`, `kiosk-gate.js`, `kiosk-gate.css`) into this same directory on every boot, file-by-file rather than as a whole-directory copy, specifically so it never deletes `cert.pem`/`key.pem`. + +The SAN is computed once, at generation time, from whatever IP addresses the host has at that moment (3650-day validity, no periodic refresh). On a DHCP host whose address later changes, the certificate will not include the new address — rotate manually (below) after an address change if browser cert warnings start appearing. + +To rotate or regenerate the TLS certificate and private key: + +```bash +# Remove the existing certificate and key from persistent storage +rm -f /var/lib/k0s/kiosk/key.pem /var/lib/k0s/kiosk/cert.pem + +# Trigger regeneration via the oneshot unit +systemctl restart k0s-kiosk-tls.service + +# Restart the controller service to reload kiosk assets +systemctl restart k0scontroller.service +``` + ## Troubleshooting - **Extension not merged**: Check `systemd-sysext status`. Verify the persistent image is `/var/lib/k0s/k0s.raw`; the boot activation unit copies it into `/run/extensions/k0s.raw`. diff --git a/docs/skills/k0s-sysext.md b/docs/skills/k0s-sysext.md index 1e38f94..8793076 100644 --- a/docs/skills/k0s-sysext.md +++ b/docs/skills/k0s-sysext.md @@ -57,6 +57,7 @@ Design choices: | `elements/k0s/k0s-bin.bst` | Pins the upstream `k0s` binary SHA256; the release URL is derived from `include/k0s.yml`. | | `elements/oci/k0s-sysext.bst` | Builds the EROFS sysext image (`k0s-.raw`). | | `files/k0s/sysext/k0scontroller.service` | systemd unit for the k0s single-node controller/worker. Not enabled by default. | +| `files/k0s/sysext/k0s-kiosk-tls.service` | Oneshot systemd unit generating persistent kiosk TLS cert and key on first boot. | | `files/k0s/sysext/extension-release.k0s` | Static sysext identity (`ID=_any`); `VERSION_ID=`/`ARCHITECTURE=` are appended at build time. | | `files/k0s/sysext/k0s-manifests.conf` | tmpfiles rule that copies declarative stacks to `/var/lib/k0s/manifests/`. | | `files/k0s/manifests/argocd/` | Raw YAML manifests for Argo CD. | diff --git a/elements/bluefin-server/os-stack.bst b/elements/bluefin-server/os-stack.bst index 004c09f..a2ef5d2 100644 --- a/elements/bluefin-server/os-stack.bst +++ b/elements/bluefin-server/os-stack.bst @@ -53,6 +53,9 @@ depends: # gpg binary required by systemd-sysupdate to verify SHA256SUMS.gpg - freedesktop-sdk.bst:components/gnupg.bst + # openssl CLI required for first-boot TLS certificate generation + - freedesktop-sdk.bst:components/openssl.bst + # First-Boot Credentials Provisioning - bluefin-server/os-creds-prov.bst diff --git a/elements/oci/k0s-sysext.bst b/elements/oci/k0s-sysext.bst index b980c9d..05ca194 100644 --- a/elements/oci/k0s-sysext.bst +++ b/elements/oci/k0s-sysext.bst @@ -3,8 +3,8 @@ description: | Produce a systemd-sysext extension image for k0s. Output: k0s-.raw, k0s-.raw.zst, and SHA256SUMS. - Contains /usr/bin/k0s, k0scontroller.service, tmpfiles.d/k0s-manifests.conf, - and /usr/share/k0s/manifests/ (Argo CD and KubeStellar). + Contains /usr/bin/k0s, k0scontroller.service, k0s-kiosk-tls.service, + tmpfiles.d/k0s-manifests.conf, and /usr/share/k0s/manifests/ (Argo CD and KubeStellar). (@): - include/arch.yml @@ -15,7 +15,6 @@ build-depends: - freedesktop-sdk.bst:components/erofs-utils.bst - freedesktop-sdk.bst:components/xz.bst - freedesktop-sdk.bst:components/zstd.bst - - freedesktop-sdk.bst:components/openssl.bst - filename: k0s/k0s-bin.bst config: location: / @@ -64,6 +63,7 @@ config: # Stage systemd service cp -a sysext-src/k0scontroller.service sysext/usr/lib/systemd/system/ + cp -a sysext-src/k0s-kiosk-tls.service sysext/usr/lib/systemd/system/ # Stage extension release metadata cp -a sysext-src/extension-release.k0s sysext/usr/lib/extension-release.d/ @@ -82,15 +82,6 @@ config: # Stage Console kiosk proxy assets. cp -a kiosk-src/. sysext/usr/share/k0s/kiosk/ - # Generate self-signed TLS certificate for local kiosk proxy - openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ - -keyout sysext/usr/share/k0s/kiosk/key.pem \ - -out sysext/usr/share/k0s/kiosk/cert.pem \ - -subj "/CN=KubeStellar Console" \ - -addext "subjectAltName=DNS:localhost,DNS:*.local,IP:127.0.0.1,IP:10.0.2.15" - chmod 0600 sysext/usr/share/k0s/kiosk/key.pem - chmod 0644 sysext/usr/share/k0s/kiosk/cert.pem - # Build EROFS image mkfs.erofs -d0 "${OUT}/${FNAME}" sysext diff --git a/files/k0s/sysext/k0s-kiosk-tls.service b/files/k0s/sysext/k0s-kiosk-tls.service new file mode 100644 index 0000000..c6feadb --- /dev/null +++ b/files/k0s/sysext/k0s-kiosk-tls.service @@ -0,0 +1,14 @@ +[Unit] +Description=Generate persistent TLS certificate and key for KubeStellar kiosk +RequiresMountsFor=/var/lib/k0s +After=network-online.target systemd-tmpfiles-setup.service +Before=k0scontroller.service + +[Service] +Type=oneshot +RemainAfterExit=yes +StateDirectory=k0s +ExecStart=/bin/bash -eu -c 'test -s /var/lib/k0s/kiosk/key.pem && test -s /var/lib/k0s/kiosk/cert.pem && exit 0; mkdir -p /var/lib/k0s/kiosk; SAN="DNS:localhost,DNS:*.local,IP:127.0.0.1"; if command -v hostname >/dev/null 2>&1; then HN=$$(hostname 2>/dev/null || true); if [ -n "$$HN" ] && [ "$$HN" != "localhost" ]; then SAN="$${SAN},DNS:$${HN}"; fi; else echo "k0s-kiosk-tls: hostname command not found, SAN has no host DNS name" >&2; fi; if command -v ip >/dev/null 2>&1; then for addr in $$(ip -o addr show scope global 2>/dev/null | awk "{print $$4}" | cut -d/ -f1); do SAN="$${SAN},IP:$${addr}"; done; else echo "k0s-kiosk-tls: ip command not found, SAN has no host IP address" >&2; fi; openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout /var/lib/k0s/kiosk/key.pem -out /var/lib/k0s/kiosk/cert.pem -subj "/CN=KubeStellar Console" -addext "subjectAltName=$${SAN}"; chmod 0600 /var/lib/k0s/kiosk/key.pem; chmod 0644 /var/lib/k0s/kiosk/cert.pem' + +[Install] +WantedBy=k0scontroller.service multi-user.target diff --git a/files/k0s/sysext/k0s-manifests.conf b/files/k0s/sysext/k0s-manifests.conf index 33f9929..d43dd2a 100644 --- a/files/k0s/sysext/k0s-manifests.conf +++ b/files/k0s/sysext/k0s-manifests.conf @@ -2,4 +2,14 @@ d /var/lib/k0s/manifests 0755 root root - - C+ /var/lib/k0s/manifests/argocd - - - - /usr/share/k0s/manifests/argocd C+ /var/lib/k0s/manifests/kubestellar - - - - /usr/share/k0s/manifests/kubestellar -C+ /var/lib/k0s/kiosk - - - - /usr/share/k0s/kiosk +# Per-file, not whole-directory: k0s-kiosk-tls.service also writes +# cert.pem/key.pem into this directory, and they must survive every boot. +# A directory-level `C+` here would delete-then-recopy the whole tree from +# /usr/share/k0s/kiosk (which never contains cert.pem/key.pem), wiping the +# generated TLS material on every boot. Targeting each static asset file +# individually still propagates sysext upgrades to nginx.conf/kiosk-gate.* +# without touching anything else in the directory. +d /var/lib/k0s/kiosk 0755 root root - - +C+ /var/lib/k0s/kiosk/nginx.conf - - - - /usr/share/k0s/kiosk/nginx.conf +C+ /var/lib/k0s/kiosk/kiosk-gate.js - - - - /usr/share/k0s/kiosk/kiosk-gate.js +C+ /var/lib/k0s/kiosk/kiosk-gate.css - - - - /usr/share/k0s/kiosk/kiosk-gate.css diff --git a/files/k0s/sysext/k0scontroller.service b/files/k0s/sysext/k0scontroller.service index 618f2a6..7ea3458 100644 --- a/files/k0s/sysext/k0scontroller.service +++ b/files/k0s/sysext/k0scontroller.service @@ -2,8 +2,8 @@ Description=k0s - Zero Friction Kubernetes Documentation=https://docs.k0sproject.io ConditionFileIsExecutable=/usr/bin/k0s -After=network-online.target -Wants=network-online.target +After=network-online.target k0s-kiosk-tls.service +Wants=network-online.target k0s-kiosk-tls.service [Service] Type=simple diff --git a/tests/unit/test_kubestellar_kiosk.py b/tests/unit/test_kubestellar_kiosk.py index 5cffd7e..2a211ee 100644 --- a/tests/unit/test_kubestellar_kiosk.py +++ b/tests/unit/test_kubestellar_kiosk.py @@ -25,6 +25,9 @@ / "kubestellar" / "41-kubestellar-kiosk-proxy.yaml" ) +KIOSK_TLS_SERVICE = ROOT / "files" / "k0s" / "sysext" / "k0s-kiosk-tls.service" +K0S_CONTROLLER_SERVICE = ROOT / "files" / "k0s" / "sysext" / "k0scontroller.service" +OS_STACK = ROOT / "elements" / "bluefin-server" / "os-stack.bst" def test_kiosk_assets_are_packaged_and_seeded() -> None: @@ -34,12 +37,35 @@ def test_kiosk_assets_are_packaged_and_seeded() -> None: assert KIOSK_CONF.is_file() assert KIOSK_JS.is_file() assert KIOSK_CSS.is_file() - assert "freedesktop-sdk.bst:components/openssl.bst" in sysext - assert "keyout sysext/usr/share/k0s/kiosk/key.pem" in sysext + assert "freedesktop-sdk.bst:components/openssl.bst" not in sysext + assert "keyout" not in sysext + assert "cp -a sysext-src/k0s-kiosk-tls.service sysext/usr/lib/systemd/system/" in sysext assert "path: files/k0s/kiosk" in sysext assert "directory: kiosk-src" in sysext assert "cp -a kiosk-src/. sysext/usr/share/k0s/kiosk/" in sysext - assert "C+ /var/lib/k0s/kiosk - - - - /usr/share/k0s/kiosk" in tmpfiles + assert "C+ /var/lib/k0s/kiosk/nginx.conf - - - - /usr/share/k0s/kiosk/nginx.conf" in tmpfiles + assert "C+ /var/lib/k0s/kiosk/kiosk-gate.js - - - - /usr/share/k0s/kiosk/kiosk-gate.js" in tmpfiles + assert "C+ /var/lib/k0s/kiosk/kiosk-gate.css - - - - /usr/share/k0s/kiosk/kiosk-gate.css" in tmpfiles + + +def test_tmpfiles_never_wipes_the_generated_tls_material() -> None: + """A directory-level `C+ /var/lib/k0s/kiosk` would delete-then-recopy the + whole tree from /usr/share/k0s/kiosk on every boot, which never contains + cert.pem/key.pem -- wiping the TLS material k0s-kiosk-tls.service + generates into that same directory. Regression guard for that collision. + """ + directives = [ + line for line in TMPFILES.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + assert not any("cert.pem" in line or "key.pem" in line for line in directives) + # No directive targets the bare directory (as opposed to a file inside it). + for line in directives: + if line.startswith("C+"): + assert line.split()[1] != "/var/lib/k0s/kiosk", ( + f"whole-directory C+ rule would delete cert.pem/key.pem: {line!r}" + ) def test_proxy_injects_only_csp_safe_same_origin_assets() -> None: @@ -104,3 +130,37 @@ def test_proxy_is_the_only_public_console_endpoint() -> None: "nginx@sha256:62223d644fa234c3a1cc785ee14242ec47a77364226f1c811d2f669f96dc2ac8" in proxy ) + + +def test_k0s_kiosk_tls_service_contract() -> None: + assert KIOSK_TLS_SERVICE.is_file(), "k0s-kiosk-tls.service is missing" + content = KIOSK_TLS_SERVICE.read_text(encoding="utf-8") + + assert "Type=oneshot" in content + assert "Before=k0scontroller.service" in content + assert "RequiresMountsFor=/var/lib/k0s" in content + assert "After=network-online.target systemd-tmpfiles-setup.service" in content + assert "StateDirectory=k0s" in content + assert "chmod 0600 /var/lib/k0s/kiosk/key.pem" in content + assert "chmod 0644 /var/lib/k0s/kiosk/cert.pem" in content + assert "/CN=KubeStellar Console" in content + assert "DNS:localhost,DNS:*.local,IP:127.0.0.1" in content + assert "ip -o addr show scope global" in content + assert ( + "test -s /var/lib/k0s/kiosk/key.pem && " + "test -s /var/lib/k0s/kiosk/cert.pem && exit 0" + ) in content + + +def test_k0scontroller_orders_after_kiosk_tls() -> None: + assert K0S_CONTROLLER_SERVICE.is_file(), "k0scontroller.service is missing" + content = K0S_CONTROLLER_SERVICE.read_text(encoding="utf-8") + + assert "k0s-kiosk-tls.service" in content + assert "After=network-online.target k0s-kiosk-tls.service" in content + assert "Wants=network-online.target k0s-kiosk-tls.service" in content + + +def test_os_stack_includes_openssl() -> None: + content = OS_STACK.read_text(encoding="utf-8") + assert "freedesktop-sdk.bst:components/openssl.bst" in content