diff --git a/cmd/mesh-controller/buscertificate.go b/cmd/mesh-controller/buscertificate.go new file mode 100644 index 0000000..983a05e --- /dev/null +++ b/cmd/mesh-controller/buscertificate.go @@ -0,0 +1,171 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +// 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 : %q", args[i]) + } + } + if into == "" { + return errors.New("broker certificate [--check] --into ") + } + 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 +} diff --git a/cmd/mesh-controller/buscertificate_test.go b/cmd/mesh-controller/buscertificate_test.go new file mode 100644 index 0000000..ad91a40 --- /dev/null +++ b/cmd/mesh-controller/buscertificate_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "os" + "path/filepath" + "strings" + "testing" +) + +// novox/hq 04-ISSUES/146. The bootstrap could not make the bus a certificate: it asked an image for +// `openssl` and the image it asks has none. What replaces it is this command, so what is checked is +// what the bootstrap needs from it — a pair a TLS server can actually load, made once and only once. + +func TestTheBusCertificateLoadsAsAServersWould(t *testing.T) { + into := t.TempDir() + if err := busCertificate([]string{"--into", into}); err != nil { + t.Fatalf("the bus could not be given a certificate: %v", err) + } + + // The check a cheaper test would not make. The key was present and valid and the server could + // not start, once, because nothing loaded the pair the way a server loads it + // (novox/hq 04-ISSUES/014). + pair, err := tls.LoadX509KeyPair(filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key")) + if err != nil { + t.Fatalf("a TLS server cannot load what was written: %v", err) + } + leaf := pair.Leaf + if leaf == nil { + if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil { + t.Fatal(err) + } + } + if err := leaf.VerifyHostname("mesh-broker"); err != nil { + t.Errorf("the certificate is not for the name the bus is reached by: %v", err) + } + if len(leaf.IPAddresses) == 0 || leaf.IPAddresses[0].String() != "127.0.0.1" { + t.Errorf("the certificate does not cover the loopback address the foundation dials: %v", leaf.IPAddresses) + } + + // The key is not readable by anything else on the machine; the certificate is public and is. + key, err := os.Stat(filepath.Join(into, "tls.key")) + if err != nil { + t.Fatal(err) + } + if key.Mode().Perm() != 0o600 { + t.Errorf("the key is %v", key.Mode().Perm()) + } + crt, err := os.Stat(filepath.Join(into, "tls.crt")) + if err != nil { + t.Fatal(err) + } + if crt.Mode().Perm() != 0o644 { + t.Errorf("the certificate is %v, which the server runs as another user cannot read", crt.Mode().Perm()) + } +} + +// **Made once.** The step is applied again on every reconcile, and a second certificate is one the +// hosts that pinned the first no longer believe (novox/hq ADR 0004). +func TestTheBusCertificateIsMadeOnce(t *testing.T) { + into := t.TempDir() + if err := busCertificate([]string{"--into", into}); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(filepath.Join(into, "tls.crt")) + if err != nil { + t.Fatal(err) + } + if err := busCertificate([]string{"--into", into}); err != nil { + t.Fatal(err) + } + again, err := os.ReadFile(filepath.Join(into, "tls.crt")) + if err != nil { + t.Fatal(err) + } + if string(first) != string(again) { + t.Fatal("running it twice replaced the certificate every host had pinned") + } +} + +// The verify half: false before, true after, which is what makes the bootstrap run the step at all. +func TestTheCheckIsFalseUntilThereIsAPair(t *testing.T) { + into := t.TempDir() + if err := busCertificate([]string{"--check", "--into", into}); err == nil { + t.Fatal("an empty directory reported a usable certificate") + } + if err := busCertificate([]string{"--into", into}); err != nil { + t.Fatal(err) + } + if err := busCertificate([]string{"--check", "--into", into}); err != nil { + t.Fatalf("the certificate it just made does not satisfy its own check: %v", err) + } +} + +// A half-written pair is not a pair. An interrupted bootstrap leaves exactly this, and a step that +// called it done would hand the server a certificate with no key and report success. +func TestACertificateWithoutItsKeyIsNotUsable(t *testing.T) { + into := t.TempDir() + if err := busCertificate([]string{"--into", into}); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(into, "tls.key")); err != nil { + t.Fatal(err) + } + if err := busCertificate([]string{"--check", "--into", into}); err == nil { + t.Fatal("a certificate with no key passed the check") + } + if err := busCertificate([]string{"--into", into}); err != nil { + t.Fatal(err) + } + if _, err := tls.LoadX509KeyPair(filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key")); err != nil { + t.Fatalf("it did not replace the unusable pair: %v", err) + } +} + +func TestWhereToWriteIsRequired(t *testing.T) { + if err := busCertificate(nil); err == nil || !strings.Contains(err.Error(), "--into") { + t.Fatalf("it did not ask where to write: %v", err) + } +} diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index e9cfa29..5a8dd13 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -364,8 +364,11 @@ func identityCommand(ctx context.Context, args []string) error { } func brokerCommand(args []string) error { + if len(args) > 0 && args[0] == "certificate" { + return busCertificate(args[1:]) + } if len(args) == 0 || args[0] != "show" { - return errors.New("broker show") + return errors.New("broker show | broker certificate [--check] --into ") } known, err := broker.FromEnvironment() if errors.Is(err, broker.ErrNotConfigured) { diff --git a/internal/catalogue/catrust_manifest_test.go b/internal/catalogue/catrust_manifest_test.go new file mode 100644 index 0000000..8ad3705 --- /dev/null +++ b/internal/catalogue/catrust_manifest_test.go @@ -0,0 +1,71 @@ +package catalogue + +import ( + "os" + "strings" + "testing" +) + +// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR +// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless +// the mesh fills in where the authority is — which is the one thing about it the manifest cannot +// state, because the authority's address is a fact about the mesh and not about the module. +// +// So what is checked here is the rendering, not the parsing: the script the machine will run +// names the authority it was bound to, and the unit runs that script both ways. The verification +// itself — a plain client trusting an internal name on a machine holding this, and failing on one +// that does not — is the lab's, and cannot be had here. +func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { + raw, err := os.ReadFile("../../../mesh-catalog/modules/ca-trust/module.json") + if err != nil { + t.Skipf("the catalogue is not beside this checkout: %v", err) + } + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the trust module does not parse:\n%v", err) + } + + r := Resolution{ + Node: "workstation", + Modules: []Manifest{m}, + Needs: []Needed{{ + Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", + Serves: map[string]any{ + "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", + }, + }}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatalf("the trust module could not be composed for a machine: %v", err) + } + + script := fileNamed(out, "ca-trust.anchor") + if script == nil { + t.Fatalf("nothing writes the script the unit runs: %v", out) + } + body, _ := script["content"].(string) + if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { + t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) + } + if script["mode"] != "0755" { + t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) + } + + unit := fileNamed(out, "ca-trust.unit") + if unit == nil { + t.Fatalf("no unit: %v", out) + } + text, _ := unit["content"].(string) + // Both halves. A unit that only installs the anchor leaves a machine trusting an authority + // nobody assigned it to any more, which is the half issue 129 asked for by name. + for _, want := range []string{ + "ExecStart=" + script["path"].(string) + " install", + "ExecStop=" + script["path"].(string) + " remove", + "RemainAfterExit=yes", + } { + if !strings.Contains(text, want) { + t.Errorf("the unit does not say %q:\n%s", want, text) + } + } +}