// Package broker is what the control plane knows about the broker nodes dial. // // Two facts, and a token needs both (novox/hq ADR 0004): where it is, and what certificate to // expect there. They are the two parts of a token this control plane does not generate itself. // // The address is configuration. The fingerprint is **not** — it is derived from the certificate // the broker is actually serving. Configuring a fingerprint separately would let it drift from // the certificate it describes, and a drifted pin is worse than none: every node issued a token // during the drift refuses to connect, and the failure looks like an attack. package broker import ( "crypto/sha256" "crypto/x509" "encoding/hex" "encoding/pem" "errors" "fmt" "github.com/novox/mesh-controller/internal/envfile" "os" "strings" ) // Where the two settings come from. const ( AddressVar = "MESH_BROKER_ADDRESS" CertificateVar = "MESH_BROKER_CERTIFICATE" ) // Broker is what a token needs to say about it. type Broker struct { Address string Fingerprint string } // ErrNotConfigured means this control plane has not been told where its broker is. // // Not a failure to start. A control plane can hold node records and a signing key without one; // what it cannot do is issue a token anybody could use, and that is where this surfaces. var ErrNotConfigured = errors.New("this control plane has not been told about its broker") // FromEnvironment reads the two settings, if they are there. // // The address's port follows MESH_BROKER_ADDRESS_PORT when the node's settings moved the bus // (novox/hq 04-ISSUES/102): the address genesis wrote is a public name and the port genesis // chose, and only the port is the node's to move. func FromEnvironment() (Broker, error) { address, err := envfile.Placed(AddressVar) if err != nil { return Broker{}, err } path := strings.TrimSpace(os.Getenv(CertificateVar)) if address == "" && path == "" { return Broker{}, ErrNotConfigured } // One without the other is worse than neither: a token with an address and no fingerprint // invites a node to connect to something it cannot check. if address == "" || path == "" { return Broker{}, fmt.Errorf( "%s and %s must be set together — an address with nothing to check the certificate "+ "against is a node connecting to whatever answers", AddressVar, CertificateVar) } fingerprint, err := FingerprintOf(path) if err != nil { return Broker{}, err } // **One bus, one address** (novox/hq ADR 0131). Everything the mesh hands out — a token, a // machine's membership, a person's credential — must name the bus the control plane itself is // connected to; the setting above predates the move and, on a mesh that has moved, still names // the broker it moved from. The first person issued after the move was handed the retired // broker's port and could not connect to anything (2026-09-28). // // Read from the credential rather than from a second setting somebody keeps in step: the // control plane cannot be wrong about where it is connected. if bus, on, err := OnNATS(); err == nil && on { if where := strings.TrimPrefix(BareAddress(bus), "nats://"); where != "" { address = where } } return Broker{Address: address, Fingerprint: fingerprint}, nil } // FingerprintOf reads a PEM certificate and returns what a client pins. // // SHA-256 over the DER bytes, which is what a TLS client can compute from the certificate the // server presents — so the two are comparing the same thing. A digest over the PEM text would // not be: the same certificate re-wrapped with different line endings would hash differently // while being the same certificate. func FingerprintOf(path string) (string, error) { raw, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("cannot read the broker's certificate at %s: %w", path, err) } block, _ := pem.Decode(raw) if block == nil || block.Type != "CERTIFICATE" { return "", fmt.Errorf( "%s does not contain a PEM certificate. If this is a private key, it is the wrong "+ "file — what a node pins is the certificate the broker presents", path) } // Parsed rather than hashed straight from the block, so a malformed certificate is caught // here rather than becoming a pin that matches nothing. if _, err := x509.ParseCertificate(block.Bytes); err != nil { return "", fmt.Errorf("the certificate at %s could not be parsed: %w", path, err) } sum := sha256.Sum256(block.Bytes) return "sha256:" + hex.EncodeToString(sum[:]), nil }