Genesis makes the root secrets, the operator key, and installs the vault
The template raises the store with the password 'bootstrap' and the broker with its image's default administrator, and the installer carried both into the mesh as accepted secrets — permanent, and not secret (novox/hq issue 071). Now the installer makes both credentials, once, at the paths the postgres and lavinmq modules declare as their own secrets, rewrites the produced bundle to use them (the store reads its password from a file; the broker's default account is given the new password by an action before anything dials it), and writes the bundle at 0600 since it now carries them. Before the first secret is accepted it makes the operator's sealing key beside the bundle and gives the mesh the public half, so everything minted from there is sealed to it too (ADR 0085, amended). Phase three adopts the broker as the lavinmq module beside the store and installs mesh-vault as a foundation module; the run ends by writing the operator-sealed export beside the key.
This commit is contained in:
@@ -48,6 +48,7 @@ const (
|
|||||||
StepApply Step = "apply"
|
StepApply Step = "apply"
|
||||||
StepVerify Step = "verify"
|
StepVerify Step = "verify"
|
||||||
StepEnrol Step = "enrol"
|
StepEnrol Step = "enrol"
|
||||||
|
StepOperator Step = "operator"
|
||||||
StepRegistry Step = "registry"
|
StepRegistry Step = "registry"
|
||||||
StepPublish Step = "publish"
|
StepPublish Step = "publish"
|
||||||
StepControlPlane Step = "control-plane"
|
StepControlPlane Step = "control-plane"
|
||||||
@@ -57,6 +58,8 @@ const (
|
|||||||
StepSDK Step = "sdk"
|
StepSDK Step = "sdk"
|
||||||
StepBase Step = "base"
|
StepBase Step = "base"
|
||||||
StepStore Step = "store"
|
StepStore Step = "store"
|
||||||
|
StepBroker Step = "broker"
|
||||||
|
StepVault Step = "vault"
|
||||||
StepCatalogue Step = "catalogue"
|
StepCatalogue Step = "catalogue"
|
||||||
StepNetwork Step = "network"
|
StepNetwork Step = "network"
|
||||||
StepFilter Step = "filter"
|
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.
|
// mesh made, out of a repository and a commit it can name, and can therefore make again.
|
||||||
var Steps = []Step{
|
var Steps = []Step{
|
||||||
StepPreflight, StepLoad, StepBuild, StepBundle, StepApply, StepVerify,
|
StepPreflight, StepLoad, StepBuild, StepBundle, StepApply, StepVerify,
|
||||||
StepEnrol, StepRegistry, StepPublish, StepControlPlane, StepRetire, StepBuilder,
|
StepEnrol, StepOperator, StepRegistry, StepPublish, StepControlPlane, StepRetire, StepBuilder,
|
||||||
StepPackages, StepSDK,
|
StepPackages, StepSDK,
|
||||||
// Phase two. The twelve above make a mesh that RUNS; these make one that WORKS — able to
|
// 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
|
// 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.
|
// 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.
|
// 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.
|
// Result is what the bootstrap did, in the shape `--json` prints.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
System string `json:"system"`
|
// OperatorKey is where the operator's private key was written; OperatorKeyMade whether this
|
||||||
DryRun bool `json:"dry-run,omitempty"`
|
// 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
|
// Image is what THIS MACHINE'S RUNTIME holds the control plane as, read back from it — and
|
||||||
// what the produced bundle names it by.
|
// 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.BundleWas, result.BundlePlaces, result.Bundle = rewritten.Was, rewritten.Places, o.Out
|
||||||
result.Temporary = rewritten.TempName
|
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 {
|
if rewritten.Renamed {
|
||||||
say(fmt.Sprintf(" control plane %s, renamed from %s",
|
say(fmt.Sprintf(" control plane %s, renamed from %s",
|
||||||
rewritten.TempName, rewritten.WasCalled))
|
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)
|
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 ----------------------------------------------------------------------
|
// ---- 7. registry ----------------------------------------------------------------------
|
||||||
say("registry — somewhere for this mesh to keep its own images")
|
say("registry — somewhere for this mesh to keep its own images")
|
||||||
registry, err := InstallRegistry(ctx, o, d, temporary, say)
|
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)
|
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 --------------------------------------------------------------------
|
// ---- 15. catalogue --------------------------------------------------------------------
|
||||||
say("catalogue — the module graph: what is held, what a change reaches, what to rebuild")
|
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 {
|
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)
|
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, " +
|
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.")
|
"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.")
|
say("what remains is somebody else's: adding nodes, and assigning what they should run.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/novox/mesh-host/internal/declaration"
|
"github.com/novox/mesh-host/internal/declaration"
|
||||||
@@ -116,6 +117,123 @@ func InstallStore(ctx context.Context, o Options, control controlPlane,
|
|||||||
return nil
|
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.
|
// storeIn finds the store container in the bundle this installer produced.
|
||||||
func storeIn(d *declaration.Declaration) (*declaration.Container, error) {
|
func storeIn(d *declaration.Declaration) (*declaration.Container, error) {
|
||||||
return foundationContainer(d, StoreID, "store")
|
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 {
|
store *declaration.Container, say func(string)) error {
|
||||||
|
|
||||||
const secret = "superuser"
|
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 == "" {
|
if value == "" {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"the foundation's store names no POSTGRES_PASSWORD, so the %s module has no superuser "+
|
"neither %s nor the foundation's store names the superuser password, so the %s module "+
|
||||||
"to open it with — and the mesh cannot invent the one that already made the databases",
|
"has nothing to open the store with — and the mesh cannot invent the one that already "+
|
||||||
module)
|
"made the databases", StoreSuperuserFile, module)
|
||||||
}
|
}
|
||||||
at := "/accepting-" + secret
|
at := "/accepting-" + secret
|
||||||
if err := control.carrying(ctx, "mesh-accepting-"+secret, []byte(value), at); err != nil {
|
if err := control.carrying(ctx, "mesh-accepting-"+secret, []byte(value), at); err != nil {
|
||||||
|
|||||||
@@ -316,17 +316,17 @@ func sortStrings(values []string) {
|
|||||||
// writeBundleFile puts the produced bundle where a person can read it, creating the directory it
|
// writeBundleFile puts the produced bundle where a person can read it, creating the directory it
|
||||||
// lives in.
|
// lives in.
|
||||||
//
|
//
|
||||||
// 0644, and that is deliberate: this file names an image and describes a foundation, and it holds
|
// 0600: the produced bundle carries the credentials genesis made — the store's and the broker's,
|
||||||
// the bootstrap credentials the template happens to carry — which are the same ones anybody can
|
// inside the temporary control plane's connection strings (novox/hq issue 071). It used to be
|
||||||
// read in the template itself. It is meant to be read. What must not be world-readable is the
|
// 0644 and say so was fine because the template's credentials were the same ones anybody could
|
||||||
// node's identity, and that lives elsewhere and is written elsewhere (`internal/identity`).
|
// read in the template; they are not any more. Still meant to be read, by root.
|
||||||
func writeBundleFile(path string, content []byte) error {
|
func writeBundleFile(path string, content []byte) error {
|
||||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return fmt.Errorf("cannot make %s to write the produced bundle into: %w", dir, err)
|
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(
|
return fmt.Errorf(
|
||||||
"cannot write the produced bundle to %s: %w\nIt is what is about to be applied, and "+
|
"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",
|
"applying something nobody can read afterwards is how a machine becomes a mystery",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user