novox/hq 04-ISSUES/146. The composed user list names an enrolment user for every machine with a live token and nothing minted a credential for it, so the composer left it out as a user with no password — and every enrolment since the mesh moved to this bus was refused before the mesh heard of it. The comment above the issuing code already said the account is created before the token is handed over; now it is. Recorded rather than minted, because the token's secret is the password. And 'broker accounts', which composes the same list the declaration carries and writes it to standard output. For genesis, where no declaration can reach the machine running the bus because that machine is not yet a node. It says what it composed; whoever is raising the machine places it. A control plane that wrote the file itself would have to learn where the bus keeps its configuration and how to make it reload, which is the module's knowledge.
259 lines
8.9 KiB
Go
259 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
)
|
|
|
|
// The bus's own certificate, made by the mesh rather than borrowed from an image.
|
|
//
|
|
// **The foundation asked a third-party image for a tool it never said must be there** (novox/hq
|
|
// 04-ISSUES/146). The bootstrap made this certificate by running `openssl` inside the broker's
|
|
// image, which worked while the broker was one that happened to carry it and stopped the day the
|
|
// bus changed: the new one has a shell and no openssl, so the step exited 127 and no mesh could be
|
|
// raised. Substituting another image the bundle names does not help — none of them carry it
|
|
// either.
|
|
//
|
|
// So the program that needs a certificate makes one. It is the mesh's own binary, already on the
|
|
// machine at this point in the bootstrap (the schema step ran it), and it needs nothing from the
|
|
// image it writes into but a mounted directory.
|
|
//
|
|
// **Self-signed, and that is the design** — a host pins this server's exact certificate and
|
|
// authenticates with a password (novox/hq ADR 0004). There is no authority above it to ask, and at
|
|
// this moment in a bootstrap there is no mesh to ask one of.
|
|
//
|
|
// Idempotent, because the step is applied again on every reconcile and a second certificate would
|
|
// be one the hosts that pinned the first no longer believe.
|
|
|
|
// busCertificateNames is what the bus is reached by: the container name on a mesh network, and the
|
|
// loopback address the machine's own foundation dials.
|
|
var busCertificateNames = []string{"mesh-broker"}
|
|
|
|
const busCertificateLife = 10 * 365 * 24 * time.Hour
|
|
|
|
// busCertificate makes the bus's certificate in a directory, or says whether one is there.
|
|
//
|
|
// broker certificate --into /tls make it if it is not there
|
|
// broker certificate --check --into /tls exit non-zero unless a usable pair is
|
|
func busCertificate(args []string) error {
|
|
into, check := "", false
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--check":
|
|
check = true
|
|
case "--into":
|
|
if i+1 >= len(args) {
|
|
return errors.New("--into needs a directory")
|
|
}
|
|
into = args[i+1]
|
|
i++
|
|
default:
|
|
return fmt.Errorf("broker certificate [--check] --into <directory>: %q", args[i])
|
|
}
|
|
}
|
|
if into == "" {
|
|
return errors.New("broker certificate [--check] --into <directory>")
|
|
}
|
|
crt, key := filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key")
|
|
|
|
if usable, err := busCertificateUsable(crt, key); err != nil {
|
|
return err
|
|
} else if usable {
|
|
fmt.Printf("the bus already has a certificate at %s, and it was left alone\n", crt)
|
|
return nil
|
|
}
|
|
if check {
|
|
// Said as a failure, because that is what the caller asked: a bootstrap's verify runs
|
|
// this and a false answer is what makes the step run.
|
|
return fmt.Errorf("no usable certificate and key at %s", into)
|
|
}
|
|
return writeBusCertificate(crt, key)
|
|
}
|
|
|
|
// busCertificateUsable says whether a certificate and its key are both there and parse.
|
|
//
|
|
// Both, and parsed rather than stat'ed: a half-written pair is the state a bootstrap interrupted
|
|
// between the two files leaves behind, and a step that treated it as done would hand the server a
|
|
// certificate with no key and report success.
|
|
func busCertificateUsable(crt, key string) (bool, error) {
|
|
certPEM, err := os.ReadFile(crt)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
keyPEM, err := os.ReadFile(key)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if _, err := tlsPairParses(certPEM, keyPEM); err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func tlsPairParses(certPEM, keyPEM []byte) (*x509.Certificate, error) {
|
|
block, _ := pem.Decode(certPEM)
|
|
if block == nil || block.Type != "CERTIFICATE" {
|
|
return nil, errors.New("not a certificate")
|
|
}
|
|
certificate, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyBlock, _ := pem.Decode(keyPEM)
|
|
if keyBlock == nil {
|
|
return nil, errors.New("not a key")
|
|
}
|
|
if _, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes); err != nil {
|
|
if _, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return certificate, nil
|
|
}
|
|
|
|
func writeBusCertificate(crt, key string) error {
|
|
private, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
template := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: busCertificateNames[0]},
|
|
DNSNames: busCertificateNames,
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(busCertificateLife),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &private.PublicKey, private)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pkcs8, err := x509.MarshalPKCS8PrivateKey(private)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// **The key first, and only then the certificate**, so the pair a reader finds is never a
|
|
// certificate whose key has not been written yet — the one order in which an interruption
|
|
// leaves something that looks finished (novox/hq 04-ISSUES/014, a key present and unusable).
|
|
if err := os.WriteFile(key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(crt, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("made the bus a certificate for %v, valid until %s\n %s\n %s\n",
|
|
busCertificateNames, template.NotAfter.Format(time.RFC3339), crt, key)
|
|
return nil
|
|
}
|
|
|
|
// busAccounts writes the mesh's composed user list to a file.
|
|
//
|
|
// **For genesis, where no declaration can deliver it** (novox/hq 04-ISSUES/146). Everywhere else
|
|
// the list reaches the machine running the bus as a resource of the module that holds it — which
|
|
// requires that machine to be an enrolled node, and at genesis it is not: the first node cannot
|
|
// enrol because the account it would enrol with cannot be composed onto a bus it has no declaration
|
|
// for. The installer breaks that circle by placing the file itself, once, and the module takes the
|
|
// file over from its first push.
|
|
//
|
|
// The same composition, not a second one: this asks the store for the same records and renders them
|
|
// with the same composer the declaration uses. A genesis that hand-wrote an account would be a
|
|
// second statement of who may say what, able to disagree with the first.
|
|
//
|
|
// **It writes to standard output unless told a file**, and that is the point: the control plane
|
|
// composes and says what it composed, and whoever is raising the machine puts it where that
|
|
// machine's bus reads it. A control plane that wrote into the bus's own directory would have to
|
|
// know where that is and how to make the server re-read it — which is the module's knowledge, and
|
|
// the module is what takes this over on the first push.
|
|
//
|
|
// broker accounts > /var/lib/mesh-bus-conf/accounts.conf
|
|
func busAccounts(ctx context.Context, args []string) error {
|
|
into := ""
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--into":
|
|
if i+1 >= len(args) {
|
|
return errors.New("--into needs a file")
|
|
}
|
|
into = args[i+1]
|
|
i++
|
|
default:
|
|
return fmt.Errorf("broker accounts --into <file>: %q", args[i])
|
|
}
|
|
}
|
|
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
|
|
records, err := open.inventory.BusRecords(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
users, err := broker.Users(records)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
kept, err := open.inventory.BusUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hashes := make(map[string]string, len(kept))
|
|
for name, u := range kept {
|
|
hashes[name] = u.PasswordHash
|
|
}
|
|
filled, missing := broker.WithPasswords(users, hashes)
|
|
if len(missing) > 0 {
|
|
// To standard error, always: the composed file may be going to standard output, and a
|
|
// remark in the middle of it is a configuration the server refuses to parse.
|
|
fmt.Fprintf(os.Stderr, "leaving out %d user(s) the mesh has minted no credential for: %s\n",
|
|
len(missing), strings.Join(missing, ", "))
|
|
}
|
|
if len(filled) == 0 {
|
|
return errors.New("not one user has a credential, so this list would refuse every " +
|
|
"connection in the mesh")
|
|
}
|
|
accounts, err := broker.ComposeAccounts(filled)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if into == "" {
|
|
fmt.Print(accounts)
|
|
return nil
|
|
}
|
|
if err := os.WriteFile(into, []byte(accounts), 0o600); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("wrote %d user(s) to %s\n", len(filled), into)
|
|
return nil
|
|
}
|