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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ./... )
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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 => ../
|
||||||
@@ -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=
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user