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>
113 lines
3.6 KiB
Go
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
|
|
}
|