The bundle raised the store as mesh-store, on upstream postgres, in a docker volume; the postgres module keeps it as postgres, on its pgvector image, in /var/lib/mesh-store owned 999:70, reading its superuser password from a file. The store step refused, and genesis stopped at step 17 of 24. The bundle now raises the store with the module's name, image, data directory and password file, and every step of the bundle and the installer reaches it by that name. The refusal to raise a store over data it cannot open looks for a cluster in the store's directory, no longer for the old volume.
252 lines
9.5 KiB
Go
252 lines
9.5 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// testCredentials are made values with a real bcrypt hash of the bus password, as RootSecrets makes.
|
|
func testCredentials(t *testing.T, store, bus string) RootCredentials {
|
|
t.Helper()
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(bus), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return RootCredentials{Store: store, Bus: bus, BusHash: string(hash)}
|
|
}
|
|
|
|
// theExample is the one genesis bundle, rewritten for an image as the installer does.
|
|
func theExample(t *testing.T) Rewritten {
|
|
t.Helper()
|
|
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)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// The produced bundle carries no well-known credential: the store reads its password from the
|
|
// file genesis made, and the controller's account on the bus is the made one in both places the
|
|
// bundle names it — the address the control plane dials and the user list the bus is raised with
|
|
// (novox/hq issue 071, ADR 0331 §3).
|
|
func TestTheProducedBundleCarriesNoWellKnownCredential(t *testing.T) {
|
|
r := theExample(t)
|
|
creds := testCredentials(t, "STORE-PW-40-characters-of-random-base64u", "BUS-PW-40-characters-of-random-base64uuu")
|
|
got, err := RewriteRoot(&r, creds)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(r.Bundle)
|
|
for _, gone := range []string{`"bootstrap"`, "postgres:bootstrap@", "controller:bootstrap@"} {
|
|
if strings.Contains(text, gone) {
|
|
t.Errorf("the produced bundle still says %s", gone)
|
|
}
|
|
}
|
|
if got.StoreURLs < 3 || got.BusURLs != 1 {
|
|
t.Errorf("rewrote %d store and %d bus connections; the template has three and one", got.StoreURLs, got.BusURLs)
|
|
}
|
|
var store, accounts, control bool
|
|
for _, res := range r.Declaration.Resources {
|
|
switch x := res.(type) {
|
|
case *declaration.Container:
|
|
switch x.Name {
|
|
case "postgres":
|
|
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 r.TempName:
|
|
control = true
|
|
if want := "nats://controller:" + creds.Bus + "@127.0.0.1:5671"; x.Env[BusVar] != want {
|
|
t.Errorf("the temporary control plane dials the bus at %q, not with the made account", x.Env[BusVar])
|
|
}
|
|
}
|
|
case *declaration.File:
|
|
if x.ID != "bus-accounts" {
|
|
continue
|
|
}
|
|
accounts = true
|
|
hash := hashOf(t, x.Content, "controller")
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(creds.Bus)) != nil {
|
|
t.Errorf("the bus is raised with a controller account whose hash %q is not the made password's", hash)
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")) == nil {
|
|
t.Error("the bus is still raised with the controller at bootstrap")
|
|
}
|
|
}
|
|
}
|
|
if !store || !accounts || !control {
|
|
t.Fatalf("store=%v accounts=%v control=%v", store, accounts, control)
|
|
}
|
|
}
|
|
|
|
// hashOf is the password hash a NATS user list gives one user.
|
|
func hashOf(t *testing.T, list, user string) string {
|
|
t.Helper()
|
|
marker := `{ user: "` + user + `", password: "`
|
|
at := strings.Index(list, marker)
|
|
if at < 0 {
|
|
t.Fatalf("the user list names no %s:\n%s", user, list)
|
|
}
|
|
rest := list[at+len(marker):]
|
|
return rest[:strings.Index(rest, `"`)]
|
|
}
|
|
|
|
// The bus's placeholders are what the installer looks for, and a template naming no bus connection
|
|
// it knows is refused at preflight, naming the placeholder (novox/hq ADR 0331 §3, §4).
|
|
func TestABundleNamingNoBusConnectionItKnowsIsRefusedByItsPlaceholder(t *testing.T) {
|
|
template, err := os.ReadFile("../../examples/foundation-first-node.lock")
|
|
if err != nil {
|
|
t.Skip("no example bundle beside this checkout")
|
|
}
|
|
parse := func(b string) *declaration.Declaration {
|
|
d, err := declaration.ParseFileTrusted([]byte(b))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
if err := BusPlaceholders(template, parse(string(template))); err != nil {
|
|
t.Fatalf("the shipped bundle is refused: %v", err)
|
|
}
|
|
for _, c := range []struct{ what, from, to, names string }{
|
|
{"no bus address", `"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",`, "", BusVar},
|
|
{"the previous broker's", `"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671"`,
|
|
`"MESH_BUS_NATS": "amqp://guest:guest@127.0.0.1:5671"`, BusURLPlaceholder},
|
|
{"no pinned certificate", `"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"`, `"MESH_OTHER": "x"`, CertificateVar},
|
|
{"no controller in the user list", `{ user: \"controller\"`, `{ user: \"someone\"`, BusUserPlaceholder},
|
|
} {
|
|
if !strings.Contains(string(template), c.from) {
|
|
t.Fatalf("%s: the bundle does not say %s", c.what, c.from)
|
|
}
|
|
changed := strings.Replace(string(template), c.from, c.to, 1)
|
|
err := BusPlaceholders([]byte(changed), parse(changed))
|
|
if err == nil {
|
|
t.Errorf("%s: accepted", c.what)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), c.names) {
|
|
t.Errorf("%s: refused without naming %s: %v", c.what, c.names, err)
|
|
}
|
|
if strings.Contains(err.Error(), "guest:guest") {
|
|
t.Errorf("%s: the refusal quotes a password: %v", c.what, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The account is made once, with its hash, and a second run puts the same bytes in the bundle — a
|
|
// hash salted afresh every run would rewrite the bus's user list every run.
|
|
func TestTheBusAccountIsKeptWithItsHash(t *testing.T) {
|
|
dir := t.TempDir()
|
|
first, err := rootSecretsAt(dir+"/store", dir+"/bus", dir+"/bus.bcrypt", false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !first.BusMade || len(first.Bus) != 40 || bcrypt.CompareHashAndPassword([]byte(first.BusHash), []byte(first.Bus)) != nil {
|
|
t.Fatalf("first run: made=%v hash ok=%v", first.BusMade, bcrypt.CompareHashAndPassword([]byte(first.BusHash), []byte(first.Bus)) == nil)
|
|
}
|
|
second, err := rootSecretsAt(dir+"/store", dir+"/bus", dir+"/bus.bcrypt", false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.BusMade || second.Bus != first.Bus || second.BusHash != first.BusHash {
|
|
t.Fatalf("a second run changed the bus account: made=%v same=%v same hash=%v",
|
|
second.BusMade, second.Bus == first.Bus, second.BusHash == first.BusHash)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// Nothing the installer says after making the credentials contains them.
|
|
func TestTheTranscriptNeverSaysTheCredentials(t *testing.T) {
|
|
var said []string
|
|
say := Masking(func(l string) { said = append(said, l) }, RootCredentials{Store: "STORE-PW", Bus: "BUS-PW"})
|
|
say("created context-schemas (docker run -e MESH_STORE_INVENTORY=postgres://postgres:STORE-PW@127.0.0.1:5432/inventory)")
|
|
say("failed control-plane (MESH_BUS_NATS=nats://controller:BUS-PW@127.0.0.1:5671)")
|
|
for _, l := range said {
|
|
if strings.Contains(l, "STORE-PW") || strings.Contains(l, "BUS-PW") {
|
|
t.Errorf("said a credential: %s", l)
|
|
}
|
|
}
|
|
if !strings.Contains(said[0], "postgres:…@") || !strings.Contains(said[1], "controller:…@") {
|
|
t.Errorf("the lines were not the same lines with the values masked: %v", said)
|
|
}
|
|
}
|
|
|
|
// The foundation filters before anything listens: nftables is in place before the store, and its
|
|
// rules refuse the store's and broker's client ports from outside while keeping ssh, the bus a node
|
|
// enrols over and the registry a node pulls from (novox/hq issue 054).
|
|
func TestTheFoundationFiltersBeforeAnythingListens(t *testing.T) {
|
|
r := theExample(t)
|
|
if _, err := RewriteRoot(&r, testCredentials(t, "s", "b")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var filterAt, loadedAt, storeAt, brokerAt = -1, -1, -1, -1
|
|
var rules string
|
|
for i, res := range r.Declaration.Resources {
|
|
switch res.Identity() {
|
|
case "base-filter":
|
|
filterAt = i
|
|
rules = res.(*declaration.File).Content
|
|
case "base-filter-loaded":
|
|
loadedAt = i
|
|
case "store":
|
|
storeAt = i
|
|
case "broker":
|
|
brokerAt = i
|
|
}
|
|
}
|
|
if !(filterAt >= 0 && filterAt < loadedAt && loadedAt < storeAt && storeAt < brokerAt) {
|
|
t.Fatalf("order: filter %d loaded %d store %d broker %d", filterAt, loadedAt, storeAt, brokerAt)
|
|
}
|
|
for _, want := range []string{"policy drop", "tcp dport 22 accept", "ct original proto-dst 5671 accept",
|
|
"ct original proto-dst 5000 accept", "tcp dport 5671 accept", "tcp dport 5000 accept",
|
|
"ip saddr 172.16.0.0/12 accept", "table inet mesh"} {
|
|
if !strings.Contains(rules, want) {
|
|
t.Errorf("the base filter lacks %q", want)
|
|
}
|
|
}
|
|
for _, mustNot := range []string{"5432", "5672"} {
|
|
if strings.Contains(rules, mustNot) {
|
|
t.Errorf("the base filter names %s, which must be reached from the machine and its containers only", mustNot)
|
|
}
|
|
}
|
|
}
|