Files
llmgw/internal/tlsconf/tlsconf.go
T
Ilker ManapandClaude Opus 5 1e3cafdc86 Add a private CA and mutual TLS for the agent stream
A node's identity was whatever the agent said it was. With mutual TLS the
control plane compares the client certificate's common name against the
claimed node id and refuses the connection when they disagree, so identity
becomes something a node proves rather than something it picks.

A public authority cannot supply these certificates: Let's Encrypt and its
peers stopped issuing the clientAuth usage, and Go verifies client chains
against it. Hence internal/tlsconf/ca.go, a small authority whose key lives
on one machine and whose root is trusted everywhere, and `llmgw ctl cert`
over it so issuing is reproducible instead of remembered openssl.

The server certificate's hosts matter more than they look: hostname
verification reads subject alternative names and ignores the common name
entirely, so a certificate without the address agents dial — bare IP
included — is rejected no matter what its subject says. There is a test for
exactly that, because it is the failure everyone hits once.

requireIdentity stays wired to false until the configuration lands, so this
commit changes no running behaviour.

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

113 lines
3.6 KiB
Go

// Package tlsconf loads the certificates the control plane and its agents
// authenticate each other with (plan §5.6).
//
// Both directions are verified. The agent checking the server is the ordinary
// half; the server checking the agent is the half that matters here, because
// the client certificate is what makes a node's identity something it cannot
// choose for itself.
package tlsconf
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"os"
)
// Config names the files a component authenticates with.
type Config struct {
// CA is the bundle used to verify the other side. In a real deployment this
// is whichever authority issued the fleet's certificates.
CA string `koanf:"ca"`
// Cert and Key are this component's own identity.
Cert string `koanf:"cert"`
Key string `koanf:"key"`
// Insecure disables TLS entirely. It exists for local runs against
// sim-engines; anything using it must stay on a trusted network.
Insecure bool `koanf:"insecure"`
}
// Enabled reports whether TLS is configured.
func (c Config) Enabled() bool { return !c.Insecure && c.CA != "" }
// Validate rejects a half-configured setup, which would otherwise fail at the
// first connection rather than at startup.
func (c Config) Validate() error {
if c.Insecure {
if c.CA != "" || c.Cert != "" || c.Key != "" {
return errors.New("tls: insecure is set alongside certificate paths; pick one")
}
return nil
}
switch {
case c.CA == "" && c.Cert == "" && c.Key == "":
// Nothing configured at all. The caller decides whether that is
// acceptable; it is not this package's business to refuse.
return nil
case c.CA == "":
return errors.New("tls: ca is required")
case c.Cert == "" || c.Key == "":
return errors.New("tls: cert and key are both required")
}
return nil
}
// ServerConfig builds a TLS configuration that demands and verifies a client
// certificate.
//
// RequireAndVerifyClientCert rather than VerifyClientCertIfGiven: an agent that
// cannot prove who it is must not reach the control plane at all. Accepting it
// unauthenticated would leave node identity exactly as forgeable as it was
// without TLS.
func (c Config) ServerConfig() (*tls.Config, error) {
cert, pool, err := c.load()
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
// connect's bidirectional streams need HTTP/2, which is negotiated
// through ALPN. Omitting this leaves the connection on HTTP/1.1 and the
// stream fails with an error that says nothing about why.
NextProtos: []string{"h2"},
}, nil
}
// ClientConfig builds a TLS configuration that presents this component's
// certificate and verifies the server against the CA.
func (c Config) ClientConfig(serverName string) (*tls.Config, error) {
cert, pool, err := c.load()
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
ServerName: serverName,
MinVersion: tls.VersionTLS13,
NextProtos: []string{"h2"},
}, nil
}
func (c Config) load() (tls.Certificate, *x509.CertPool, error) {
cert, err := tls.LoadX509KeyPair(c.Cert, c.Key)
if err != nil {
return tls.Certificate{}, nil, fmt.Errorf("tls: loading %s: %w", c.Cert, err)
}
pem, err := os.ReadFile(c.CA)
if err != nil {
return tls.Certificate{}, nil, fmt.Errorf("tls: reading %s: %w", c.CA, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return tls.Certificate{}, nil, fmt.Errorf("tls: %s contains no certificates", c.CA)
}
return cert, pool, nil
}