fleet-manager: checkpoint 2 — gRPC agent plane (bidi mTLS stream)
The steady-state host<->manager transport, resolving the design's OPEN FORK to bespoke gRPC (not OTLP+Wazuh). One long-lived, host-dialed, mTLS bidi stream: health/metrics up, desired-state/commands down — no polling, real-time control, still no inbound host port. - proto/fleet_agent.proto: FleetAgent.Session(stream AgentUp) -> stream ManagerDown. AgentUp = Hello | HealthSnapshot | CommandResult; ManagerDown = DesiredState | Command(reboot/drain/resync). int64 unix times (no well-known-type dep). Generated stubs committed (CI needs no protoc). - agent.go: gRPC server with mTLS verified by the FLEET CA (the host's reused TPM identity cert — distinct from the admin plane's admin CA). Identity is the peer cert SERIAL, taken from the verified cert = inventory key, never self-asserted; an unenrolled serial is refused (enrolment gates the stream). Per-conn send channel decouples admin pushes from stream.Send. Stream registry (serial->conn) with reconnect eviction. On connect + on change, desired-state is pushed live; inventory stays authoritative so offline hosts reconcile on reconnect. Metrics go through a MetricsSink seam (no-op now; InfluxDB in ckpt 4). - main.go: agent plane starts only when its cert/key/CA are set; admin can run standalone. Lifecycle PUT now live-pushes and reports pushed_live. Full mTLS integration test (in-process fleet CA + host client cert): initial desired-state on connect, Hello+snapshot -> inventory + online presence, live desired-state push, and unenrolled-serial rejection. go build/test/vet/gofmt clean. Deps (server-side only): google.golang.org/grpc + protobuf.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
fleetpb "fleet-manager/proto"
|
||||
)
|
||||
|
||||
// TestAgentSession drives the full agent plane over real mTLS: a host dials in
|
||||
// with a fleet-CA client cert, the manager derives its serial from that cert,
|
||||
// pushes the current desired-state on connect, ingests a Hello + HealthSnapshot
|
||||
// into inventory, and delivers a live desired-state change over the same stream.
|
||||
func TestAgentSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caCert, caKey := makeCA(t)
|
||||
caPath := writeFile(t, dir, "ca.crt", pemCert(caCert.Raw))
|
||||
|
||||
srvCertPEM, srvKeyPEM := makeLeaf(t, caCert, caKey, "fleet-manager", big.NewInt(1), true)
|
||||
srvCrt := writeFile(t, dir, "srv.crt", srvCertPEM)
|
||||
srvKey := writeFile(t, dir, "srv.key", srvKeyPEM)
|
||||
|
||||
const serialHexStr = "1234abcd"
|
||||
cliCertPEM, cliKeyPEM := makeLeaf(t, caCert, caKey, "fleet-host", big.NewInt(0x1234abcd), false)
|
||||
cliCert, err := tls.X509KeyPair(cliCertPEM, cliKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("client keypair: %v", err)
|
||||
}
|
||||
|
||||
store, err := newJSONStore("") // in-memory
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
if err := store.Upsert(&Host{
|
||||
Identity: Identity{CertSerial: serialHexStr, Hostnqn: "nqn.test:host-" + serialHexStr, StarshipName: "test-ship"},
|
||||
Desired: Desired{TargetVersion: "0.5.0", KioskURL: "http://k/"},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
hub := newAgentHub(store, nil)
|
||||
gs, err := agentServer(config{agentCert: srvCrt, agentKey: srvKey, agentClientCA: caPath}, hub)
|
||||
if err != nil {
|
||||
t.Fatalf("agentServer: %v", err)
|
||||
}
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
go gs.Serve(lis)
|
||||
defer gs.Stop()
|
||||
|
||||
// ---- host dials in ----
|
||||
caPool := x509.NewCertPool()
|
||||
caPool.AddCert(caCert)
|
||||
creds := credentials.NewTLS(&tls.Config{
|
||||
Certificates: []tls.Certificate{cliCert},
|
||||
RootCAs: caPool,
|
||||
ServerName: "localhost",
|
||||
})
|
||||
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(creds))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
stream, err := fleetpb.NewFleetAgentClient(conn).Session(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("session: %v", err)
|
||||
}
|
||||
|
||||
// 1. current desired-state arrives on connect.
|
||||
down, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("recv desired: %v", err)
|
||||
}
|
||||
if got := down.GetDesired().GetTargetVersion(); got != "0.5.0" {
|
||||
t.Fatalf("initial desired target_version = %q, want 0.5.0", got)
|
||||
}
|
||||
|
||||
// 2. host reports Hello (running an older version) + a health snapshot.
|
||||
if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Hello{Hello: &fleetpb.Hello{ImageVersion: "0.4.2", Kernel: "5.14.0"}}}); err != nil {
|
||||
t.Fatalf("send hello: %v", err)
|
||||
}
|
||||
if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Snapshot{Snapshot: &fleetpb.HealthSnapshot{
|
||||
TmpfsUsedPct: 63, NvmeRoot: "ok", Auth: "up", Selinux: "enforcing", KioskActive: true,
|
||||
}}}); err != nil {
|
||||
t.Fatalf("send snapshot: %v", err)
|
||||
}
|
||||
|
||||
// 3. inventory reflects the report + online presence.
|
||||
waitFor(t, 3*time.Second, func() bool {
|
||||
h, ok := store.Get(serialHexStr)
|
||||
return ok && h.Online && h.Version.ImageVersion == "0.4.2" && h.Health.TmpfsUsedPct == 63 && h.Health.NVMeRoot == "ok"
|
||||
}, "inventory did not reflect the agent report")
|
||||
|
||||
// 4. an admin changes desired-state -> live push over the same stream.
|
||||
if _, _, err := store.SetDesired(serialHexStr, Desired{TargetVersion: "0.6.0"}); err != nil {
|
||||
t.Fatalf("set desired: %v", err)
|
||||
}
|
||||
if !hub.pushDesired(serialHexStr, Desired{TargetVersion: "0.6.0"}) {
|
||||
t.Fatal("pushDesired reported the host as not live, but it is connected")
|
||||
}
|
||||
down2, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("recv desired push: %v", err)
|
||||
}
|
||||
if got := down2.GetDesired().GetTargetVersion(); got != "0.6.0" {
|
||||
t.Fatalf("pushed desired target_version = %q, want 0.6.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentRejectsUnenrolled: a valid fleet-CA cert whose serial has no inventory
|
||||
// record must be refused (enrolment gates the stream).
|
||||
func TestAgentRejectsUnenrolled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caCert, caKey := makeCA(t)
|
||||
caPath := writeFile(t, dir, "ca.crt", pemCert(caCert.Raw))
|
||||
srvCertPEM, srvKeyPEM := makeLeaf(t, caCert, caKey, "fleet-manager", big.NewInt(1), true)
|
||||
srvCrt := writeFile(t, dir, "srv.crt", srvCertPEM)
|
||||
srvKey := writeFile(t, dir, "srv.key", srvKeyPEM)
|
||||
cliCertPEM, cliKeyPEM := makeLeaf(t, caCert, caKey, "fleet-host", big.NewInt(0xdead), false)
|
||||
cliCert, _ := tls.X509KeyPair(cliCertPEM, cliKeyPEM)
|
||||
|
||||
store, _ := newJSONStore("") // empty — the host is NOT enrolled
|
||||
hub := newAgentHub(store, nil)
|
||||
gs, err := agentServer(config{agentCert: srvCrt, agentKey: srvKey, agentClientCA: caPath}, hub)
|
||||
if err != nil {
|
||||
t.Fatalf("agentServer: %v", err)
|
||||
}
|
||||
lis, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
go gs.Serve(lis)
|
||||
defer gs.Stop()
|
||||
|
||||
caPool := x509.NewCertPool()
|
||||
caPool.AddCert(caCert)
|
||||
creds := credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cliCert}, RootCAs: caPool, ServerName: "localhost"})
|
||||
conn, _ := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(creds))
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
stream, err := fleetpb.NewFleetAgentClient(conn).Session(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("session open: %v", err)
|
||||
}
|
||||
if _, err := stream.Recv(); err == nil {
|
||||
t.Fatal("expected the stream to be rejected for an unenrolled host, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- test helpers ---------------------------------------------------------
|
||||
|
||||
func makeCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ca key: %v", 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 | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("ca cert: %v", err)
|
||||
}
|
||||
cert, _ := x509.ParseCertificate(der)
|
||||
return cert, key
|
||||
}
|
||||
|
||||
func makeLeaf(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey, cn string, serial *big.Int, server bool) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("leaf key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
if server {
|
||||
tmpl.ExtKeyUsage = append(tmpl.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
|
||||
tmpl.DNSNames = []string{"localhost"}
|
||||
tmpl.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("leaf cert: %v", err)
|
||||
}
|
||||
kb, _ := x509.MarshalECPrivateKey(key)
|
||||
return pemCert(der), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})
|
||||
}
|
||||
|
||||
func pemCert(der []byte) []byte {
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name string, b []byte) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, b, 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal(msg)
|
||||
}
|
||||
Reference in New Issue
Block a user