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'
|
||||
|
||||
-
|
||||
name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi)
|
||||
name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi, fleet-agent)
|
||||
# Built HERE on the runner, NOT inside the Packer VM, so the Rocky build
|
||||
# image never carries the Go toolchain or the go-tpm dep tree. Packer
|
||||
# file-provisions the resulting static binaries into the rootfs. Runs
|
||||
# BEFORE packer validate/build so the file provisioners' sources exist.
|
||||
# Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid.
|
||||
# fleet-enrol fetches go-tpm from the proxy (pinned by go.sum); hostapi is
|
||||
# stdlib-only (GOPROXY=off).
|
||||
# fleet-enrol + fleet-agent fetch go-tpm/grpc from the proxy (pinned by
|
||||
# go.sum); hostapi is stdlib-only (GOPROXY=off).
|
||||
# shell: bash is explicit — the Gitea/act runner defaults run steps to sh
|
||||
# (dash), where `set -o pipefail` is an illegal option. `set -e` is
|
||||
# load-bearing: without it a failed go build/test in the first ( … ) group
|
||||
@@ -219,13 +219,20 @@ jobs:
|
||||
( cd files/hostapi \
|
||||
&& CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \
|
||||
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . )
|
||||
file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi
|
||||
( cd files/agent \
|
||||
&& CGO_ENABLED=0 GOTOOLCHAIN=local \
|
||||
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-agent . \
|
||||
&& CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 go test ./... )
|
||||
file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi files/agent/bin/fleet-agent
|
||||
|
||||
-
|
||||
name: TPM enrolment ceremony test (go-tpm software simulator)
|
||||
# Exercises the real key-gen/sign/NV path against a software TPM (CGO in a
|
||||
# separate module, so the simulator never enters the shipped enrol build).
|
||||
run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... )
|
||||
name: TPM ceremony + agent mTLS tests (go-tpm software simulator)
|
||||
# Exercises the real key-gen/sign/NV path (enrol) AND the reused-TPM mTLS
|
||||
# client-auth handshake (agent) against a software TPM (CGO in separate
|
||||
# modules, so the simulator never enters the shipped enrol/agent builds).
|
||||
run: |
|
||||
( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... )
|
||||
( cd files/agent/simtest && CGO_ENABLED=1 go test ./... )
|
||||
|
||||
-
|
||||
name: packer init
|
||||
|
||||
@@ -19,3 +19,4 @@ keys
|
||||
# provisioner; built by the "Build host Go binaries" CI step).
|
||||
files/enrol/bin/
|
||||
files/hostapi/bin/
|
||||
files/agent/bin/
|
||||
|
||||
+46
-2
@@ -130,6 +130,12 @@ variable "kiosk_url" {
|
||||
default = "http://boot.fleet.lab/kiosk/"
|
||||
}
|
||||
|
||||
variable "enable_agent" {
|
||||
type = bool
|
||||
description = "Build-time feature flag for the on-host fleet-agent (telemetry client that dials the fleet-manager over TPM-mTLS and streams health; docs/fleet-management-design.md). true (default) installs /usr/local/bin/fleet-agent + fleet-agent.service; false skips it (the auth stack still boots — the agent is a management add-on). Config is fleet-generic in files/etc/fleet-agent/agent.env; set the manager DNS name there per environment."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "boot_splash" {
|
||||
type = bool
|
||||
description = "true (default): bake 'rhgb quiet' into the UKI cmdline so Plymouth shows a graphical boot splash (covers the network-auth + NVMe/TCP + verity phase). false: bake 'plymouth.enable=0' for a fully verbose console boot — use while debugging the boot chain. Plymouth itself is always in the image; this only flips the cmdline. (The cmdline is immutable once signed, so this is a build-time choice.)"
|
||||
@@ -259,6 +265,18 @@ build {
|
||||
destination = "/tmp/fleet-hostapi"
|
||||
}
|
||||
|
||||
# On-host fleet-agent: the systemd unit is staged from here; the static binary
|
||||
# (built OUT OF THE VM like enrol/hostapi) is staged separately. The baked,
|
||||
# fleet-generic config lands via the /tmp/etc-stage contents upload below.
|
||||
provisioner "file" {
|
||||
source = "files/agent"
|
||||
destination = "/tmp/"
|
||||
}
|
||||
provisioner "file" {
|
||||
source = "files/agent/bin/fleet-agent"
|
||||
destination = "/tmp/fleet-agent"
|
||||
}
|
||||
|
||||
# Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to
|
||||
# /tmp/plymouth, installed + set default in the package step before dracut runs.
|
||||
provisioner "file" {
|
||||
@@ -608,6 +626,32 @@ build {
|
||||
]
|
||||
}
|
||||
|
||||
# --- 2b-agent. On-host fleet-agent (Go, TPM-mTLS telemetry client) --------
|
||||
# Installs the OUT-OF-VM-built /usr/local/bin/fleet-agent (the "Build host Go
|
||||
# binaries" CI step; staged to /tmp/fleet-agent) + its systemd unit + baked,
|
||||
# fleet-generic config, and enables it. The agent dials the fleet-manager over
|
||||
# mTLS using the host's reused TPM identity (signing key at 0x81000100 + cert in
|
||||
# NV 0x01800100) and streams health; NOTHING host-specific is baked (invariant
|
||||
# #1) — the manager DNS name in agent.env is fleet-generic. Gated on
|
||||
# enable_agent so a lean/auth-only build can skip it. The binary is
|
||||
# fapolicyd-trusted in 2c-STIG-postfix (non-RPM exec, like hostapi/kiosk-session).
|
||||
provisioner "shell" {
|
||||
environment_vars = [
|
||||
"ENABLE_AGENT=${var.enable_agent}",
|
||||
]
|
||||
inline = [
|
||||
"set -euxo pipefail",
|
||||
"if [ \"$ENABLE_AGENT\" != \"true\" ]; then echo 'enable_agent=false -> skipping fleet-agent install.'; exit 0; fi",
|
||||
"install -D -m0755 /tmp/fleet-agent /usr/local/bin/fleet-agent",
|
||||
"install -D -m0644 /tmp/agent/fleet-agent.service /etc/systemd/system/fleet-agent.service",
|
||||
"install -D -m0644 /tmp/etc-stage/fleet-agent/agent.env /etc/fleet-agent/agent.env",
|
||||
"restorecon -R /usr/local/bin/fleet-agent /etc/systemd/system/fleet-agent.service /etc/fleet-agent 2>/dev/null || true",
|
||||
"systemctl enable fleet-agent.service",
|
||||
"rm -rf /tmp/agent /tmp/fleet-agent",
|
||||
"echo '==> fleet-agent enabled (TPM-mTLS telemetry to the fleet-manager)'",
|
||||
]
|
||||
}
|
||||
|
||||
# --- 2c. Admin access: sudo-enabled key user + locked root --------------
|
||||
# Fleet-generic (same user + key on every host => NOT per-host state). The
|
||||
# key comes from var.ssh_authorized_key (CI secret). If no key is supplied we
|
||||
@@ -871,9 +915,9 @@ build {
|
||||
# custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston
|
||||
# /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.)
|
||||
"if command -v fapolicyd-cli >/dev/null 2>&1; then",
|
||||
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
|
||||
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
|
||||
" fapolicyd-cli --update 2>/dev/null || true",
|
||||
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'",
|
||||
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent) + rebuilt trust db'",
|
||||
"fi",
|
||||
]
|
||||
}
|
||||
|
||||
+23
-5
@@ -1396,13 +1396,31 @@ the design forks resolved:
|
||||
+ agent (`:9600`) listeners to the mgmt net; run `server-pki.sh` when the agent
|
||||
lands; decide `:9500` vs a 443 proxy; digest-pin the images (invariant #12). See
|
||||
DEPLOY.md "Hardening status".
|
||||
- **STILL TO BUILD:** (a) the **on-host agent** — the gRPC report/reconcile client
|
||||
that pairs with `fleet-hostapi` (feeds this plane; nothing on the host talks to
|
||||
the manager yet); (b) **per-host boot-version assignment** — `target_version` is
|
||||
- **[~] (a) the on-host agent — TELEMETRY HALF BUILT 2026-07-05 (`files/agent/`).**
|
||||
The gRPC client that pairs with `fleet-hostapi` and feeds this plane. It reuses
|
||||
the host **TPM identity** for mTLS (signing key at `0x81000100` via
|
||||
`tpmkey.LoadSigner` — a read-only copy of the enrol `tpmkey`; cert from NV
|
||||
`0x01800100`), opens the long-lived `Session` stream, sends `Hello` + periodic
|
||||
`HealthSnapshot` (load/cpu/temp/mem/tmpfs/nvme/auth/selinux/chrony/kiosk probed
|
||||
read-only from `/proc`+`/sys` + hostapi facts), and — **telemetry-only v1** —
|
||||
**receives `DesiredState`/`Command` but only LOGS them** (zero host mutation).
|
||||
Static `CGO_ENABLED=0` binary built out-of-VM in CI (like enrol/hostapi), staged
|
||||
by the `2b-agent` provisioner (`enable_agent`, default on), fapolicyd-trusted,
|
||||
fleet-generic config in `files/etc/fleet-agent/agent.env`. mTLS handshake proven
|
||||
end-to-end against the go-tpm software simulator (`files/agent/simtest`). No
|
||||
dracut/initramfs changes (post-switch-root only). **STILL TO BUILD (deferred):**
|
||||
the **reconcile actuators** — apply kiosk_url, reboot_at/REBOOT, drain — wired to
|
||||
be received but not acted on; land after the telemetry half is bench-verified.
|
||||
**BENCH-VERIFY:** on an enrolled booted host, `systemctl status fleet-agent`
|
||||
active; manager logs `agent connected: <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
|
||||
cohort steering + multi-version UKI serving (**#6**). Kiosk URL / reboot / drain
|
||||
reconcile via the agent once (a) lands. Version pin is labelled "recorded intent"
|
||||
in the Lifecycle UI.
|
||||
reconcile via the agent's deferred actuator half (above). Version pin is labelled
|
||||
"recorded intent" in the Lifecycle UI.
|
||||
|
||||
Depends on the step-ca fleet CA (#8) for the mTLS identity.
|
||||
|
||||
|
||||
@@ -41,13 +41,20 @@ The **server-side control plane + admin SPA is built** and supersedes several
|
||||
- **Agent identity confirmed:** the agent reuses the **same TPM identity cert**
|
||||
as 802.1X EAP-TLS / NVMe hostnqn — "one identity, three stages". Peer cert
|
||||
serial is authoritative; an unenrolled serial is refused.
|
||||
- **NOT yet built (recorded intent):** per-host **boot-version assignment**.
|
||||
- **On-host agent — telemetry half BUILT (2026-07-05, `files/agent/`).** The
|
||||
gRPC client that feeds this plane. Reuses the host TPM identity for mTLS (read-only
|
||||
`tpmkey.LoadSigner` at `0x81000100` + cert from NV `0x01800100`; private key never
|
||||
leaves the TPM), opens `Session`, sends `Hello` + periodic `HealthSnapshot`
|
||||
(read-only `/proc`+`/sys` probes + `fleet-hostapi` facts). **Telemetry-only:** it
|
||||
receives `DesiredState`/`Command` but only **logs** them. Static out-of-VM CI
|
||||
binary, `2b-agent` provisioner (`enable_agent`), fapolicyd-trusted; mTLS proven
|
||||
against the go-tpm simulator (`files/agent/simtest`).
|
||||
- **NOT yet built (recorded intent):** (1) the agent's **reconcile actuators** —
|
||||
apply kiosk_url, reboot_at/REBOOT, drain — deferred until the telemetry half is
|
||||
bench-verified; and (2) per-host **boot-version assignment**.
|
||||
`desired.target_version` is stored + pushed, but changing what a host *boots*
|
||||
needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6).
|
||||
Kiosk URL / reboot / drain reconcile via the agent today; the version pin is
|
||||
labelled "recorded intent" in the Lifecycle UI. The **on-host agent** that
|
||||
feeds this plane (extend `fleet-hostapi` with the gRPC report/reconcile client)
|
||||
is likewise still to build.
|
||||
needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6). The
|
||||
version pin is labelled "recorded intent" in the Lifecycle UI.
|
||||
|
||||
Everything below is the original design context; treat the as-built note as
|
||||
authoritative where they differ.
|
||||
|
||||
@@ -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