Unify trunk on main: initialization → main #3
+35
-7
@@ -331,11 +331,12 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, sou
|
||||
return err
|
||||
}
|
||||
|
||||
report, updated, applyErr := apply.Apply(ctx, sys, d, known, store.OriginCarried, apply.ExecRunner, func(line string) {
|
||||
if !opts.json {
|
||||
fmt.Println(line)
|
||||
}
|
||||
})
|
||||
report, updated, applyErr := apply.Apply(ctx, sys, d, known, store.OriginCarried,
|
||||
apply.ExecRunner, func(line string) {
|
||||
if !opts.json {
|
||||
fmt.Println(line)
|
||||
}
|
||||
}, sealOpener(opts.state))
|
||||
|
||||
// Saved whichever way it went. Recording only on success would lose the footprint of a
|
||||
// failed apply, and that footprint is on the machine either way.
|
||||
@@ -449,6 +450,15 @@ func enrol(ctx context.Context, opts options) error {
|
||||
}
|
||||
fmt.Printf("generated this node's overlay key: %s\n", mine.Overlay.Public)
|
||||
|
||||
// And the key secrets are sealed to. Here, with the others, because the mesh cannot seal
|
||||
// anything to a key it has not been told about — a key made later would leave a node that
|
||||
// looks enrolled and can receive no credential.
|
||||
sealing, err := identity.GenerateSealingKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("generated this node's sealing key: %s\n", sealing.Public)
|
||||
|
||||
// What this machine can be asked to do, gathered before joining rather than after. The
|
||||
// control plane cannot decide what a node should run without it, so it travels with the
|
||||
// request instead of being asked for in a second round trip.
|
||||
@@ -459,7 +469,7 @@ func enrol(ctx context.Context, opts options) error {
|
||||
}
|
||||
|
||||
reply, err := link.Enrol(ctx, token.Broker, token.Fingerprint, *name, token.Secret,
|
||||
mine.Public, mine.Overlay.Public, reported, opts.timeout)
|
||||
mine.Public, mine.Overlay.Public, sealing.Public, reported, opts.timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -504,6 +514,10 @@ func enrol(ctx context.Context, opts options) error {
|
||||
[]byte(mine.Overlay.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write this node's overlay key: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(identity.SealingKeyPath(opts.state),
|
||||
[]byte(sealing.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write this node's sealing key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nenrolled as %s\n", reply.Node)
|
||||
fmt.Printf(" identity %s\n", identityPath)
|
||||
@@ -649,7 +663,7 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
||||
// Declared, not carried. A declaration from the mesh removes only what the mesh previously
|
||||
// declared — never what this machine raised for itself from its bundle (04-ISSUES/010).
|
||||
outcome, updated, applyErr := apply.Apply(ctx, built, declared, known, store.OriginDeclared,
|
||||
apply.ExecRunner, nil)
|
||||
apply.ExecRunner, nil, sealOpener(opts.state))
|
||||
|
||||
// Saved whichever way it went. Recording only on success would lose the footprint of a
|
||||
// failed apply, and that footprint is on the machine either way.
|
||||
@@ -709,3 +723,17 @@ func waitForEnrolment(ctx context.Context, opts options) (identity.Identity, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sealOpener is how a sealed file is opened.
|
||||
//
|
||||
// Looked up per file rather than held, because most declarations contain no sealed file at all
|
||||
// and a node with no key must fail on the one that needs it rather than on every apply.
|
||||
func sealOpener(statePath string) apply.Unseal {
|
||||
return func(sealed string) ([]byte, error) {
|
||||
key, err := identity.LoadSealingKey(identity.SealingKeyPath(statePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key.Unseal(sealed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
module github.com/novox/mesh-host
|
||||
|
||||
go 1.24
|
||||
go 1.25.0
|
||||
|
||||
require github.com/rabbitmq/amqp091-go v1.14.0 // indirect
|
||||
require (
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
|
||||
+36
-9
@@ -98,6 +98,7 @@ func Apply(
|
||||
origin string,
|
||||
run Runner,
|
||||
log func(string),
|
||||
unseal Unseal,
|
||||
) (Report, store.State, error) {
|
||||
if log == nil {
|
||||
log = func(string) {}
|
||||
@@ -129,7 +130,7 @@ func Apply(
|
||||
|
||||
for _, resource := range d.Resources {
|
||||
was, _ := known.Find(resource.Identity())
|
||||
outcome, err := applyOne(ctx, sys, resource, run, changed, was)
|
||||
outcome, err := applyOne(ctx, sys, resource, run, changed, was, unseal)
|
||||
if err != nil {
|
||||
return report, known, &Error{Resource: resource.Identity(), Err: err, Done: report}
|
||||
}
|
||||
@@ -150,13 +151,17 @@ func Apply(
|
||||
return report, known, nil
|
||||
}
|
||||
|
||||
// Unseal opens a value the mesh sealed to this node. Nil when the node has no sealing key, which
|
||||
// makes every sealed file an error rather than a silently skipped one.
|
||||
type Unseal func(sealed string) ([]byte, error)
|
||||
|
||||
func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner,
|
||||
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||
changed map[string]bool, previous store.Applied, unseal Unseal) (Outcome, error) {
|
||||
switch res := r.(type) {
|
||||
case *declaration.Directory:
|
||||
return applyDirectory(res)
|
||||
case *declaration.File:
|
||||
return applyFile(res, previous)
|
||||
return applyFile(res, previous, unseal)
|
||||
case *declaration.Service:
|
||||
return applyService(ctx, sys, res, run, changed)
|
||||
case *declaration.Package:
|
||||
@@ -239,10 +244,32 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyFile(r *declaration.File, previous store.Applied) (Outcome, error) {
|
||||
func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outcome, error) {
|
||||
out := begin(r)
|
||||
out.wrote = digestOf(r.Content)
|
||||
mode, err := modeOf(r.Mode, 0o644)
|
||||
|
||||
// What actually goes on disk. For a sealed file the mesh never had this, and neither did
|
||||
// whatever carried the declaration here.
|
||||
content := r.Content
|
||||
// A secret written world-readable is a secret. The default differs from an ordinary file's
|
||||
// for that reason alone; an explicit mode still wins, because a module may need its own user
|
||||
// to read it and only the module knows which.
|
||||
fallback := os.FileMode(0o644)
|
||||
if r.Secret() {
|
||||
fallback = 0o600
|
||||
if unseal == nil {
|
||||
// Refused rather than skipped. A machine that quietly does not apply the one resource
|
||||
// carrying a credential is a machine that looks configured and cannot connect.
|
||||
return out, fmt.Errorf(
|
||||
"%s is sealed to this node and this node has no sealing key", r.Path)
|
||||
}
|
||||
opened, err := unseal(r.Sealed)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("cannot open %s: %w", r.Path, err)
|
||||
}
|
||||
content = string(opened)
|
||||
}
|
||||
out.wrote = digestOf(content)
|
||||
mode, err := modeOf(r.Mode, fallback)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -260,7 +287,7 @@ func applyFile(r *declaration.File, previous store.Applied) (Outcome, error) {
|
||||
}
|
||||
}
|
||||
|
||||
contentSame := existed && string(existing) == r.Content
|
||||
contentSame := existed && string(existing) == content
|
||||
|
||||
// Whether the machine still holds what this host last put there. When it does not, and the
|
||||
// declaration has not changed either, somebody edited it — and saying so is the whole
|
||||
@@ -272,7 +299,7 @@ func applyFile(r *declaration.File, previous store.Applied) (Outcome, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := writeAtomically(r.Path, []byte(r.Content), mode); err != nil {
|
||||
if err := writeAtomically(r.Path, []byte(content), mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
} else if !modeSame {
|
||||
@@ -286,7 +313,7 @@ func applyFile(r *declaration.File, previous store.Applied) (Outcome, error) {
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("wrote %s and cannot read it back: %w", r.Path, err)
|
||||
}
|
||||
if string(written) != r.Content {
|
||||
if string(written) != content {
|
||||
return out, fmt.Errorf("%s does not contain what was declared after writing it", r.Path)
|
||||
}
|
||||
info, err := os.Stat(r.Path)
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+dir+`/etc/a.conf","content":"hello\n","mode":"0640"}
|
||||
]}`)
|
||||
|
||||
first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil)
|
||||
first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) {
|
||||
t.Fatal("the first apply on an empty machine changed nothing")
|
||||
}
|
||||
|
||||
second, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil)
|
||||
second, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestADriftedMachineIsReturned(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+path+`","content":"correct\n","mode":"0644"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func TestADriftedMachineIsReturned(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestADroppedResourceIsRemoved(t *testing.T) {
|
||||
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"},
|
||||
{"id":"drop","type":"file","path":"`+drop+`","content":"b\n"}
|
||||
]}`)
|
||||
_, state, err := Apply(context.Background(), archHost(t), both, store.State{}, store.OriginCarried, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), both, store.State{}, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestADroppedResourceIsRemoved(t *testing.T) {
|
||||
one := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"}
|
||||
]}`)
|
||||
report, state, err := Apply(context.Background(), archHost(t), one, state, store.OriginCarried, noServices, nil)
|
||||
report, state, err := Apply(context.Background(), archHost(t), one, state, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestNothingTheHostDidNotCreateIsTouched(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"ours","type":"file","path":"`+filepath.Join(dir, "ours.conf")+`","content":"a\n"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) {
|
||||
before := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"old","type":"file","path":"`+path+`","content":"old\n"}
|
||||
]}`)
|
||||
_, state, err := Apply(context.Background(), archHost(t), before, store.State{}, store.OriginCarried, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), before, store.State{}, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) {
|
||||
after := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"new","type":"file","path":"`+path+`","content":"new\n"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), after, state, store.OriginCarried, noServices, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), after, state, store.OriginCarried, noServices, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestAFailedStepFailsTheApply(t *testing.T) {
|
||||
{"id":"never","type":"file","path":"`+filepath.Join(dir, "never.conf")+`","content":"b\n"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("an impossible resource did not fail the apply")
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func TestNothingIsRecordedUntilItWorked(t *testing.T) {
|
||||
{"id":"doomed","type":"directory","path":"`+blocker+`"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure")
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func TestAModeIsMaintainedNotJustSet(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+path+`","content":"s\n","mode":"0600"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -253,7 +253,7 @@ func TestAModeIsMaintainedNotJustSet(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func TestAServiceIsReadBackNotAssumed(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"doomed.service","state":"running"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a service that died immediately was reported as running")
|
||||
}
|
||||
@@ -300,7 +300,7 @@ func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"odd.service","state":"running"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "neither running nor stopped") {
|
||||
t.Errorf("an unrecognised service state was not refused: %v", err)
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func TestADroppedServiceIsStoppedNotDeleted(t *testing.T) {
|
||||
{"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, run, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(commands, "; ")
|
||||
@@ -350,7 +350,7 @@ func TestAUnitThatDoesNotExistIsNotStopped(t *testing.T) {
|
||||
{"id":"s","type":"service","unit":"never-installed.service","state":"stopped"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, absent, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, absent, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a unit that does not exist was reported as satisfactorily stopped")
|
||||
}
|
||||
@@ -371,7 +371,7 @@ func TestAMaskedUnitIsRefused(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"masked.service","state":"running"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, masked, nil); err == nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, masked, nil, nil); err == nil {
|
||||
t.Fatal("a masked unit was accepted")
|
||||
}
|
||||
}
|
||||
@@ -399,7 +399,7 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil)
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a vanished unit stranded the apply: %v", err)
|
||||
}
|
||||
@@ -443,7 +443,7 @@ func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
|
||||
{"id":"rt","type":"package","package":"docker"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a broken package database was read as 'not installed'")
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func TestAnInstalledPackageIsNotReinstalled(t *testing.T) {
|
||||
{"id":"rt","type":"package","package":"docker"}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -494,7 +494,7 @@ func TestAPackageIsNeverUninstalled(t *testing.T) {
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil)
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dropping a package stranded the apply: %v", err)
|
||||
}
|
||||
@@ -524,7 +524,7 @@ func TestAnActionThatIsAlreadyTrueDoesNotRun(t *testing.T) {
|
||||
{"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -550,7 +550,7 @@ func TestAnActionThatSucceedsAndDoesNothingFails(t *testing.T) {
|
||||
{"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("an action that reported success and did nothing was accepted")
|
||||
}
|
||||
@@ -577,7 +577,7 @@ func TestAnActionRunsInsideTheContainerItNames(t *testing.T) {
|
||||
{"id":"db","type":"action","in":"store","command":["createdb","mesh"],"verify":["psql","-lqt"]}
|
||||
]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil); err != nil {
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
if !sawExec {
|
||||
@@ -604,7 +604,7 @@ func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) {
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a container that exited immediately was reported as applied")
|
||||
}
|
||||
@@ -646,7 +646,7 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -676,7 +676,7 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -736,7 +736,7 @@ func TestAServiceIsEnabledAtBootWhenAsked(t *testing.T) {
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried,
|
||||
systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil)
|
||||
systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -756,7 +756,7 @@ func TestBootIsEnabledBeforeTheUnitIsStarted(t *testing.T) {
|
||||
{"id":"rt","type":"service","unit":"docker.service","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried,
|
||||
systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil); err != nil {
|
||||
systemctlStub(t, "loaded", "inactive", "disabled", &verbs), nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(verbs) < 2 || verbs[0] != "enable" {
|
||||
@@ -771,7 +771,7 @@ func TestAlreadyEnabledAndRunningIsUnchanged(t *testing.T) {
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried,
|
||||
systemctlStub(t, "loaded", "active", "enabled", &verbs), nil)
|
||||
systemctlStub(t, "loaded", "active", "enabled", &verbs), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
@@ -791,7 +791,7 @@ func TestOmittingBootLeavesItAlone(t *testing.T) {
|
||||
{"id":"rt","type":"service","unit":"docker.service","state":"running"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried,
|
||||
systemctlStub(t, "loaded", "inactive", "enabled", &verbs), nil); err != nil {
|
||||
systemctlStub(t, "loaded", "inactive", "enabled", &verbs), nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range verbs {
|
||||
@@ -811,7 +811,7 @@ func TestAStaticUnitCannotBeEnabled(t *testing.T) {
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried,
|
||||
systemctlStub(t, "loaded", "active", "static", &verbs), nil)
|
||||
systemctlStub(t, "loaded", "active", "static", &verbs), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a static unit was accepted as enable-able")
|
||||
}
|
||||
@@ -826,7 +826,7 @@ func TestAnUnknownBootStateIsRefusedNotGuessed(t *testing.T) {
|
||||
{"id":"rt","type":"service","unit":"x.service","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried,
|
||||
systemctlStub(t, "loaded", "active", "indirect", &verbs), nil)
|
||||
systemctlStub(t, "loaded", "active", "indirect", &verbs), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("an unrecognised boot state was guessed at instead of refused")
|
||||
}
|
||||
@@ -901,7 +901,7 @@ func TestAContainerUsesTheRuntimeTheMachineHas(t *testing.T) {
|
||||
|
||||
// It will fail at read-back — the stub never reports it running — and what matters is
|
||||
// WHICH binary it used getting there.
|
||||
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
|
||||
for _, c := range calledWith {
|
||||
if c != "podman" {
|
||||
@@ -923,7 +923,7 @@ func TestNoRuntimeIsSaidPlainly(t *testing.T) {
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a machine with no container runtime applied a container")
|
||||
}
|
||||
@@ -964,14 +964,14 @@ func TestAServiceIsRestartedWhenWhatItReflectsChanges(t *testing.T) {
|
||||
run := recordingServices(&commands)
|
||||
|
||||
if _, state, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, run, nil); err != nil {
|
||||
store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
// Second apply with the same content: nothing moved, so nothing restarts. A machine that
|
||||
// restarted its services on every reconcile would never be steady.
|
||||
commands = nil
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, state,
|
||||
store.OriginCarried, run, nil); err != nil {
|
||||
store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range commands {
|
||||
@@ -987,7 +987,7 @@ func TestAServiceIsRestartedWhenWhatItReflectsChanges(t *testing.T) {
|
||||
]}`, path))
|
||||
commands = nil
|
||||
if _, _, err := Apply(context.Background(), archHost(t), changedDecl, state,
|
||||
store.OriginCarried, run, nil); err != nil {
|
||||
store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stopped, started bool
|
||||
@@ -1021,7 +1021,7 @@ func TestAServiceIsNotRestartedByAChangeItDoesNotName(t *testing.T) {
|
||||
var commands []string
|
||||
run := recordingServices(&commands)
|
||||
_, state, err := Apply(context.Background(), archHost(t), first, store.State{},
|
||||
store.OriginCarried, run, nil)
|
||||
store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1033,7 +1033,7 @@ func TestAServiceIsNotRestartedByAChangeItDoesNotName(t *testing.T) {
|
||||
]}`, conf, other))
|
||||
commands = nil
|
||||
if _, _, err := Apply(context.Background(), archHost(t), second, state,
|
||||
store.OriginCarried, run, nil); err != nil {
|
||||
store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range commands {
|
||||
@@ -1072,7 +1072,7 @@ func TestAFileChangedOnTheMachineIsCorrectedAndSaidSo(t *testing.T) {
|
||||
]}`, path))
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil)
|
||||
store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1083,7 +1083,7 @@ func TestAFileChangedOnTheMachineIsCorrectedAndSaidSo(t *testing.T) {
|
||||
}
|
||||
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, state,
|
||||
store.OriginCarried, noServices, nil)
|
||||
store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1115,12 +1115,12 @@ func TestTheMeshChangingItsMindIsNotDrift(t *testing.T) {
|
||||
]}`, path))
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), first, store.State{},
|
||||
store.OriginCarried, noServices, nil)
|
||||
store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, _, err := Apply(context.Background(), archHost(t), second, state,
|
||||
store.OriginCarried, noServices, nil)
|
||||
store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1138,12 +1138,12 @@ func TestAnUntouchedFileIsStillUnchanged(t *testing.T) {
|
||||
]}`, path))
|
||||
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil)
|
||||
store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state,
|
||||
store.OriginCarried, noServices, nil)
|
||||
store.OriginCarried, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A file the mesh delivers without being able to read.
|
||||
//
|
||||
// Everything else in a declaration is visible to whatever carried it: the message is signed, so
|
||||
// it cannot be forged, and signing does not make it unreadable. A password in `content` is a
|
||||
// password the broker sees — the transitive trust this design refuses everywhere else.
|
||||
|
||||
func sealedTo(t *testing.T, key identity.SealingKey, value string) string {
|
||||
t.Helper()
|
||||
sealed, err := identity.Seal(key.Public, []byte(value))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sealed
|
||||
}
|
||||
|
||||
func opener(key identity.SealingKey) Unseal {
|
||||
return func(sealed string) ([]byte, error) { return key.Unseal(sealed) }
|
||||
}
|
||||
|
||||
func sealedFile(t *testing.T, path, sealed string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
raw := map[string]any{"declaration": 1, "resources": []map[string]any{
|
||||
{"id": "creds", "type": "file", "path": path, "sealed": sealed},
|
||||
}}
|
||||
body, _ := json.Marshal(raw)
|
||||
d, err := declaration.Parse(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestASealedFileIsOpenedAndWritten(t *testing.T) {
|
||||
key, err := identity.GenerateSealingKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := dir + "/db.json"
|
||||
d := sealedFile(t, path, sealedTo(t, key, `{"password":"hunter2"}`))
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil, opener(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Changed() {
|
||||
t.Fatal("nothing changed")
|
||||
}
|
||||
on, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(on) != `{"password":"hunter2"}` {
|
||||
t.Fatalf("the file holds %q", on)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecretIsNotWorldReadableByDefault(t *testing.T) {
|
||||
// An ordinary file defaults to 0644, which for a credential is the whole problem. The default
|
||||
// differs because the consequence differs; an explicit mode still wins, since a module may
|
||||
// need its own user to read it and only the module knows which.
|
||||
key, _ := identity.GenerateSealingKey()
|
||||
dir := t.TempDir()
|
||||
path := dir + "/db.json"
|
||||
d := sealedFile(t, path, sealedTo(t, key, "secret"))
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil, opener(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("a credential landed mode %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingSealedToAnotherNodeIsRefused(t *testing.T) {
|
||||
// Refused, not skipped, and refused before anything is written. A machine that quietly does
|
||||
// not apply the one resource carrying a credential looks configured and cannot connect.
|
||||
mine, _ := identity.GenerateSealingKey()
|
||||
theirs, _ := identity.GenerateSealingKey()
|
||||
dir := t.TempDir()
|
||||
path := dir + "/db.json"
|
||||
d := sealedFile(t, path, sealedTo(t, theirs, "not for you"))
|
||||
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil, opener(mine))
|
||||
if err == nil {
|
||||
t.Fatal("a file sealed to another node was applied")
|
||||
}
|
||||
if _, statErr := os.Stat(path); statErr == nil {
|
||||
t.Fatal("something was written before the failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithNoSealingKeyRefusesRatherThanSkipping(t *testing.T) {
|
||||
key, _ := identity.GenerateSealingKey()
|
||||
dir := t.TempDir()
|
||||
d := sealedFile(t, dir+"/db.json", sealedTo(t, key, "secret"))
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a sealed file was skipped by a node that cannot open one")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sealing key") {
|
||||
t.Fatalf("the failure does not say why: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSecretIsNeverInWhatTheMeshIsToldBack(t *testing.T) {
|
||||
// The node reports what it applied, and that report goes over the same broker the sealing was
|
||||
// for. A digest is a fact about the file; the file is not.
|
||||
key, _ := identity.GenerateSealingKey()
|
||||
dir := t.TempDir()
|
||||
d := sealedFile(t, dir+"/db.json", sealedTo(t, key, "hunter2"))
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil, opener(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, _ := json.Marshal(report)
|
||||
kept, _ := json.Marshal(state)
|
||||
for what, blob := range map[string][]byte{"the report": said, "the node's state": kept} {
|
||||
if strings.Contains(string(blob), "hunter2") {
|
||||
t.Fatalf("%s carries the secret in plain text:\n%s", what, blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASealedFileStillNoticesAHandEdit(t *testing.T) {
|
||||
// Drift detection must survive not holding the plaintext. It does, because what is recorded
|
||||
// is a digest of what was written rather than what was written.
|
||||
key, _ := identity.GenerateSealingKey()
|
||||
dir := t.TempDir()
|
||||
path := dir + "/db.json"
|
||||
d := sealedFile(t, path, sealedTo(t, key, "hunter2"))
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginCarried, noServices, nil, opener(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("meddled"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _, err := Apply(context.Background(), archHost(t), d, state,
|
||||
store.OriginCarried, noServices, nil, opener(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.Changed() {
|
||||
t.Fatal("a hand-edited credential was left as it was found")
|
||||
}
|
||||
on, _ := os.ReadFile(path)
|
||||
if string(on) != "hunter2" {
|
||||
t.Fatalf("it was not put back: %q", on)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentAndSealedTogetherIsRefused(t *testing.T) {
|
||||
// Otherwise nobody can tell by looking whether what landed on the machine was the secret or
|
||||
// the placeholder.
|
||||
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"/etc/x","content":"a","sealed":"b"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a file that is both literal and sealed was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not both") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,25 @@ type File struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// Sealed is content encrypted to this node's sealing key, for a file the mesh must deliver
|
||||
// without being able to read.
|
||||
//
|
||||
// The one thing here the host cannot simply write. Everything else in a declaration is
|
||||
// visible to whatever carried it — the broker relays the message, and the message is signed
|
||||
// so it cannot be forged, but signing does not make it unreadable. A password travelling in
|
||||
// `content` would be a password the broker sees, which is the transitive trust the design
|
||||
// refuses everywhere else (novox/hq ADR 0004).
|
||||
//
|
||||
// Exclusive with Content: a file is one or the other, so that "was this secret" is answerable
|
||||
// by looking rather than by knowing which field won.
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
}
|
||||
|
||||
// Secret reports whether this file arrived sealed, which is what decides both that it must be
|
||||
// opened before writing and that its contents must never appear in a report.
|
||||
func (f *File) Secret() bool { return f.Sealed != "" }
|
||||
|
||||
func (f *File) Identity() string { return f.ID }
|
||||
func (f *File) Kind() Type { return TypeFile }
|
||||
func (f *File) Target() string { return f.Path }
|
||||
@@ -94,6 +111,11 @@ func (f *File) validate(where string, _ bool) []string {
|
||||
if f.Path == "" {
|
||||
problems = append(problems, where+": a file needs a path")
|
||||
}
|
||||
if f.Content != "" && f.Sealed != "" {
|
||||
problems = append(problems, where+
|
||||
": a file has content or is sealed, not both — otherwise nobody can tell by looking "+
|
||||
"whether what landed on the machine was the secret or the placeholder")
|
||||
}
|
||||
return append(problems, checkMode(where, f.Mode)...)
|
||||
}
|
||||
|
||||
|
||||
@@ -311,3 +311,69 @@ func TestAnIdentityThatCannotBeReadIsNotReportedAsAbsent(t *testing.T) {
|
||||
t.Errorf("the error does not say why this is different from having none: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASealingKeyOpensOnlyWhatWasSealedToIt(t *testing.T) {
|
||||
mine, err := GenerateSealingKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := GenerateSealingKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, err := Seal(mine.Public, []byte("hunter2"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := mine.Unseal(sealed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "hunter2" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if _, err := theirs.Unseal(sealed); err == nil {
|
||||
t.Fatal("another node opened it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealingTheSameValueTwiceLooksDifferent(t *testing.T) {
|
||||
// Sealed boxes are randomised, so an observer cannot tell that two nodes were given the same
|
||||
// password, nor that a rotation changed nothing. Worth asserting because the alternative is
|
||||
// a subtle leak nobody would look for.
|
||||
key, _ := GenerateSealingKey()
|
||||
first, _ := Seal(key.Public, []byte("same"))
|
||||
second, _ := Seal(key.Public, []byte("same"))
|
||||
if first == second {
|
||||
t.Fatal("sealing is deterministic, so equal secrets are visible as equal blobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithNoSealingKeySaysWhatToDo(t *testing.T) {
|
||||
// Rather than making one. A key the mesh was never told about is a key nothing can be sealed
|
||||
// to, so a node that quietly created one would look fine and receive nothing for ever.
|
||||
_, err := LoadSealingKey(t.TempDir() + "/absent.key")
|
||||
if err == nil {
|
||||
t.Fatal("a sealing key appeared out of nowhere")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "join again") {
|
||||
t.Fatalf("the failure does not say what to do: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASealingKeyOnDiskSurvivesATrailingNewline(t *testing.T) {
|
||||
// It is written with one, the way every other key file here is, and reading it back has to
|
||||
// cope — otherwise the key works until the first restart.
|
||||
key, _ := GenerateSealingKey()
|
||||
path := t.TempDir() + "/sealing.key"
|
||||
if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := LoadSealingKey(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Public != key.Public {
|
||||
t.Fatalf("a round trip through the disk changed the key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// The key a secret is sealed to, so the mesh can carry one without ever holding a usable copy.
|
||||
//
|
||||
// **The fault this exists to avoid is documented, in another mesh, in its own tooling.** There,
|
||||
// credentials live in the control plane's database, encrypted at rest — which protects against
|
||||
// somebody reading the database file and nothing else. The same secret is also in each node's
|
||||
// environment file in plain text, and, worse, inside every connection string composed from it, so
|
||||
// the tool for finding copies has to search *by value* rather than by name. Its own documentation
|
||||
// says the copies inside composed URLs "are often the only copies actually in use". Encryption at
|
||||
// rest also cost the ability to audit: a query against the encrypted column returns zero rows and
|
||||
// proves nothing.
|
||||
//
|
||||
// So the arrangement here is the other one. **The node generates this key and the mesh only ever
|
||||
// sees the public half**, exactly as with the identity and overlay keys
|
||||
// (novox/hq ADR 0004). A secret is sealed to that public half before it is stored, so:
|
||||
//
|
||||
// - the control plane's database holds nothing usable, and a copy of it grants nothing
|
||||
// - the broker relays a blob it cannot read, which is the point of not trusting it
|
||||
// - *compromise of a node is compromise of that node* becomes true of secrets too, rather
|
||||
// than being true of identity and quietly false of everything that matters
|
||||
//
|
||||
// A third key rather than reusing one of the two that exist. The identity key signs and is
|
||||
// Ed25519; the overlay key is WireGuard's and is tied to being on the private network, which a
|
||||
// machine may not be. A key used for two purposes is one rotation away from breaking the other.
|
||||
|
||||
// SealingKey is an X25519 keypair used only for receiving secrets.
|
||||
type SealingKey struct {
|
||||
// Public is what the mesh records.
|
||||
Public string `json:"public"`
|
||||
// Private never leaves this machine.
|
||||
Private string `json:"private"`
|
||||
}
|
||||
|
||||
// GenerateSealingKey makes this node's key for receiving secrets.
|
||||
func GenerateSealingKey() (SealingKey, error) {
|
||||
private, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return SealingKey{}, fmt.Errorf("cannot generate this node's sealing key: %w", err)
|
||||
}
|
||||
return SealingKey{
|
||||
Public: base64.StdEncoding.EncodeToString(private.PublicKey().Bytes()),
|
||||
Private: base64.StdEncoding.EncodeToString(private.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SealingKeyPath is where the private half lives.
|
||||
func SealingKeyPath(statePath string) string {
|
||||
return dirOf(statePath) + "/sealing.key"
|
||||
}
|
||||
|
||||
// LoadSealingKey reads this node's sealing key.
|
||||
//
|
||||
// It does not make one. A key the mesh has never been told about is a key nothing can be sealed
|
||||
// to, so creating one here would produce a node that silently cannot receive any secret and looks
|
||||
// fine — the key is generated at enrolment, where its public half is reported in the same breath.
|
||||
func LoadSealingKey(path string) (SealingKey, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return SealingKey{}, fmt.Errorf(
|
||||
"this node has no sealing key at %s, so nothing can be sealed to it — it is made "+
|
||||
"at enrolment, and a node that joined before secrets existed must join again",
|
||||
path)
|
||||
}
|
||||
return SealingKey{}, err
|
||||
}
|
||||
{
|
||||
private, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
|
||||
if decodeErr != nil || len(private) != 32 {
|
||||
return SealingKey{}, fmt.Errorf(
|
||||
"%s is not a sealing key; move it aside to have a new one made", path)
|
||||
}
|
||||
key, keyErr := ecdh.X25519().NewPrivateKey(private)
|
||||
if keyErr != nil {
|
||||
return SealingKey{}, fmt.Errorf("%s is not a usable sealing key: %w", path, keyErr)
|
||||
}
|
||||
return SealingKey{
|
||||
Public: base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()),
|
||||
Private: base64.StdEncoding.EncodeToString(key.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Unseal opens something the mesh sealed to this node.
|
||||
//
|
||||
// Anonymous sealed boxes: the sender is not authenticated here, and does not need to be. What a
|
||||
// node applies is bounded by the declaration's signature, which is checked before any of this —
|
||||
// so a blob that arrives in a verified declaration came from the mesh, and this only has to
|
||||
// answer whether it was meant for this machine.
|
||||
func (s SealingKey) Unseal(sealed string) ([]byte, error) {
|
||||
blob, err := base64.StdEncoding.DecodeString(sealed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("this is not a sealed value: %w", err)
|
||||
}
|
||||
private, err := base64.StdEncoding.DecodeString(s.Private)
|
||||
if err != nil || len(private) != 32 {
|
||||
return nil, fmt.Errorf("this node's sealing key is unusable")
|
||||
}
|
||||
public, err := base64.StdEncoding.DecodeString(s.Public)
|
||||
if err != nil || len(public) != 32 {
|
||||
return nil, fmt.Errorf("this node's sealing key is unusable")
|
||||
}
|
||||
|
||||
var pub, priv [32]byte
|
||||
copy(pub[:], public)
|
||||
copy(priv[:], private)
|
||||
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
|
||||
if !ok {
|
||||
// Sealed to a different node, or to a key this one no longer has. Said as one thing
|
||||
// because from here they are indistinguishable, and both mean the same: this machine
|
||||
// cannot read it and applying it would write a file of rubbish.
|
||||
return nil, fmt.Errorf("this was not sealed to this node's current key")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Seal closes a value to a node's public sealing key. Here so that a test can produce what the
|
||||
// mesh produces, rather than asserting against a blob nobody can regenerate.
|
||||
func Seal(publicKey string, value []byte) (string, error) {
|
||||
public, err := base64.StdEncoding.DecodeString(publicKey)
|
||||
if err != nil || len(public) != 32 {
|
||||
return "", fmt.Errorf("%q is not a sealing key", publicKey)
|
||||
}
|
||||
var pub [32]byte
|
||||
copy(pub[:], public)
|
||||
sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
@@ -35,6 +35,11 @@ type EnrolRequest struct {
|
||||
// and unreachable for a round trip, which is the state everything else here works to avoid.
|
||||
OverlayKey string `json:"overlay_key,omitempty"`
|
||||
|
||||
// SealingKey is the public half of the key secrets are sealed to. A third key, and the
|
||||
// reasoning is the same one twice over: the mesh must be able to send this node something
|
||||
// nothing else can read, and it must never be able to read it either.
|
||||
SealingKey string `json:"sealing_key,omitempty"`
|
||||
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
@@ -65,7 +70,7 @@ var ErrRefused = errors.New("the mesh refused this enrolment")
|
||||
// says once it is in, and the secret travels again because the control plane must not have to ask
|
||||
// the broker who connected.
|
||||
func Enrol(ctx context.Context, address, pin, node, secret string, public []byte,
|
||||
overlayKey string, profile map[string]any, timeout time.Duration) (EnrolReply, error) {
|
||||
overlayKey, sealingKey string, profile map[string]any, timeout time.Duration) (EnrolReply, error) {
|
||||
|
||||
config, err := PinnedConfig(pin)
|
||||
if err != nil {
|
||||
@@ -111,7 +116,7 @@ func Enrol(ctx context.Context, address, pin, node, secret string, public []byte
|
||||
}
|
||||
|
||||
request := EnrolRequest{Node: node, Secret: secret, PublicKey: public,
|
||||
OverlayKey: overlayKey, Profile: profile}
|
||||
OverlayKey: overlayKey, SealingKey: sealingKey, Profile: profile}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
|
||||
Reference in New Issue
Block a user