Files
diskless-workstation/serving/dot1x-bench
Audiobuzz a89c3d2af1 fleet-manager: fix CA install, add register-host, surface agent status
Four fixes from the on-host agent bring-up:

1. Bake the real fleet CA into the image. New `fleet_ca` build var (default =
   the committed placeholder) file-provisions the trust anchor to
   /etc/dot1x/radius-ca.pem — used by wpa_supplicant (RADIUS) AND the fleet-agent
   (verify the manager). CI materializes a FLEET_CA secret to a runner-temp file
   and passes -var fleet_ca; unset => placeholder (lab). No CA material committed.

2. server-pki.sh installs the agent CLIENT CA as root+intermediate (not root
   only). Host TPM certs are leaf-only in NV and signed by the step-ca
   intermediate, so a root-only client CA makes the manager advertise only the
   root in its CertificateRequest -> Go clients send no cert ("tls: certificate
   required") and it couldn't verify a leaf-only cert anyway. bootstrap.sh now
   exports intermediate_ca.crt so FLEET_CA_INTERMEDIATE resolves.

3. New `fleet-manager register-host <serial> [-name] [-hostnqn]` subcommand:
   seeds/updates an inventory record for a host enrolled out of band (bench
   broker, migrated fleet) through the real store, so its agent stream is
   accepted. The manual counterpart to the authorise-approve flow.

4. The agent surfaces the authoritative recv-side gRPC status instead of the
   bare io.EOF gRPC returns on the send side when the server ends the stream —
   so "host is not enrolled" (PermissionDenied) shows up instead of "EOF".
2026-07-10 05:51:57 +00:00
..
2026-06-22 20:50:48 +00:00
2026-06-22 20:50:48 +00:00
2026-06-28 10:33:14 +00:00
2026-06-22 20:50:48 +00:00

802.1X EAP-TLS + MACsec secure-path bench

Bench harness for the secure path (disable_dot1x=false): wired 802.1X EAP-TLS with a TPM-held client key, link encryption via MACsec/MKA, then DHCP + NVMe/TCP root over the encrypted link. This proves the half of the system the open-port (rd.dot1x=0) lab build deliberately skips. Tracks TODO #8 / SSP residual risk R-01.

Status: scaffold. Configs marked BENCH-VERIFY below are best-effort and need confirming on your wpa_supplicant / hostapd / tpm2 builds (see CLAUDE.md "Known bench-verify items"). This is a lab rig, expected to be iterated.

The three components

Component Runs on What it does
step-ca (step-ca/) serving host the fleet CA — signs each host's TPM-generated CSR, issues the RADIUS server cert, and is the trust anchor baked into the image as radius-ca.pem.
FreeRADIUS (freeradius/) serving host terminates EAP-TLS, validates the client cert against step-ca's root, returns the MSK so MKA can key MACsec.
authenticator (authenticator/) a node between each workstation and the serving network hostapd wired 802.1X + MACsec; bridges the decrypted link to the serving VLAN. One per-workstation P2P link.

The CA + RADIUS sit on the trusted side (the serving host). The authenticator is the only thing on the untrusted link with each workstation. Workstations never reach DHCP/NVMe except through an authenticated, MACsec-encrypted link.

Why a separate isolated-bridge topology (not your flat VLAN)

802.1X EAPOL (01:80:c2:00:00:03) is a reserved multicast that switches/bridges drop by default, and MACsec is point-to-point per link — so a single shared bridge can't authenticate or encrypt per host, and if the serving host shares that bridge there's no authenticator in the path at all. Keep the flat VLAN for the open-port build; build this sandwich alongside it.

 VM-A (supplicant) ──[vmbr-secA]── eth1 ┐
                                         │  authenticator node
 VM-B (supplicant) ──[vmbr-secB]── eth2 ┤  (hostapd-wired + MACsec;
                                         │   bridges macsecN ↔ eth0)
   820 ───[inline box / MACsec switch]── ┘
                                    eth0 │
                       [vmbr-serve]──────┴── serving host (step-ca, FreeRADIUS,
                                                            dnsmasq, NVMe/TCP)

Per supplicant-facing bridge, EAPOL must be allowed through:

echo 8 > /sys/class/net/<bridge>/bridge/group_fwd_mask   # forward 01:80:c2:00:00:03

Proxmox commands (VM legs)

# One isolated bridge per workstation link (vmbr-secA, vmbr-secB) — see your
# /etc/network/interfaces or the Proxmox UI. Then, after each is up:
for b in vmbr-secA vmbr-secB; do echo 8 > /sys/class/net/$b/bridge/group_fwd_mask; done

# Attach the workstation VM's SECURE nic to its isolated bridge:
qm set <vmid-A> --net1 virtio,bridge=vmbr-secA
qm set <vmid-B> --net1 virtio,bridge=vmbr-secB
# Authenticator VM: one nic per workstation link + one on the serving bridge:
qm set <vmid-auth> --net1 virtio,bridge=vmbr-secA \
                   --net2 virtio,bridge=vmbr-secB \
                   --net0 virtio,bridge=vmbr-serve
# swtpm for each workstation VM (Proxmox vTPM):
qm set <vmid-A> --tpmstate0 <storage>:1,version=v2.0

Run order

  1. Bring up the CA + RADIUS on the serving host:

    cd serving/dot1x-bench
    docker compose up -d step-ca
    ./step-ca/bootstrap.sh          # init CA, make a provisioner, issue the RADIUS cert
    docker compose up -d freeradius
    

    bootstrap.sh writes the root to pki/root_ca.crt — copy it to files/etc/dot1x/radius-ca.pem for the image build (this is what the supplicant uses to trust the RADIUS server).

  2. Build a secure-path image — DISABLE_DOT1X=false (CI var or -var disable_dot1x=false), with radius-ca.pem = step-ca root and a nvmet_traddr that resolves over the serving VLAN.

  3. Enrol each workstation's TPM (one-shot, on a provisioning network that can reach step-ca) — see enrol/tpm-provision.sh: generates the key in the TPM at 0x81000100, builds a CSR with it, has step-ca sign it, writes the cert to NV 0x01800100. Each host gets a unique cert → unique hostnqn.

  4. Set up the authenticator — authenticator/setup-link.sh (bridge + macsec plumbing per link) then run hostapd authenticator/hostapd-wired-<link>.conf.

  5. Boot the workstations on the secure-path image and check the criteria below.

Pass criteria (per booted workstation)

wpa_cli -p /run/wpa_supplicant -i <nic> status | grep suppPortStatus   # = Authorized
ip macsec show                       # macsec0: GCM-AES cipher, encrypt on, TX/RX SC
ip -s macsec show macsec0            # encrypted-pkts counters incrementing
ip -4 addr show macsec0              # DHCP lease ON macsec0
cat /etc/nvme/hostnqn                # unique per host: …:host-<cert-serial>
findmnt / ; nvme list                # NVMe root connected + verity-mapped, overlay writable
ps -o args= -C wpa_supplicant        # @wpa_supplicant ALIVE after switch-root (invariant #3)

Plus the renewal check (the reason for the network-manager port): leave it up past the DHCP lease and confirm macsec0 keeps its address and the link doesn't drop (~6 s without MKA keepalives = dead link). On the authenticator, tcpdump -i <link> ether proto 0x88e5 should show only MACsec frames after auth — proof the wire is actually encrypted.

Multi-host point

The whole reason for 2 VMs + the 820: one identical image, three distinct TPM certs → three distinct hostnqns, each authenticating independently. That verifies invariant #1 (no per-host state in the image) and lets you test NVMe host-filtering per NQN.

Next step: real TPM attestation

This starting solution signs a TPM-generated CSR — the key never leaves the TPM, but step-ca doesn't yet prove TPM residency. The upgrade is step-ca's ACME device-attest-01 provisioner (EK/AK attestation): switch the enrolment from step ca sign to step ca acme with a TPM attestation provisioner on the same CA. That closes the "is the key really in a TPM" gap and strengthens SSP R-01. See step-ca/bootstrap.sh (the commented acme-attest provisioner block).