Three-zone PKI (Web TLS / Client Communication / Secure Boot) + install-time customization preservation - #1015
Open
darksidemilk wants to merge 61 commits into
Open
Three-zone PKI (Web TLS / Client Communication / Secure Boot) + install-time customization preservation#1015darksidemilk wants to merge 61 commits into
darksidemilk wants to merge 61 commits into
Conversation
…y channel - updatefog.sh now defaults to -F/--no-vhost when re-invoking installfog.sh, since an update always has a pre-existing vhost that createSSLCA() would otherwise regenerate from scratch. --overwrite-vhost opts back in. - Fixed the nginx vhost write and Debian's /etc/default/tftpd-hpa write: both called diffconfig() without ever taking the mv -fv backup it needs to detect a change, so the existing "Changed configurations" warning (already correct for Apache's vhost and the cron reporting file) silently never fired for either. Confirmed php.ini/php-fpm/mariadb config are only ever touched via targeted sed on FOG's own known lines, not full overwrites, so they don't have this problem. - Added --branch <name> to check out an arbitrary branch for testing, independent of the tracked channel. - --channel now actually persists: it calls the existing writeUpdateFile() before touching git, instead of only affecting that one run. - Renamed channel values from stable/dev/beta to stable/staging/dev to match the README's Channel table (dev-branch=staging, working-1.6=dev) instead of colliding with it. - gitUpdateToChannel() -> gitUpdateToBranch(branch), since branch resolution now happens once in the caller for both the channel and --branch paths. Part of #1012. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
The doc claimed iPXE's CA is "compiled into the binaries at build time," implying it's coupled to fog-client's pinning the same way. It isn't: upstream iPXE's src/config/crypto.h unconditionally defines CROSSCERT="http://ca.ipxe.org/auto", a public-CA cross-signing fallback that FOG's own build never disables (the fog-ipxe config overlay only replaces general.h/settings.h/console.h) and that the republished Secure-Boot-signed binaries rely on exclusively (upstream's own release build passes no TRUST=/CERT= at all). So a real Let's Encrypt certificate on the web vhost already validates for iPXE's netboot fetches with no FOG-side change, independent of Secure Boot status -- fog-client's pinning is the actual constraint on public Let's Encrypt, not iPXE. Verified against pinned upstream sources (permalinks + References section added); outbound internet access to ca.ipxe.org is the common case here, not an edge case worth hedging the framing around. Part of #1013. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
Verified: _ensureSecureBootKeys() (lib/common/functions.sh) generates its own independent, self-signed codeSigning-only keypair (MOK.key/MOK.pem), never derived from or defaulting to .fogCA.key/.fogCA.pem. The two used to be conflatable back when FOG had only one CA doing everything; Secure Boot support split that out into its own key. Noting this in the doc so the same confusion doesn't recur. Part of #1013. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
Design for #1013's remaining scope now that the iPXE/CROSSCERT and Secure-Boot-key doc corrections are in: a non-interactive --hostname override, an additive --extra-server-name list, and a new setupacme.sh that automates leaf renewal against an already-imported --external-ca CA via acme.sh, scheduled through the same cron.d pattern setupFogReporting() already uses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
Gathered exact current line numbers/content post-rebase onto #1012 for every touched location in installfog.sh, updatefog.sh, and functions.sh. Corrects the design doc's cron.d assumption: acme.sh --install already sets up its own renewal cron job, so setupacme.sh doesn't add a second one. Part of #1013. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
…f usage()'s exit 0 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
… non-systemd reload fallback
…re actually populated
…sh exec bit, external-CA detection, updatefog.sh vhost flag, ACME domain defaulting, cert chain, install failure detection Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
The acmeLeaf gate skipped leaf regeneration unconditionally, but --recreate-keys/--recreate-ca regenerate the private key unconditionally -- combining either with an ACME-managed install reintroduced the exact cert/key mismatch the acmeLeaf marker exists to prevent. Falling through to the normal self-signed regeneration path when either flag is given restores a consistent pair, matching pre-acmeLeaf behavior for that specific case. Found in the final whole-branch review's fix-wave re-review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
…HOST claim Ad-hoc testing confirmed a real Let's Encrypt certificate on the vhost validates for iPXE netboot with no FOG-side change, as this doc already claimed. Records the two settings that were needed in practice (httpproto=https, FOG_WEB_HOST set to the FQDN). Also settles a long-standing suspicion that an FQDN FOG_WEB_HOST upsets the PHP CLI daemons. It does not: waitInterfaceReady() gates on in_array(FOG_WEB_HOST, self::$ips), but getIPAddress() builds that list as the detected IPs plus reverse-DNS names plus FOG_WEB_HOST itself, so the value is compared against a list it was just inserted into and the clause can never fail. Documents what can actually stall that loop instead (no detectable IPs, or no overlap with the storage-node IPs in the DB). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…tion Two spec+plan pairs for follow-on work discussed in #1014: Customization preservation moves backup/restore of admin customizations into installfog.sh itself so it protects every run, not only ones routed through updatefog.sh. Covers a FOG-managed vhost block (so security fixes to the template still land on a hand-edited vhost), setting-driven FOG_IPXE_BG_FILE handling, versioned kernel/init backups, an optional custom.ipxe hook, and closing a gap where an admin-supplied Secure Boot key living under $webdirdest is deleted by configureHttpd()'s own wipe. Three-zone PKI separation splits today's flat CA into a Root plus Web, Client Communication and Secure Boot intermediates. Records a finding from tracing the code: .srvprivate.key is the web vhost's TLS key AND the key certDecrypt() uses for every fog-client handshake, so replacing the web certificate breaks client authentication today. Secure Boot's enrolled MOK is currently the signing leaf itself, which is why rotating it needs a firmware trip to every machine; an intermediate fixes that. Both are plans only -- no implementation, and the PKI work is gated on three Phase 0 verifications that need a live server, real UEFI hardware, and the zazzles source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…IPXE_BG_FILE Two changes in one, because neither is useful alone. First, the protection now runs from installfog.sh's own sequence rather than only from bin/updatefog.sh. backupCustomizations() lived in update.sh, so a bare `./installfog.sh` upgrade -- how most people upgrade -- got none of it and silently lost customizations to configureHttpd()'s rm -rf $webdirdest. Second, it reads the actual FOG_IPXE_BG_FILE globalSettings value instead of the hardcoded string "bg.png". That setting exists precisely so an admin can rename the background, and the old list could not see a renamed file at all. The value is trimmed, NULL-guarded and basename'd before it reaches a cp destination, since it is admin-supplied and a path there would write outside the backup directory. Failure severity is split deliberately, because errorStat() exits the installer whenever $exitFail is unset -- every normal installfog.sh run. Losing an optional legacy refind blob only warns. Failing to save a background we positively identified aborts, on purpose and before configureHttpd() has destroyed anything, so the admin's file is still intact where it always was. The restore side never aborts: by then the tree is already rebuilt, and the files are still sitting in $customizationsDir to be copied back by hand, so it says that instead of stranding the install. Verified by running the real functions against a sandboxed tree across a simulated wipe: renamed background survives, refind restored, bzImage correctly left at the newly downloaded version, and the fresh-install case (globalSettings not yet created) resolves to "nothing customized" with no special-casing. Not yet run against a live FOG install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
--secure-boot-key/--secure-boot-cert are persisted to .fogsettings verbatim and _ensureSecureBootKeys() then trusts that path forever, but nothing ever copied the files anywhere. An admin who parks the pair under $webdirdest -- not unreasonable, it is where the enrolment kit gets published -- loses it to configureHttpd()'s rm -rf $webdirdest, in the same run that first accepted the flags and before _resignKernels() ever reads it. Copies the pair into $fogprogramdir/secureboot/ instead, which nothing in the installer deletes, and repoints the variables at the copy. Runs right after the existing pair validation, long before the web tree is rebuilt. The admin's original file is never modified. Named admin-MOK.* rather than MOK.*, correcting the plan, which specified the latter. MOK.key/MOK.pem hold FOG's OWN generated pair, which is deliberately never regenerated because every client that already enrolled it would be stranded; writing an admin key over that path would destroy it with no backup. The guard also skips outright when the configured path already resolves under $fogprogramdir/secureboot, so FOG's generated pair is never touched and the function is a no-op on every subsequent run. Verified by running the real function against a sandboxed tree with the pair parked inside $webdirdest and a pre-existing FOG-generated MOK: the admin key survives the wipe at its new path, FOG's MOK.key is bit-for-bit untouched, and a second invocation correctly does nothing. Not yet run against a live FOG install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
… file createSSLCA() regenerated $etcconf from scratch every run, so the only way to keep a hand-edited vhost was -F/--no-vhost, which then also skipped every future security fix FOG makes to the parts it owns. All or nothing, with no middle ground. It now writes only between two marker lines and leaves anything outside them untouched. spliceManagedBlock() handles three cases and deliberately no fourth: no file, write one; both markers present, replace between them; anything else -- no markers, or only one because a run died mid-write -- append a fresh block and touch nothing already there. Never guess at a partial patch. '#' comments in both nginx and Apache syntax, so the markers are inert in either. Implemented as a variable swap rather than the 261 individual edits the plan called for. beginManagedVhost points $etcconf at a scratch file and endManagedVhost splices it into the real one, so all 261 existing write sites are byte-for-byte untouched. 261 near-identical mechanical edits is exactly where a missed line hides, and a missed line writes half a vhost to the wrong path. Confirmed the 8 non-append readers of $etcconf each still get the path they need, and that nothing between begin and end can exit early and strand the variable on the scratch path. The nginx splice lands before nginx -t, which tests the real file. updatefog.sh's default flips from -F to regenerating, since the reason for that default no longer holds. --no-vhost is the new opt-out; --overwrite-vhost stays as a deprecated no-op so existing cron jobs do not die in getopt. --hostname/--extra-server-name still override an explicit --no-vhost, or the cert SAN and server_name would silently disagree about the server's name. Verified against a sandboxed vhost: admin content after the block survives an upgrade while FOG's own block picks up new directives, a marker-less file is appended to rather than overwritten, a half-corrupted one self-heals on the next run, the scratch file is cleaned up, and all four flag combinations resolve correctly. Not yet run against a live web server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
default.ipxe is regenerated in full on every run, so an admin wanting
site-specific pre-boot behaviour -- a boot delay, a prompt, a local menu --
had nowhere to put it that survives an update. The old answer was an
alternate boot file maintained by hand outside FOG entirely.
The generated file now starts with `chain custom.ipxe || goto fog_default`.
Absent, which is the overwhelming default, the chain fails, the || fires, and
boot proceeds byte-for-byte as before. Present, it runs before FOG's own
params/menu logic and then falls straight through into :fog_default, because
chain without --replace returns control to the following line once the
chained script ends normally. No resume convention to get wrong and no way to
loop back into default.ipxe.
The hook file needs no backup/restore machinery: it sits at the TFTP root,
which configureTFTPandPXE() only snapshots and copies into -- it never deletes
destination files that are absent from the source tree -- so it survives
updates structurally, the same way the Secure Boot keys do by living outside
$webdirdest.
Chosen over editing autoexec.ipxe, which is not FOG-authored: it ships in the
FOGProject/fog-ipxe release tarball and is replaced wholesale on every run.
default.ipxe is generated by this function, so it is the file this repo can
actually own.
Rendered output checked for correct escaping (iPXE's ${buildarch} etc. stay
literal while $ipaddress/$webroot expand). Real PXE boot verification, with
and without a custom.ipxe present, still needs hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
The intermediate model was only described against MokManager enrollment. FOG has a second route -- _publishSecureBootAuthVars() builds signed PK/KEK/db blobs so a client in Setup Mode enrolls unattended -- and the two are verified by different code: shim's own logic against MokList, and the UEFI firmware against db. fog-build-sb-authvars puts FOG's SIGNING certificate into db. If MOK.der became the intermediate while db kept the leaf, rotating a signing leaf would silently strand every Setup-Mode-enrolled client while continuing to work for MokManager-enrolled ones -- the worst kind of split, because it looks like it works. db has to carry the intermediate for the same reason MOK does. That is the standard UEFI model, not a workaround: Microsoft's own db entries are CAs and Windows validates by chaining to them. Root cause is the same dual-purpose pattern this design keeps turning up, now for the third time: SECUREBOOT_CERT in .fog-secureboot is read by fog-sign-kernel as the signing cert AND by fog-build-sb-authvars as a trust anchor. Adds a distinct SECUREBOOT_MOK_CERT for the anchor, with a fallback so an existing flat-mode .fog-secureboot keeps working. Also catches a gap in the earlier write-up: fog-sign-kernel is the sudo helper behind the web UI's Kernel Update page, a signing path entirely separate from _resignKernels(), and it needs --addcert too. Without it a kernel downloaded through the GUI is signed with no chain attached and fails to boot on exactly the clients this design exists to serve. Phase 0's hardware verification now covers both enrollment routes, including rotating a leaf after Setup Mode enrollment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…e.sh's copies
backupPreservedCustomizations() now snapshots the whole service/ipxe directory
into a rotated generation (gen-1..N, default 3, --kernel-backup-count) rather
than a fixed list of filenames. Snapshotting the directory rather than a list
is what makes a per-host custom kernel/init -- bootmenu.class.php's
Host->get('kernel')/get('init') -- survive an update without FOG ever having
to learn its name. cp -a carries the version/tag_name xattrs downloadfiles()
stamps, so each generation reports which FOS release it came from with no
manifest to drift.
On restore, anything NOT one of the six names downloadfiles() re-downloads is
put back unconditionally -- FOG did not put it there and will never put it
back. The six defaults are deliberately left alone, because the point of an
update is to pick up the new kernel. --restore-kernel-backup is the single
exception, passed by revertUpdate() when it re-runs the installer against the
previous commit: an older commit wants the older kernels. It is applied but
never persisted to .fogsettings, or every later update would silently roll
back.
bin/restorekernel.sh is the explicit way back: --list shows each generation
with its release tag and flags custom-named files, --generation N restores one
after a confirmation naming every file involved. Its own script rather than an
installfog.sh flag because it must be usable precisely when an update has left
an unbootable kernel and re-running the installer is what you do not want to
do. It re-signs afterwards when Secure Boot is configured, since a restored
kernel carries its old signature and the signing key may have rotated since.
update.sh loses _updateAssetFiles, backupCustomizations, restoreCustomizations
and _restorePreviousKernel entirely, and updatefog.sh loses both call sites --
all superseded. revertUpdate()'s careful restore-after-reinstall ordering goes
with them: installfog.sh now does its own backup and restore within each run,
so however that re-install attempt goes, it is the thing that puts the
customizations back.
Verified against a sandbox across four simulated updates: rotation bounds at
three generations and evicts the oldest, the live kernel stays newest, a
custom-named kernel survives every update, the revert flag rolls the default
names back to the previous release, and restorekernel.sh lists, validates and
restores correctly. Not yet run against a live FOG install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…IONS.md Every preservation mechanism added on this branch was invisible unless you read the installer source. This says plainly, per category, what is kept automatically, what you have to place yourself, and what is deliberately not kept -- including the awkward parts, so none of them are discovered the hard way: edits inside the FOG-managed vhost block are overwritten, direct edits to default.ipxe are regenerated, and generations past --kernel-backup-count are evicted. Cross-linked from installfog.sh's -F/--no-vhost and --kernel-backup-count help and from updatefog.sh's usage, since those are the flags where someone is most likely to be deciding what they are about to lose. Two claims corrected against the source while writing it: the pre-rebuild web tree snapshot lands under $backupPath (/home/ by default, -B to change), not the docroot; and the per-host override fields are labelled Host Kernel/Host Init. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
… the file First run against a real FOG server wiped a hand-added vhost block -- exactly what this feature exists to prevent. createSSLCA() runs `mv -fv $etcconf $etcconf.$timestamp` before generating, so diffconfig() has something to compare against. That happens BEFORE beginManagedVhost, so by the time spliceManagedBlock is called $etcconf does not exist: it took the "no file" branch and wrote a fresh single-block file, and the admin's content stayed behind in the timestamped backup. spliceManagedBlock now takes the prior-content path as a third argument and restores from it when the live file is missing. beginManagedVhost records $etcconf.$timestamp for that purpose. Every sandbox test passed because they all called spliceManagedBlock against a file that was still in place, so the branch that actually runs in production was never exercised. A test that does not reproduce the caller's sequence proves less than it appears to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…existed
Real-server run wrote every preserved customization to /customizations -- the
filesystem root -- instead of /opt/fog/customizations.
functions.sh is sourced by installfog.sh at line ~93, but $fogprogramdir is
not settled until config.sh runs several hundred lines later. A top-level
assignment therefore expanded to "${fogprogramdir}/customizations" with
fogprogramdir empty, giving "/customizations". The backup and restore both
worked, consistently, against the wrong directory.
Resolved on call instead, with an /opt/fog fallback matching what config.sh
would have set.
The sandbox missed it because every harness set fogprogramdir before sourcing
functions.sh, which is the opposite order from the real caller. Second bug in
this session found only by running against a real install, both from the same
root cause: the test reproduced the function, not the sequence that calls it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Committed as 100644 while every other script in bin/ is 100755, so on a real install it failed with "Permission denied" the first time it was invoked. core.filemode is false on the Windows checkout it was authored in, so the local chmod +x was never recorded and nothing locally could show the difference. Caught by running it on the dev server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…pdate The rule "anything that is not one of the six kernel/init names is a custom file" is wrong, and a real install proves it. service/ipxe also contains boot.php, advanced.php, bgdark.png, the .unsigned/.old kernel siblings and more -- all shipped by FOG. Under that rule every update copied the PREVIOUS version's boot.php back over the newly installed one, silently reverting FOG's own code while reporting success. Absence is the honest test. Restore a snapshotted file only when the completed install did not write a file of that name: if FOG shipped it, the new copy wins; if nothing wrote it, the admin put it there and nothing else will put it back. That still covers the case this exists for -- a per-host kernel/init override -- without touching anything FOG owns. --restore-kernel-backup keeps forcing the six default names back, since a revert to an older commit does want its older kernels. restorekernel.sh --list applied the same wrong rule and labelled a dozen FOG files "(custom)"; it now lists only what is genuinely absent from the live tree, which is exactly the set the restore will put back. Found by running --list against real kernel backups on the dev server. No sandbox would have caught it: it needed a real service/ipxe with FOG's actual file set in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Overwriting bzImage in place with a hand-built kernel is common, and it is the one case none of the other rules can resolve. It gets backed up like any other non-shipped file, but downloadfiles() re-downloads FOG's kernel over it and the restore deliberately leaves freshly-installed default names alone -- so it was silently replaced on every update. Neither silent outcome is acceptable. Keeping the custom kernel means never receiving a kernel update again; replacing it means losing it. The failure in both is the same: the admin does not find out. So the installer now detects the case and says so, leaving the choice where it belongs. The signal already exists: downloadfiles() stamps version/tag_name xattrs on everything it fetches, so a default-named kernel WITHOUT them was placed by hand. No hashing and no reference copy needed -- verified on a real server, where FOG's bzImage carries tag_name "20260806-111046" and hand-placed files carry nothing. An admin who copied with `cp -a` could drag a stale tag along and defeat it; plain cp/scp/mv, the normal way, does not. The file is never at risk either way -- it is in gen-1 before anything is replaced -- so the message names it and gives the exact restorekernel.sh commands to put it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…verwrite The previous commit's detector does not work, and testing on a real server showed exactly why: writing over bzImage in place -- `> bzImage`, dd, cp onto the existing path, which is how a custom kernel actually gets installed -- leaves the file's existing xattrs untouched. The hand-written kernel kept FOG's old version/tag_name and still reported 2 xattrs, so an absence-of-xattrs test saw nothing to report. The very case it was written for was the case it missed. downloadfiles() now also stamps a sha256 (_stampFogSum) on each kernel/init it fetches, and the check recomputes and compares it. Content is what changed, so comparing content works however the write was performed. Three-state on purpose: 0 matches, 1 modified, 2 nothing to compare against. An install whose kernels predate the stamp returns 2 and stays silent -- reporting a custom kernel on every existing server at its first upgrade would be noise, and the file is safely in gen-1 either way. Servers become detectable after one update has stamped them. Cannot be tested from the Windows dev box: attr is a Linux tool, so every local probe returns 2. Verified on the dev server instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…se positive Two fixes. The checksum detector reported bzImage32 and arm_Image as hand-installed on a server where nobody had touched them. _stampFogSum ran in downloadfiles' copy block, but _resignKernels rewrites each kernel in place afterwards, so the recorded checksum described a file that no longer existed. Stamping now happens after signing -- after everything that modifies the file has run. Second, the per-version sibling from the original brief, which the generation directories did not actually deliver. When a kernel is about to be replaced its current copy is kept alongside, named for the release it came from: bzImage.20260806-111046 next to bzImage. The generation directories remain the complete rotated history; this is the copy visible while looking at the boot directory, and the one a single host can be pointed at by name without restoring anything. Named per version rather than a single .prev so several updates accumulate, which is cheap next to the images this server already holds. Those siblings are excluded from the generational sweep -- they are already copies of a kernel, and snapshotting them into every generation would multiply the same bytes by the generation count for no added recoverability. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
The sibling was written into service/ipxe during the backup phase, which runs before configureHttpd() -- and configureHttpd() rm -rf's the entire web tree. Every sibling it created was deleted minutes later in the same run, which is why none ever appeared on disk. Built from the generation snapshot during the restore phase instead, after the tree has been rebuilt. The snapshot is the copy that survives the wipe, so it is the only thing that can source the sibling. Also skips writing one when the update did not actually change that kernel -- cmp against the freshly installed file first, since an identical sibling is pure duplication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Kernel and init were free-text fields, so choosing one meant knowing the exact filename and typing it correctly, with no indication of what was available. The installer now leaves the outgoing kernel behind as bzImage.<release> on every update, which only helps if selecting one is easy -- otherwise the versions accumulate somewhere nobody looks. FOGPage::kernelFileList() reads FOG_TFTP_PXE_KERNEL_DIR and splits by shape rather than a fixed name list, so custom kernels and the per-release siblings both appear. .unsigned copies are excluded -- they are _resignKernels() working files, not something to boot. Plain names sort above their versioned siblings. Applied to Host Kernel/Init, Group Kernel/Init, and the FOG_TFTP_PXE_KERNEL/ _32/_ARM/FOG_MEMTEST_KERNEL defaults, so rolling the default back to a previous release is now a selection instead of a typed guess. Two things it deliberately does not do. A stored value naming a file no longer on disk is kept in the list, selected, and marked "not found on disk" -- dropping it would silently rewrite a host's kernel to the default the moment anyone opened the form. And when the directory cannot be read it falls back to the original text input, so a server whose kernel directory has moved stays editable rather than showing an empty, unusable dropdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
"Anything that is not an init is a kernel" swept boot.php, advanced.php, index.php, the bg images and refind.conf into the kernel list -- 22 entries, most of which cannot be booted, which is not a menu anybody wants to pick a kernel out of. Excludes .php, image extensions, .conf and .efi alongside the existing .unsigned exclusion. What remains is the bootable set: the kernels, their per-release siblings, custom kernels, and memdisk/memtest.bin/grub.exe, which FOG_MEMTEST_KERNEL legitimately points at. Caught by rendering the helper against a real service/ipxe rather than reasoning about what lives there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
An empty kernel/init on a host or group means "inherit the global default",
so the blank option in the dropdown is load-bearing rather than filler: it has
to be present, first, and never pre-selected. It already was -- verified that a
host with no kernel set renders with nothing selected, so the browser picks the
blank entry and submits "", exactly as the old empty text field did, and the
save path is the unchanged ->set('kernel', $kernel).
What was wrong was the wording. "Please select an option" reads as though a
choice is required, which invites someone to pick a kernel on a host that was
deliberately inheriting -- pinning it to a specific version, silently, at the
next FOS release.
Callers now pass their own blank label, so hosts and groups say "Use the
default kernel"/"Use the default init". The global settings keep the generic
label, where blank does not mean inherit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…iates Builds the structure from the three-zone design, gated behind pkiMode=split and inert otherwise. A server that has not opted in runs the original flat code path unchanged and none of these directories are created or read. The Client Communication zone is the substance here. Today .srvprivate.key is simultaneously the web vhost's TLS private key and the key certDecrypt() opens to decrypt every fog-client authorize() handshake, so any legitimate replacement of the web certificate -- an ACME renewal, --recreate-keys, a purchased cert -- silently breaks client authentication. Split mode gives the zone its own CA (CN=$fogClientCACN, the cert published as ca.cert.der and pinned by the client) and its own communication leaf, whose private half is what certDecrypt() will read. srvpublic.crt, which is what fog-client fetches as the server's encryption certificate, becomes that comm leaf; the vhost's own certificate moves to the Web zone, out of the web-served directory. Two certificates with separate lifetimes where there was one file doing both jobs. Comm leaf is RSA 4096 to match what createSSLCA() already generates, because certDecrypt() chunks the ciphertext by modulus size -- a different key size changes the wire framing. The flat/split branch point is a single if in createSSLCA(). Both paths set the same three variables, so the CSR, SAN loop, leaf signing and vhost writer below are shared, unmodified code. Default deliberately left at flat, including for fresh installs, despite the design calling for split. Two of its assumptions are still unverified -- how fog-client obtains the server's encryption certificate, and whether shim accepts a CA in MokList with an --addcert chain -- and defaulting fresh installs to split would bet every new install on them. _resolvePkiMode carries a marked one-word change to make once Phase 0 has been run on real hardware. --external-ca with split mode refuses outright for now: validateExternalCA() is not zone-aware yet (plan Task 1.5) and would import to paths no zone reads. Better to refuse than to import somewhere wrong and let the admin find out when clients stop connecting. Verified by running the real functions: root, both intermediates and the comm leaf are generated with the right subjects and all three chains verify with openssl. pkiMode resolution checked across every persisted/caCreated combination -- an existing install always resolves to flat, an explicit split is always honoured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Wrapping acme.sh meant owning its failure modes, renewal scheduling and credential handling without doing anything certbot or acme.sh do not already do better. Running an ACME client is a solved problem; FOG should read the certificate it is given, not fetch it. EXTERNAL_CA_AND_LETSENCRYPT.md now shows the equivalent done directly, with the install hook pointed at the two paths FOG's vhost reads. Keeps the acmeLeaf marker, which setupacme.sh used to set and which now has to be set by hand in .fogsettings. It matters more without the wrapper than with it: without acmeLeaf, the next installfog.sh run regenerates the leaf from FOG's ORIGINAL CSR -- a stale public key -- while the private key on disk is the externally-issued one, producing a cert/key mismatch that stops the web server. That is now documented rather than being a side effect of having run a particular script. Also documents a trap the removal makes more visible: .srvprivate.key is currently both the vhost's private key and the key certDecrypt() uses for every fog-client handshake, so pointing an ACME client's --key-file at it breaks client authentication with a perfectly valid certificate. Until the split PKI lands, issue against FOG's existing key rather than replacing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
… tree FOG's consumers -- the vhost, sbsign, certDecrypt -- now reference a fixed canonical path that may itself be a symlink to wherever the admin keeps the real file: /etc/pki, /etc/letsencrypt/live, a mounted secret. Relocating a certificate stops meaning editing the vhost, which combined with the managed vhost block means it stops being a config change at all. Fixes a live bug while doing it. The four existing link lines were guarded inconsistently: the first two tested $sslpath/.fogCA.key while linking $sslpath/CA/.fogCA.key, so on a default install they reduced to `ln -sf X X`. GNU ln refuses that as "the same file", so every run logged an error into fog_error.log and the intended canonical link was never created. _linkCanonical returns early when both paths already resolve to the same file, so the default install is now silent. Also refuses to link to a source that does not exist, rather than leaving a dangling canonical path that later reads as "configured". Documents the two caveats that bite when actually relocating something: SELinux labels follow the symlink target, so a certificate outside the expected directories may need restorecon; and a private key relocated into a world-readable directory silently defeats the 0600 root:root separation the fog-sign-kernel sudo helper depends on. Verified: no spurious error on the same-file case, canonical path resolves to relocated content, no dangling link when the source is missing. Symlink semantics specifically are NOT verified -- Git Bash's ln -s copies rather than links, so that part needs a Linux box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…exists Without this the split PKI was internally inconsistent: the installer already publishes ca.cert.der from the Client CA and srvpublic.crt from the comm leaf, but certDecrypt() still opened .srvprivate.key -- the WEB vhost's key. A client would encrypt against the comm leaf's public half and the server would try to decrypt with an unrelated keypair, so authentication could not succeed in split mode at all. Detected from disk rather than from a setting. The comm leaf only exists when the installer built a split PKI, so its presence is itself the answer and there is no pkiMode value to plumb into PHP and keep in sync. Flat mode is provably untouched: that file never exists there, so the existing .srvprivate.key branch is what runs, unchanged. Confirmed the two sides agree on the path -- the installer POSTs its $sslpath into the storage node's sslpath column (create_update_node.php), which is the same column certDecrypt() resolves against, and the constructed path matches byte for byte in both modes. This is what makes replacing the web certificate safe on a split server: the key that decrypts client handshakes is no longer the key the web server uses. Not linted -- no PHP on this machine. Needs php -l and a real client handshake before it is trusted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…ndently Called with no argument it behaves exactly as before -- reads $extcacert/$extcakey/$extcaroot, imports to $sslpath/CA/.fogCA.*, sets sslcakey/sslcapem/sslcachain -- so every existing caller and every existing .fogsettings is unaffected. The zone argument adds web and client, each with its own source variables and its own destination directory. Two things this gets right that a naive parameterisation would not: The client zone does NOT set sslcakey/sslcapem/sslcachain. Those name the CA that signs the web vhost's leaf, so importing a client CA would otherwise silently repoint web certificate signing at it -- the exact conflation this whole design exists to remove, reintroduced through the back door. The client zone warns on a CN mismatch instead of refusing. fog-client is understood to require an exact CN on the certificate it pins, but that is unverified against the zazzles source, and an admin deliberately testing whether it matters must be able to proceed. Refusing would make the question unanswerable. Replaces the temporary refusal of --external-ca under split mode: it now targets the Web zone, which is what it has always effectively meant. $sslpubcert is repointed for both web paths, generated and imported, since the vhost's leaf leaves the web-served directory in split mode regardless of who signed it. Verified against throwaway CAs: flat imports to the historic path, web and client import to their own directories, the client import leaves sslcapem untouched, and a wrong-CN client CA warns and continues rather than aborting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
--split-pki opts into the three-zone layout, --legacy-pki keeps the single self-signed CA. Both are explicit and both are supported: legacy is a real choice for anyone who does not want the extra structure, not a deprecation path. Until Phase 0 is verified --legacy-pki is also the default, so it currently only matters as documentation of intent. Per-zone import flags let an admin replace one zone and keep FOG's own for the others: --web-ca-* for the certificate the vhost serves, --client-ca-* for the certificate fog-client pins, --root-ca-* to supply the anchor itself. --client-ca-cn overrides the CN fog-client is expected to require, which exists because that requirement is unverified -- if it turns out to be a different string, this is the flag that unblocks an admin without a code change. Supplying any --web-ca-* file implies --external-ca, matching what --ca-cert has always effectively done. Without it, handing the installer CA files and getting a working install signed by the wrong CA, with nothing printed to explain it, is far too easy. Staging vars are applied after .fogsettings is sourced, so precedence runs flag > persisted value > the caCreated-based default in _resolvePkiMode. Verified: every flag and combination parses to the expected staging values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
iPXE can only validate a chain terminating in a public root, via its ca.ipxe.org cross-signing fallback. A FOG-PKI certificate is fine for browsers, the API and fog-client -- all of which can be told to trust the root -- but iPXE cannot be told anything, so HTTPS netboot against a private CA simply fails. The historic answer was rebuilding iPXE with TRUST=, which works and costs the signed Secure Boot shim, since a locally rebuilt binary is not the signed one. Separating the two protocols avoids that trade: the web UI gets real HTTPS while netboot fetches stay on HTTP -- the same exposure a default HTTP install already has, on a pre-boot network. Cheap to implement because FOGBase::$httpproto is derived from the current request's $_SERVER['HTTPS'], not from a stored setting. Every boot-menu URL bootmenu.class.php emits inherits the protocol iPXE actually connected with, so chaining over HTTP in default.ipxe makes the whole boot sequence HTTP with no PHP change at all. The two things that did need changing are the chain line and the HTTP->HTTPS redirect, which otherwise drags iPXE straight back onto the HTTPS it cannot validate -- an exclusion for service/ipxe/ is emitted in both the nginx and Apache branches, and only when the protocols actually differ, so an unaffected install's vhost is unchanged. Keyed on split mode alone, deliberately, not on "is this CA private". A flat install with --external-ca is also using a private CA and would also benefit, but it has had HTTPS netboot with a TRUST=-rebuilt iPXE for as long as that flag has existed, and silently dropping it to HTTP on the next update would break a working setup to fix a problem its admin does not have. Split mode is opt-in, so nobody lands there by accident; --netboot-proto lets anyone else choose it deliberately. Verified across five scenarios: plain HTTP, HTTPS+flat, HTTPS+split, HTTPS+external-CA and an explicit override. Every non-split path resolves exactly as it does today. The vhost redirect exclusions still need testing on real Apache and nginx. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Covers the three zones and why they were separated, choosing a layout, bringing your own CA per zone, the canonical-path indirection, the self-service ACME story, and the netboot protocol table. Leads with the finding that motivated the whole thing, because it is the part most likely to be rediscovered the hard way: .srvprivate.key is the web server's TLS key AND the key certDecrypt() opens on every fog-client handshake, so replacing the web certificate breaks client authentication with a valid certificate installed and nothing in the logs connecting the two. The Secure Boot MOK has the same shape -- an enrolled leaf that cannot issue -- which is why rotating it costs a firmware trip per machine. States current status plainly rather than describing the design as if it were all shipped: what is implemented, what is not, and that the default is still legacy because two assumptions about software outside this repo are unverified -- how fog-client obtains the server's encryption certificate, and whether shim accepts a CA in MokList with an --addcert chain. Cross-linked from EXTERNAL_CA_AND_LETSENCRYPT.md, whose "How FOG uses certificates" table describes the single-CA layout and now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…cert"
Verified on a live FOG server -- .srvprivate.key's modulus matches
srvpublic.crt, not ca.cert.pem, so certDecrypt() really is opening the web
vhost's TLS private key on every fog-client handshake.
But the earlier wording ("replacing the web certificate breaks client
authentication") was too broad, and the imprecision pointed at the wrong
workaround. The coupling is to that specific path:
- Pointing SSLCertificateFile/SSLCertificateKeyFile at your own certificate
somewhere else is SAFE. FOG's key is untouched and clients keep working.
This is what an admin actually wants to do, and the managed vhost block is
what preserves those directives across upgrades.
- Overwriting .srvprivate.key in place breaks authentication -- acme.sh
--install-cert --key-file aimed at it, certbot writing over it,
--recreate-keys.
The ACME example was itself demonstrating the unsafe form, writing directly
over FOG's paths; it now writes to /etc/pki/fog and says to point the vhost
there instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Verified end to end on a real server first -- uninstalled, purged the CA, and installed fresh: root and both intermediates issue correctly, all four chains verify, ca.cert.der publishes the Client CA while the vhost serves the web leaf, and the key certDecrypt() opens is provably a different keypair from the web server's. That last point is the entire reason the split exists: replacing the web certificate can no longer break client authentication. An existing server still keeps whatever layout it has. Switching one underneath a fleet that pinned the old CA is the one thing this must never decide on its own; --split-pki is how an admin asks for it. --legacy-pki opts a fresh install back to the single self-signed CA, which stays fully supported. Two things remain unverified and neither is reached by what ships here. No real fog-client has been observed against a split server -- the comm certificate is published at srvpublic.crt, the path the client has always fetched, so no client change is expected, but expected is not observed. And shim's acceptance of a CA in MokList only matters for the Secure Boot intermediate, which is not implemented: Secure Boot still uses its existing self-signed key, untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…mediate The flat model enrols the SIGNING certificate itself -- a self-signed leaf that can issue nothing -- which makes the thing you must never change and the thing you want to rotate the same object. Replacing the signing key means a physical MokManager trip to every machine, and a storage node cannot sign at all without being handed the one key the entire fleet trusts. Split mode issues a FOG Secure Boot CA from the root and a short-lived leaf from that. Firmware enrols the intermediate, so the leaf can be rotated, revoked, or issued per node and the fleet keeps booting. Done now, before the stable release ships Secure Boot, because until a release has been out there is no enrolled fleet to strand. After that this same change costs a firmware trip to every machine that enrolled the old key. This is the last cheap moment to get the shape right. The change is really about splitting one overloaded variable into two: secureBootKey/secureBootCert stay the SIGNER, and secureBootMokCert names what endpoints TRUST. sbsign --addcert ships the intermediate inside the signature so shim can build the chain; MOK.der publishes it; and fog-build-sb-authvars puts it in db rather than the signer, or a rotated leaf would strand every Setup-Mode-enrolled client while MokManager-enrolled ones kept working -- the worst kind of split, because it looks like it works. fog-sign-kernel gets --addcert too: it is the sudo helper behind the web UI's Kernel Update page, a signing path entirely separate from _resignKernels. In flat mode secureBootMokCert is the same file as secureBootCert, every command line is byte-identical to before, and the readconf fallbacks mean an existing .fog-secureboot without SECUREBOOT_MOK_CERT behaves exactly as it does today. A server that has ever generated a MOK keeps using it even under --split-pki, since a machine may already have enrolled it. NOT VERIFIED: whether shim accepts a CA in MokList with an --addcert chain, and the same question for firmware validating db. That needs real UEFI hardware, which is not available here. If it fails, the fix is to enrol the leaf instead -- one variable, not a redesign. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
A fresh split install produced the old self-signed MOK, not the intermediate. downloadfiles() reaches _ensureSecureBootKeys() BEFORE createSSLCA() runs, so $pkiMode was still empty when the split branch was tested and it fell through to the flat path -- visible in the install output, where the Secure Boot lines print above "Creating FOG Server ROOT CA". $sslpath had the same problem one step further in: createSSLCA() is where it normally gets its default, so createRootCA() called from the Secure Boot path would have written the root CA to "/CA/root" at the filesystem root -- the same shape as the customizationsDir bug earlier in this branch. Both are now resolved by idempotent helpers that either caller can invoke, rather than assuming createSSLCA() got there first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
PKI_ZONES.md gains the Secure Boot section: what the intermediate buys, the sbverify output proving a signed kernel carries both certificates, and the honest limits -- shim has never been asked to boot one of these kernels, and efitools is not packaged for RHEL 9 (not even EPEL) so the db/Setup-Mode path has not been exercised at all. CONTEXT-1013-pki-and-customizations.md is written for whoever picks this up next: branch layout, what is verified against a real server versus what only looks right, the timing argument for landing Secure Boot before the stable release, and the ordering bugs that cost the most time here -- every one of which came from a harness that reproduced a function without reproducing the sequence that calls it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Stated as "not packaged for RHEL 9", which is too strong: it installs normally on Rocky 9. What was actually observed is narrower -- unavailable on the CentOS Stream 9 test box with EPEL and CRB both enabled, and nothing else there provides sign-efi-sig-list/cert-to-efi-sig-list. The consequence for this work is unchanged and is the part that matters: the db/Setup-Mode path is untested, because the one machine available could not install the tooling that exercises it. Verify on Rocky 9 or Debian/Ubuntu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Three data points now, and they disagree in a way worth recording rather than smoothing over: the CentOS Stream 9 test box cannot find it with EPEL and CRB both enabled; the upstream RPM tracker lists Fedora branches only, with no EL9/EPEL rows; and it is nonetheless installed and working on at least one Rocky 9 FOG server, source unestablished. The useful conclusion is not "it works on Rocky" or "it is missing on RHEL" but that its presence on EL9 cannot be assumed by the installer -- which is what the code already does, skipping the auth-var build with a warning. Adds the rpm -q query to identify where a working copy came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Built the three efitools userspace tools on the test box and re-ran a fresh install. The auth-var path executes for the first time: PK.auth, KEK.auth and db.auth are produced, and db.auth embeds CN=FOG Secure Boot CA -- the intermediate -- beside Microsoft's CAs, with the signing leaf's CN absent. That closes the last open question about the Secure Boot restructure that could be answered without UEFI hardware. Leaf rotation is now known safe for Setup-Mode-enrolled clients as well as MokManager-enrolled ones, rather than assumed safe. Had db kept the signer, rotating a leaf would have stranded exactly the clients that enrolled unattended, while the MokManager path kept working -- a split that looks like success. Documents the build recipe, including that gnu-efi-devel is required even for the userspace tools because they include efi.h. The EFI binaries are not needed and are not built. Still open and still needing hardware: whether shim actually chains a leaf-signed kernel to an enrolled CA, and the firmware-side equivalent for db. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…he others Web, Client and Root each had per-zone import flags; Secure Boot did not. An admin supplying their own could only give --secure-boot-key/--secure-boot-cert, which made the enrolled certificate the same as the signing one -- the flat model again, and precisely the coupling the intermediate exists to break. There was no way to say "enrol this CA, sign with this leaf issued from it". --secureboot-ca-cert names the certificate endpoints enrol. Paired with the existing leaf flags it expresses the drop-in an enterprise actually wants: mint a Secure Boot intermediate in AD CS or step-ca, enrol its public cert on the fleet, keep its private key off the FOG server entirely, and hand FOG only a short-lived code-signing leaf -- which can then be rotated without anyone touching firmware. Persisted in managedKeys so it survives upgrades and a rotated leaf keeps pointing at the same enrolled CA. Supplying only the leaf pair still enrols that certificate, exactly as before. All four zones are now independently replaceable, which was the point of separating them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
createRootCA short-circuited on "key AND cert both present". Moving the root key to a vault -- the end state this design explicitly recommends -- leaves the cert and removes the key, so that test failed and the next ordinary update minted a brand new root, orphaning every intermediate beneath it and every client that pinned anything under them. Silently. The CERTIFICATE is what defines that a root exists. An absent key is not a missing root, it is an offline one. Issuing still needs the key, but only for a NEW intermediate -- existing ones short-circuit without touching it, which is what makes an offline root practical day to day. When one genuinely must be issued, _issueIntermediateCA now says which file to restore and that it can be removed again afterwards, rather than failing inside openssl with an unreadable-file error. Documents the manual procedure, including that .fogRootCA.pem stays put. The export helper is still Phase 3; this at least makes doing it by hand safe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Machines boot FOG's leaf-signed kernels while trusting only the intermediate -- whether that intermediate is enrolled as MOK.der through MokManager, or written into db through the Setup Mode PK/KEK/db path. That settles the one question this restructure rested on and that no amount of local testing could answer: whether firmware and shim accept a chain terminating at the enrolled CA, or demand the exact signing certificate. Both accept the chain. A signing leaf can therefore be rotated, revoked, or issued per storage node with no firmware trip to any machine, which is the entire reason for enrolling the issuer instead of the signer. Also closes PXE boot by implication -- those machines netbooted to run the kernels they then validated. Remaining unverified: no real fog-client has authenticated against a split server, and nginx has never executed the vhost splice or the netbootproto redirect exclusion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
…low-up fog-client authenticates correctly against a split-mode server. It fetches the comm certificate from the path it always has, so the separation needed no client-side change -- which was the hoped-for outcome and the last significant unknown in this branch. One thing it does is worth acting on, in zazzles rather than here: at install it adds FOG Server CA -- the Client Communication INTERMEDIATE -- to the Windows Root store instead of FOG Server ROOT CA. Nothing is broken, but it is the wrong anchor and it costs two things. Rotation, first: trusting an intermediate as an anchor means replacing that intermediate requires re-pushing trust to every client, which is precisely the cost the Secure Boot zone just eliminated by enrolling the issuer. Trusting the root would give the Client zone the same freedom. HTTPS by default, second: the client trusts only the Client zone's intermediate, and that intermediate does not sign the web certificate -- the Web CA does. So the web certificate is untrusted and HTTPS cannot be turned on by default. If the client trusted the root, every zone beneath it would validate and an all-FOG-PKI install could enable HTTPS out of the box. That is a real payoff and it falls out of the zone structure rather than needing anything new. Nothing in this repo changes for it: the root is already published in the chain, and ca.cert.der keeps carrying the intermediate for the existing pinning behaviour. Also drops a stale paragraph still describing Secure Boot as unimplemented and unverified. nginx is now the only untested item on this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Adding FOG Server ROOT CA to the Windows trust store by hand makes HTTPS to the FOG web UI validate. That moves the root-trust follow-up from theory to proven mechanics: the only thing standing between here and HTTPS-on-by-default for an all-FOG-PKI install is which certificate fog-client installs at setup. It also demonstrates the zone structure doing what it was built for -- one trust anchor at the root validating every zone beneath it, rather than a separate arrangement per consumer. Still nothing to change in this repo: the root is already published in the chain, and ca.cert.der continues to carry the intermediate so existing client pinning is unaffected. The change is a zazzles one and can land independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
Recorded, not acted on, at the reporter's request. The kernel/init dropdown still lists non-kernels -- memdisk, grub.exe and memtest.bin appear as Host/Group Kernel choices. The current filter excludes by extension (.php, images, .conf, .efi, .unsigned) and calls everything else a kernel, which was a narrowing of a worse bug rather than a correct rule. The better shape is the one backupPreservedCustomizations() already uses: subtract what FOG ships in packages/web/service/ipxe, which drops all three automatically while keeping custom kernels of any name. FOG_MEMTEST_KERNEL needs its own list, since memtest.bin is a legitimate value there. And a dropdown is still wanted where the default kernel/init is selected. It was applied to the FOG_TFTP_PXE_KERNEL settings on the Configuration page, so the field meant needs identifying before anything changes -- possibly a different surface, and note there is no global default *init* setting at all today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ
5 tasks
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.
Splits FOG's certificate handling into three independent zones (Web TLS,
Client Communication, Secure Boot) and moves customization preservation into
installfog.shitself. Stacked: this branch includescustomization-preservation, because the PKI work depends on its vhostmanaged-block.
Continuation notes:
docs/superpowers/CONTEXT-1013-pki-and-customizations.md.The finding that drove this
.srvprivate.keyis both the web vhost's TLS private key and the keyFOGBase::certDecrypt()opens on every fog-clientauthorize()handshake.Confirmed on a live server by modulus comparison — it pairs with
srvpublic.crt, notca.cert.pem.So today, overwriting that file — an ACME renewal with
--key-file,--recreate-keys, a purchased cert dropped in place — breaks clientauthentication while installing a perfectly valid certificate, with nothing in
the logs connecting the two. Pointing the vhost at a different file is safe;
overwriting FOG's is not. This is a live bug independent of the redesign.
PKI
Default on fresh installs. An existing server always resolves to
flatand is never switched automatically — switching one underneath a fleetthat pinned the old CA is the one thing this must not decide on its own.
--split-pkiopts in,--legacy-pkiopts a fresh install out; both fullysupported. Per-zone
--web-ca-*/--client-ca-*/--root-ca-*let an adminreplace any single zone.
Secure Boot now enrols the issuer rather than the signer, so signing
leaves can be rotated, revoked, or issued per storage node without a firmware
trip. Landing it before Secure Boot ships in a stable release is deliberate:
with no release out there is no enrolled fleet, so the restructure is free
now and costs a MokManager visit to every machine later.
Also removes
bin/setupacme.sh— FOG should read the certificate it is given,not run an ACME client.
Customization preservation
Backup/restore moved into
installfog.sh, so a bare./installfog.shupgrade is protected too — previously only
updatefog.shdid any of this.survives, so admins keep security fixes and their own directives. Ends the
all-or-nothing
-Ftrade-off.FOG_IPXE_BG_FILEvalue, not"bg.png".bzImage.<release>beside the new one,plus bounded generations and
bin/restorekernel.sh.Blank still means "inherit the default" and is never pre-selected.
custom.ipxehook so PXE customization does not mean editing agenerated file.
$webdirdestbefore theinstaller deletes it.
Verified on a real server
CentOS Stream 9 / Apache, across repeated installs and a full
uninstall→purge→fresh cycle: all chains verify; comm key and vhost key are
provably different keypairs; a signed kernel carries both the leaf and the
intermediate (
sbverify --list);--legacy-pkireproduces the original flatCA exactly; an existing install's CA is byte-identical after upgrade; custom
vhost block, renamed background and custom kernels all survive; FOG's own
shipped files are never reverted.
Eleven bugs were found this way and none by sandbox testing — three would
have shipped as silent data loss. The recurring cause was harnesses that
reproduced a function without reproducing the sequence that calls it.
Verified on real UEFI hardware
Secure Boot works via both enrolment routes with only the intermediate
trusted — enrolled as
MOK.derthrough MokManager, or written intodbthrough the Setup Mode PK/KEK/db path. Machines boot FOG's leaf-signed
kernels.
That settles the question this restructure rested on and that no local testing
could answer: firmware and shim accept a chain terminating at the enrolled CA
rather than demanding the exact signer. A signing leaf can therefore be
rotated, revoked, or issued per storage node with no firmware trip — which is
the entire reason for enrolling the issuer instead of the signer. It also
exercises PXE boot by implication.
fog-client confirmed, with one follow-up for
zazzlesfog-client authenticates correctly against a split server — it fetches the
comm certificate from the path it always has, so the separation needed no
client-side change.
At install it does add
FOG Server CA— the Client Communicationintermediate — to the Windows Root store, rather than
FOG Server ROOT CA.Nothing is broken, but it is the wrong anchor and costs two things:
requires re-pushing trust to every client — exactly the cost the Secure Boot
zone just removed by enrolling the issuer.
sign the web certificate (the Web CA does), so the web certificate is
untrusted. If the client trusted the root, every zone beneath it would
validate and an all-FOG-PKI install could enable HTTPS out of the box.
Confirmed by adding the root to the Windows trust store by hand -- HTTPS
then works, so the mechanics are proven and only the client-side choice of
certificate remains.
That change belongs in
zazzles, not this PR. Nothing here needs to changefor it: the root is already published in the chain, and
ca.cert.derkeepscarrying the intermediate for existing pinning.
Still not verified
nginx. All vhost work was verified on Apache only; the managed-block
splice and the
netbootprotoredirect exclusion both have nginx branches thathave never executed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ