Repository navigation
fix(bootstrap): root CA with keyCertSign; tn-k3s-ca.sh setup, check and remediate - #7
Merged
Merged
Conversation
…nd remediate k3s v1.34.12/v1.35.9/v1.36.5/v1.37.1 and later (k3s-io/k3s#14699) drop CA bundle certificates without keyCertSign. server.sh.tmpl copied the device certificate (rsa_public.pem, never a CA with keyCertSign) verbatim to server/tls/root-ca.pem, so a new device hangs on 'CA cert validation failed' and an existing one bricks on a k3s upgrade. pkg/templates/scripts/tn-k3s-ca.sh (POSIX sh, embedded in server.sh.tmpl, also usable stand-alone): - tn_k3s_ca_setup: re-issues the device certificate as the root from the SAME key, subject and subject key identifier (CA:TRUE, keyCertSign, 20 years), then issues the intermediate and the five leaf CAs as before. k3s never started: PKI created (a valid one is kept). k3s started and the root is fine: nothing is touched. k3s started and the root is old: repaired with rotate-ca before the unpinned k3s installer runs (TN_K3S_CA_AUTO_REMEDIATE=false stops with advice instead). - check: exit 0 ok / 2 needs rotation / 3 needs regeneration / 4 foreign CA. - remediate [--phase prepare|rotate|finalize|all]: stages the new bundles, runs k3s certificate rotate-ca against the running server (works on a post-fix server that refuses the old root, via SSL_CERT_FILE), copies the files, restarts k3s. A datastore that k3s never initialised (hung fresh bootstrap: no server/token, rotate-ca fails with 'token must not be empty') has nothing to rotate: the PKI is regenerated in place. rotate-ca gets --force only when the old root cannot vouch for the chain (expired, or a keyUsage without keyCertSign); the new bundle is verified with openssl first. - Does not change shell options, traps or the working directory of the caller (the old inline block overwrote the script's EXIT trap and cd'ed into server/tls), works under set -e and noglob, CN capped at 64 characters (long device names broke 'openssl req' before). Tests: pkg/templates/k3sca_test.go (openssl, fake k3s), docs (troubleshooting, bootstrap), a CI workflow (unit tests on PRs, docker matrix on dispatch). The docker harness in test/k3s-ca runs the library against real k3s releases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI found it: on Debian/Ubuntu `info` is the GNU info reader. The library fell back to its own logging only if `command -v info` found nothing, so a stand-alone `sh tn-k3s-ca.sh check|remediate` on a machine with the info package printed "No menu item ..." instead of its messages. The library now logs with tn_ca_info/tn_ca_warn/tn_ca_fatal (same format as header.sh.tmpl). legacy-ca.sh (harness) asks for a function with `type` for the same reason. Every unit test now runs with a failing `info` binary first in PATH; a regression test covers the stand-alone and the setup path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
k3s v1.34.12, v1.35.9, v1.36.5, v1.37.1 and later (k3s-io/k3s#14699 + backport #14708, "Improve CA hash validation", CVE fix) prune every certificate without the
keyCertSignkey usage from the CA bundle.pkg/templates/server.sh.tmplcopied the device certificate (rsa_public.pemof the Device CR: Backstageopenssl req -x509without keyUsage, or the device-controller certificate withDigitalSignature|KeyEncipherment) verbatim toserver/tls/root-ca.pemand issued the intermediate and the five leaf CAs below it. Result:stablebecame v1.36.5 (2026-10-01) hangs:Failed to validate connection to cluster ... CA cert validation failed ... x509: certificate signed by unknown authority;Stacked on #6 (the harness); base branch is
test/k3s-ca-harness. Please merge #6 first, then retarget this PR tomain.Change
pkg/templates/scripts/tn-k3s-ca.sh(POSIX sh, ~630 lines): the CA logic as a library, embedded inserver.sh.tmpl({{template "tn-k3s-ca.sh" .}}+tn_k3s_ca_setup) and usable stand-alone (sh tn-k3s-ca.sh check|remediate).The fix (root). The device certificate is re-issued from the same key, subject and subject key identifier as a self-signed root with
CA:TRUE(critical),keyUsage = digitalSignature, keyEncipherment, keyCertSign, cRLSign(critical), no EKU, AKI == SKI, serial random, 7300 days. The device files in/etc/teknoirare never modified; everything signed below the old root stays valid (same key and name), which is why existing clusters can be repaired in place.tn_k3s_ca_setup(what the bootstrap script runs):server/token)rotate-ca(below), before the installer can upgrade k3s;TN_K3S_CA_AUTO_REMEDIATE=falsestops with advice; a failed repair stops the scriptcheck: exit 0 ok (also: an agent / no tls dir), 2 needs rotation, 3 needs regeneration, 4 foreign CA (the root is unusable but we do not hold its key), 1 error. Read-only.remediate [--phase prepare|rotate|finalize|all]preparebacks upserver/tls, stagesserver/.tn-ca-rotate-<ts>/(mode 700) with the bundles in which the old root is replaced by the new one, checks that every leaf CA verifies through the new root, and writes the exactROTATE_CMD;rotaterunsk3s certificate rotate-ca --path <stage>against the running server, withSSL_CERT_FILE= the server's own leaf CA so that a post-fix k3s that refuses the old root can still be reached (it only has to be up);finalizecopies the files only afterROTATEDexists;allalso restarts k3s. Safe to repeat (verified: prepare + rotate twice before finalize).server/token, sorotate-cafails withtoken must not be empty, and nothing was stored in the cluster that could need rotating. The PKI is regenerated in place (old files saved asserver/tls-backup-tn-<ts>.tar) and k3s is restarted. Re-running the fixed bootstrap script does the same. This replaces the design in which S7 was remediated withrotate-ca.rotate-caverifies the new bundle against the old chain, which Go refuses if the old root has expired or has a keyUsage withoutkeyCertSign(the device-controller certificates): first run of the full matrix failed exactly there ("certificate error ID ... new CA cert cannot be verified using old CA chain ... parent certificate cannot sign this kind of certificate"). For those rootsROTATE_CMDgets--force(and says so); the new bundle was verified with openssl first. A root without any keyUsage (Backstage certificates) is rotated without--force.rotate-cafails (nothing was changed): put the last pre-fix patch of the same minor in place (v1.34.11 / v1.35.8 / v1.36.4 / v1.37.0), remediate, upgrade again. Documented indocs/troubleshooting.mdand exercised asrem-s6-fallback.Library hygiene. Under
set -eandset -o noglob; does not set traps (the old inline block overwrote the script'sEXITtrap, so$TMPwas never cleaned) or change the working directory (itcd'ed intoserver/tls, so the installer was downloaded there); CNs are cut to 64 characters (long<device>.<domain>names failed withstring too long maxsize=64before). The agent and docker-server templates are unchanged: agents have no CA of their own.Also: docs (
docs/troubleshooting.md"Bootstrapping & k3s",docs/commands/bootstrap.md),.github/workflows/k3s-ca.yml(shellcheck +go teston PRs touching the library; the docker matrix onworkflow_dispatchonly),pkg/templates/k3sca_test.go.Evidence
Unit tests (
go test ./pkg/templates/, openssl 3.5 locally, fake k3s): setup produces a root that passes the k3s pruning rules and a chain that verifies; second setup keeps the PKI; initialised dir untouched; long device names;checkstates 0/2/3/4; prepare changes nothing on disk, stages a 700 dir,ROTATE_CMDwithout secrets; finalize refuses beforeROTATED; a failingrotate-caleaves the root as it was and names the fallback versions; full run with restart; idempotent second run;--forceonly for roots that cannot vouch; setup auto-repairs / opt-out / stops on a failed rotation; hung fresh bootstrap (prepare,all, re-run of setup); program mode vs. sourcing; the rendered server script has nocp rsa_public.pem ... root-ca.pem, passessh -n, runs the CA step before the installer.Docker harness against real
rancher/k3s(stacked #6 at740cc3d+ this PR atb33c6a3,test/k3s-ca/run.sh --suite all -j 4;stable= v1.36.5): 294 / 294 PASS, rc=0:tn_k3s_ca_setupx 5 generators x 5 versionscheck= 0, second remediate no-op; 3 generators--force), expired (--force)check= 0remediate --phase all; sourcedtn_k3s_ca_setuprepairing an old root, upgrade, no-op re-runReal generator output (backstage #350 / device-controller #40 heads) is READY with the old scripts and with this library on v1.34.11/v1.36.5/v1.37.1 (see #6). No
PRIVATE KEYin any log, no labelled container left,k3d-teknoir-devuntouched.Review fixes
CI on this PR found a bug in the first version, fixed in
b33c6a3: the library logged through the caller'sinfo/warn/fataland only defined its own ifcommand -v infofound nothing. On Debian/Ubuntuinfois the GNU info reader, so a stand-alonesh tn-k3s-ca.sh check|remediateprintedNo menu item ...instead of its messages, and the test fixtures broke on the runner. The library now logs with its owntn_ca_info/warn/fatal; every unit test runs with a failinginfobinary first inPATH, and a regression test fails against the old library. The whole matrix below was re-run on the fixed commit.This PR is the "core fix" the review found missing (WP2):
tn-k3s-ca.sh, theserver.sh.tmplchange and the check/remediate tool did not exist anywhere, so existing Device CRs still hung on v1.36.5, bootstrapped devices still bricked on an upgrade, and there was no check/remediate tool. The S7 stand-in result (token must not be empty) is handled as described above (regeneration, norotate-ca), and the docs match the behaviour implemented.Notes
docker_server.sh.tmplhas no CA code.vd1-templates-module(nestedpkg/templatesmodule) will needscripts/in its embed; trivial rebase.ab148da).tnctlrelease is needed for operators to get the fixed scripts.Re-review fixes (
ab148da)Three findings from the re-review, all reproduced first and fixed in
pkg/templates/scripts/tn-k3s-ca.sh:tn_ca_generate_pki). Every caller runs it in anif/||context, where POSIX shells (bash, dash) ignoreerrexiteven if it is set again in the subshell, so the subshell status was that of its last command,rm -rf .ca. A failingopenssl caleft a server/tls without intermediate or leaf certificates andtn_k3s_ca_setupreturned 0. Now every step ends in|| exit 1, the signing requests go through files instead ofreq | ca -in /dev/stdin(so a chroot without/dev/stdinworks, and a failingreqis not hidden by the pipeline status), and the newtn_ca_validate_pkichecks the generated directory (all 22 files present, bundle sizes 2/3, every key matches its certificate, intermediate and all five leaf CAs chain to the root) intn_ca_install_pkibefore the old server/tls is backed up, removed and replaced. A failure leaves the old directory and no backup behind.tn_k3s_ca_setup, initialised data dir). NewTN_CA_ROOT_USABLEfromtn_ca_state: 0 for a multi-certificate bundle whose root has no keyCertSign, or an expired root. Stateforeignwith an unusable root now callstn_ca_fatal("... k3s must not be upgraded. Nothing was changed."), so the unpinned installer is not reached. A foreign root that has keyCertSign and only expires within 90 days warns and continues.okandnot-a-serverare unchanged.checkexit codes are unchanged (checkis still 4 for foreign).tn_ca_reissue_root). OpenSSL 1.1.1 ignores-set_serialinx509 -in SRC -signkey KEY, so on Jetson Ubuntu 18.04/20.04 and Raspberry Pi bullseye the new root kept the serial of the device certificate (same issuer + serial for two certificates, RFC 5280 4.1.2.2; NSS rejects that). Nowx509 -x509toreq -in SRC -signkey KEYfollowed byx509 -req -signkey KEY -extfile root.cnf -extensions v3_root -set_serial 0x<rand> -days 7300 -sha256: subject DER, public key and SKI are unchanged, AKI = SKI, no EKU, and the serial is random on 1.1.1w, 1.1.1 (18.04) and 3.x. The library also warns if the serial still equals the original. The "serial random" wording in this description is now true on all supported OpenSSL versions.Tests added
pkg/templates/k3sca_test.go:TestSetupFailsWhenAnOpensslStepFails(wrapper openssl that fails the Nthgenrsa/req/ca/ecparamcall: 9 cases; setup must fail, no partial server/tls, no work dir left),TestRemediateKeepsTheOldPKIWhenGenerationFails,TestValidatePKI(5 corruptions),TestSetupStopsOnAnUnusableForeignRoot,TestSetupStopsOnAnExpiredForeignRoot,TestSetupContinuesOnAForeignRootThatOnlyExpiresSoon,TestReissuedRootHasANewSerial(Backstage and device-controller style certificates, setup and remediate). Against the previous library the failing-openssl, validate and foreign-root tests fail (13 cases); the serial test only fails on OpenSSL 1.1.1 (see the compat run below).test/k3s-ca/cmd/k3sverify:CheckRootfails when the root has the issuer and serial of the original certificate (unit test added);test/k3s-ca/compat/assert.shasserts root serial != original for setup (run 1 and 2) and remediation.Results (openssl 3.5.7 locally, docker for the rest)
go test ./pkg/templates/... ./test/k3s-ca/...: all packages ok, 39 top-level tests / 57 including subtests passing (go vet,shellcheck -s sh -S warningclean).run.sh --suite openssl-compat(11 distro/arch images incl. debian bullseye 1.1.1w, ubuntu 18.04 1.1.1, ubuntu 20.04 1.1.1f, arm64, arm/v7): 132 / 132 PASS. Negative control: the previous library on debian:bullseye and ubuntu:18.04 fails the new serial assertion on every generator (12 PASS / 24 FAIL over bullseye, 18.04 and bookworm).run.sh --suite fresh,remediation --versions "v1.34.11 v1.36.5" --gens bs-o111against realrancher/k3s: 26 / 26 PASS (fresh, remediation S5 with two helper images, bricked, fallback, hung).🤖 Generated with Claude Code
Final fixes (
eca0ea1)Reviewer should-fix items for a tool that runs on real devices, plus the approved k3s pin (Anders: "K3s can be pinned yes", "Cert param changes are ok").
tn_ca_resolve_datadir).check,remediateandtn_k3s_ca_setupno longer assume/var/lib/rancher/k3s. Order: explicitDATA_DIR/--data-dir;--data-dir,--data-dir=,-dof theExecStartofk3s.service(multi-line, quoted, drop-in resets); the lastdata-dirofconfig.yaml.d/*.yaml(lexical);data-dirofconfig.yaml;K3S_DATA_DIRof the unit; default. Deviation from the task text: it listed config.yaml before conf.d before the unit. I tested what k3s does (rancher/k3s v1.36.5, three containers): a command line flag beatsconfig.yaml.d, which beatsconfig.yaml(flag/var/lib/cli> drop-in/var/lib/drop> config/var/lib/cfg), so the library follows k3s, as the task intended ("the way k3s does").checkis never ok when a k3s server unit exists but the resolved dir has noserver/tls/server-ca.crt: stateno-server-data, exit 4, with the dir, where it came from and "pass --data-dir". Tests for each source (TestDataDirResolution, plus check/remediate/setup on a resolved dir).TN_K3S_STOP_CMD, elsesystemctl stopif the unit is active), backs up, moves the oldserver/tlsaside by rename, asserts the target is absent, moves the new dir in, validates the installed result withtn_ca_validate_pki, restores the old dir on any failure, then starts k3s (TN_K3S_START_CMD). A failed stop changes nothing. Tests: order of stop/replace/start, a failing validation of the installed dir (old dir restored, k3s started), a failing stop.rotate-ca --forcealso for an old root with nobasicConstraints,CA:FALSE, or not yet valid, and generally whenever openssl cannot verify the old chain against the old root while the new chain verifies (reason is logged). Four fixtures.remediatefinalizes a completed rotation instead of rotating again; a leftoverROTATEDstaging dir (key copies) is removed when the CA is ok; the newest 3tls-backup-tn-*.tarare kept;--phase/--data-dir/--config-dirwithout a value is a usage error (it used to loop); a k3s older than v1.24.12/v1.25.8/v1.26.3 (nocertificate rotate-ca) fails safe before anything is changed, with matching advice (upgrade stepwise to a release with rotate-ca that is older than v1.34.12). Rollback limits are documented in the library header anddocs/troubleshooting.md(the datastore is not backed up; restoring the tar after a rotation does not undo it).INSTALL_K3S_VERSION(defaultv1.36.5+k3s1, confirmed asstableon https://update.k3s.io/v1-release/channels today, and the release the harness proves).tnctl bootstrap --k3s-version vX.Y.Z+k3sNchanges it (validated, it ends up in a shell script),stablekeeps the old behaviour (nothing exported),latestsetsINSTALL_K3S_CHANNEL;INSTALL_K3S_VERSION=... sh scriptoverrides at run time. Server and agent are rendered from the same data, so they get the same version. Known limitation: a pin older than what a device runs makes the installer downgrade it (documented). The server script also writes a non-defaultDATA_DIRtoconfig.yamland puts the manifests there. Docs: README,docs/commands/bootstrap.md,docs/troubleshooting.md.Tests / results
go test ./pkg/templates/... ./test/k3s-ca/... ./cmd/...: ok (also with mawk asawk);go vet,shellcheck -s sh -S warningclean.run.sh --suite fresh,remediation --versions "v1.34.11 v1.36.5" --gens "bs-o111 go-sha1"against realrancher/k3s: 44 / 44 PASS (fresh 4, remediation 40), no labelled container left,k3d-teknoir-devuntouched.--forceprobes).Rebase note: cli PR #5 (
vd1-templates-module, the nestedpkg/templatesmodule) must be rebased on this branch:scripts/*.sh, the new partialpartials/k3s_version.sh.tmplandTemplateData.K3SVersionhave to survive the module move.bootstrap-device is re-vendored from this head (teknoir/bootstrap-device#11,
c014e40).