diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index a640b5f..278f0ae 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -48,6 +48,7 @@ const ( StepApply Step = "apply" StepVerify Step = "verify" StepEnrol Step = "enrol" + StepOperator Step = "operator" StepRegistry Step = "registry" StepPublish Step = "publish" StepControlPlane Step = "control-plane" @@ -57,6 +58,8 @@ const ( StepSDK Step = "sdk" StepBase Step = "base" StepStore Step = "store" + StepBroker Step = "broker" + StepVault Step = "vault" StepCatalogue Step = "catalogue" StepNetwork Step = "network" StepFilter Step = "filter" @@ -76,12 +79,12 @@ const ( // mesh made, out of a repository and a commit it can name, and can therefore make again. var Steps = []Step{ StepPreflight, StepLoad, StepBuild, StepBundle, StepApply, StepVerify, - StepEnrol, StepRegistry, StepPublish, StepControlPlane, StepRetire, StepBuilder, + StepEnrol, StepOperator, StepRegistry, StepPublish, StepControlPlane, StepRetire, StepBuilder, StepPackages, StepSDK, // Phase two. The twelve above make a mesh that RUNS; these make one that WORKS — able to // build, to say what it holds, on its network, filtering. They used to be things somebody // typed afterwards, which is how they went missing without anything complaining. - StepBase, StepStore, StepCatalogue, StepNetwork, StepFilter, StepExtras, + StepBase, StepStore, StepBroker, StepVault, StepCatalogue, StepNetwork, StepFilter, StepExtras, } // Error is a failure, named by the step it happened in. @@ -201,8 +204,13 @@ type Deps struct { // Result is what the bootstrap did, in the shape `--json` prints. type Result struct { - System string `json:"system"` - DryRun bool `json:"dry-run,omitempty"` + // OperatorKey is where the operator's private key was written; OperatorKeyMade whether this + // run made it. RootExport is where the operator-sealed export landed. + OperatorKey string + OperatorKeyMade bool + RootExport string + System string `json:"system"` + DryRun bool `json:"dry-run,omitempty"` // Image is what THIS MACHINE'S RUNTIME holds the control plane as, read back from it — and // what the produced bundle names it by. @@ -380,6 +388,31 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro } result.BundleWas, result.BundlePlaces, result.Bundle = rewritten.Was, rewritten.Places, o.Out result.Temporary = rewritten.TempName + // The mesh's root credentials: made here, never the template's (novox/hq issue 071). + creds, err := RootSecrets(o.DryRun) + if err != nil { + return result, failed(StepBundle, err) + } + root, err := RewriteRoot(&rewritten, creds) + if err != nil { + return result, failed(StepBundle, err) + } + for _, c := range []struct { + what, path string + made bool + }{{"store superuser", StoreSuperuserFile, creds.StoreMade}, {"broker admin", BrokerAdminFile, creds.BrokerMade}} { + switch { + case c.made && o.DryRun: + say(fmt.Sprintf(" %-17s would be made and kept at %s (0600)", c.what, c.path)) + case c.made: + say(fmt.Sprintf(" %-17s made, kept at %s (0600)", c.what, c.path)) + default: + say(fmt.Sprintf(" %-17s already at %s — kept", c.what, c.path)) + } + } + say(fmt.Sprintf(" credentials the template's bootstrap and guest are gone: %d store and %d broker "+ + "connection(s) rewritten, the store reads its password from a file, the broker's admin is changed once it answers", + root.StoreURLs, root.BrokerURLs)) if rewritten.Renamed { say(fmt.Sprintf(" control plane %s, renamed from %s", rewritten.TempName, rewritten.WasCalled)) @@ -509,6 +542,18 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro return result, failed(StepEnrol, err) } + // ---- operator — the key the mesh's root secrets are also sealed to, held by a person ------ + // + // Before anything is accepted into the mesh: the store's and broker's credentials go in during + // the control-plane step, and they must be sealed to this key as well as to the node, or they + // are as unrecoverable as the constants they replaced (novox/hq ADR 0085, amended). + say("operator — a key the mesh seals its root secrets to, held by a person and never by the mesh") + operator, err := MakeOperatorKey(ctx, o, temporary, say) + result.OperatorKey, result.OperatorKeyMade = operator.Path, operator.Made + if err != nil { + return result, failed(StepOperator, err) + } + // ---- 7. registry ---------------------------------------------------------------------- say("registry — somewhere for this mesh to keep its own images") registry, err := InstallRegistry(ctx, o, d, temporary, say) @@ -598,6 +643,20 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro return result, failed(StepStore, err) } + // ---- 14b. broker ---------------------------------------------------------------------- + say("broker — the foundation's broker, adopted as the lavinmq module: one bus, not two") + if err := InstallBroker(ctx, o, permanentControl, rewritten.Declaration, say); err != nil { + return result, failed(StepBroker, err) + } + + // ---- 14c. vault ----------------------------------------------------------------------- + // A foundation module (novox/hq ADR 0085, amended): it keeps every operator-sealed secret on + // its own disk, outside the store, from the first push that carries one. + say("vault — the mesh's secrets get an owner, and their operator-sealed copies a place to live") + if err := InstallVault(ctx, o, permanentControl, say); err != nil { + return result, failed(StepVault, err) + } + // ---- 15. catalogue -------------------------------------------------------------------- say("catalogue — the module graph: what is held, what a change reaches, what to rebuild") if err := InstallFromCatalogue(ctx, o, permanentControl, "mesh-catalog", say); err != nil { @@ -622,6 +681,14 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro return result, failed(StepExtras, err) } + // ---- export — what the operator keeps beside the key --------------------------------- + say("export — every root secret, sealed to the operator key, written beside it") + exported, err := ExportRootSecrets(ctx, o, permanentControl, say) + result.RootExport = exported + if err != nil { + return result, failed(StepExtras, err) + } + say("\nthis machine is a mesh of one node: it builds its own software, holds its graph, " + "sits on its private network, and filters what modules declared.") say("what remains is somebody else's: adding nodes, and assigning what they should run.") diff --git a/internal/bootstrap/operator.go b/internal/bootstrap/operator.go new file mode 100644 index 0000000..0bc6776 --- /dev/null +++ b/internal/bootstrap/operator.go @@ -0,0 +1,107 @@ +package bootstrap + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/novox/mesh-host/internal/identity" +) + +// The operator's sealing key: made at genesis, before the mesh is told any secret. +// +// Every secret a module holds for itself is sealed to the node that uses it; from here on it is +// sealed to this key as well, and the vault keeps those copies (novox/hq ADR 0085, amended). The +// private half is written once, beside the produced bundle, and given to nothing: the mesh +// records the public half and can open nothing it seals to it. The operator copies the file off +// the machine and keeps it — it is what recovers the mesh's root secrets when a node cannot. +// +// **Before enrolment's first `secret accept`**, or the credentials genesis made would be sealed +// to the node alone and be exactly as unrecoverable as the constants they replaced. + +// OperatorKeyFile is where the private half is written, beside the bundle. +func OperatorKeyFile(o Options) string { + return filepath.Join(filepath.Dir(o.Out), "operator.key") +} + +// RootExportFile is where the export of every operator-sealed secret is written at the end. +func RootExportFile(o Options) string { + return filepath.Join(filepath.Dir(o.Out), "root-secrets.export.json") +} + +type OperatorKey struct { + Path string + Fingerprint string + Made bool +} + +// MakeOperatorKey makes the key if this machine has none, and tells the mesh its public half. +func MakeOperatorKey(ctx context.Context, o Options, control controlPlane, say func(string)) (OperatorKey, error) { + out := OperatorKey{Path: OperatorKeyFile(o)} + key, err := identity.LoadSealingKey(out.Path) + switch { + case err == nil: + say(" operator key already at " + out.Path + " — kept") + case os.IsNotExist(underlying(err)) || strings.Contains(err.Error(), "no sealing key at"): + key, err = identity.GenerateSealingKey() + if err != nil { + return out, err + } + if err := os.MkdirAll(filepath.Dir(out.Path), 0o755); err != nil { + return out, err + } + if err := os.WriteFile(out.Path, []byte(key.Private+"\n"), 0o600); err != nil { + return out, err + } + out.Made = true + default: + return out, err + } + sum := sha256.Sum256([]byte(key.Public)) + out.Fingerprint = "sha256:" + hex.EncodeToString(sum[:8]) + + if _, err := control.tell(ctx, "operator", "key", "set", key.Public); err != nil { + return out, err + } + if out.Made { + say(" operator key " + out.Fingerprint + " — private half at " + out.Path + " (0600)") + say(" COPY IT OFF THIS MACHINE AND KEEP IT: it opens the mesh's root secrets, and") + say(" nothing else does. The mesh holds only the public half.") + } else { + say(" operator key " + out.Fingerprint + " — the mesh seals its root secrets to it") + } + return out, nil +} + +func underlying(err error) error { + for { + next, ok := err.(interface{ Unwrap() error }) + if !ok || next.Unwrap() == nil { + return err + } + err = next.Unwrap() + } +} + +// ExportRootSecrets writes the export beside the operator key: every secret sealed to it, as +// ciphertext, and the honest list of what is not. What the vault keeps on its disk, kept once +// more by the person who holds the key. +func ExportRootSecrets(ctx context.Context, o Options, control controlPlane, say func(string)) (string, error) { + path := RootExportFile(o) + body, err := control.tell(ctx, "secret", "export") + if err != nil { + return path, err + } + if !strings.Contains(body, `"kept"`) { + return path, fmt.Errorf("`secret export` did not produce an export:\n%s", body) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + return path, err + } + say(" exported " + path + " (0600) — ciphertext, sealed to the operator key; keep it with the key") + return path, nil +} diff --git a/internal/bootstrap/phase3.go b/internal/bootstrap/phase3.go index a74ce76..5ef8734 100644 --- a/internal/bootstrap/phase3.go +++ b/internal/bootstrap/phase3.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "strings" "github.com/novox/mesh-host/internal/declaration" @@ -116,6 +117,123 @@ func InstallStore(ctx context.Context, o Options, control controlPlane, return nil } +func readCredentialFile(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + value := strings.TrimRight(string(raw), "\r\n") + if value == "" { + return "", fmt.Errorf("%s is empty", path) + } + return value, nil +} + +// InstallBroker makes the foundation's broker the `lavinmq` module, adopted in place — the same +// shape as InstallStore, for the same reasons. The administrator's password is the one genesis +// gave the image's default account (rootsecrets.go), carried in through `secret accept` so the +// module's provisioner can reach the management API as it. +func InstallBroker(ctx context.Context, o Options, control controlPlane, + foundation *declaration.Declaration, say func(string)) error { + + const module = "lavinmq" + manifest, err := readManifest(o.Catalogue, module) + if err != nil { + return err + } + broker, err := brokerIn(foundation) + if err != nil { + return err + } + if err := serverMatchesFoundation(manifest, broker, module); err != nil { + return err + } + say(" adopting " + broker.Name + " — the broker the foundation raised, unchanged") + + remote := "/" + module + "-module.json" + if err := control.carrying(ctx, module+"-module.json", manifest, remote); err != nil { + return err + } + if _, err := control.tell(ctx, "module", "add", remote); err != nil { + return err + } + say(" registered " + module) + if o.CatalogSource.Repository == "" { + return fmt.Errorf("%s has to be built and there is no --catalog-source to build it from", module) + } + say(" building " + module + " (the provisioner; the server is adopted, not built)") + if _, err := control.within(buildWait).tell(ctx, "build", o.CatalogSource.Repository, + "--path", "modules/"+module, "--ref", refOr(o.CatalogSource.Ref), "--wait", "1200s"); err != nil { + return err + } + if _, err := control.tell(ctx, "module", "issue", module, "--node", o.Node); err != nil { + say(" no account " + module + " — it declares nothing to say on the broker") + } else { + say(" account issued " + module) + } + if _, err := control.tell(ctx, "assign", o.Node, module); err != nil { + return err + } + + value, err := readCredentialFile(BrokerAdminFile) + if err != nil { + return fmt.Errorf("the broker's administrator password genesis made is not at %s: %w", BrokerAdminFile, err) + } + at := "/accepting-admin" + if err := control.carrying(ctx, "mesh-accepting-admin", []byte(value), at); err != nil { + return err + } + if _, err := control.tell(ctx, "secret", "accept", o.Node, module, "admin", "--from", at); err != nil { + return err + } + say(" accepted admin — the broker's administrator, as genesis made it") + + if _, err := pushNode(ctx, o, control, say); err != nil { + return err + } + say(" adopted " + broker.Name + " — the foundation's broker is now the " + module + " module") + return nil +} + +// InstallVault installs the vault as a foundation module (novox/hq ADR 0085, amended). Nothing to +// adopt: it is its own runtime, built from the catalogue like any provider, and from its first push +// it keeps the export of every operator-sealed secret on its own disk. +func InstallVault(ctx context.Context, o Options, control controlPlane, say func(string)) error { + const module = "mesh-vault" + manifest, err := readManifest(o.Catalogue, module) + if err != nil { + return err + } + remote := "/" + module + "-module.json" + if err := control.carrying(ctx, module+"-module.json", manifest, remote); err != nil { + return err + } + if _, err := control.tell(ctx, "module", "add", remote); err != nil { + return err + } + say(" registered " + module) + if o.CatalogSource.Repository == "" { + return fmt.Errorf("%s has to be built and there is no --catalog-source to build it from", module) + } + say(" building " + module) + if _, err := control.within(buildWait).tell(ctx, "build", o.CatalogSource.Repository, + "--path", "modules/"+module, "--ref", refOr(o.CatalogSource.Ref), "--wait", "1200s"); err != nil { + return err + } + if _, err := control.tell(ctx, "module", "issue", module, "--node", o.Node); err != nil { + return err + } + say(" account issued " + module) + if _, err := control.tell(ctx, "assign", o.Node, module); err != nil { + return err + } + if _, err := pushNode(ctx, o, control, say); err != nil { + return err + } + say(" installed " + module + " — the operator-sealed export now lives on this machine, outside the store") + return nil +} + // storeIn finds the store container in the bundle this installer produced. func storeIn(d *declaration.Declaration) (*declaration.Container, error) { return foundationContainer(d, StoreID, "store") @@ -186,12 +304,17 @@ func deliverSuperuser(ctx context.Context, o Options, control controlPlane, modu store *declaration.Container, say func(string)) error { const secret = "superuser" - value := strings.TrimSpace(store.Env["POSTGRES_PASSWORD"]) + // Genesis made it and kept it in the file the store was raised from (rootsecrets.go); the + // template's environment variable is accepted too, for a bundle produced before that. + value, err := readCredentialFile(StoreSuperuserFile) + if err != nil { + value = strings.TrimSpace(store.Env["POSTGRES_PASSWORD"]) + } if value == "" { return fmt.Errorf( - "the foundation's store names no POSTGRES_PASSWORD, so the %s module has no superuser "+ - "to open it with — and the mesh cannot invent the one that already made the databases", - module) + "neither %s nor the foundation's store names the superuser password, so the %s module "+ + "has nothing to open the store with — and the mesh cannot invent the one that already "+ + "made the databases", StoreSuperuserFile, module) } at := "/accepting-" + secret if err := control.carrying(ctx, "mesh-accepting-"+secret, []byte(value), at); err != nil { diff --git a/internal/bootstrap/rewrite.go b/internal/bootstrap/rewrite.go index 45eb470..cbfc8cd 100644 --- a/internal/bootstrap/rewrite.go +++ b/internal/bootstrap/rewrite.go @@ -316,17 +316,17 @@ func sortStrings(values []string) { // writeBundleFile puts the produced bundle where a person can read it, creating the directory it // lives in. // -// 0644, and that is deliberate: this file names an image and describes a foundation, and it holds -// the bootstrap credentials the template happens to carry — which are the same ones anybody can -// read in the template itself. It is meant to be read. What must not be world-readable is the -// node's identity, and that lives elsewhere and is written elsewhere (`internal/identity`). +// 0600: the produced bundle carries the credentials genesis made — the store's and the broker's, +// inside the temporary control plane's connection strings (novox/hq issue 071). It used to be +// 0644 and say so was fine because the template's credentials were the same ones anybody could +// read in the template; they are not any more. Still meant to be read, by root. func writeBundleFile(path string, content []byte) error { if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("cannot make %s to write the produced bundle into: %w", dir, err) } } - if err := os.WriteFile(path, content, 0o644); err != nil { + if err := os.WriteFile(path, content, 0o600); err != nil { return fmt.Errorf( "cannot write the produced bundle to %s: %w\nIt is what is about to be applied, and "+ "applying something nobody can read afterwards is how a machine becomes a mystery", diff --git a/internal/bootstrap/rootsecrets.go b/internal/bootstrap/rootsecrets.go new file mode 100644 index 0000000..9cd6263 --- /dev/null +++ b/internal/bootstrap/rootsecrets.go @@ -0,0 +1,197 @@ +package bootstrap + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// The mesh's root credentials, made at genesis rather than copied from the template. +// +// **The template carries `bootstrap` and `guest`, and a mesh raised from it kept them** (novox/hq +// issue 071). The store's superuser and the broker's administrator are the two credentials every +// other one rests on, and they were the two that were not secret: constants in a file anybody can +// read, carried into the mesh by `secret accept` and marked as something the mesh must never +// replace — which is correct for a credential that already created the databases, and made the +// well-known value permanent. +// +// So the installer makes them. Two random values, **made once and kept on this machine** at the +// paths the postgres and lavinmq modules declare as their own secrets — so that when phase three +// adopts the store and the broker, `secret accept` carries in exactly the value the servers were +// raised with, and the host's later write of the sealed secret lands the same bytes in the same +// file. A second run finds the files and changes nothing, which is what lets the installer say +// "already done" about a store it must not restart. +// +// **The store reads its password from a file, not its environment.** `POSTGRES_PASSWORD` in a +// container's environment is in `docker inspect` for ever; the module that adopts the store +// declares the same file mount, so the two specs are one and the applier reconciles rather than +// recreates (phase3.go). The broker has no such file: its image's default administrator is changed +// in place by an action once the broker answers, and the produced bundle carries that action. +const ( + // StoreSuperuserFile is where the store's superuser password lives on the machine — the + // postgres module's own-secret path, so genesis and adoption write the same file. + StoreSuperuserFile = "/var/lib/postgres/superuser.secret" + // BrokerAdminFile is the same for the broker's administrator — the lavinmq module's. + BrokerAdminFile = "/var/lib/lavinmq-module/admin.secret" + // BrokerAdminUser is the broker's administrator. The image's default account, kept by name + // and given a password that is not the image's default; a renamed account would have to be + // created before anything can authenticate, and the thing that creates accounts is the thing + // that has to authenticate first. + BrokerAdminUser = "guest" + + storeSuperuserMount = "/run/secrets/superuser" + + // What the template says, matched exactly. A template that says something else is a template + // this installer does not know how to make safe, and it says so rather than guessing. + templateStorePassword = `"POSTGRES_PASSWORD": "bootstrap"` + templateStoreVolumes = `"volumes": ["mesh-store-data:/var/lib/postgresql/data"]` + templateStoreURL = "postgres:bootstrap@" + templateBrokerURL = "guest:guest@" + templateBrokerReady = "\"verify\": [\"lavinmqctl\", \"status\"]\n }," + brokerAdminMarker = "/var/lib/lavinmq/.mesh-admin" +) + +// RootCredentials are the two values, and whether this run made them. +type RootCredentials struct { + Store, Broker string + StoreMade, BrokerMade bool +} + +// RootSecrets reads the credentials this machine already holds, or makes them. +// +// A dry run makes them in memory and writes nothing — so the bundle it reports is the shape of the +// real one, and a machine that was only asked is not left holding half a genesis. +func RootSecrets(dryRun bool) (RootCredentials, error) { + var out RootCredentials + var err error + if out.Store, out.StoreMade, err = keptOrMade(StoreSuperuserFile, dryRun); err != nil { + return out, err + } + if out.Broker, out.BrokerMade, err = keptOrMade(BrokerAdminFile, dryRun); err != nil { + return out, err + } + return out, nil +} + +func keptOrMade(path string, dryRun bool) (value string, made bool, err error) { + raw, err := os.ReadFile(path) + if err == nil { + value = strings.TrimRight(string(raw), "\r\n") + if value == "" { + return "", false, fmt.Errorf("%s exists and is empty; move it aside to have one made", path) + } + return value, false, nil + } + if !os.IsNotExist(err) { + return "", false, err + } + value, err = freshSecret() + if err != nil { + return "", false, err + } + if dryRun { + return value, true, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", false, err + } + // Written whole and renamed into place, at 0600, owned by whoever runs the installer — root, + // which is also who the host runs as when it later writes the sealed copy here. + tmp := path + ".genesis" + if err := os.WriteFile(tmp, []byte(value+"\n"), 0o600); err != nil { + return "", false, err + } + if err := os.Rename(tmp, path); err != nil { + return "", false, err + } + return value, true, nil +} + +// freshSecret is the same shape the controller mints: 30 random bytes as unpadded base64url, 40 +// characters, URL-safe — it lands inside connection strings. +func freshSecret() (string, error) { + b := make([]byte, 30) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// RootRewrite says what RewriteRoot did to the bundle. +type RootRewrite struct { + StoreURLs, BrokerURLs int +} + +// RewriteRoot puts the made credentials into the produced bundle, in place of the template's. +// +// Byte for byte, like the image rewrite, so the file keeps its comments and a person can read what +// was applied. Every replacement is counted and a count of zero is refused: a template that no +// longer says what this expects is one whose credentials this would silently leave at the +// well-known values, which is the fault this exists to remove. +func RewriteRoot(r *Rewritten, c RootCredentials) (RootRewrite, error) { + var out RootRewrite + bundle := r.Bundle + + // The store: a file, not an environment variable. + var err error + if bundle, err = replaceOnce(bundle, templateStorePassword, + `"POSTGRES_PASSWORD_FILE": "`+storeSuperuserMount+`"`, "the store's password"); err != nil { + return out, err + } + if bundle, err = replaceOnce(bundle, templateStoreVolumes, + `"volumes": ["mesh-store-data:/var/lib/postgresql/data", "`+StoreSuperuserFile+":"+storeSuperuserMount+`:ro"]`, + "the store's volumes"); err != nil { + return out, err + } + // Everything that dials the store or the broker with the template's credentials. + out.StoreURLs = bytes.Count(bundle, []byte(templateStoreURL)) + if out.StoreURLs == 0 { + return out, fmt.Errorf("the template names no %q connection, so this installer cannot tell what it would be leaving well-known", templateStoreURL) + } + bundle = bytes.ReplaceAll(bundle, []byte(templateStoreURL), []byte("postgres:"+c.Store+"@")) + out.BrokerURLs = bytes.Count(bundle, []byte(templateBrokerURL)) + if out.BrokerURLs == 0 { + return out, fmt.Errorf("the template names no %q connection, so this installer cannot tell what it would be leaving well-known", templateBrokerURL) + } + bundle = bytes.ReplaceAll(bundle, []byte(templateBrokerURL), []byte(BrokerAdminUser+":"+c.Broker+"@")) + + // The broker's administrator, changed once the broker answers and before anything dials it. + // Verified by a marker on the broker's own data volume, because the image carries nothing that + // can try a password from inside; what proves the password is the control plane answering + // over it, a few resources later. + action := templateBrokerReady + "\n" + + " {\n" + + " \"id\": \"broker-admin\",\n" + + " \"type\": \"action\",\n" + + " \"in\": \"mesh-broker\",\n" + + " \"command\": [\"sh\", \"-c\", \"lavinmqctl change_password " + BrokerAdminUser + " '" + c.Broker + "' && touch " + brokerAdminMarker + "\"],\n" + + " \"verify\": [\"sh\", \"-c\", \"test -f " + brokerAdminMarker + "\"]\n" + + " }," + if bundle, err = replaceOnce(bundle, templateBrokerReady, action, "the broker's readiness check"); err != nil { + return out, err + } + + parsed, err := declaration.ParseFileTrusted(bundle) + if err != nil { + return out, fmt.Errorf("the bundle stopped being a declaration after its credentials were rewritten, which is this installer's fault: %w", err) + } + r.Bundle, r.Declaration, r.Resources = bundle, parsed, len(parsed.Resources) + return out, nil +} + +func replaceOnce(in []byte, from, to, what string) ([]byte, error) { + switch n := bytes.Count(in, []byte(from)); n { + case 1: + return bytes.Replace(in, []byte(from), []byte(to), 1), nil + case 0: + return nil, fmt.Errorf("the template does not say %s the way this installer expects (%s), so it cannot be made safe here", what, from) + default: + return nil, fmt.Errorf("the template says %s %d times, and this installer expected once", what, n) + } +} diff --git a/internal/bootstrap/rootsecrets_test.go b/internal/bootstrap/rootsecrets_test.go new file mode 100644 index 0000000..ced776a --- /dev/null +++ b/internal/bootstrap/rootsecrets_test.go @@ -0,0 +1,123 @@ +package bootstrap + +import ( + "os" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +// The produced bundle carries no well-known credential: the store reads its password from the +// file genesis made, every connection string names the made values, and the broker's default +// administrator is changed by an action before anything dials it (novox/hq issue 071). +func TestTheProducedBundleCarriesNoWellKnownCredential(t *testing.T) { + template, err := os.ReadFile("../../examples/foundation-first-node.lock") + if err != nil { + t.Skip("no example bundle beside this checkout") + } + r, err := Rewrite(template, "sha256:"+strings.Repeat("ab", 32)) + if err != nil { + t.Fatal(err) + } + creds := RootCredentials{Store: "STORE-PW-40-characters-of-random-base64u", Broker: "BROKER-PW-40-characters-of-random-base64"} + got, err := RewriteRoot(&r, creds) + if err != nil { + t.Fatal(err) + } + text := string(r.Bundle) + for _, gone := range []string{`"bootstrap"`, "postgres:bootstrap@", "guest:guest@"} { + if strings.Contains(text, gone) { + t.Errorf("the produced bundle still says %s", gone) + } + } + if got.StoreURLs < 3 || got.BrokerURLs < 2 { + t.Errorf("rewrote %d store and %d broker connections; the template has three and two", got.StoreURLs, got.BrokerURLs) + } + var store, action bool + for _, res := range r.Declaration.Resources { + switch x := res.(type) { + case *declaration.Container: + if x.Name != "mesh-store" { + continue + } + store = true + if _, has := x.Env["POSTGRES_PASSWORD"]; has { + t.Error("the store still takes its password from its environment") + } + if x.Env["POSTGRES_PASSWORD_FILE"] != storeSuperuserMount { + t.Errorf("the store reads its password from %q", x.Env["POSTGRES_PASSWORD_FILE"]) + } + if !strings.Contains(strings.Join(x.Volumes, " "), StoreSuperuserFile+":"+storeSuperuserMount) { + t.Errorf("the store does not mount %s: %v", StoreSuperuserFile, x.Volumes) + } + case *declaration.Action: + if x.ID != "broker-admin" { + continue + } + action = true + if x.In != "mesh-broker" || !strings.Contains(strings.Join(x.Command, " "), "change_password "+BrokerAdminUser+" '"+creds.Broker+"'") { + t.Errorf("the broker-admin action is %v in %q", x.Command, x.In) + } + } + } + if !store || !action { + t.Fatalf("store=%v action=%v", store, action) + } + // The order matters: the broker's password changes after it answers and before the control + // plane, which dials it with the new one, is raised. + var readyAt, adminAt, controlAt int + for i, res := range r.Declaration.Resources { + switch res.Identity() { + case "broker-ready": + readyAt = i + case "broker-admin": + adminAt = i + case "control-plane": + controlAt = i + } + } + if !(readyAt < adminAt && adminAt < controlAt) { + t.Errorf("order ready=%d admin=%d control=%d", readyAt, adminAt, controlAt) + } +} + +// A template that no longer says what this expects is refused, not half-rewritten. +func TestATemplateWithoutTheKnownCredentialsIsRefused(t *testing.T) { + template, err := os.ReadFile("../../examples/foundation-first-node.lock") + if err != nil { + t.Skip("no example bundle beside this checkout") + } + changed := strings.Replace(string(template), `"POSTGRES_PASSWORD": "bootstrap"`, `"POSTGRES_PASSWORD": "other"`, 1) + r, err := Rewrite([]byte(changed), "sha256:"+strings.Repeat("ab", 32)) + if err != nil { + t.Fatal(err) + } + if _, err := RewriteRoot(&r, RootCredentials{Store: "x", Broker: "y"}); err == nil { + t.Fatal("a template with an unknown store password was rewritten") + } +} + +// Made once and kept: a second run reads the same value; a dry run writes nothing. +func TestRootSecretsAreKeptAcrossRuns(t *testing.T) { + dir := t.TempDir() + path := dir + "/superuser.secret" + first, made, err := keptOrMade(path, false) + if err != nil || !made || len(first) != 40 { + t.Fatalf("first: %q made=%v err=%v", first, made, err) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 { + t.Errorf("mode %v", info.Mode().Perm()) + } + second, made, err := keptOrMade(path, false) + if err != nil || made || second != first { + t.Fatalf("second: %q made=%v err=%v", second, made, err) + } + dry := dir + "/dry.secret" + if _, made, err := keptOrMade(dry, true); err != nil || !made { + t.Fatal(err) + } + if _, err := os.Stat(dry); err == nil { + t.Fatal("a dry run wrote a secret") + } +}