136 lines
4.5 KiB
Go
136 lines
4.5 KiB
Go
// host.go — the mTLS HOST plane: enrolled hosts get their per-boot SSH host key
|
|
// signed by the fleet SSH CA.
|
|
//
|
|
// This is the THIRD listener, distinct from the two in main.go:
|
|
//
|
|
// HOST (mTLS, boot-LAN reachable) -- fleet-CA host client cert:
|
|
// POST /ssh/hostcert {publicKey, principals, keyID} -> {cert}
|
|
//
|
|
// It is the automatic, self-authenticating counterpart of enrolment: a host
|
|
// proves it is enrolled by presenting its TPM identity as the mTLS client cert
|
|
// (verified to the fleet CA), and the broker has step-ca sign an SSH HOST cert —
|
|
// no human approval, no CA secret on the host. Admins then trust every fleet host
|
|
// via one `@cert-authority` line instead of per-boot TOFU (F-02 / POA&M P-17).
|
|
// Keep step-ca itself off the boot LAN; the broker is the single front door.
|
|
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
type sshHostCertRequest struct {
|
|
PublicKey string `json:"publicKey"`
|
|
Principals []string `json:"principals"`
|
|
KeyID string `json:"keyID"`
|
|
}
|
|
|
|
type sshHostCertResponse struct {
|
|
Cert string `json:"cert,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (b *broker) serveHost() {
|
|
if b.cfg.hostAddr == "" {
|
|
log.Printf("host plane disabled (set BROKER_HOST_ADDR + BROKER_HOST_CERT/KEY to enable SSH host-cert issuance)")
|
|
return
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /ssh/hostcert", b.handleSSHHostCert)
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
|
|
srv := &http.Server{
|
|
Addr: b.cfg.hostAddr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 40 * time.Second, // signing blocks on step-ca
|
|
IdleTimeout: 30 * time.Second,
|
|
MaxHeaderBytes: 16 << 10,
|
|
}
|
|
|
|
if b.cfg.hostInsecure {
|
|
log.Printf("WARNING: BROKER_HOST_INSECURE=true — SSH host plane is PLAINTEXT with NO client-cert auth. DEV ONLY.")
|
|
log.Fatalf("host listener: %v", srv.ListenAndServe())
|
|
return
|
|
}
|
|
if b.cfg.hostCert == "" || b.cfg.hostKey == "" {
|
|
log.Fatalf("host mTLS requires BROKER_HOST_CERT and BROKER_HOST_KEY (or BROKER_HOST_INSECURE=true for dev)")
|
|
}
|
|
clientCAPath := b.cfg.hostClientCA
|
|
if clientCAPath == "" {
|
|
clientCAPath = b.cfg.caRoot // default: the fleet root that signs host identities
|
|
}
|
|
cert, err := tls.LoadX509KeyPair(b.cfg.hostCert, b.cfg.hostKey)
|
|
if err != nil {
|
|
log.Fatalf("host server cert: %v", err)
|
|
}
|
|
caPEM, err := os.ReadFile(clientCAPath)
|
|
if err != nil {
|
|
log.Fatalf("host client CA: %v", err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
log.Fatalf("host client CA %s contains no certificates", clientCAPath)
|
|
}
|
|
srv.TLSConfig = &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
ClientAuth: tls.RequireAndVerifyClientCert, // must be an enrolled fleet host
|
|
ClientCAs: pool,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
log.Printf("host plane up: %s (mTLS, fleet-CA host client certs) ssh-signer=%s", b.cfg.hostAddr, b.sshSigner.Kind())
|
|
log.Fatalf("host listener: %v", srv.ListenAndServeTLS("", ""))
|
|
}
|
|
|
|
func (b *broker) handleSSHHostCert(w http.ResponseWriter, r *http.Request) {
|
|
// RequireAndVerifyClientCert already guaranteed the peer chains to the fleet
|
|
// CA; the presented identity is who we're issuing for.
|
|
peerCN := hostPeerCN(r)
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
|
var req sshHostCertRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeSSHErr(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
if req.KeyID == "" {
|
|
req.KeyID = peerCN
|
|
}
|
|
if len(req.Principals) == 0 {
|
|
writeSSHErr(w, http.StatusBadRequest, "no principals requested")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := ctxWithTimeout(35 * time.Second)
|
|
defer cancel()
|
|
certLine, err := b.sshSigner.SignHost(ctx, req.PublicKey, req.KeyID, req.Principals)
|
|
if err != nil {
|
|
log.Printf("audit ssh-hostcert-FAILED cn=%q principals=%v err=%v", peerCN, req.Principals, err)
|
|
writeSSHErr(w, http.StatusBadGateway, "signing failed: "+err.Error())
|
|
return
|
|
}
|
|
log.Printf("audit SSH-HOSTCERT cn=%q keyid=%q principals=%v", peerCN, req.KeyID, req.Principals)
|
|
writeJSON(w, http.StatusOK, sshHostCertResponse{Cert: certLine})
|
|
}
|
|
|
|
func hostPeerCN(r *http.Request) string {
|
|
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
|
if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" {
|
|
return cn
|
|
}
|
|
return "unknown-host"
|
|
}
|
|
return "dev-insecure"
|
|
}
|
|
|
|
func writeSSHErr(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, sshHostCertResponse{Error: msg})
|
|
}
|