not a service A shell, a terminal, a chat client, a desktop are a package plus configuration in somebody's home. A mesh with no notion of a user can own /etc and nothing anybody looks at, which is most of the reason to manage a machine at all. Three shapes, and the vocabulary test asserts the count precisely because widening it widens what a compromised control plane can express: user a login, its shell and its groups archive a set of files, fetched by digest and unpacked (file) gains `bytes` for what is not text, and `owner` `user` also makes "zsh is my login shell" declared state. chsh is a command, the link may not carry one, and a shell settable only by hand is a shell the mesh cannot manage. Groups are additive and never pruned — usermod without --append REPLACES them, which would silently remove every group that makes a login able to use the machine. A machine's own groups are not the mesh's to know about. The archive is the one place this host reaches out on its own; everywhere else it holds one outbound connection and fetches nothing. So it carries the discipline the bootstrap already uses for images: pinned by digest, and the digest checked before a single file is written. Two decisions in the unpacker worth naming: - an entry naming a path outside the archive is REFUSED, not sanitised. Rewriting it to land inside would put a file somewhere nobody asked for and report success. Found by the test: the first version quietly relocated it. - symlinks and device nodes are refused rather than skipped, or an archive that needed one arrives silently incomplete. A partial host does archives and refuses users: an archive needs a filesystem and a way to fetch; a user needs a user database it is allowed to write.
188 lines
6.0 KiB
Go
188 lines
6.0 KiB
Go
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(), "exactly once") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|