fleet-agent: on-host TPM-mTLS telemetry client (v1)

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.
This commit is contained in:
Audiobuzz
2026-07-05 03:15:13 +00:00
parent 8ab11a74c5
commit d5c833addf
21 changed files with 2679 additions and 21 deletions
+15 -8
View File
@@ -197,14 +197,14 @@ jobs:
go-version: '1.26.4' 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 # 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 # image never carries the Go toolchain or the go-tpm dep tree. Packer
# file-provisions the resulting static binaries into the rootfs. Runs # file-provisions the resulting static binaries into the rootfs. Runs
# BEFORE packer validate/build so the file provisioners' sources exist. # BEFORE packer validate/build so the file provisioners' sources exist.
# Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid. # 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 # fleet-enrol + fleet-agent fetch go-tpm/grpc from the proxy (pinned by
# stdlib-only (GOPROXY=off). # go.sum); hostapi is stdlib-only (GOPROXY=off).
# shell: bash is explicit — the Gitea/act runner defaults run steps to sh # 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 # (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 # load-bearing: without it a failed go build/test in the first ( … ) group
@@ -219,13 +219,20 @@ jobs:
( cd files/hostapi \ ( cd files/hostapi \
&& CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \ && CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . ) 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) name: TPM ceremony + agent mTLS tests (go-tpm software simulator)
# Exercises the real key-gen/sign/NV path against a software TPM (CGO in a # Exercises the real key-gen/sign/NV path (enrol) AND the reused-TPM mTLS
# separate module, so the simulator never enters the shipped enrol build). # client-auth handshake (agent) against a software TPM (CGO in separate
run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... ) # 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 name: packer init
+1
View File
@@ -19,3 +19,4 @@ keys
# provisioner; built by the "Build host Go binaries" CI step). # provisioner; built by the "Build host Go binaries" CI step).
files/enrol/bin/ files/enrol/bin/
files/hostapi/bin/ files/hostapi/bin/
files/agent/bin/
+46 -2
View File
@@ -130,6 +130,12 @@ variable "kiosk_url" {
default = "http://boot.fleet.lab/kiosk/" 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" { variable "boot_splash" {
type = bool 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.)" 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" 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 # Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to
# /tmp/plymouth, installed + set default in the package step before dracut runs. # /tmp/plymouth, installed + set default in the package step before dracut runs.
provisioner "file" { 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 -------------- # --- 2c. Admin access: sudo-enabled key user + locked root --------------
# Fleet-generic (same user + key on every host => NOT per-host state). The # 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 # 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 # 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.) # /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.)
"if command -v fapolicyd-cli >/dev/null 2>&1; then", "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", " 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", "fi",
] ]
} }
+23 -5
View File
@@ -1396,13 +1396,31 @@ the design forks resolved:
+ agent (`:9600`) listeners to the mgmt net; run `server-pki.sh` when the agent + 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 lands; decide `:9500` vs a 443 proxy; digest-pin the images (invariant #12). See
DEPLOY.md "Hardening status". DEPLOY.md "Hardening status".
- **STILL TO BUILD:** (a) the **on-host agent** — the gRPC report/reconcile client - **[~] (a) the on-host agent — TELEMETRY HALF BUILT 2026-07-05 (`files/agent/`).**
that pairs with `fleet-hostapi` (feeds this plane; nothing on the host talks to The gRPC client that pairs with `fleet-hostapi` and feeds this plane. It reuses
the manager yet); (b) **per-host boot-version assignment** — `target_version` is 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: <serial>`; 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 stored + pushed but does not change what a host *boots*; that needs dnsmasq
cohort steering + multi-version UKI serving (**#6**). Kiosk URL / reboot / drain cohort steering + multi-version UKI serving (**#6**). Kiosk URL / reboot / drain
reconcile via the agent once (a) lands. Version pin is labelled "recorded intent" reconcile via the agent's deferred actuator half (above). Version pin is labelled
in the Lifecycle UI. "recorded intent" in the Lifecycle UI.
Depends on the step-ca fleet CA (#8) for the mTLS identity. Depends on the step-ca fleet CA (#8) for the mTLS identity.
+13 -6
View File
@@ -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** - **Agent identity confirmed:** the agent reuses the **same TPM identity cert**
as 802.1X EAP-TLS / NVMe hostnqn — "one identity, three stages". Peer cert as 802.1X EAP-TLS / NVMe hostnqn — "one identity, three stages". Peer cert
serial is authoritative; an unenrolled serial is refused. 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* `desired.target_version` is stored + pushed, but changing what a host *boots*
needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6). needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6). The
Kiosk URL / reboot / drain reconcile via the agent today; the version pin is version pin is labelled "recorded intent" in the Lifecycle UI.
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.
Everything below is the original design context; treat the as-built note as Everything below is the original design context; treat the as-built note as
authoritative where they differ. authoritative where they differ.
+69
View File
@@ -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 ./... )
```
+53
View File
@@ -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
+28
View File
@@ -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
)
+42
View File
@@ -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=
+96
View File
@@ -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
}
+245
View File
@@ -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 ""
}
+810
View File
@@ -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
}
+96
View File
@@ -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
}
}
+130
View File
@@ -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",
}
+21
View File
@@ -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 => ../
+26
View File
@@ -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=
+207
View File
@@ -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)")
}
}
+318
View File
@@ -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
}
+107
View File
@@ -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)
}
}
+304
View File
@@ -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
}
+29
View File
@@ -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