The agent's baked default pointed at the PUBLIC admin-UI name
(diskless-server.bitasylum.org), which resolves to the serving host's outbound IP
— unreachable from the fleet LAN. There's no split-horizon to "automate": the
manager agent plane is just another *.fleet.lab, served by the fleet dnsmasq like
boot/nvme/audit.
- serving/.env.example: MGMT_HOST=manager.fleet.lab.
- serving/docker-compose.yml: dnsmasq --address=/${MGMT_HOST}/${SERVER_IP}.
- files/agent/agent.env + main.go default: FLEET_AGENT_MANAGER=manager.fleet.lab:9600.
- server-pki.sh: AGENT_SANS default -> manager.fleet.lab (cert SAN matches the
baked name, so no FLEET_AGENT_SERVER_NAME override).
- docs/TODO #14 gotcha #4 updated: fleet name, not the IP+server-name workaround.
The public diskless-server.bitasylum.org stays the ADMIN-UI name (Traefik/:443).
274 lines
8.6 KiB
Go
274 lines
8.6 KiB
Go
// 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"
|
|
"io"
|
|
"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
|
|
}
|
|
|
|
// Receive loop. Its error is the AUTHORITATIVE session status — when the manager
|
|
// ends the stream (e.g. PermissionDenied "host is not enrolled"), gRPC only
|
|
// returns a bare io.EOF on the SEND side, so a raw Send error is uninformative.
|
|
// We surface the recv-side status instead. v1 LOGS desired-state/commands.
|
|
recvErr := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
down, err := stream.Recv()
|
|
if err != nil {
|
|
recvErr <- err
|
|
cancel()
|
|
return
|
|
}
|
|
logDown(down)
|
|
}
|
|
}()
|
|
|
|
// 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 statusFrom(err, recvErr)
|
|
}
|
|
log.Printf("connected to %s — streaming health every %s", cfg.manager, cfg.interval)
|
|
|
|
// 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 statusFrom(err, recvErr)
|
|
}
|
|
for {
|
|
select {
|
|
case err := <-recvErr:
|
|
return err
|
|
case <-sctx.Done():
|
|
// Prefer the recv-side status if that's what cancelled us.
|
|
select {
|
|
case err := <-recvErr:
|
|
return err
|
|
default:
|
|
return sctx.Err()
|
|
}
|
|
case <-t.C:
|
|
if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Snapshot{Snapshot: p.snapshot(sctx)}}); err != nil {
|
|
return statusFrom(err, recvErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// statusFrom turns a Send-side io.EOF (which gRPC returns when the server has
|
|
// closed the stream) into the authoritative recv-side status — the real reason
|
|
// the manager ended the session — falling back to the original error.
|
|
func statusFrom(err error, recvErr chan error) error {
|
|
if err == io.EOF {
|
|
select {
|
|
case re := <-recvErr:
|
|
return re
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}
|
|
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", "manager.fleet.lab: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 ""
|
|
}
|