Skip to content

Three-zone PKI (Web TLS / Client Communication / Secure Boot) + install-time customization preservation - #1015

Open
darksidemilk wants to merge 61 commits into
working-1.6from
pki-three-zone-phase1
Open

Three-zone PKI (Web TLS / Client Communication / Secure Boot) + install-time customization preservation#1015
darksidemilk wants to merge 61 commits into
working-1.6from
pki-three-zone-phase1

Conversation

@darksidemilk

@darksidemilk darksidemilk commented Aug 8, 2026

Copy link
Copy Markdown
Member

Splits FOG's certificate handling into three independent zones (Web TLS,
Client Communication, Secure Boot) and moves customization preservation into
installfog.sh itself. Stacked: this branch includes
customization-preservation, because the PKI work depends on its vhost
managed-block.

Continuation notes: docs/superpowers/CONTEXT-1013-pki-and-customizations.md.

The finding that drove this

.srvprivate.key is both the web vhost's TLS private key and the key
FOGBase::certDecrypt() opens on every fog-client authorize() handshake.
Confirmed on a live server by modulus comparison — it pairs with
srvpublic.crt, not ca.cert.pem.

So today, overwriting that file — an ACME renewal with --key-file,
--recreate-keys, a purchased cert dropped in place — breaks client
authentication 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

FOG Server ROOT CA
├── FOG Web CA          → web server certificate (vhost)
├── FOG Server CA       → ca.cert.der (pinned by fog-client)
│   └── FOG Client Communication → the key certDecrypt() opens
└── FOG Secure Boot CA  → MOK.der (enrolled in firmware)
    └── FOG Project Secure Boot Signing → signs kernels, --addcert'd

Default on fresh installs. An existing server always resolves to
flat and is never switched automatically — switching one underneath a fleet
that pinned the old CA is the one thing this must not decide on its own.
--split-pki opts in, --legacy-pki opts a fresh install out; both fully
supported. Per-zone --web-ca-*/--client-ca-*/--root-ca-* let an admin
replace 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.sh
upgrade is protected too — previously only updatefog.sh did any of this.

  • Vhost written as a FOG-managed block; anything outside the markers
    survives, so admins keep security fixes and their own directives. Ends the
    all-or-nothing -F trade-off.
  • iPXE background keyed to the real FOG_IPXE_BG_FILE value, not "bg.png".
  • Kernels: previous release kept as bzImage.<release> beside the new one,
    plus bounded generations and bin/restorekernel.sh.
  • Kernel/init fields in the web UI are now dropdowns of what is on disk.
    Blank still means "inherit the default" and is never pre-selected.
  • Optional custom.ipxe hook so PXE customization does not mean editing a
    generated file.
  • Admin-supplied Secure Boot keys copied out of $webdirdest before the
    installer 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-pki reproduces the original flat
CA 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.der through MokManager, or written into db
through 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 zazzles

fog-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 Communication
intermediate — to the Windows Root store, rather than FOG Server ROOT CA.
Nothing is broken, but it is the wrong anchor and costs two things:

  • Rotation. Trusting an intermediate as an anchor means replacing it
    requires re-pushing trust to every client — exactly the cost the Secure Boot
    zone just removed by enrolling the issuer.
  • HTTPS by default. The client trusts only the Client zone, which does not
    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 change
for it: the root is already published in the chain, and ca.cert.der keeps
carrying the intermediate for existing pinning.

Still not verified

nginx. All vhost work was verified on Apache only; the managed-block
splice and the netbootproto redirect exclusion both have nginx branches that
have never executed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YE3jeTexYEcAxWFbZcZgpZ

darksidemilk and others added 30 commits August 6, 2026 19:54
…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
…f usage()'s exit 0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178sa2JC7gq3Py4bRkRapQW
…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
darksidemilk and others added 29 commits August 7, 2026 19:05
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant