From d5c833addff111e12920f0a7e6d0922b4da95044 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Sun, 5 Jul 2026 03:15:13 +0000 Subject: [PATCH] fleet-agent: on-host TPM-mTLS telemetry client (v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the on-host agent that pairs with fleet-hostapi and feeds the fleet-manager control plane (TODO #14 "STILL TO BUILD (a)"). Nothing on the workstation talked to the manager before this; Monitor + InfluxDB stayed empty. Scope: telemetry only. The agent reuses the host TPM identity for mTLS (read-only tpmkey.LoadSigner at 0x81000100 + cert from NV 0x01800100; the private key never leaves the TPM), opens the long-lived FleetAgent.Session bidi stream, sends Hello + periodic HealthSnapshot (load/cpu/temp/mem/tmpfs/nvme/auth/selinux/chrony/kiosk, probed read-only from /proc+/sys plus hostapi facts), and LOGS but does not act on the DesiredState/Command it receives. Reconcile actuators (kiosk URL, reboot, drain) are deferred until the telemetry half is bench-verified. - files/agent/: new module (go-tpm + grpc), static CGO_ENABLED=0 like enrol/hostapi. Copies the enrol tpmkey (+ read-only LoadSigner) and the generated fleet.agent.v1 proto so it stays self-contained without importing the initramfs enrol module. - files/agent/simtest/: proves the TPM-signed cert completes a real mTLS client-auth handshake (RequireAndVerifyClientCert) against the go-tpm software simulator. - files/etc/fleet-agent/agent.env: fleet-generic config (manager DNS name, fleet CA reused from radius-ca.pem, TPM handle/nvindex, interval). No per-host state. - fleet-agent.service: hardened unit (tss group for /dev/tpmrm0, INET egress). - CI builds fleet-agent out-of-VM + runs vet/test + the simulator handshake test. - Packer: enable_agent flag (default on) + 2b-agent provisioner + fapolicyd trust. Post-switch-root only, so no dracut/initramfs changes. - docs: TODO #14 + fleet-management-design as-built updated. Requires the host already enrolled (broker approve births the inventory row) — else the manager returns PermissionDenied, the correct "not enrolled yet" signal. --- .gitea/workflows/gitea-main-ci.yaml | 23 +- .gitignore | 1 + diskless.pkr.hcl | 48 +- docs/TODO.md | 28 +- docs/fleet-management-design.md | 19 +- files/agent/README.md | 69 ++ files/agent/fleet-agent.service | 53 ++ files/agent/go.mod | 28 + files/agent/go.sum | 42 ++ files/agent/identity.go | 96 +++ files/agent/main.go | 245 +++++++ files/agent/proto/fleet_agent.pb.go | 810 +++++++++++++++++++++++ files/agent/proto/fleet_agent.proto | 96 +++ files/agent/proto/fleet_agent_grpc.pb.go | 130 ++++ files/agent/simtest/go.mod | 21 + files/agent/simtest/go.sum | 26 + files/agent/simtest/tpm_tls_test.go | 207 ++++++ files/agent/snapshot.go | 318 +++++++++ files/agent/snapshot_test.go | 107 +++ files/agent/tpmkey/tpmkey.go | 304 +++++++++ files/etc/fleet-agent/agent.env | 29 + 21 files changed, 2679 insertions(+), 21 deletions(-) create mode 100644 files/agent/README.md create mode 100644 files/agent/fleet-agent.service create mode 100644 files/agent/go.mod create mode 100644 files/agent/go.sum create mode 100644 files/agent/identity.go create mode 100644 files/agent/main.go create mode 100644 files/agent/proto/fleet_agent.pb.go create mode 100644 files/agent/proto/fleet_agent.proto create mode 100644 files/agent/proto/fleet_agent_grpc.pb.go create mode 100644 files/agent/simtest/go.mod create mode 100644 files/agent/simtest/go.sum create mode 100644 files/agent/simtest/tpm_tls_test.go create mode 100644 files/agent/snapshot.go create mode 100644 files/agent/snapshot_test.go create mode 100644 files/agent/tpmkey/tpmkey.go create mode 100644 files/etc/fleet-agent/agent.env diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml index 4e4f007..8d02f4e 100644 --- a/.gitea/workflows/gitea-main-ci.yaml +++ b/.gitea/workflows/gitea-main-ci.yaml @@ -197,14 +197,14 @@ jobs: go-version: '1.26.4' - - name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi) + name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi, fleet-agent) # Built HERE on the runner, NOT inside the Packer VM, so the Rocky build # image never carries the Go toolchain or the go-tpm dep tree. Packer # file-provisions the resulting static binaries into the rootfs. Runs # BEFORE packer validate/build so the file provisioners' sources exist. # Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid. - # fleet-enrol fetches go-tpm from the proxy (pinned by go.sum); hostapi is - # stdlib-only (GOPROXY=off). + # fleet-enrol + fleet-agent fetch go-tpm/grpc from the proxy (pinned by + # go.sum); hostapi is stdlib-only (GOPROXY=off). # shell: bash is explicit — the Gitea/act runner defaults run steps to sh # (dash), where `set -o pipefail` is an illegal option. `set -e` is # load-bearing: without it a failed go build/test in the first ( … ) group @@ -219,13 +219,20 @@ jobs: ( cd files/hostapi \ && CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \ go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . ) - file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi + ( cd files/agent \ + && CGO_ENABLED=0 GOTOOLCHAIN=local \ + go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-agent . \ + && CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 go test ./... ) + file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi files/agent/bin/fleet-agent - - name: TPM enrolment ceremony test (go-tpm software simulator) - # Exercises the real key-gen/sign/NV path against a software TPM (CGO in a - # separate module, so the simulator never enters the shipped enrol build). - run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... ) + name: TPM ceremony + agent mTLS tests (go-tpm software simulator) + # Exercises the real key-gen/sign/NV path (enrol) AND the reused-TPM mTLS + # client-auth handshake (agent) against a software TPM (CGO in separate + # modules, so the simulator never enters the shipped enrol/agent builds). + run: | + ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... ) + ( cd files/agent/simtest && CGO_ENABLED=1 go test ./... ) - name: packer init diff --git a/.gitignore b/.gitignore index 36b336a..dccc3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ keys # provisioner; built by the "Build host Go binaries" CI step). files/enrol/bin/ files/hostapi/bin/ +files/agent/bin/ diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index 720a79d..a06a057 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -130,6 +130,12 @@ variable "kiosk_url" { default = "http://boot.fleet.lab/kiosk/" } +variable "enable_agent" { + type = bool + description = "Build-time feature flag for the on-host fleet-agent (telemetry client that dials the fleet-manager over TPM-mTLS and streams health; docs/fleet-management-design.md). true (default) installs /usr/local/bin/fleet-agent + fleet-agent.service; false skips it (the auth stack still boots — the agent is a management add-on). Config is fleet-generic in files/etc/fleet-agent/agent.env; set the manager DNS name there per environment." + default = true +} + variable "boot_splash" { type = bool description = "true (default): bake 'rhgb quiet' into the UKI cmdline so Plymouth shows a graphical boot splash (covers the network-auth + NVMe/TCP + verity phase). false: bake 'plymouth.enable=0' for a fully verbose console boot — use while debugging the boot chain. Plymouth itself is always in the image; this only flips the cmdline. (The cmdline is immutable once signed, so this is a build-time choice.)" @@ -259,6 +265,18 @@ build { destination = "/tmp/fleet-hostapi" } + # On-host fleet-agent: the systemd unit is staged from here; the static binary + # (built OUT OF THE VM like enrol/hostapi) is staged separately. The baked, + # fleet-generic config lands via the /tmp/etc-stage contents upload below. + provisioner "file" { + source = "files/agent" + destination = "/tmp/" + } + provisioner "file" { + source = "files/agent/bin/fleet-agent" + destination = "/tmp/fleet-agent" + } + # Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to # /tmp/plymouth, installed + set default in the package step before dracut runs. provisioner "file" { @@ -608,6 +626,32 @@ build { ] } + # --- 2b-agent. On-host fleet-agent (Go, TPM-mTLS telemetry client) -------- + # Installs the OUT-OF-VM-built /usr/local/bin/fleet-agent (the "Build host Go + # binaries" CI step; staged to /tmp/fleet-agent) + its systemd unit + baked, + # fleet-generic config, and enables it. The agent dials the fleet-manager over + # mTLS using the host's reused TPM identity (signing key at 0x81000100 + cert in + # NV 0x01800100) and streams health; NOTHING host-specific is baked (invariant + # #1) — the manager DNS name in agent.env is fleet-generic. Gated on + # enable_agent so a lean/auth-only build can skip it. The binary is + # fapolicyd-trusted in 2c-STIG-postfix (non-RPM exec, like hostapi/kiosk-session). + provisioner "shell" { + environment_vars = [ + "ENABLE_AGENT=${var.enable_agent}", + ] + inline = [ + "set -euxo pipefail", + "if [ \"$ENABLE_AGENT\" != \"true\" ]; then echo 'enable_agent=false -> skipping fleet-agent install.'; exit 0; fi", + "install -D -m0755 /tmp/fleet-agent /usr/local/bin/fleet-agent", + "install -D -m0644 /tmp/agent/fleet-agent.service /etc/systemd/system/fleet-agent.service", + "install -D -m0644 /tmp/etc-stage/fleet-agent/agent.env /etc/fleet-agent/agent.env", + "restorecon -R /usr/local/bin/fleet-agent /etc/systemd/system/fleet-agent.service /etc/fleet-agent 2>/dev/null || true", + "systemctl enable fleet-agent.service", + "rm -rf /tmp/agent /tmp/fleet-agent", + "echo '==> fleet-agent enabled (TPM-mTLS telemetry to the fleet-manager)'", + ] + } + # --- 2c. Admin access: sudo-enabled key user + locked root -------------- # Fleet-generic (same user + key on every host => NOT per-host state). The # key comes from var.ssh_authorized_key (CI secret). If no key is supplied we @@ -871,9 +915,9 @@ build { # custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston # /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.) "if command -v fapolicyd-cli >/dev/null 2>&1; then", - " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", + " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", " fapolicyd-cli --update 2>/dev/null || true", - " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'", + " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent) + rebuilt trust db'", "fi", ] } diff --git a/docs/TODO.md b/docs/TODO.md index 53338ba..e552f5e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1396,13 +1396,31 @@ the design forks resolved: + agent (`:9600`) listeners to the mgmt net; run `server-pki.sh` when the agent lands; decide `:9500` vs a 443 proxy; digest-pin the images (invariant #12). See DEPLOY.md "Hardening status". -- **STILL TO BUILD:** (a) the **on-host agent** — the gRPC report/reconcile client - that pairs with `fleet-hostapi` (feeds this plane; nothing on the host talks to - the manager yet); (b) **per-host boot-version assignment** — `target_version` is +- **[~] (a) the on-host agent — TELEMETRY HALF BUILT 2026-07-05 (`files/agent/`).** + The gRPC client that pairs with `fleet-hostapi` and feeds this plane. It reuses + the host **TPM identity** for mTLS (signing key at `0x81000100` via + `tpmkey.LoadSigner` — a read-only copy of the enrol `tpmkey`; cert from NV + `0x01800100`), opens the long-lived `Session` stream, sends `Hello` + periodic + `HealthSnapshot` (load/cpu/temp/mem/tmpfs/nvme/auth/selinux/chrony/kiosk probed + read-only from `/proc`+`/sys` + hostapi facts), and — **telemetry-only v1** — + **receives `DesiredState`/`Command` but only LOGS them** (zero host mutation). + Static `CGO_ENABLED=0` binary built out-of-VM in CI (like enrol/hostapi), staged + by the `2b-agent` provisioner (`enable_agent`, default on), fapolicyd-trusted, + fleet-generic config in `files/etc/fleet-agent/agent.env`. mTLS handshake proven + end-to-end against the go-tpm software simulator (`files/agent/simtest`). No + dracut/initramfs changes (post-switch-root only). **STILL TO BUILD (deferred):** + the **reconcile actuators** — apply kiosk_url, reboot_at/REBOOT, drain — wired to + be received but not acted on; land after the telemetry half is bench-verified. + **BENCH-VERIFY:** on an enrolled booted host, `systemctl status fleet-agent` + active; manager logs `agent connected: `; SPA Monitor shows the host + online + health; InfluxDB `fleet_health` accrues points. Requires the host + already enrolled (broker approve birthed its inventory row) — else the manager + returns `PermissionDenied` (the correct "not enrolled yet" signal). +- **STILL TO BUILD:** (b) **per-host boot-version assignment** — `target_version` is stored + pushed but does not change what a host *boots*; that needs dnsmasq cohort steering + multi-version UKI serving (**#6**). Kiosk URL / reboot / drain - reconcile via the agent once (a) lands. Version pin is labelled "recorded intent" - in the Lifecycle UI. + reconcile via the agent's deferred actuator half (above). Version pin is labelled + "recorded intent" in the Lifecycle UI. Depends on the step-ca fleet CA (#8) for the mTLS identity. diff --git a/docs/fleet-management-design.md b/docs/fleet-management-design.md index 7a7acee..2e46aaa 100644 --- a/docs/fleet-management-design.md +++ b/docs/fleet-management-design.md @@ -41,13 +41,20 @@ The **server-side control plane + admin SPA is built** and supersedes several - **Agent identity confirmed:** the agent reuses the **same TPM identity cert** as 802.1X EAP-TLS / NVMe hostnqn — "one identity, three stages". Peer cert serial is authoritative; an unenrolled serial is refused. -- **NOT yet built (recorded intent):** per-host **boot-version assignment**. +- **On-host agent — telemetry half BUILT (2026-07-05, `files/agent/`).** The + gRPC client that feeds this plane. Reuses the host TPM identity for mTLS (read-only + `tpmkey.LoadSigner` at `0x81000100` + cert from NV `0x01800100`; private key never + leaves the TPM), opens `Session`, sends `Hello` + periodic `HealthSnapshot` + (read-only `/proc`+`/sys` probes + `fleet-hostapi` facts). **Telemetry-only:** it + receives `DesiredState`/`Command` but only **logs** them. Static out-of-VM CI + binary, `2b-agent` provisioner (`enable_agent`), fapolicyd-trusted; mTLS proven + against the go-tpm simulator (`files/agent/simtest`). +- **NOT yet built (recorded intent):** (1) the agent's **reconcile actuators** — + apply kiosk_url, reboot_at/REBOOT, drain — deferred until the telemetry half is + bench-verified; and (2) per-host **boot-version assignment**. `desired.target_version` is stored + pushed, but changing what a host *boots* - needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6). - Kiosk URL / reboot / drain reconcile via the agent today; the version pin is - labelled "recorded intent" in the Lifecycle UI. The **on-host agent** that - feeds this plane (extend `fleet-hostapi` with the gRPC report/reconcile client) - is likewise still to build. + needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6). The + version pin is labelled "recorded intent" in the Lifecycle UI. Everything below is the original design context; treat the as-built note as authoritative where they differ. diff --git a/files/agent/README.md b/files/agent/README.md new file mode 100644 index 0000000..3b96469 --- /dev/null +++ b/files/agent/README.md @@ -0,0 +1,69 @@ +# fleet-agent — on-host control-plane client (telemetry v1) + +The workstation-side half of the fleet management plane. It dials the +**fleet-manager** agent plane, identifies with the host's **TPM**, and streams +health telemetry up. It is the client peer of the server hub in +`serving/fleet-manager/` (`agent.go` + `proto/fleet_agent.proto`). + +Baked into the immutable verity image, so — like `fleet-hostapi` and `fleet-enrol` +— it is a **static, reproducible, `CGO_ENABLED=0`** Go binary built **out of the +build VM** by CI (deps proxy-fetched, `go.sum`-pinned) and file-provisioned in. + +## What it does (v1 — telemetry only) + +1. **Reuses the TPM identity for mTLS.** Loads the signing key at the persistent + handle (`0x81000100`) as a `crypto.Signer` (`tpmkey.LoadSigner` — the private + key never leaves the TPM) and the enrolment cert from NV (`0x01800100`), and + presents them as the mTLS client cert. The manager verifies the chain to the + fleet CA and derives the **cert serial = inventory key** — a host can only ever + report as itself (`agent.go`: `peerSerial`). +2. **Opens the long-lived `Session` bidi stream** (host dials out; no inbound host + port — works across the OPNsense L3 hop from the host VLAN to the mgmt plane). +3. **Sends `Hello`** (image version, kernel, kiosk URL, root hash, starship name — + the fleet-generic facts, pulled from `fleet-hostapi` on `127.0.0.1:9099`). +4. **Sends a `HealthSnapshot` every `FLEET_AGENT_INTERVAL`** — load/cpu/temp/mem/ + tmpfs, nvme-root, 802.1X-macsec, selinux, chrony, kiosk-active — probed + read-only from `/proc`+`/sys` (each probe degrades gracefully; a failure never + kills the stream). +5. **Receives `DesiredState`/`Command` and LOGS them.** This build does **not** + act on them. The reconcile actuators (apply kiosk URL, reboot, drain) are a + deliberate follow-up (see `docs/TODO.md` #14) — the receive path is wired now so + adding them needs no protocol change. + +A dropped stream is retried with jittered backoff; the manager treats stream loss +as the host going **offline**. + +## Prerequisite: the host must already be enrolled + +The manager rejects the stream (`PermissionDenied`) until an inventory record +exists for the host's cert serial — that record is born when an admin **approves** +the host's TPM enrolment (`fleet-enrol` → enrol-broker → manager). Enrolment +precedes the agent; this ordering is expected. + +## Config (`/etc/fleet-agent/agent.env`) + +All values are **fleet-generic** (invariant #1). See the file for the full list: +`FLEET_AGENT_MANAGER` (manager `host:port`), `FLEET_AGENT_CA` (fleet root, reused +from `/etc/dot1x/radius-ca.pem`), `FLEET_AGENT_HANDLE` / `FLEET_AGENT_NVINDEX` (TPM +identity, mirror `rd.dot1x.*`), `FLEET_AGENT_INTERVAL`, `FLEET_AGENT_HOSTAPI`. + +## Layout + +| file | purpose | +|------|---------| +| `main.go` | config, connect loop (jittered backoff), `Session` send/recv | +| `identity.go` | TPM-backed mTLS client cert + fleet-CA-verifying `tls.Config` | +| `snapshot.go` | read-only `/proc`+`/sys` health probes + hostapi facts fetch | +| `tpmkey/` | **copy** of `files/enrol/tpmkey` + read-only `LoadSigner` (see its header) | +| `proto/` | **copy** of the generated `fleet.agent.v1` package (one `.proto` contract, two peers) | +| `fleet-agent.service` | hardened unit (tss group for the TPM, INET egress) | + +## Build / test + +```bash +cd files/agent +CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-agent . +go test ./... +# TPM handshake end-to-end (go-tpm software simulator, CGO — isolated module): +( cd simtest && CGO_ENABLED=1 go test ./... ) +``` diff --git a/files/agent/fleet-agent.service b/files/agent/fleet-agent.service new file mode 100644 index 0000000..901fb01 --- /dev/null +++ b/files/agent/fleet-agent.service @@ -0,0 +1,53 @@ +[Unit] +Description=Fleet on-host control-plane agent (telemetry: TPM-mTLS to fleet-manager) +Documentation=https://git.bitasylum.org/audiobuzz/diskless-workstation/docs/fleet-management-design.md +# Needs the network up to dial the manager; hostapi is a SOFT dep (facts source) — +# After, not Requires, so the agent still streams health if hostapi is down. +After=network-online.target fleet-hostapi.service +Wants=network-online.target + +[Service] +Type=exec +EnvironmentFile=/etc/fleet-agent/agent.env +ExecStart=/usr/local/bin/fleet-agent +# Always retry — a diskless box has nothing better to do than reconnect. The agent +# also self-reconnects the stream; Restart covers a hard crash / TPM transient. +Restart=always +RestartSec=5 + +# --- sandboxing (STIG-friendly; looser than hostapi — needs the TPM + egress) --- +# Runs as a transient unprivileged user in the tss group for /dev/tpmrm0 (the +# private key stays in the TPM; the agent only signs the TLS handshake through it). +DynamicUser=yes +SupplementaryGroups=tss +# Expose ONLY the TPM resource-manager node (no PrivateDevices — that would hide it). +DeviceAllow=/dev/tpmrm0 rw +DevicePolicy=closed +CapabilityBoundingSet= +AmbientCapabilities= +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=default +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +# INET egress to the manager (not loopback-only, unlike hostapi); AF_UNIX for the +# journal, AF_NETLINK for Go's source-address/interface selection. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/files/agent/go.mod b/files/agent/go.mod new file mode 100644 index 0000000..37d1819 --- /dev/null +++ b/files/agent/go.mod @@ -0,0 +1,28 @@ +// fleet-agent — the on-host steady-state control-plane client (telemetry v1). +// +// Runs in the BOOTED system (post switch-root), NOT the initramfs. It reuses the +// host's TPM identity (signing key at 0x81000100 + cert in NV 0x01800100) as an +// mTLS client cert, opens the long-lived FleetAgent.Session bidi stream to the +// fleet-manager, sends Hello + periodic HealthSnapshot, and (v1) logs but does not +// act on the desired-state/commands it receives. See docs/fleet-management-design.md. +// +// Unlike fleet-hostapi (stdlib-only) this needs gRPC + go-tpm; like fleet-enrol it +// is a static CGO_ENABLED=0 binary whose deps are proxy-fetched, go.sum-PINNED, and +// built out-of-VM in CI (never enters the reproducible image build's toolchain). +// Dep versions track serving/fleet-manager/go.mod (the peer of this wire contract). +module fleet-agent + +go 1.26 + +require ( + github.com/google/go-tpm v0.9.8 + google.golang.org/grpc v1.82.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect +) diff --git a/files/agent/go.sum b/files/agent/go.sum new file mode 100644 index 0000000..4521f19 --- /dev/null +++ b/files/agent/go.sum @@ -0,0 +1,42 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/files/agent/identity.go b/files/agent/identity.go new file mode 100644 index 0000000..d9c49c7 --- /dev/null +++ b/files/agent/identity.go @@ -0,0 +1,96 @@ +// identity.go — the host's mTLS identity for the agent plane, reused from the TPM. +// +// The private key never leaves the TPM: we present the enrolment cert (DER read +// from NV 0x01800100) as the leaf and hand crypto/tls a crypto.Signer backed by the +// persistent signing key at 0x81000100 (tpmkey.LoadSigner). The manager verifies +// this chain to the fleet CA and derives the cert SERIAL as the inventory key +// (agent.go: RequireAndVerifyClientCert + peerSerial), so a host can only ever be +// itself. The server cert is verified against the same fleet CA bundle. +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "fleet-agent/tpmkey" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +// tpmIdentity holds the open TPM transport plus the loaded identity so the caller +// can close the device when the agent exits. +type tpmIdentity struct { + dev *os.File + cert tls.Certificate +} + +func (id *tpmIdentity) Close() error { + if id.dev != nil { + return id.dev.Close() + } + return nil +} + +// loadTPMIdentity opens the TPM, loads the signing key at handle, reads the cert +// from NV index, and assembles a tls.Certificate whose PrivateKey is the TPM +// signer. The cert must parse and its public key must match the loaded signer +// (a stale NV cert for a rotated key would fail the handshake anyway — we surface +// it here with a clear error instead). +func loadTPMIdentity(tpmPath string, handle, nvIndex tpm2.TPMHandle) (*tpmIdentity, error) { + // /dev/tpmrm0 (kernel resource manager) is a command/response char device; + // transport.FromReadWriter over the *os.File avoids the linuxtpm transport and + // its extra deps — the same lean approach fleet-enrol uses. + f, err := os.OpenFile(tpmPath, os.O_RDWR, 0) + if err != nil { + return nil, fmt.Errorf("open TPM %s: %w", tpmPath, err) + } + tpm := transport.FromReadWriter(f) + + signer, err := tpmkey.LoadSigner(tpm, handle) + if err != nil { + f.Close() + return nil, fmt.Errorf("load signing key: %w (host enrolled?)", err) + } + der, err := tpmkey.ReadNVCert(tpm, nvIndex) + if err != nil { + f.Close() + return nil, fmt.Errorf("read identity cert from NV %#x: %w (host enrolled?)", uint32(nvIndex), err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + f.Close() + return nil, fmt.Errorf("parse identity cert: %w", err) + } + + return &tpmIdentity{ + dev: f, + cert: tls.Certificate{ + Certificate: [][]byte{der}, + PrivateKey: signer, // crypto.Signer — TLS signs the handshake in the TPM + Leaf: leaf, + }, + }, nil +} + +// clientTLS builds the mTLS config: our TPM-backed client cert + the fleet CA as +// the root that verifies the manager's server cert. serverName is the manager's +// DNS name (SNI + hostname verification). +func clientTLS(id *tpmIdentity, caFile, serverName string) (*tls.Config, error) { + pem, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read fleet CA %s: %w", caFile, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("no certificates parsed from fleet CA %s", caFile) + } + return &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{id.cert}, + RootCAs: pool, + ServerName: serverName, + }, nil +} diff --git a/files/agent/main.go b/files/agent/main.go new file mode 100644 index 0000000..f38e0a3 --- /dev/null +++ b/files/agent/main.go @@ -0,0 +1,245 @@ +// fleet-agent — the on-host steady-state control-plane client (telemetry v1). +// +// Runs in the booted system as a systemd service. It dials the fleet-manager over +// mTLS using the host's reused TPM identity (private key stays in the TPM), opens +// the long-lived FleetAgent.Session bidi stream, sends a Hello then periodic +// HealthSnapshots, and — in this telemetry-only build — LOGS but does NOT act on +// the DesiredState/Command messages the manager pushes back. The receive path is +// wired now so the reconcile actuators (kiosk URL, reboot, drain) can be added in a +// follow-up without a protocol change. +// +// No inbound host port: the host always dials out (works behind the OPNsense L3 +// hop from the authenticated host VLAN to the mgmt plane). A dropped stream is +// retried with jittered backoff; the manager treats stream loss as "offline". +package main + +import ( + "context" + "log" + "math/rand" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + fleetpb "fleet-agent/proto" + + "github.com/google/go-tpm/tpm2" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/keepalive" +) + +type config struct { + manager string // manager host:port (DNS name — fleet-generic, invariant #1 safe) + serverName string // TLS SNI / hostname to verify (defaults to manager host) + caFile string // fleet CA bundle: verifies the manager's server cert + tpmPath string + handle tpm2.TPMHandle + nvIndex tpm2.TPMHandle + interval time.Duration + hostapiURL string + kioskEnv string // path to /etc/kiosk/kiosk.env (source of KIOSK_URL) +} + +func main() { + log.SetFlags(0) + log.SetPrefix("fleet-agent: ") + cfg := loadConfig() + + id, err := loadTPMIdentity(cfg.tpmPath, cfg.handle, cfg.nvIndex) + if err != nil { + log.Fatalf("TPM identity: %v", err) + } + defer id.Close() + log.Printf("identity: cert serial %x (CN=%q) — dialing %s", + id.cert.Leaf.SerialNumber, id.cert.Leaf.Subject.CommonName, cfg.manager) + + tlsCfg, err := clientTLS(id, cfg.caFile, cfg.serverName) + if err != nil { + log.Fatalf("client TLS: %v", err) + } + cc, err := grpc.NewClient(cfg.manager, + grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: 20 * time.Second, // >= server MinTime (10s) + Timeout: 10 * time.Second, + PermitWithoutStream: true, + }), + ) + if err != nil { + log.Fatalf("grpc client: %v", err) + } + defer cc.Close() + client := fleetpb.NewFleetAgentClient(cc) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + p := &prober{hostapiURL: cfg.hostapiURL} + backoff := time.Second + for ctx.Err() == nil { + start := time.Now() + if err := runSession(ctx, client, cfg, p); err != nil && ctx.Err() == nil { + log.Printf("session ended: %v", err) + } + if ctx.Err() != nil { + break + } + // Reset backoff after a session that stayed up a while; otherwise grow it + // (capped) with jitter to avoid reconnect storms across the fleet. + if time.Since(start) > 30*time.Second { + backoff = time.Second + } + sleep := min(backoff+time.Duration(rand.Int63n(int64(backoff+1))), 30*time.Second) + log.Printf("reconnecting in %s", sleep.Round(time.Millisecond)) + select { + case <-ctx.Done(): + case <-time.After(sleep): + } + if backoff < 15*time.Second { + backoff *= 2 + } + } + log.Printf("shutting down") +} + +// runSession opens one Session stream, sends Hello + periodic snapshots, and logs +// any ManagerDown. It returns when the stream fails or ctx is cancelled. +func runSession(ctx context.Context, client fleetpb.FleetAgentClient, cfg config, p *prober) error { + sctx, cancel := context.WithCancel(ctx) + defer cancel() + + stream, err := client.Session(sctx) + if err != nil { + return err + } + + // Hello first — what we're running, so the manager reconciles version drift. + facts := fetchFacts(sctx, cfg.hostapiURL) + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Hello{Hello: buildHello(facts, readKioskURL(cfg.kioskEnv))}}); err != nil { + return err + } + log.Printf("connected to %s — streaming health every %s", cfg.manager, cfg.interval) + + // Receive loop: v1 LOGS desired-state/commands, does not act on them. + go func() { + for { + down, err := stream.Recv() + if err != nil { + cancel() + return + } + logDown(down) + } + }() + + // Send one snapshot immediately, then on the interval. + t := time.NewTicker(cfg.interval) + defer t.Stop() + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Snapshot{Snapshot: p.snapshot(sctx)}}); err != nil { + return err + } + for { + select { + case <-sctx.Done(): + return sctx.Err() + case <-t.C: + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Snapshot{Snapshot: p.snapshot(sctx)}}); err != nil { + return err + } + } + } +} + +// logDown records a manager->host message. TELEMETRY-ONLY v1: received, logged, +// NOT applied. The reconcile actuators land in a follow-up (see docs/TODO.md #14). +func logDown(down *fleetpb.ManagerDown) { + switch m := down.GetMsg().(type) { + case *fleetpb.ManagerDown_Desired: + d := m.Desired + log.Printf("desired-state (NOT applied — telemetry-only): target_version=%q kiosk_url=%q reboot_at=%d drain=%v", + d.GetTargetVersion(), d.GetKioskUrl(), d.GetRebootAtUnix(), d.GetDrain()) + case *fleetpb.ManagerDown_Command: + log.Printf("command (NOT applied — telemetry-only): id=%s kind=%s", m.Command.GetCommandId(), m.Command.GetKind()) + } +} + +// ---- config ---------------------------------------------------------------- + +func loadConfig() config { + manager := env("FLEET_AGENT_MANAGER", "diskless-server.bitasylum.org:9600") + cfg := config{ + manager: manager, + serverName: env("FLEET_AGENT_SERVER_NAME", hostOf(manager)), + caFile: env("FLEET_AGENT_CA", "/etc/dot1x/radius-ca.pem"), + tpmPath: env("FLEET_AGENT_TPM", "/dev/tpmrm0"), + handle: mustHandle(env("FLEET_AGENT_HANDLE", "0x81000100")), + nvIndex: mustHandle(env("FLEET_AGENT_NVINDEX", "0x01800100")), + interval: envDuration("FLEET_AGENT_INTERVAL", 30*time.Second), + hostapiURL: env("FLEET_AGENT_HOSTAPI", "http://127.0.0.1:9099/v1/host"), + kioskEnv: env("FLEET_AGENT_KIOSK_ENV", "/etc/kiosk/kiosk.env"), + } + return cfg +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func envDuration(k string, def time.Duration) time.Duration { + if v := os.Getenv(k); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + log.Printf("bad %s=%q — using %s", k, v, def) + } + return def +} + +// hostOf strips the :port from a host:port, leaving the DNS name for TLS +// verification. Handles bare hosts and IPv6 in brackets conservatively. +func hostOf(hostport string) string { + if i := strings.LastIndex(hostport, ":"); i > strings.LastIndex(hostport, "]") { + return strings.Trim(hostport[:i], "[]") + } + return strings.Trim(hostport, "[]") +} + +func mustHandle(s string) tpm2.TPMHandle { + h, err := parseHandle(s) + if err != nil { + log.Fatalf("bad TPM handle/index %q: %v", s, err) + } + return h +} + +// parseHandle parses a 0x-prefixed (or bare) hex TPM handle/NV index. +func parseHandle(s string) (tpm2.TPMHandle, error) { + v, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(s), "0x"), 16, 32) + if err != nil { + return 0, err + } + return tpm2.TPMHandle(v), nil +} + +// readKioskURL extracts KIOSK_URL from the kiosk env file (KEY=VALUE lines). +// Best-effort: "" if the file is absent (the manager still gets a Hello). +func readKioskURL(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + for line := range strings.SplitSeq(string(b), "\n") { + line = strings.TrimSpace(line) + if v, ok := strings.CutPrefix(line, "KIOSK_URL="); ok { + return strings.Trim(strings.TrimSpace(v), `"'`) + } + } + return "" +} diff --git a/files/agent/proto/fleet_agent.pb.go b/files/agent/proto/fleet_agent.pb.go new file mode 100644 index 0000000..2da81fb --- /dev/null +++ b/files/agent/proto/fleet_agent.pb.go @@ -0,0 +1,810 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: proto/fleet_agent.proto + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. + +package fleetpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Command_Kind int32 + +const ( + Command_KIND_UNSPECIFIED Command_Kind = 0 + Command_REBOOT Command_Kind = 1 // reboot now (into desired target_version) + Command_DRAIN Command_Kind = 2 // stop taking work / prepare for maintenance + Command_RESYNC Command_Kind = 3 // re-fetch + re-apply desired-state now +) + +// Enum value maps for Command_Kind. +var ( + Command_Kind_name = map[int32]string{ + 0: "KIND_UNSPECIFIED", + 1: "REBOOT", + 2: "DRAIN", + 3: "RESYNC", + } + Command_Kind_value = map[string]int32{ + "KIND_UNSPECIFIED": 0, + "REBOOT": 1, + "DRAIN": 2, + "RESYNC": 3, + } +) + +func (x Command_Kind) Enum() *Command_Kind { + p := new(Command_Kind) + *p = x + return p +} + +func (x Command_Kind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Command_Kind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_fleet_agent_proto_enumTypes[0].Descriptor() +} + +func (Command_Kind) Type() protoreflect.EnumType { + return &file_proto_fleet_agent_proto_enumTypes[0] +} + +func (x Command_Kind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Command_Kind.Descriptor instead. +func (Command_Kind) EnumDescriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{6, 0} +} + +type AgentUp struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: + // + // *AgentUp_Hello + // *AgentUp_Snapshot + // *AgentUp_Result + Msg isAgentUp_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentUp) Reset() { + *x = AgentUp{} + mi := &file_proto_fleet_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentUp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentUp) ProtoMessage() {} + +func (x *AgentUp) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentUp.ProtoReflect.Descriptor instead. +func (*AgentUp) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentUp) GetMsg() isAgentUp_Msg { + if x != nil { + return x.Msg + } + return nil +} + +func (x *AgentUp) GetHello() *Hello { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentUp) GetSnapshot() *HealthSnapshot { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Snapshot); ok { + return x.Snapshot + } + } + return nil +} + +func (x *AgentUp) GetResult() *CommandResult { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Result); ok { + return x.Result + } + } + return nil +} + +type isAgentUp_Msg interface { + isAgentUp_Msg() +} + +type AgentUp_Hello struct { + Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` // first frame after connect +} + +type AgentUp_Snapshot struct { + Snapshot *HealthSnapshot `protobuf:"bytes,2,opt,name=snapshot,proto3,oneof"` // periodic telemetry (also written to InfluxDB) +} + +type AgentUp_Result struct { + Result *CommandResult `protobuf:"bytes,3,opt,name=result,proto3,oneof"` // outcome of a ManagerDown.Command +} + +func (*AgentUp_Hello) isAgentUp_Msg() {} + +func (*AgentUp_Snapshot) isAgentUp_Msg() {} + +func (*AgentUp_Result) isAgentUp_Msg() {} + +// Hello — what the host is running, sent once on connect so the manager can +// reconcile version drift immediately. +type Hello struct { + state protoimpl.MessageState `protogen:"open.v1"` + ImageVersion string `protobuf:"bytes,1,opt,name=image_version,json=imageVersion,proto3" json:"image_version,omitempty"` + RootfsDigest string `protobuf:"bytes,2,opt,name=rootfs_digest,json=rootfsDigest,proto3" json:"rootfs_digest,omitempty"` + Kernel string `protobuf:"bytes,3,opt,name=kernel,proto3" json:"kernel,omitempty"` + KioskUrl string `protobuf:"bytes,4,opt,name=kiosk_url,json=kioskUrl,proto3" json:"kiosk_url,omitempty"` + StarshipName string `protobuf:"bytes,5,opt,name=starship_name,json=starshipName,proto3" json:"starship_name,omitempty"` // advisory; manager already knows it from inventory + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hello) Reset() { + *x = Hello{} + mi := &file_proto_fleet_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hello) ProtoMessage() {} + +func (x *Hello) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Hello.ProtoReflect.Descriptor instead. +func (*Hello) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *Hello) GetImageVersion() string { + if x != nil { + return x.ImageVersion + } + return "" +} + +func (x *Hello) GetRootfsDigest() string { + if x != nil { + return x.RootfsDigest + } + return "" +} + +func (x *Hello) GetKernel() string { + if x != nil { + return x.Kernel + } + return "" +} + +func (x *Hello) GetKioskUrl() string { + if x != nil { + return x.KioskUrl + } + return "" +} + +func (x *Hello) GetStarshipName() string { + if x != nil { + return x.StarshipName + } + return "" +} + +// HealthSnapshot — one telemetry sample. Cached as the latest value in inventory +// AND written as a time-series point to InfluxDB (tagged with the authenticated +// cert serial / hostnqn). +type HealthSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + AtUnix int64 `protobuf:"varint,1,opt,name=at_unix,json=atUnix,proto3" json:"at_unix,omitempty"` // sample time, unix seconds (0 = manager stamps now) + LoadAvg float64 `protobuf:"fixed64,2,opt,name=load_avg,json=loadAvg,proto3" json:"load_avg,omitempty"` + CpuPct float64 `protobuf:"fixed64,3,opt,name=cpu_pct,json=cpuPct,proto3" json:"cpu_pct,omitempty"` + TempC float64 `protobuf:"fixed64,4,opt,name=temp_c,json=tempC,proto3" json:"temp_c,omitempty"` + MemUsedPct float64 `protobuf:"fixed64,5,opt,name=mem_used_pct,json=memUsedPct,proto3" json:"mem_used_pct,omitempty"` + TmpfsUsedPct float64 `protobuf:"fixed64,6,opt,name=tmpfs_used_pct,json=tmpfsUsedPct,proto3" json:"tmpfs_used_pct,omitempty"` // overlay RAM pressure + NvmeRoot string `protobuf:"bytes,7,opt,name=nvme_root,json=nvmeRoot,proto3" json:"nvme_root,omitempty"` // ok|degraded|down + Auth string `protobuf:"bytes,8,opt,name=auth,proto3" json:"auth,omitempty"` // 802.1x/macsec: up|down + Selinux string `protobuf:"bytes,9,opt,name=selinux,proto3" json:"selinux,omitempty"` // enforcing|permissive + Chrony string `protobuf:"bytes,10,opt,name=chrony,proto3" json:"chrony,omitempty"` // synced|unsynced + KioskActive bool `protobuf:"varint,11,opt,name=kiosk_active,json=kioskActive,proto3" json:"kiosk_active,omitempty"` + SessionUptime string `protobuf:"bytes,12,opt,name=session_uptime,json=sessionUptime,proto3" json:"session_uptime,omitempty"` + ChromiumCrashes int32 `protobuf:"varint,13,opt,name=chromium_crashes,json=chromiumCrashes,proto3" json:"chromium_crashes,omitempty"` + Display string `protobuf:"bytes,14,opt,name=display,proto3" json:"display,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthSnapshot) Reset() { + *x = HealthSnapshot{} + mi := &file_proto_fleet_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthSnapshot) ProtoMessage() {} + +func (x *HealthSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthSnapshot.ProtoReflect.Descriptor instead. +func (*HealthSnapshot) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *HealthSnapshot) GetAtUnix() int64 { + if x != nil { + return x.AtUnix + } + return 0 +} + +func (x *HealthSnapshot) GetLoadAvg() float64 { + if x != nil { + return x.LoadAvg + } + return 0 +} + +func (x *HealthSnapshot) GetCpuPct() float64 { + if x != nil { + return x.CpuPct + } + return 0 +} + +func (x *HealthSnapshot) GetTempC() float64 { + if x != nil { + return x.TempC + } + return 0 +} + +func (x *HealthSnapshot) GetMemUsedPct() float64 { + if x != nil { + return x.MemUsedPct + } + return 0 +} + +func (x *HealthSnapshot) GetTmpfsUsedPct() float64 { + if x != nil { + return x.TmpfsUsedPct + } + return 0 +} + +func (x *HealthSnapshot) GetNvmeRoot() string { + if x != nil { + return x.NvmeRoot + } + return "" +} + +func (x *HealthSnapshot) GetAuth() string { + if x != nil { + return x.Auth + } + return "" +} + +func (x *HealthSnapshot) GetSelinux() string { + if x != nil { + return x.Selinux + } + return "" +} + +func (x *HealthSnapshot) GetChrony() string { + if x != nil { + return x.Chrony + } + return "" +} + +func (x *HealthSnapshot) GetKioskActive() bool { + if x != nil { + return x.KioskActive + } + return false +} + +func (x *HealthSnapshot) GetSessionUptime() string { + if x != nil { + return x.SessionUptime + } + return "" +} + +func (x *HealthSnapshot) GetChromiumCrashes() int32 { + if x != nil { + return x.ChromiumCrashes + } + return 0 +} + +func (x *HealthSnapshot) GetDisplay() string { + if x != nil { + return x.Display + } + return "" +} + +type CommandResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + Ok bool `protobuf:"varint,2,opt,name=ok,proto3" json:"ok,omitempty"` + Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandResult) Reset() { + *x = CommandResult{} + mi := &file_proto_fleet_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CommandResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandResult) ProtoMessage() {} + +func (x *CommandResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandResult.ProtoReflect.Descriptor instead. +func (*CommandResult) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *CommandResult) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *CommandResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *CommandResult) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +type ManagerDown struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: + // + // *ManagerDown_Desired + // *ManagerDown_Command + Msg isManagerDown_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ManagerDown) Reset() { + *x = ManagerDown{} + mi := &file_proto_fleet_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ManagerDown) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ManagerDown) ProtoMessage() {} + +func (x *ManagerDown) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ManagerDown.ProtoReflect.Descriptor instead. +func (*ManagerDown) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ManagerDown) GetMsg() isManagerDown_Msg { + if x != nil { + return x.Msg + } + return nil +} + +func (x *ManagerDown) GetDesired() *DesiredState { + if x != nil { + if x, ok := x.Msg.(*ManagerDown_Desired); ok { + return x.Desired + } + } + return nil +} + +func (x *ManagerDown) GetCommand() *Command { + if x != nil { + if x, ok := x.Msg.(*ManagerDown_Command); ok { + return x.Command + } + } + return nil +} + +type isManagerDown_Msg interface { + isManagerDown_Msg() +} + +type ManagerDown_Desired struct { + Desired *DesiredState `protobuf:"bytes,1,opt,name=desired,proto3,oneof"` // full desired-state: sent on connect + on change +} + +type ManagerDown_Command struct { + Command *Command `protobuf:"bytes,2,opt,name=command,proto3,oneof"` // imperative action +} + +func (*ManagerDown_Desired) isManagerDown_Msg() {} + +func (*ManagerDown_Command) isManagerDown_Msg() {} + +// DesiredState — server-authoritative target the agent reconciles toward. +type DesiredState struct { + state protoimpl.MessageState `protogen:"open.v1"` + TargetVersion string `protobuf:"bytes,1,opt,name=target_version,json=targetVersion,proto3" json:"target_version,omitempty"` + KioskUrl string `protobuf:"bytes,2,opt,name=kiosk_url,json=kioskUrl,proto3" json:"kiosk_url,omitempty"` + RebootAtUnix int64 `protobuf:"varint,3,opt,name=reboot_at_unix,json=rebootAtUnix,proto3" json:"reboot_at_unix,omitempty"` // 0 = no scheduled reboot + Drain bool `protobuf:"varint,4,opt,name=drain,proto3" json:"drain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DesiredState) Reset() { + *x = DesiredState{} + mi := &file_proto_fleet_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DesiredState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DesiredState) ProtoMessage() {} + +func (x *DesiredState) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DesiredState.ProtoReflect.Descriptor instead. +func (*DesiredState) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *DesiredState) GetTargetVersion() string { + if x != nil { + return x.TargetVersion + } + return "" +} + +func (x *DesiredState) GetKioskUrl() string { + if x != nil { + return x.KioskUrl + } + return "" +} + +func (x *DesiredState) GetRebootAtUnix() int64 { + if x != nil { + return x.RebootAtUnix + } + return 0 +} + +func (x *DesiredState) GetDrain() bool { + if x != nil { + return x.Drain + } + return false +} + +// Command — an imperative action pushed live (admin clicked a button in the SPA). +type Command struct { + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + Kind Command_Kind `protobuf:"varint,2,opt,name=kind,proto3,enum=fleet.agent.v1.Command_Kind" json:"kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Command) Reset() { + *x = Command{} + mi := &file_proto_fleet_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Command) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Command) ProtoMessage() {} + +func (x *Command) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Command.ProtoReflect.Descriptor instead. +func (*Command) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *Command) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *Command) GetKind() Command_Kind { + if x != nil { + return x.Kind + } + return Command_KIND_UNSPECIFIED +} + +var File_proto_fleet_agent_proto protoreflect.FileDescriptor + +const file_proto_fleet_agent_proto_rawDesc = "" + + "\n" + + "\x17proto/fleet_agent.proto\x12\x0efleet.agent.v1\"\xb6\x01\n" + + "\aAgentUp\x12-\n" + + "\x05hello\x18\x01 \x01(\v2\x15.fleet.agent.v1.HelloH\x00R\x05hello\x12<\n" + + "\bsnapshot\x18\x02 \x01(\v2\x1e.fleet.agent.v1.HealthSnapshotH\x00R\bsnapshot\x127\n" + + "\x06result\x18\x03 \x01(\v2\x1d.fleet.agent.v1.CommandResultH\x00R\x06resultB\x05\n" + + "\x03msg\"\xab\x01\n" + + "\x05Hello\x12#\n" + + "\rimage_version\x18\x01 \x01(\tR\fimageVersion\x12#\n" + + "\rrootfs_digest\x18\x02 \x01(\tR\frootfsDigest\x12\x16\n" + + "\x06kernel\x18\x03 \x01(\tR\x06kernel\x12\x1b\n" + + "\tkiosk_url\x18\x04 \x01(\tR\bkioskUrl\x12#\n" + + "\rstarship_name\x18\x05 \x01(\tR\fstarshipName\"\xae\x03\n" + + "\x0eHealthSnapshot\x12\x17\n" + + "\aat_unix\x18\x01 \x01(\x03R\x06atUnix\x12\x19\n" + + "\bload_avg\x18\x02 \x01(\x01R\aloadAvg\x12\x17\n" + + "\acpu_pct\x18\x03 \x01(\x01R\x06cpuPct\x12\x15\n" + + "\x06temp_c\x18\x04 \x01(\x01R\x05tempC\x12 \n" + + "\fmem_used_pct\x18\x05 \x01(\x01R\n" + + "memUsedPct\x12$\n" + + "\x0etmpfs_used_pct\x18\x06 \x01(\x01R\ftmpfsUsedPct\x12\x1b\n" + + "\tnvme_root\x18\a \x01(\tR\bnvmeRoot\x12\x12\n" + + "\x04auth\x18\b \x01(\tR\x04auth\x12\x18\n" + + "\aselinux\x18\t \x01(\tR\aselinux\x12\x16\n" + + "\x06chrony\x18\n" + + " \x01(\tR\x06chrony\x12!\n" + + "\fkiosk_active\x18\v \x01(\bR\vkioskActive\x12%\n" + + "\x0esession_uptime\x18\f \x01(\tR\rsessionUptime\x12)\n" + + "\x10chromium_crashes\x18\r \x01(\x05R\x0fchromiumCrashes\x12\x18\n" + + "\adisplay\x18\x0e \x01(\tR\adisplay\"V\n" + + "\rCommandResult\x12\x1d\n" + + "\n" + + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x0e\n" + + "\x02ok\x18\x02 \x01(\bR\x02ok\x12\x16\n" + + "\x06detail\x18\x03 \x01(\tR\x06detail\"\x83\x01\n" + + "\vManagerDown\x128\n" + + "\adesired\x18\x01 \x01(\v2\x1c.fleet.agent.v1.DesiredStateH\x00R\adesired\x123\n" + + "\acommand\x18\x02 \x01(\v2\x17.fleet.agent.v1.CommandH\x00R\acommandB\x05\n" + + "\x03msg\"\x8e\x01\n" + + "\fDesiredState\x12%\n" + + "\x0etarget_version\x18\x01 \x01(\tR\rtargetVersion\x12\x1b\n" + + "\tkiosk_url\x18\x02 \x01(\tR\bkioskUrl\x12$\n" + + "\x0ereboot_at_unix\x18\x03 \x01(\x03R\frebootAtUnix\x12\x14\n" + + "\x05drain\x18\x04 \x01(\bR\x05drain\"\x9b\x01\n" + + "\aCommand\x12\x1d\n" + + "\n" + + "command_id\x18\x01 \x01(\tR\tcommandId\x120\n" + + "\x04kind\x18\x02 \x01(\x0e2\x1c.fleet.agent.v1.Command.KindR\x04kind\"?\n" + + "\x04Kind\x12\x14\n" + + "\x10KIND_UNSPECIFIED\x10\x00\x12\n" + + "\n" + + "\x06REBOOT\x10\x01\x12\t\n" + + "\x05DRAIN\x10\x02\x12\n" + + "\n" + + "\x06RESYNC\x10\x032Q\n" + + "\n" + + "FleetAgent\x12C\n" + + "\aSession\x12\x17.fleet.agent.v1.AgentUp\x1a\x1b.fleet.agent.v1.ManagerDown(\x010\x01B\x1dZ\x1bfleet-manager/proto;fleetpbb\x06proto3" + +var ( + file_proto_fleet_agent_proto_rawDescOnce sync.Once + file_proto_fleet_agent_proto_rawDescData []byte +) + +func file_proto_fleet_agent_proto_rawDescGZIP() []byte { + file_proto_fleet_agent_proto_rawDescOnce.Do(func() { + file_proto_fleet_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_fleet_agent_proto_rawDesc), len(file_proto_fleet_agent_proto_rawDesc))) + }) + return file_proto_fleet_agent_proto_rawDescData +} + +var file_proto_fleet_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_proto_fleet_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_proto_fleet_agent_proto_goTypes = []any{ + (Command_Kind)(0), // 0: fleet.agent.v1.Command.Kind + (*AgentUp)(nil), // 1: fleet.agent.v1.AgentUp + (*Hello)(nil), // 2: fleet.agent.v1.Hello + (*HealthSnapshot)(nil), // 3: fleet.agent.v1.HealthSnapshot + (*CommandResult)(nil), // 4: fleet.agent.v1.CommandResult + (*ManagerDown)(nil), // 5: fleet.agent.v1.ManagerDown + (*DesiredState)(nil), // 6: fleet.agent.v1.DesiredState + (*Command)(nil), // 7: fleet.agent.v1.Command +} +var file_proto_fleet_agent_proto_depIdxs = []int32{ + 2, // 0: fleet.agent.v1.AgentUp.hello:type_name -> fleet.agent.v1.Hello + 3, // 1: fleet.agent.v1.AgentUp.snapshot:type_name -> fleet.agent.v1.HealthSnapshot + 4, // 2: fleet.agent.v1.AgentUp.result:type_name -> fleet.agent.v1.CommandResult + 6, // 3: fleet.agent.v1.ManagerDown.desired:type_name -> fleet.agent.v1.DesiredState + 7, // 4: fleet.agent.v1.ManagerDown.command:type_name -> fleet.agent.v1.Command + 0, // 5: fleet.agent.v1.Command.kind:type_name -> fleet.agent.v1.Command.Kind + 1, // 6: fleet.agent.v1.FleetAgent.Session:input_type -> fleet.agent.v1.AgentUp + 5, // 7: fleet.agent.v1.FleetAgent.Session:output_type -> fleet.agent.v1.ManagerDown + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_proto_fleet_agent_proto_init() } +func file_proto_fleet_agent_proto_init() { + if File_proto_fleet_agent_proto != nil { + return + } + file_proto_fleet_agent_proto_msgTypes[0].OneofWrappers = []any{ + (*AgentUp_Hello)(nil), + (*AgentUp_Snapshot)(nil), + (*AgentUp_Result)(nil), + } + file_proto_fleet_agent_proto_msgTypes[4].OneofWrappers = []any{ + (*ManagerDown_Desired)(nil), + (*ManagerDown_Command)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_fleet_agent_proto_rawDesc), len(file_proto_fleet_agent_proto_rawDesc)), + NumEnums: 1, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_fleet_agent_proto_goTypes, + DependencyIndexes: file_proto_fleet_agent_proto_depIdxs, + EnumInfos: file_proto_fleet_agent_proto_enumTypes, + MessageInfos: file_proto_fleet_agent_proto_msgTypes, + }.Build() + File_proto_fleet_agent_proto = out.File + file_proto_fleet_agent_proto_goTypes = nil + file_proto_fleet_agent_proto_depIdxs = nil +} diff --git a/files/agent/proto/fleet_agent.proto b/files/agent/proto/fleet_agent.proto new file mode 100644 index 0000000..80f230b --- /dev/null +++ b/files/agent/proto/fleet_agent.proto @@ -0,0 +1,96 @@ +syntax = "proto3"; + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. +package fleet.agent.v1; + +option go_package = "fleet-manager/proto;fleetpb"; + +service FleetAgent { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + rpc Session(stream AgentUp) returns (stream ManagerDown); +} + +// ---- host -> manager ------------------------------------------------------ + +message AgentUp { + oneof msg { + Hello hello = 1; // first frame after connect + HealthSnapshot snapshot = 2; // periodic telemetry (also written to InfluxDB) + CommandResult result = 3; // outcome of a ManagerDown.Command + } +} + +// Hello — what the host is running, sent once on connect so the manager can +// reconcile version drift immediately. +message Hello { + string image_version = 1; + string rootfs_digest = 2; + string kernel = 3; + string kiosk_url = 4; + string starship_name = 5; // advisory; manager already knows it from inventory +} + +// HealthSnapshot — one telemetry sample. Cached as the latest value in inventory +// AND written as a time-series point to InfluxDB (tagged with the authenticated +// cert serial / hostnqn). +message HealthSnapshot { + int64 at_unix = 1; // sample time, unix seconds (0 = manager stamps now) + double load_avg = 2; + double cpu_pct = 3; + double temp_c = 4; + double mem_used_pct = 5; + double tmpfs_used_pct = 6; // overlay RAM pressure + string nvme_root = 7; // ok|degraded|down + string auth = 8; // 802.1x/macsec: up|down + string selinux = 9; // enforcing|permissive + string chrony = 10; // synced|unsynced + bool kiosk_active = 11; + string session_uptime = 12; + int32 chromium_crashes = 13; + string display = 14; +} + +message CommandResult { + string command_id = 1; + bool ok = 2; + string detail = 3; +} + +// ---- manager -> host ------------------------------------------------------ + +message ManagerDown { + oneof msg { + DesiredState desired = 1; // full desired-state: sent on connect + on change + Command command = 2; // imperative action + } +} + +// DesiredState — server-authoritative target the agent reconciles toward. +message DesiredState { + string target_version = 1; + string kiosk_url = 2; + int64 reboot_at_unix = 3; // 0 = no scheduled reboot + bool drain = 4; +} + +// Command — an imperative action pushed live (admin clicked a button in the SPA). +message Command { + string command_id = 1; + Kind kind = 2; + + enum Kind { + KIND_UNSPECIFIED = 0; + REBOOT = 1; // reboot now (into desired target_version) + DRAIN = 2; // stop taking work / prepare for maintenance + RESYNC = 3; // re-fetch + re-apply desired-state now + } +} diff --git a/files/agent/proto/fleet_agent_grpc.pb.go b/files/agent/proto/fleet_agent_grpc.pb.go new file mode 100644 index 0000000..7e42882 --- /dev/null +++ b/files/agent/proto/fleet_agent_grpc.pb.go @@ -0,0 +1,130 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.21.12 +// source: proto/fleet_agent.proto + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. + +package fleetpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + FleetAgent_Session_FullMethodName = "/fleet.agent.v1.FleetAgent/Session" +) + +// FleetAgentClient is the client API for FleetAgent service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type FleetAgentClient interface { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + Session(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentUp, ManagerDown], error) +} + +type fleetAgentClient struct { + cc grpc.ClientConnInterface +} + +func NewFleetAgentClient(cc grpc.ClientConnInterface) FleetAgentClient { + return &fleetAgentClient{cc} +} + +func (c *fleetAgentClient) Session(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentUp, ManagerDown], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &FleetAgent_ServiceDesc.Streams[0], FleetAgent_Session_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[AgentUp, ManagerDown]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FleetAgent_SessionClient = grpc.BidiStreamingClient[AgentUp, ManagerDown] + +// FleetAgentServer is the server API for FleetAgent service. +// All implementations must embed UnimplementedFleetAgentServer +// for forward compatibility. +type FleetAgentServer interface { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + Session(grpc.BidiStreamingServer[AgentUp, ManagerDown]) error + mustEmbedUnimplementedFleetAgentServer() +} + +// UnimplementedFleetAgentServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedFleetAgentServer struct{} + +func (UnimplementedFleetAgentServer) Session(grpc.BidiStreamingServer[AgentUp, ManagerDown]) error { + return status.Error(codes.Unimplemented, "method Session not implemented") +} +func (UnimplementedFleetAgentServer) mustEmbedUnimplementedFleetAgentServer() {} +func (UnimplementedFleetAgentServer) testEmbeddedByValue() {} + +// UnsafeFleetAgentServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to FleetAgentServer will +// result in compilation errors. +type UnsafeFleetAgentServer interface { + mustEmbedUnimplementedFleetAgentServer() +} + +func RegisterFleetAgentServer(s grpc.ServiceRegistrar, srv FleetAgentServer) { + // If the following call panics, it indicates UnimplementedFleetAgentServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&FleetAgent_ServiceDesc, srv) +} + +func _FleetAgent_Session_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(FleetAgentServer).Session(&grpc.GenericServerStream[AgentUp, ManagerDown]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FleetAgent_SessionServer = grpc.BidiStreamingServer[AgentUp, ManagerDown] + +// FleetAgent_ServiceDesc is the grpc.ServiceDesc for FleetAgent service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var FleetAgent_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "fleet.agent.v1.FleetAgent", + HandlerType: (*FleetAgentServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Session", + Handler: _FleetAgent_Session_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "proto/fleet_agent.proto", +} diff --git a/files/agent/simtest/go.mod b/files/agent/simtest/go.mod new file mode 100644 index 0000000..303b2fa --- /dev/null +++ b/files/agent/simtest/go.mod @@ -0,0 +1,21 @@ +// fleet-agent-simtest — a SEPARATE module so the go-tpm software simulator (CGO + +// the MS TPM reference C) and go-tpm-tools stay OUT of the shipped fleet-agent +// module. Run from this dir: +// go test ./... (needs CGO + a C toolchain for the simulator) +// It proves the agent's reused TPM identity (tpmkey.LoadSigner) completes a real +// mTLS client-auth handshake against a server that RequireAndVerifyClientCert — +// exactly what the fleet-manager agent plane does — so the go-tpm signer is proven +// in the TLS path off-hardware without bloating the image build. +module fleet-agent-simtest + +go 1.26 + +require ( + fleet-agent v0.0.0 + github.com/google/go-tpm v0.9.8 + github.com/google/go-tpm-tools v0.4.9 +) + +require golang.org/x/sys v0.43.0 // indirect + +replace fleet-agent => ../ diff --git a/files/agent/simtest/go.sum b/files/agent/simtest/go.sum new file mode 100644 index 0000000..f53674e --- /dev/null +++ b/files/agent/simtest/go.sum @@ -0,0 +1,26 @@ +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007 h1:DoeEFwEGBdqcawmpiWtSsSVVZ+wk3zpqvcvssO2JLmY= +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007/go.mod h1:s8F0JYEods/WL03WxZaGsWCnumZeeLD+WKHzspOV9u0= +github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc h1:SG12DWUUM5igxm+//YX5Yq4vhdoRnOG9HkCodkOn+YU= +github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0 h1:STyioPkz8nqMMIk3+YlyJ/WyEJZxho1YUZXu99uAbQ0= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ= +github.com/google/go-sev-guest v0.14.0 h1:dCb4F3YrHTtrDX3cYIPTifEDz7XagZmXQioxRBW4wOo= +github.com/google/go-sev-guest v0.14.0/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760= +github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d h1:Ff8goEP/ue2/rZT5qyoRicuySCYDbAXEZS8Cf1fgsUo= +github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d/go.mod h1:uHy3VaNXNXhl0fiPxKqTxieeouqQmW6A0EfLcaeCYBk= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI= +github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4= +github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ= +github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/files/agent/simtest/tpm_tls_test.go b/files/agent/simtest/tpm_tls_test.go new file mode 100644 index 0000000..495d181 --- /dev/null +++ b/files/agent/simtest/tpm_tls_test.go @@ -0,0 +1,207 @@ +// Package simtest validates the agent's reused-TPM mTLS identity against the +// go-tpm software simulator (the MS TPM 2.0 reference). It lives in its own module +// so the CGO simulator never enters the shipped fleet-agent build; see go.mod. +// +// The crux it proves: crypto/tls can drive tpmkey's go-tpm crypto.Signer to sign +// the TLS CertificateVerify, so a host holding ONLY a TPM key (no exportable +// private key) authenticates to a server doing RequireAndVerifyClientCert against +// the fleet CA — the exact posture of the fleet-manager agent plane (agent.go). +package simtest + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "fleet-agent/tpmkey" + + "github.com/google/go-tpm-tools/simulator" + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +func openSim(t *testing.T) transport.TPM { + t.Helper() + sim, err := simulator.Get() + if err != nil { + t.Fatalf("simulator: %v", err) + } + t.Cleanup(func() { sim.Close() }) + return transport.FromReadWriter(sim) +} + +// testCA is a throwaway fleet CA for the handshake. +type testCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + pool *x509.CertPool +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test fleet CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatal(err) + } + cert, _ := x509.ParseCertificate(der) + pool := x509.NewCertPool() + pool.AddCert(cert) + return &testCA{cert: cert, key: key, pool: pool} +} + +// issue signs a leaf cert for pub with the CA (as the enrol-broker/step-ca does). +func (ca *testCA) issue(t *testing.T, tmpl *x509.Certificate, pub crypto.PublicKey) []byte { + t.Helper() + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, pub, ca.key) + if err != nil { + t.Fatalf("CA issue: %v", err) + } + return der +} + +func TestTPMClientCertHandshake(t *testing.T) { + tpm := openSim(t) + const ( + keyHandle = tpm2.TPMHandle(0x81000100) + nvIndex = tpm2.TPMHandle(0x01800100) + ) + + // Enrolment side: create the persistent signing key (once). + if _, _, err := tpmkey.EnsureKey(tpm, keyHandle); err != nil { + t.Fatalf("EnsureKey: %v", err) + } + // Agent side: load it read-only (the function the agent actually uses). + signer, err := tpmkey.LoadSigner(tpm, keyHandle) + if err != nil { + t.Fatalf("LoadSigner: %v", err) + } + + ca := newTestCA(t) + + // The host's identity cert (CA-signed over the TPM public key). Its SERIAL is + // the value the manager derives as the inventory key. + wantSerial := big.NewInt(0xC0FFEE) + leafDER := ca.issue(t, &x509.Certificate{ + SerialNumber: wantSerial, + Subject: pkix.Name{CommonName: "fleet-host"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + }, signer.Public()) + + // Exercise the NV round-trip the agent's identity path uses. + if err := tpmkey.WriteNVCert(tpm, nvIndex, leafDER); err != nil { + t.Fatalf("WriteNVCert: %v", err) + } + got, err := tpmkey.ReadNVCert(tpm, nvIndex) + if err != nil { + t.Fatalf("ReadNVCert: %v", err) + } + if string(got) != string(leafDER) { + t.Fatalf("NV cert round-trip mismatch") + } + leaf, _ := x509.ParseCertificate(leafDER) + + // TPM-backed client cert (private key = the TPM signer; never exported). + clientCert := tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: signer, Leaf: leaf} + + // Server standing in for the manager agent plane: verify the client to the CA. + serverKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + serverDER := ca.issue(t, &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "fleet-manager"}, + DNSNames: []string{"fleet-manager"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, serverKey.Public()) + serverCfg := &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{serverDER}, PrivateKey: serverKey}}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: ca.pool, + MinVersion: tls.VersionTLS12, + } + clientCfg := &tls.Config{ + Certificates: []tls.Certificate{clientCert}, + RootCAs: ca.pool, + ServerName: "fleet-manager", + MinVersion: tls.VersionTLS12, + } + + ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + serialCh := make(chan *big.Int, 1) + errCh := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + errCh <- err + return + } + defer conn.Close() + tc := conn.(*tls.Conn) + tc.SetDeadline(time.Now().Add(5 * time.Second)) + if err := tc.Handshake(); err != nil { + errCh <- err + return + } + st := tc.ConnectionState() + if len(st.PeerCertificates) == 0 { + errCh <- err + return + } + serialCh <- st.PeerCertificates[0].SerialNumber + }() + + conn, err := tls.Dial("tcp", ln.Addr().String(), clientCfg) + if err != nil { + t.Fatalf("client handshake with TPM cert failed: %v", err) + } + defer conn.Close() + + select { + case err := <-errCh: + t.Fatalf("server rejected TPM client cert: %v", err) + case serial := <-serialCh: + if serial.Cmp(wantSerial) != 0 { + t.Fatalf("server saw serial %x, want %x", serial, wantSerial) + } + case <-time.After(5 * time.Second): + t.Fatal("handshake timed out") + } +} + +// TestLoadSignerRejectsEmptyHandle proves the agent fails clearly on a host that +// isn't enrolled (no key at the handle) rather than creating one. +func TestLoadSignerRejectsEmptyHandle(t *testing.T) { + tpm := openSim(t) + if _, err := tpmkey.LoadSigner(tpm, tpm2.TPMHandle(0x81000100)); err == nil { + t.Fatal("LoadSigner on an empty handle should error (agent must not create a key)") + } +} diff --git a/files/agent/snapshot.go b/files/agent/snapshot.go new file mode 100644 index 0000000..6492e2f --- /dev/null +++ b/files/agent/snapshot.go @@ -0,0 +1,318 @@ +// snapshot.go — read-only host health probes → *fleetpb.HealthSnapshot, plus the +// hostapi facts fetch used to build the connect-time Hello. +// +// Every probe degrades gracefully: on any error the field is left at its zero +// value ("" / 0) rather than failing. A probe must NEVER kill the stream — a +// missing thermal zone or an unmounted selinuxfs is normal on some targets/VMs. +// Health METRICS (load/cpu/mem/temp/nvme/auth/selinux) are read straight from +// /proc + /sys; host FACTS (image/kernel/hostname/kiosk) are reused from +// fleet-hostapi (127.0.0.1:9099) so we don't re-probe DMI/GPU. +package main + +import ( + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + fleetpb "fleet-agent/proto" +) + +// hostFacts is the subset of fleet-hostapi's /v1/host response the agent uses. +type hostFacts struct { + OS struct { + Kernel string `json:"kernel"` + Image string `json:"image"` + } `json:"os"` + Net struct { + Hostname string `json:"hostname"` + } `json:"net"` + Kiosk struct { + Uptime string `json:"uptime"` + Chromium string `json:"chromium"` + } `json:"kiosk"` +} + +// fetchFacts GETs fleet-hostapi over loopback. Best-effort: returns a zero struct +// on any error (the agent still streams; Hello/snapshot fields just stay empty). +func fetchFacts(ctx context.Context, url string) hostFacts { + var f hostFacts + if url == "" { + return f + } + rctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(rctx, http.MethodGet, url, nil) + if err != nil { + return f + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return f + } + defer resp.Body.Close() + _ = json.NewDecoder(resp.Body).Decode(&f) + return f +} + +// buildHello assembles the connect-time Hello from hostapi facts + the kiosk URL +// + the verity root hash (best-effort). starship_name is advisory (the manager +// already knows it from inventory). +func buildHello(f hostFacts, kioskURL string) *fleetpb.Hello { + return &fleetpb.Hello{ + ImageVersion: f.OS.Image, + RootfsDigest: rootfsDigest(), + Kernel: f.OS.Kernel, + KioskUrl: kioskURL, + StarshipName: f.Net.Hostname, + } +} + +// prober carries the small state needed for rate metrics (the CPU busy delta +// between two /proc/stat samples). One prober is reused across the connection. +type prober struct { + hostapiURL string + prevTotal uint64 + prevIdle uint64 + hasPrev bool +} + +// snapshot gathers one HealthSnapshot. at_unix is left 0 so the manager stamps +// receive-time (matches agent.go's snapshotToHealth path). +func (p *prober) snapshot(ctx context.Context) *fleetpb.HealthSnapshot { + f := fetchFacts(ctx, p.hostapiURL) + s := &fleetpb.HealthSnapshot{ + LoadAvg: loadAvg(), + CpuPct: p.cpuPct(), + TempC: tempC(), + MemUsedPct: memUsedPct(), + TmpfsUsedPct: fsUsedPct("/"), + NvmeRoot: nvmeRoot(), + Auth: authState(), + Selinux: selinuxState(), + Chrony: chronyState(), + KioskActive: unitActive("kiosk.service"), + SessionUptime: f.Kiosk.Uptime, + } + return s +} + +// ---- individual probes (all best-effort) ---------------------------------- + +func loadAvg() float64 { + b, err := os.ReadFile("/proc/loadavg") + if err != nil { + return 0 + } + return parseLoadAvg(b) +} + +// parseLoadAvg extracts the 1-minute load from /proc/loadavg content. +func parseLoadAvg(b []byte) float64 { + fields := strings.Fields(string(b)) + if len(fields) == 0 { + return 0 + } + v, _ := strconv.ParseFloat(fields[0], 64) + return v +} + +// cpuPct returns the busy percentage since the previous sample. The first call +// (no prior sample) returns 0 and primes the delta. +func (p *prober) cpuPct() float64 { + total, idle, ok := readCPU() + if !ok { + return 0 + } + defer func() { p.prevTotal, p.prevIdle, p.hasPrev = total, idle, true }() + if !p.hasPrev { + return 0 + } + return cpuBusyPct(p.prevTotal, p.prevIdle, total, idle) +} + +// cpuBusyPct computes the busy fraction between two /proc/stat samples. +func cpuBusyPct(prevTotal, prevIdle, total, idle uint64) float64 { + if total <= prevTotal { + return 0 + } + dTotal := total - prevTotal + dIdle := min(idle-prevIdle, dTotal) + return float64(dTotal-dIdle) / float64(dTotal) * 100 +} + +// readCPU parses the aggregate "cpu" line of /proc/stat into total + idle jiffies. +func readCPU() (total, idle uint64, ok bool) { + b, err := os.ReadFile("/proc/stat") + if err != nil { + return 0, 0, false + } + return parseCPUStat(b) +} + +func parseCPUStat(b []byte) (total, idle uint64, ok bool) { + for line := range strings.SplitSeq(string(b), "\n") { + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line)[1:] // user nice system idle iowait irq softirq steal ... + for i, f := range fields { + v, err := strconv.ParseUint(f, 10, 64) + if err != nil { + continue + } + total += v + if i == 3 || i == 4 { // idle + iowait + idle += v + } + } + return total, idle, true + } + return 0, 0, false +} + +func memUsedPct() float64 { + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0 + } + return parseMemUsedPct(b) +} + +func parseMemUsedPct(b []byte) float64 { + var totalKB, availKB float64 + for line := range strings.SplitSeq(string(b), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + v, _ := strconv.ParseFloat(fields[1], 64) + switch fields[0] { + case "MemTotal:": + totalKB = v + case "MemAvailable:": + availKB = v + } + } + if totalKB == 0 { + return 0 + } + return (totalKB - availKB) / totalKB * 100 +} + +// fsUsedPct returns used-space percentage of the filesystem at path (the root +// overlay = tmpfs RAM pressure). +func fsUsedPct(path string) float64 { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil || st.Blocks == 0 { + return 0 + } + used := st.Blocks - st.Bfree + return float64(used) / float64(st.Blocks) * 100 +} + +// tempC returns the first readable thermal zone in Celsius (sysfs reports milli-C). +func tempC() float64 { + for i := range 16 { + b, err := os.ReadFile("/sys/class/thermal/thermal_zone" + strconv.Itoa(i) + "/temp") + if err != nil { + if i == 0 { + continue // zone0 may be absent; keep scanning a few + } + break + } + milli, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64) + if err == nil && milli > 0 { + return milli / 1000 + } + } + return 0 +} + +// nvmeRoot reports the verity root device health: ok if /dev/mapper/root exists, +// down otherwise. (A finer degraded state needs nvme-cli; v1 keeps it binary.) +func nvmeRoot() string { + if _, err := os.Stat("/dev/mapper/root"); err == nil { + return "ok" + } + return "down" +} + +// authState reports the 802.1X/MACsec link: up if macsec0 has carrier (secure +// path), "open" when there is no macsec0 (test/open-network path rd.dot1x=0). +func authState() string { + if _, err := os.Stat("/sys/class/net/macsec0"); err != nil { + return "open" + } + if b, err := os.ReadFile("/sys/class/net/macsec0/carrier"); err == nil && strings.TrimSpace(string(b)) == "1" { + return "up" + } + return "down" +} + +// selinuxState reads the enforce node; absent selinuxfs => "" (unknown). +func selinuxState() string { + b, err := os.ReadFile("/sys/fs/selinux/enforce") + if err != nil { + return "" + } + if strings.TrimSpace(string(b)) == "1" { + return "enforcing" + } + return "permissive" +} + +// chronyState best-effort: "synced" when chronyc tracking reports a Normal leap +// status, "unsynced" otherwise, "" if chronyc is unavailable. +func chronyState() string { + out, err := runShort("chronyc", "-n", "tracking") + if err != nil { + return "" + } + for line := range strings.SplitSeq(out, "\n") { + if strings.HasPrefix(line, "Leap status") { + if strings.Contains(line, "Normal") { + return "synced" + } + return "unsynced" + } + } + return "unsynced" +} + +// unitActive reports whether a systemd unit is active (best-effort). +func unitActive(unit string) bool { + out, err := runShort("systemctl", "is-active", unit) + return err == nil && strings.TrimSpace(out) == "active" +} + +// rootfsDigest best-effort: the dm-verity root hash from the kernel cmdline (the +// content-address of the running root). Empty if not present in this layout. +func rootfsDigest() string { + b, err := os.ReadFile("/proc/cmdline") + if err != nil { + return "" + } + for tok := range strings.FieldsSeq(string(b)) { + for _, k := range []string{"systemd.verity_root_hash=", "roothash="} { + if after, ok := strings.CutPrefix(tok, k); ok { + return after + } + } + } + return "" +} + +// runShort runs a command with a short timeout and returns its stdout. Used only +// for the best-effort chrony/systemd probes; a missing binary is a soft failure. +func runShort(name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + return string(out), err +} diff --git a/files/agent/snapshot_test.go b/files/agent/snapshot_test.go new file mode 100644 index 0000000..0c3b773 --- /dev/null +++ b/files/agent/snapshot_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "math" + "os" + "testing" +) + +func approx(a, b float64) bool { return math.Abs(a-b) < 0.01 } + +func TestParseLoadAvg(t *testing.T) { + got := parseLoadAvg([]byte("0.42 0.31 0.19 1/234 5678\n")) + if !approx(got, 0.42) { + t.Fatalf("load = %v, want 0.42", got) + } + if v := parseLoadAvg([]byte("")); v != 0 { + t.Fatalf("empty load = %v, want 0", v) + } +} + +func TestParseCPUStat(t *testing.T) { + total, idle, ok := parseCPUStat([]byte("cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 1 2 3 4\n")) + if !ok { + t.Fatal("parseCPUStat not ok") + } + // total = 100+0+50+800+50 = 1000; idle = idle(800)+iowait(50) = 850. + if total != 1000 || idle != 850 { + t.Fatalf("total=%d idle=%d, want 1000/850", total, idle) + } +} + +func TestCPUBusyPct(t *testing.T) { + // From (total 1000, idle 850) to (total 1100, idle 900): dTotal=100, dIdle=50 + // → busy = 50/100 = 50%. + if got := cpuBusyPct(1000, 850, 1100, 900); !approx(got, 50) { + t.Fatalf("busy = %v, want 50", got) + } + // No forward progress → 0 (guards a counter reset / same sample). + if got := cpuBusyPct(1000, 850, 1000, 850); got != 0 { + t.Fatalf("no-delta busy = %v, want 0", got) + } + // idle jumping more than total is clamped, never negative. + if got := cpuBusyPct(1000, 850, 1100, 1000); got != 0 { + t.Fatalf("clamped busy = %v, want 0", got) + } +} + +func TestParseMemUsedPct(t *testing.T) { + // total 1000, available 250 → used 75%. + mem := []byte("MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n") + if got := parseMemUsedPct(mem); !approx(got, 75) { + t.Fatalf("mem used = %v, want 75", got) + } + if got := parseMemUsedPct([]byte("garbage")); got != 0 { + t.Fatalf("garbage mem = %v, want 0", got) + } +} + +func TestParseHandle(t *testing.T) { + for _, tc := range []struct { + in string + want uint32 + bad bool + }{ + {"0x81000100", 0x81000100, false}, + {"81000100", 0x81000100, false}, + {"0X01800100", 0x01800100, false}, + {"nope", 0, true}, + } { + h, err := parseHandle(tc.in) + if tc.bad { + if err == nil { + t.Errorf("parseHandle(%q) = %#x, want error", tc.in, uint32(h)) + } + continue + } + if err != nil || uint32(h) != tc.want { + t.Errorf("parseHandle(%q) = %#x, %v; want %#x", tc.in, uint32(h), err, tc.want) + } + } +} + +func TestHostOf(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"diskless-server.bitasylum.org:9600", "diskless-server.bitasylum.org"}, + {"host", "host"}, + {"[2001:db8::1]:9600", "2001:db8::1"}, + } { + if got := hostOf(tc.in); got != tc.want { + t.Errorf("hostOf(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestReadKioskURL(t *testing.T) { + dir := t.TempDir() + f := dir + "/kiosk.env" + if err := os.WriteFile(f, []byte("# comment\nKIOSK_URL=\"http://boot.fleet.lab/kiosk/\"\nOTHER=x\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := readKioskURL(f); got != "http://boot.fleet.lab/kiosk/" { + t.Fatalf("kiosk url = %q", got) + } + if got := readKioskURL(dir + "/missing"); got != "" { + t.Fatalf("missing kiosk env = %q, want empty", got) + } +} diff --git a/files/agent/tpmkey/tpmkey.go b/files/agent/tpmkey/tpmkey.go new file mode 100644 index 0000000..189c892 --- /dev/null +++ b/files/agent/tpmkey/tpmkey.go @@ -0,0 +1,304 @@ +// Package tpmkey — TPM 2.0 operations via go-tpm (pure Go, no CGO, no tpm2-tools +// / no openssl tpm2 provider). Provides: ensure a persistent ECDSA signing key +// at a non-reserved owner handle (default 0x81000100 — NOT the EK slot +// 0x81010001, see EnsureKey), a crypto.Signer backed by it (for CSR signing), +// and NV cert read-presence + write at 0x01800100. Talks to any transport.TPM, +// so the same +// code runs against /dev/tpmrm0 (production) and the software simulator (tests). +// +// Exported so the ceremony can be tested against the go-tpm simulator from a +// SEPARATE module (simtest/), keeping this module's vendored deps lean (go-tpm +// only) — the simulator (CGO + the MS TPM reference C) never enters the image build. +// +// NOTE: this is a COPY of files/enrol/tpmkey — kept byte-identical apart from the +// added read-only LoadSigner — so the reused-identity agent has the exact same, +// bench-verified TPM signing path without importing across the load-bearing +// fleet-enrol module. The agent only READS the key (LoadSigner) + NV cert +// (ReadNVCert); it never creates/evicts. A future refactor could promote tpmkey to +// a shared module depended on by both binaries. +package tpmkey + +import ( + "crypto" + "crypto/ecdsa" + "encoding/asn1" + "fmt" + "io" + "math/big" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +// eccSigningTemplate: a NON-restricted ECC P-256 ECDSA-SHA256 signing key. +// Non-restricted so it can sign an externally-computed digest (the CSR TBS) with +// a NULL validation ticket; Symmetric/KDF are explicitly NULL (sign-only key). +func eccSigningTemplate() tpm2.TPMTPublic { + return tpm2.TPMTPublic{ + Type: tpm2.TPMAlgECC, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: tpm2.TPMAObject{ + FixedTPM: true, + FixedParent: true, + SensitiveDataOrigin: true, + UserWithAuth: true, + SignEncrypt: true, + NoDA: true, + }, + Parameters: tpm2.NewTPMUPublicParms(tpm2.TPMAlgECC, &tpm2.TPMSECCParms{ + Symmetric: tpm2.TPMTSymDefObject{Algorithm: tpm2.TPMAlgNull}, + Scheme: tpm2.TPMTECCScheme{ + Scheme: tpm2.TPMAlgECDSA, + Details: tpm2.NewTPMUAsymScheme(tpm2.TPMAlgECDSA, &tpm2.TPMSSigSchemeECDSA{ + HashAlg: tpm2.TPMAlgSHA256, + }), + }, + CurveID: tpm2.TPMECCNistP256, + KDF: tpm2.TPMTKDFScheme{Scheme: tpm2.TPMAlgNull}, + }), + } +} + +// Signer implements crypto.Signer using a persistent TPM key. +type Signer struct { + tpm transport.TPM + handle tpm2.TPMHandle + name tpm2.TPM2BName + pub *ecdsa.PublicKey +} + +func (s *Signer) Public() crypto.PublicKey { return s.pub } + +func (s *Signer) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { + rsp, err := tpm2.Sign{ + KeyHandle: tpm2.NamedHandle{Handle: s.handle, Name: s.name}, + Digest: tpm2.TPM2BDigest{Buffer: digest}, + InScheme: tpm2.TPMTSigScheme{ + Scheme: tpm2.TPMAlgECDSA, + Details: tpm2.NewTPMUSigScheme(tpm2.TPMAlgECDSA, &tpm2.TPMSSchemeHash{ + HashAlg: tpm2.TPMAlgSHA256, + }), + }, + Validation: tpm2.TPMTTKHashCheck{Tag: tpm2.TPMSTHashCheck}, + }.Execute(s.tpm) + if err != nil { + return nil, fmt.Errorf("tpm sign: %w", err) + } + ecc, err := rsp.Signature.Signature.ECDSA() + if err != nil { + return nil, fmt.Errorf("tpm sign: not ECDSA: %w", err) + } + r := new(big.Int).SetBytes(ecc.SignatureR.Buffer) + sInt := new(big.Int).SetBytes(ecc.SignatureS.Buffer) + // x509 expects an ASN.1 DER ECDSA-Sig-Value{ r, s }. + return asn1.Marshal(struct{ R, S *big.Int }{r, sInt}) +} + +func signerFromPub(tpm transport.TPM, handle tpm2.TPMHandle, name tpm2.TPM2BName, pub2b tpm2.TPM2BPublic) (*Signer, error) { + pub, err := pub2b.Contents() + if err != nil { + return nil, err + } + det, err := pub.Parameters.ECCDetail() + if err != nil { + return nil, fmt.Errorf("key at handle is not ECC: %w", err) + } + uniq, err := pub.Unique.ECC() + if err != nil { + return nil, err + } + pk, err := tpm2.ECDSAPub(det, uniq) + if err != nil { + return nil, err + } + return &Signer{tpm: tpm, handle: handle, name: name, pub: pk}, nil +} + +// isProtectedObject reports whether a persistent object is a restricted +// decryption key — i.e. an Endorsement Key or Storage Root Key. We must NEVER +// evict one of those (it would destroy the TPM's endorsement/storage identity, +// including the EK the future EK/AK attestation path depends on). Fail-safe: if +// the public area can't be parsed, treat it as protected. +func isProtectedObject(pub2b tpm2.TPM2BPublic) bool { + pub, err := pub2b.Contents() + if err != nil { + return true + } + a := pub.ObjectAttributes + return a.Restricted && a.Decrypt +} + +// ensureKey returns a signer for the persistent key at handle, creating + +// persisting a fresh ECDSA key there if none exists. Returns created=true when +// a new key was generated. handle MUST be a non-reserved owner handle (the fleet +// default is 0x81000100) — NOT the EK (0x81010001/2) or SRK (0x81000001/2) slots. +// +// If the handle already holds an object that is NOT our ECC signing key (e.g. an +// RSA signing key left by an older tpm2-tools/openssl provisioning path — go-tpm +// reports "key at handle is not ECC"), it is treated as STALE and evicted, then a +// fresh ECC key is created. Safe on the enrol path: this only runs on an empty-NV +// host, so no issued cert references the old key. Only a valid ECC key is reused. +// A restricted-decrypt object (EK/SRK) is NEVER evicted — it errors instead, which +// only happens if handle was misconfigured to a reserved slot. +func EnsureKey(tpm transport.TPM, handle tpm2.TPMHandle) (signer *Signer, created bool, err error) { + if rp, e := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm); e == nil { + // Guard FIRST: never touch a restricted-decrypt object (EK/SRK). An ECC + // EK/SRK would otherwise parse as a usable ECC key below and be "reused". + if isProtectedObject(rp.OutPublic) { + return nil, false, fmt.Errorf("refusing to evict a restricted-decrypt object (EK/SRK?) at handle %#x — configure a non-reserved owner handle (e.g. 0x81000100)", uint32(handle)) + } + if s, e2 := signerFromPub(tpm, handle, rp.Name, rp.OutPublic); e2 == nil { + return s, false, nil // our ECC signing key is already there — reuse it + } + // Stale/wrong-type signing key occupying the handle — evict so we recreate. + if _, e2 := (tpm2.EvictControl{ + Auth: tpm2.TPMRHOwner, + ObjectHandle: &tpm2.NamedHandle{Handle: handle, Name: rp.Name}, + PersistentHandle: handle, + }).Execute(tpm); e2 != nil { + return nil, false, fmt.Errorf("evict stale (non-ECC) key at handle: %w", e2) + } + } + + cp, err := tpm2.CreatePrimary{ + PrimaryHandle: tpm2.TPMRHOwner, + InPublic: tpm2.New2B(eccSigningTemplate()), + }.Execute(tpm) + if err != nil { + return nil, false, fmt.Errorf("createprimary: %w", err) + } + // Persist at the well-known handle, then flush the transient object. + _, err = tpm2.EvictControl{ + Auth: tpm2.TPMRHOwner, + ObjectHandle: &tpm2.NamedHandle{Handle: cp.ObjectHandle, Name: cp.Name}, + PersistentHandle: handle, + }.Execute(tpm) + _, _ = (tpm2.FlushContext{FlushHandle: cp.ObjectHandle}).Execute(tpm) + if err != nil { + return nil, false, fmt.Errorf("evictcontrol: %w", err) + } + rp, err := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm) + if err != nil { + return nil, false, fmt.Errorf("readpublic after persist: %w", err) + } + s, err := signerFromPub(tpm, handle, rp.Name, rp.OutPublic) + return s, true, err +} + +// LoadSigner returns a crypto.Signer for the persistent key ALREADY at handle, +// WITHOUT creating one. This is the reused-identity path (normal boot / the on-host +// agent): the fleet signing key was created at enrolment, so here we only sign the +// TLS handshake with it — the private key never leaves the TPM. Errors (never +// creates/evicts) if the handle is empty, holds a non-ECC object, or holds a +// restricted-decrypt EK/SRK. Contrast EnsureKey, which is the enrol-time create path. +func LoadSigner(tpm transport.TPM, handle tpm2.TPMHandle) (*Signer, error) { + rp, err := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("no persistent key at handle %#x: %w", uint32(handle), err) + } + if isProtectedObject(rp.OutPublic) { + return nil, fmt.Errorf("object at handle %#x is a restricted-decrypt key (EK/SRK?), not the fleet signing key", uint32(handle)) + } + return signerFromPub(tpm, handle, rp.Name, rp.OutPublic) +} + +// nvPresent reports whether a defined NV index exists. NOTE: "defined" does NOT +// mean "holds our fleet cert" — leftover/foreign NV (e.g. an earlier +// tpm-provision run, or another OS) also reports present. Prefer ReadNVCert + +// content validation for the enrol decision; this stays for cheap existence checks. +func NVPresent(tpm transport.TPM, index tpm2.TPMHandle) bool { + _, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) + return err == nil +} + +// ReadNVCert reads the full contents of NV index (chunked under the NV buffer +// limit). Returns an error if the index is undefined or unreadable — callers +// treat that as "no cert" (enrol). The bytes are the caller's to parse/validate; +// this does not assume they are a certificate. +func ReadNVCert(tpm transport.TPM, index tpm2.TPMHandle) ([]byte, error) { + rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("nv readpublic: %w", err) + } + pub, err := rp.NVPublic.Contents() + if err != nil { + return nil, err + } + size := int(pub.DataSize) + out := make([]byte, 0, size) + const chunk = 512 // conservatively under TPM_MAX_NV_BUFFER_SIZE + for off := 0; off < size; off += chunk { + end := min(off+chunk, size) + r, err := (tpm2.NVRead{ + AuthHandle: tpm2.AuthHandle{Handle: index, Name: rp.NVName, Auth: tpm2.PasswordAuth(nil)}, + NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, + Size: uint16(end - off), + Offset: uint16(off), + }).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("nv read @%d: %w", off, err) + } + out = append(out, r.Data.Buffer...) + } + return out, nil +} + +// nvWriteCert (re)defines the NV index sized to der and writes it under owner +// auth. Chunks the write to stay within the TPM's NV buffer limit. +func WriteNVCert(tpm transport.TPM, index tpm2.TPMHandle, der []byte) error { + // Undefine a stale index first (re-enrolment / resized cert). + if rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm); err == nil { + // A PLATFORM-created index at our handle is OEM firmware NV (bench-seen on + // a Dell 820: 0x01800001 was platformcreate|policydelete|writelocked). We + // cannot undefine it with owner auth, and TPM2_Clear/BIOS won't remove it — + // so fail LOUDLY with guidance rather than a confusing "nv undefine" error. + // The remedy is a different, free owner index via rd.dot1x.nvindex. + if pub, e := rp.NVPublic.Contents(); e == nil && pub.Attributes.PlatformCreate { + return fmt.Errorf("NV index %#x is a platform-created (firmware-owned) index — cannot be written by enrolment; pick a free owner index via rd.dot1x.nvindex / -nvindex", uint32(index)) + } + if _, err := (tpm2.NVUndefineSpace{ + AuthHandle: tpm2.TPMRHOwner, + NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, + }).Execute(tpm); err != nil { + return fmt.Errorf("nv undefine: %w", err) + } + } + + def := tpm2.NVDefineSpace{ + AuthHandle: tpm2.TPMRHOwner, + PublicInfo: tpm2.New2B(tpm2.TPMSNVPublic{ + NVIndex: index, + NameAlg: tpm2.TPMAlgSHA256, + Attributes: tpm2.TPMANV{ + OwnerWrite: true, + OwnerRead: true, + AuthWrite: true, + AuthRead: true, + NT: tpm2.TPMNTOrdinary, + NoDA: true, + }, + DataSize: uint16(len(der)), + }), + } + if _, err := def.Execute(tpm); err != nil { + return fmt.Errorf("nv define: %w", err) + } + rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) + if err != nil { + return fmt.Errorf("nv readpublic: %w", err) + } + + const chunk = 512 // conservatively under TPM_MAX_NV_BUFFER_SIZE + for off := 0; off < len(der); off += chunk { + end := min(off+chunk, len(der)) + if _, err := (tpm2.NVWrite{ + AuthHandle: tpm2.AuthHandle{Handle: index, Name: rp.NVName, Auth: tpm2.PasswordAuth(nil)}, + NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, + Data: tpm2.TPM2BMaxNVBuffer{Buffer: der[off:end]}, + Offset: uint16(off), + }).Execute(tpm); err != nil { + return fmt.Errorf("nv write @%d: %w", off, err) + } + } + return nil +} diff --git a/files/etc/fleet-agent/agent.env b/files/etc/fleet-agent/agent.env new file mode 100644 index 0000000..4d3578a --- /dev/null +++ b/files/etc/fleet-agent/agent.env @@ -0,0 +1,29 @@ +# fleet-agent config — EnvironmentFile for fleet-agent.service. +# +# FLEET-GENERIC ONLY (invariant #1): every value here is identical for every host +# of a release. Host identity is the TPM (signing key at the persistent handle + +# cert in NV) resolved at runtime — nothing per-host is baked. Override per +# environment by replacing this file (it is part of the immutable image, so a +# change means a rebuild + re-sign, like any other baked config). + +# The fleet-manager agent plane (gRPC/mTLS). A DNS name, NOT a per-host value. +# Reached from the authenticated host VLAN via the OPNsense L3 hop to the mgmt +# plane (not the boot nginx). Set to your manager's real name:port. +FLEET_AGENT_MANAGER=diskless-server.bitasylum.org:9600 + +# Fleet CA that verifies the manager's server cert. This is the SAME step-ca root +# that signs the host TPM certs + the RADIUS server cert, so we reuse the in-place +# copy rather than duplicating it. Replace per environment with your real root +# (the shipped files/etc/dot1x/radius-ca.pem is a placeholder — auth/telemetry +# cannot succeed until it is the real fleet root). +FLEET_AGENT_CA=/etc/dot1x/radius-ca.pem + +# TPM identity (overridable, mirrors rd.dot1x.handle / rd.dot1x.nvindex). These +# MUST match what enrolment used: signing key at a non-reserved owner handle, cert +# in an owner NV index. +FLEET_AGENT_HANDLE=0x81000100 +FLEET_AGENT_NVINDEX=0x01800100 + +# Health snapshot cadence and the loopback hostapi facts source. +FLEET_AGENT_INTERVAL=30s +FLEET_AGENT_HOSTAPI=http://127.0.0.1:9099/v1/host