Files
llmgw/internal/agent/mtls_test.go
T
Ilker ManapandClaude Opus 5 ec1b6b04df
CI / test (push) Successful in 54s
Fix an agent that could neither explain itself nor stop
Two bugs found while verifying mTLS against real machines.

session cancelled its own derived context and then asked that context
whether the agent was shutting down. The answer was always yes, so every
stream error read as a clean close: the log said err=<nil>, the backoff
reset, and an agent whose certificate the control plane refused hammered it
forever at the base interval with nothing to diagnose. It now keeps the
parent context to answer that question, and a refusal reaches the operator
naming both the certificate and the claim.

Cancelling the context also did not stop the agent. HTTP/2 watches the
context while the request body is being written, and a bidirectional stream
spends its life blocked on the read side instead, so the read never returned.
Measured on a deployed node: `systemctl stop llmgw-agent` took 90 seconds and
ended in SIGKILL, on every restart. Closing the response when the context
ends is what actually unblocks it.

TestAgentStopsWhenContextEnds hangs reliably without the second fix, which is
the only reason to trust that it tests it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:03:21 +02:00

266 lines
7.6 KiB
Go

package agent
import (
"context"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// pki issues a cluster's certificates into a temporary directory and returns
// the configurations the control plane and one agent would load.
func pki(t *testing.T, nodeID string) (server, client tlsconf.Config) {
t.Helper()
dir := t.TempDir()
ca, err := tlsconf.NewCA("test CA")
if err != nil {
t.Fatal(err)
}
if err := ca.Write(dir); err != nil {
t.Fatal(err)
}
srv, err := ca.IssueServer("control-plane", []string{"127.0.0.1"})
if err != nil {
t.Fatal(err)
}
if err := srv.Write(dir, "server"); err != nil {
t.Fatal(err)
}
cli, err := ca.IssueClient(nodeID)
if err != nil {
t.Fatal(err)
}
if err := cli.Write(dir, "agent"); err != nil {
t.Fatal(err)
}
caPath := filepath.Join(dir, "ca.pem")
return tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "server.pem"), Key: filepath.Join(dir, "server-key.pem")},
tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "agent.pem"), Key: filepath.Join(dir, "agent-key.pem")}
}
// startTLS runs a real service behind a real TLS listener, with identity
// required — the deployed configuration rather than an approximation of it.
func startTLS(t *testing.T, serverTLS tlsconf.Config) (*agentsvc.Service, *inventory.Store, string) {
t.Helper()
inv := inventory.NewStore()
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
tc, err := serverTLS.ServerConfig()
if err != nil {
t.Fatal(err)
}
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := httptest.NewUnstartedServer(mux)
srv.Listener = raw
srv.TLS = tc
srv.StartTLS()
t.Cleanup(srv.Close)
return svc, inv, "https://" + raw.Addr().String()
}
func runAgent(t *testing.T, cfg Config) *Agent {
t.Helper()
cfg.MgmtAddr = "127.0.0.1"
cfg.HeartbeatInterval = 50 * time.Millisecond
cfg.ReconnectBase = 20 * time.Millisecond
cfg.ReconnectMax = 100 * time.Millisecond
cfg.Discovery.SkipGPUs = true
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
cfg.Superv = supervisor.Config{
WorkDir: t.TempDir(), LogLines: 64,
StopTimeout: 2 * time.Second, HealthInterval: 20 * time.Millisecond,
}
client, err := Client(cfg.TLS, 5*time.Second)
if err != nil {
t.Fatal(err)
}
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go func() { _ = ag.Run(ctx) }()
t.Cleanup(func() {
cancel()
ag.Supervisor().StopAll(context.Background())
})
return ag
}
func registered(svc *agentsvc.Service, inv *inventory.Store, nodeID string) func() bool {
return func() bool {
_, ok := inv.Get(nodeID)
return ok && len(svc.ConnectedNodes()) == 1
}
}
func eventually(t *testing.T, limit time.Duration, pred func() bool) bool {
t.Helper()
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if pred() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
func TestAgentRegistersOverMutualTLS(t *testing.T) {
serverTLS, clientTLS := pki(t, "llmgw-node01")
svc, inv, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = url
cfg.TLS = clientTLS
runAgent(t, cfg)
if !eventually(t, 10*time.Second, registered(svc, inv, "llmgw-node01")) {
t.Fatal("the agent never registered over mutual TLS")
}
}
func TestNodeCannotClaimAnotherNodesIdentity(t *testing.T) {
// The point of the whole exercise. node01 holds a valid certificate — it is
// a legitimate member of the cluster — and still cannot register as node02,
// because the id it claims is checked against the one it can prove.
serverTLS, clientTLS := pki(t, "llmgw-node01")
svc, inv, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node02"
cfg.Server = url
cfg.TLS = clientTLS
runAgent(t, cfg)
// The agent reconnects on failure, so this has to stay false throughout
// rather than merely start false.
if eventually(t, 2*time.Second, func() bool {
_, ok := inv.Get("llmgw-node02")
return ok || len(svc.ConnectedNodes()) > 0
}) {
t.Fatal("an agent registered under a node id its certificate does not authorize")
}
}
func TestUnauthenticatedAgentIsRefused(t *testing.T) {
// The defensive half of the check: identity is required, but the connection
// carried none. In the shipped wiring TLS and this flag are tied together,
// so reaching here means someone untied them — and the answer must still be
// refusal rather than a silently trusted node id.
inv := inventory.NewStore()
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
srv := httptest.NewServer(h2c.NewHandler(mux, &http2.Server{}))
t.Cleanup(srv.Close)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = srv.URL
runAgent(t, cfg)
if eventually(t, 2*time.Second, registered(svc, inv, "llmgw-node01")) {
t.Fatal("an agent with no proven identity reached the control plane")
}
}
func TestPlaintextAgentIsRejectedWhenTLSIsConfigured(t *testing.T) {
// A misconfiguration worth catching at startup rather than at runtime: an
// agent holding certificates but dialling http:// would connect in the
// clear and never learn that it had.
_, clientTLS := pki(t, "llmgw-node01")
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = "http://127.0.0.1:9443"
cfg.TLS = clientTLS
if _, err := New(cfg, nil, slog.New(slog.DiscardHandler)); err == nil {
t.Fatal("an agent with certificates was allowed to dial a plaintext address")
}
}
func TestBareAddressGainsTheRightScheme(t *testing.T) {
_, clientTLS := pki(t, "llmgw-node01")
secure := Config{NodeID: "n", Server: "10.0.0.140:9443", TLS: clientTLS}
if err := secure.normalize(); err != nil {
t.Fatal(err)
}
if secure.Server != "https://10.0.0.140:9443" {
t.Errorf("a configured agent dials %q", secure.Server)
}
plain := Config{NodeID: "n", Server: "10.0.0.140:9443"}
if err := plain.normalize(); err != nil {
t.Fatal(err)
}
if plain.Server != "http://10.0.0.140:9443" {
t.Errorf("an unconfigured agent dials %q", plain.Server)
}
}
func TestRefusedAgentLearnsWhy(t *testing.T) {
// An agent that cannot register must be able to say why. Reporting a
// refusal as a clean disconnect leaves an operator with a silent reconnect
// loop and nothing to act on — and resets the backoff, so the loop is a
// fast one.
serverTLS, clientTLS := pki(t, "llmgw-node01")
_, _, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node02"
cfg.Server = url
cfg.TLS = clientTLS
cfg.Discovery.SkipGPUs = true
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
cfg.Superv = supervisor.Config{WorkDir: t.TempDir(), LogLines: 8}
client, err := Client(cfg.TLS, 5*time.Second)
if err != nil {
t.Fatal(err)
}
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
err = ag.session(context.Background())
if err == nil {
t.Fatal("a refused session reported success")
}
if !strings.Contains(err.Error(), "llmgw-node01") || !strings.Contains(err.Error(), "llmgw-node02") {
t.Errorf("the error names neither certificate nor claim: %v", err)
}
}