package bootstrap import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "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) { value, err = readCredentialFile(path) if err == nil { 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 } // readCredentialFile is a credential as genesis keeps it: the value alone, its line ending gone. // Missing is reported as os.IsNotExist so a caller can tell "not made yet" from "unreadable". 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 exists and is empty; move it aside to have one made", path) } return value, nil } // credentialFingerprint names a credential without being one — what the broker-admin action // leaves on the broker's volume, so its verify holds for this value and not for any value. func credentialFingerprint(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:8]) } // 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 } // RefuseExistingServers stops a run that would put a made credential in front of a server raised // by an earlier installer with the template's. // // The store's password is set by initdb, once, on an empty volume; the broker's by the action // above, once. A machine that already holds `mesh-store-data` or `mesh-broker-data` and has no // credential file was raised with `bootstrap` and `guest`, and minting new values here would make a // bundle that dials with passwords the servers do not have — failing three steps later, in the // schemas, with nothing pointing back here. Refused by name instead, with the way forward. func RefuseExistingServers(ctx context.Context, run Runner, c RootCredentials) error { for _, check := range []struct { made bool volume string what string file string }{ {c.StoreMade, "mesh-store-data", "store", StoreSuperuserFile}, {c.BrokerMade, "mesh-broker-data", "broker", BrokerAdminFile}, } { if !check.made { continue } if _, err := run(ctx, "docker", "volume", "inspect", check.volume); err != nil { continue // no such volume: a fresh machine, which is the case this installer makes } return fmt.Errorf( "this machine already holds the %s's data (volume %s) and no credential at %s, so it was raised "+ "by an earlier installer with the template's password. A new one made here would not open it. "+ "Put the password the %s has into %s (0600, the value alone) and run again; then change it "+ "on the server and accept the new value — this installer does not rotate a running %s", check.what, check.volume, check.file, check.what, check.file, check.what) } return 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 holding this password's fingerprint — // the image carries nothing that can try a password from inside, and a marker that merely // existed would let a regenerated password go unapplied for ever. What proves the password // works is the control plane answering over it, a few resources later. fp := credentialFingerprint(c.Broker) action := templateBrokerReady + "\n" + " {\n" + " \"id\": \"broker-admin\",\n" + " \"type\": \"action\",\n" + " \"in\": \"mesh-broker\",\n" + " \"command\": [\"sh\", \"-c\", \"lavinmqctl change_password " + BrokerAdminUser + " '" + c.Broker + "' && printf %s " + fp + " > " + brokerAdminMarker + "\"],\n" + " \"verify\": [\"sh\", \"-c\", \"read m < " + brokerAdminMarker + " && [ \\\"$m\\\" = " + fp + " ]\"]\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) } }