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

Build the on-host agent that pairs with fleet-hostapi and feeds the
fleet-manager control plane (TODO #14 "STILL TO BUILD (a)"). Nothing on the
workstation talked to the manager before this; Monitor + InfluxDB stayed empty.

Scope: telemetry only. The agent reuses the host TPM identity for mTLS (read-only
tpmkey.LoadSigner at 0x81000100 + cert from NV 0x01800100; the private key never
leaves the TPM), opens the long-lived FleetAgent.Session bidi stream, sends Hello
+ periodic HealthSnapshot (load/cpu/temp/mem/tmpfs/nvme/auth/selinux/chrony/kiosk,
probed read-only from /proc+/sys plus hostapi facts), and LOGS but does not act on
the DesiredState/Command it receives. Reconcile actuators (kiosk URL, reboot,
drain) are deferred until the telemetry half is bench-verified.

- files/agent/: new module (go-tpm + grpc), static CGO_ENABLED=0 like enrol/hostapi.
  Copies the enrol tpmkey (+ read-only LoadSigner) and the generated fleet.agent.v1
  proto so it stays self-contained without importing the initramfs enrol module.
- files/agent/simtest/: proves the TPM-signed cert completes a real mTLS client-auth
  handshake (RequireAndVerifyClientCert) against the go-tpm software simulator.
- files/etc/fleet-agent/agent.env: fleet-generic config (manager DNS name, fleet CA
  reused from radius-ca.pem, TPM handle/nvindex, interval). No per-host state.
- fleet-agent.service: hardened unit (tss group for /dev/tpmrm0, INET egress).
- CI builds fleet-agent out-of-VM + runs vet/test + the simulator handshake test.
- Packer: enable_agent flag (default on) + 2b-agent provisioner + fapolicyd trust.
  Post-switch-root only, so no dracut/initramfs changes.
- docs: TODO #14 + fleet-management-design as-built updated.

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