The verify reads the marker with the shell's read, which fails at end of file without a line ending; the action ran and its verify said no. And the applier reports each action with its command line, two of which now carry the real store and broker passwords — the installer masks the values it made in everything it says.
270 lines
12 KiB
Go
270 lines
12 KiB
Go
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
|
|
}
|
|
|
|
// Masking makes a reporter that never says the credentials this run made.
|
|
//
|
|
// The applier reports each action with its command line, and two of them now carry a real
|
|
// password — the context schemas' connection strings and the broker's change_password. Those
|
|
// lines go to a terminal and to whatever keeps the transcript, which for the lab is a file. The
|
|
// exact values are known here, so they are replaced wherever they appear, in every line said.
|
|
func Masking(say func(string), c RootCredentials) func(string) {
|
|
replacer := strings.NewReplacer(c.Store, "…", c.Broker, "…")
|
|
if c.Store == "" || c.Broker == "" {
|
|
return say
|
|
}
|
|
return func(line string) { say(replacer.Replace(line)) }
|
|
}
|
|
|
|
// 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. The marker ends in a
|
|
// newline because the verify reads it with the shell's `read`, which fails at end of file
|
|
// without one — an action that ran and a verify that said no, once, in the lab.
|
|
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 + "' && echo " + 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)
|
|
}
|
|
}
|