Files
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

255 lines
7.7 KiB
Go

package cli
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// defaultPKIDir is where the authority lives on the machine that issues
// certificates. Only that machine ever holds ca-key.pem; everything else gets
// ca.pem and its own leaf.
const defaultPKIDir = "/etc/llmgw/pki"
func newCertCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Short: "Issue the certificates the control plane and agents authenticate with",
Long: "llmgw's internal connections use mutual TLS, and a public authority cannot\n" +
"supply them: Let's Encrypt and its peers stopped issuing certificates\n" +
"carrying the clientAuth usage, so nothing they hand out can authenticate an\n" +
"agent. These commands run a small private authority instead.\n\n" +
"They work on files and talk to nothing, so they run wherever you keep the\n" +
"authority — not necessarily on a cluster machine.",
}
cmd.AddCommand(newInitCACmd(), newIssueServerCmd(), newIssueClientCmd(), newCertInspectCmd())
return cmd
}
func newInitCACmd() *cobra.Command {
var (
dir string
cn string
force bool
)
cmd := &cobra.Command{
Use: "init-ca",
Short: "Create the cluster's certificate authority",
Long: "Writes ca.pem and ca-key.pem. Copy ca.pem to every machine; leave\n" +
"ca-key.pem here, because whoever holds it can mint any node's identity.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
keyPath := filepath.Join(dir, "ca-key.pem")
if _, err := os.Stat(keyPath); err == nil && !force {
// Overwriting the key orphans every certificate ever issued from
// it, across every machine, with no warning until connections
// start failing.
return fmt.Errorf("%s already exists; re-running would invalidate every "+
"certificate issued from it (pass --force if that is what you want)", keyPath)
}
ca, err := tlsconf.NewCA(cn)
if err != nil {
return err
}
if err := ca.Write(dir); err != nil {
return err
}
cmd.Printf("authority %q written to %s\n", cn, dir)
cmd.Printf(" ca.pem trust this everywhere (valid until %s)\n",
ca.Cert.NotAfter.Format(time.DateOnly))
cmd.Printf(" ca-key.pem keep it here, mode 0600\n")
return nil
},
}
cmd.Flags().StringVar(&dir, "dir", defaultPKIDir, "directory holding the authority")
cmd.Flags().StringVar(&cn, "cn", "llmgw cluster CA", "common name for the authority")
cmd.Flags().BoolVar(&force, "force", false, "replace an existing authority")
return cmd
}
func newIssueServerCmd() *cobra.Command {
var (
dir string
out string
hosts []string
name string
)
cmd := &cobra.Command{
Use: "issue-server <common-name>",
Short: "Issue the control plane's certificate",
Long: "Every address an agent might dial must appear in --host, including bare\n" +
"IP addresses. Hostname verification reads subject alternative names and\n" +
"ignores the common name entirely, so a certificate that names the host\n" +
"only in its subject is rejected by every client.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ca, err := loadCA(dir)
if err != nil {
return err
}
leaf, err := ca.IssueServer(args[0], hosts)
if err != nil {
return err
}
if name == "" {
name = "server"
}
if err := leaf.Write(out, name); err != nil {
return err
}
return describeIssued(cmd, out, name, hosts)
},
}
cmd.Flags().StringVar(&dir, "dir", defaultPKIDir, "directory holding the authority")
cmd.Flags().StringVar(&out, "out", ".", "directory to write the certificate into")
cmd.Flags().StringVar(&name, "name", "server", "base filename for the certificate")
cmd.Flags().StringArrayVar(&hosts, "host", nil, "address agents dial; repeatable (IP or DNS name)")
return cmd
}
func newIssueClientCmd() *cobra.Command {
var dir, out string
cmd := &cobra.Command{
Use: "issue-client <node-id>",
Short: "Issue one agent's certificate",
Long: "The node id becomes the certificate's common name, and the control plane\n" +
"refuses any agent whose claimed id does not match it. That is what makes a\n" +
"node's identity something it proves rather than something it picks.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ca, err := loadCA(dir)
if err != nil {
return err
}
leaf, err := ca.IssueClient(args[0])
if err != nil {
return err
}
if err := leaf.Write(out, args[0]); err != nil {
return err
}
return describeIssued(cmd, out, args[0], nil)
},
}
cmd.Flags().StringVar(&dir, "dir", defaultPKIDir, "directory holding the authority")
cmd.Flags().StringVar(&out, "out", ".", "directory to write the certificate into")
return cmd
}
func newCertInspectCmd() *cobra.Command {
return &cobra.Command{
Use: "inspect <file.pem>",
Short: "Print what a certificate actually says",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cert, err := readCert(args[0])
if err != nil {
return err
}
cmd.Printf("subject %s\n", cert.Subject)
cmd.Printf("issuer %s\n", cert.Issuer)
cmd.Printf("valid %s .. %s", cert.NotBefore.Format(time.DateOnly),
cert.NotAfter.Format(time.DateOnly))
if d := time.Until(cert.NotAfter); d < 0 {
cmd.Printf(" (EXPIRED)")
} else {
cmd.Printf(" (%d days left)", int(d.Hours()/24))
}
cmd.Println()
if cert.IsCA {
cmd.Println("role certificate authority")
}
if u := usageNames(cert.ExtKeyUsage); u != "" {
cmd.Printf("usage %s\n", u)
}
if sans := sanNames(cert); sans != "" {
cmd.Printf("hosts %s\n", sans)
} else if !cert.IsCA && !hasUsage(cert, x509.ExtKeyUsageClientAuth) {
cmd.Println("hosts none — no client will accept this as a server certificate")
}
return nil
},
}
}
func loadCA(dir string) (*tlsconf.CA, error) {
ca, err := tlsconf.LoadCA(filepath.Join(dir, "ca.pem"), filepath.Join(dir, "ca-key.pem"))
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("no authority in %s — run 'llmgw ctl cert init-ca --dir %s' first", dir, dir)
}
return nil, err
}
return ca, nil
}
func describeIssued(cmd *cobra.Command, dir, name string, hosts []string) error {
cert, err := readCert(filepath.Join(dir, name+".pem"))
if err != nil {
return err
}
cmd.Printf("issued %s\n", cert.Subject.CommonName)
cmd.Printf(" %s\n", filepath.Join(dir, name+".pem"))
cmd.Printf(" %s\n", filepath.Join(dir, name+"-key.pem"))
if len(hosts) > 0 {
cmd.Printf(" hosts: %s\n", strings.Join(hosts, ", "))
}
cmd.Printf(" expires %s\n", cert.NotAfter.Format(time.DateOnly))
return nil
}
func readCert(path string) (*x509.Certificate, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(b)
if block == nil {
return nil, fmt.Errorf("%s is not PEM", path)
}
return x509.ParseCertificate(block.Bytes)
}
func hasUsage(cert *x509.Certificate, want x509.ExtKeyUsage) bool {
for _, u := range cert.ExtKeyUsage {
if u == want {
return true
}
}
return false
}
func usageNames(usages []x509.ExtKeyUsage) string {
var out []string
for _, u := range usages {
switch u {
case x509.ExtKeyUsageServerAuth:
out = append(out, "serverAuth (identifies the control plane)")
case x509.ExtKeyUsageClientAuth:
out = append(out, "clientAuth (identifies an agent)")
default:
out = append(out, fmt.Sprintf("%v", u))
}
}
return strings.Join(out, ", ")
}
func sanNames(cert *x509.Certificate) string {
names := append([]string{}, cert.DNSNames...)
for _, ip := range cert.IPAddresses {
names = append(names, ip.String())
}
return strings.Join(names, ", ")
}