diff --git a/internal/apply/apply.go b/internal/apply/apply.go index b3f6a36..5958d18 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -13,6 +13,7 @@ package apply import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -168,6 +169,10 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return applyPackage(ctx, sys, res, run) case *declaration.Container: return applyContainer(ctx, res, run) + case *declaration.User: + return applyUser(ctx, sys, res, run) + case *declaration.Archive: + return applyArchive(ctx, res, previous) case *declaration.Action: return applyAction(ctx, res, run) default: @@ -234,9 +239,21 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) { return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, after.Mode().Perm(), mode.Perm()) } + ownedAlready, err := ownedBy(r.Path, r.Owner) + if err != nil { + return out, err + } + if !ownedAlready { + if err := own(r.Path, r.Owner); err != nil { + return out, err + } + } + out.Action = "unchanged" if !existed { out.Action = "created" + } else if !ownedAlready { + out.Action = "updated" } else if before.Mode().Perm() != mode.Perm() { out.Action = "updated" out.Detail = fmt.Sprintf("mode %o to %o", before.Mode().Perm(), mode.Perm()) @@ -250,6 +267,16 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc // 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 + if r.Bytes != "" { + // Not text. Decoded here rather than written as base64, because what a declaration says + // is in a file has to be what ends up in it — a wallpaper stored as its own encoding is + // a wallpaper nothing can open. + decoded, err := base64.StdEncoding.DecodeString(r.Bytes) + if err != nil { + return out, fmt.Errorf("%s carries bytes that are not base64: %w", r.Path, err) + } + content = string(decoded) + } // 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. @@ -324,6 +351,19 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc return out, fmt.Errorf("%s is mode %o after setting %o", r.Path, info.Mode().Perm(), mode.Perm()) } + // And who it belongs to. Checked before setting, so a file already owned correctly is not + // reported as changed on every apply — which would make every reconcile look like work. + ownedAlready, err := ownedBy(r.Path, r.Owner) + if err != nil { + return out, err + } + if !ownedAlready { + if err := own(r.Path, r.Owner); err != nil { + return out, err + } + contentSame = false + } + switch { case !existed: out.Action = "created" diff --git a/internal/apply/archive.go b/internal/apply/archive.go new file mode 100644 index 0000000..490891a --- /dev/null +++ b/internal/apply/archive.go @@ -0,0 +1,185 @@ +package apply + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// A set of files, fetched by digest and unpacked. +// +// For what inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of +// files inlined would make every declaration enormous and rewrite all of them when one changed. +// +// **This is the one place the host reaches out on its own.** Everywhere else it holds a single +// outbound connection to the broker and fetches nothing; a container image is pulled by the +// runtime rather than by this process. So the discipline has to be explicit and it is the same +// one the bootstrap uses for images: **pinned by digest, and the digest is checked before +// anything is written.** What is fetched is bytes from a network the mesh does not control, and +// the only thing making them safe to unpack is that they hash to what was declared. + +// maxArchive is how much will be read before giving up. +// +// Because a fetch with no limit is a machine somebody can fill up from the far end. Chosen large +// enough for a desktop theme and small enough to notice. +const maxArchive = 512 << 20 + +func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied) (Outcome, error) { + out := begin(r) + out.Action = "unchanged" + + body, err := fetch(ctx, r.Source) + if err != nil { + return out, err + } + sum := sha256.Sum256(body) + got := "sha256:" + hex.EncodeToString(sum[:]) + if got != r.Digest { + // Refused before a single file is written. A digest that does not match means the thing + // at that address is not the thing that was declared, and unpacking it would be applying + // something nobody reviewed. + return out, fmt.Errorf( + "%s was declared as %s and what arrived is %s; nothing was unpacked", + r.Source, r.Digest, got) + } + out.wrote = got + + // Already what it should be. The digest is the whole identity of an archive, so a matching + // record means the unpacked tree came from these exact bytes. + if previous.Wrote == got { + if _, err := os.Stat(r.Path); err == nil { + owned, err := ownedBy(r.Path, r.Owner) + if err == nil && owned { + return out, nil + } + } + } + + if err := os.MkdirAll(r.Path, 0o755); err != nil { + return out, err + } + written, err := unpack(body, r.Path) + if err != nil { + return out, err + } + if err := ownAll(r.Path, r.Owner); err != nil { + return out, err + } + out.Action = "updated" + if previous.Wrote == "" { + out.Action = "created" + } + out.Detail = fmt.Sprintf("%d file(s)", written) + return out, nil +} + +func fetch(ctx context.Context, source string) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) + if err != nil { + return nil, err + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, fmt.Errorf("cannot fetch %s: %w", source, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s answered %s", source, response.Status) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1)) + if err != nil { + return nil, err + } + if len(body) > maxArchive { + return nil, fmt.Errorf("%s is larger than %d bytes, which is not an archive this host "+ + "will unpack", source, maxArchive) + } + return body, nil +} + +// unpack writes a gzipped tar into a directory, refusing anything that would land outside it. +func unpack(body []byte, into string) (int, error) { + zipped, err := gzip.NewReader(strings.NewReader(string(body))) + if err != nil { + return 0, fmt.Errorf("this is not a gzipped tar: %w", err) + } + defer zipped.Close() + + root, err := filepath.Abs(into) + if err != nil { + return 0, err + } + reader := tar.NewReader(zipped) + written := 0 + for { + header, err := reader.Next() + if err == io.EOF { + return written, nil + } + if err != nil { + return written, err + } + + // The oldest bug in unpacking: an entry named ../../etc/passwd writes outside the + // directory it was unpacked into. + // + // **Refused, not sanitised.** Rewriting the name so it lands inside would put a file + // somewhere nobody asked for and report success — the "looks configured and is not" + // failure this host exists to prevent. An archive that names a path outside itself is + // either hostile or broken, and both want the same answer. + cleaned := filepath.Clean(header.Name) + if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) { + return written, fmt.Errorf( + "%s names a path outside the archive; nothing more was unpacked", header.Name) + } + // And the same question asked of the result, because a name can be made to resolve + // outside without saying so. + target := filepath.Join(root, cleaned) + if !strings.HasPrefix(target, root+string(os.PathSeparator)) && target != root { + return written, fmt.Errorf( + "%s would land outside %s; nothing more was unpacked", header.Name, into) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(header.Mode)&os.ModePerm); err != nil { + return written, err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return written, err + } + file, err := os.OpenFile(target, + os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&os.ModePerm) + if err != nil { + return written, err + } + if _, err := io.Copy(file, io.LimitReader(reader, maxArchive)); err != nil { + file.Close() + return written, err + } + if err := file.Close(); err != nil { + return written, err + } + written++ + default: + // Symlinks, devices, fifos. Refused rather than skipped: a theme that needed one + // would silently arrive incomplete, and a device node in an archive is not something + // to unpack quietly onto a machine. + return written, fmt.Errorf( + "%s is a %c, and this host unpacks only files and directories", + header.Name, header.Typeflag) + } + } +} diff --git a/internal/apply/sealed_test.go b/internal/apply/sealed_test.go index 8f6db5a..77df034 100644 --- a/internal/apply/sealed_test.go +++ b/internal/apply/sealed_test.go @@ -181,7 +181,7 @@ func TestContentAndSealedTogetherIsRefused(t *testing.T) { if err == nil { t.Fatal("a file that is both literal and sealed was accepted") } - if !strings.Contains(err.Error(), "not both") { + if !strings.Contains(err.Error(), "exactly once") { t.Fatalf("unhelpful refusal: %v", err) } } diff --git a/internal/apply/user.go b/internal/apply/user.go new file mode 100644 index 0000000..f61519e --- /dev/null +++ b/internal/apply/user.go @@ -0,0 +1,155 @@ +package apply + +import ( + "context" + "fmt" + "os" + osuser "os/user" + "path/filepath" + "strconv" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/system" +) + +// Logins, and the files that belong to them. +// +// Most of what a person installs is not a service. A shell, a terminal, a chat client, a desktop +// are a package plus configuration **in somebody's home** — so a mesh with no notion of a user +// can manage /etc and nothing anybody looks at. + +// applyUser makes a login match what was declared. +// +// Reconciling, like everything else here: it is not told whether the user is new. Creating, +// setting a shell and adding groups are each done only when the machine does not already agree. +func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner) (Outcome, error) { + out := begin(r) + out.Action = "unchanged" + + login, exists, err := system.LookUpUser(ctx, system.Runner(run), r.Name) + if err != nil { + return out, err + } + if !exists { + if err := sys.CreateUser(ctx, system.Runner(run), r.Name, r.Home, r.Shell); err != nil { + return out, err + } + // Read back from the machine, not from the call that made it. A useradd that returns + // success and leaves no entry is exactly the failure this host takes trouble over. + login, exists, err = system.LookUpUser(ctx, system.Runner(run), r.Name) + if err != nil { + return out, err + } + if !exists { + return out, fmt.Errorf("created the user %q and the user database does not have it", + r.Name) + } + out.Action = "created" + } + + // The shell, only when it differs. Absent means the host asserts nothing — a field that + // always asserts cannot express "leave it alone", which is the difference between managing a + // machine and taking it over. + if r.Shell != "" && login.Shell != r.Shell { + if err := sys.SetUserShell(ctx, system.Runner(run), r.Name, r.Shell); err != nil { + return out, err + } + if back, _, err := system.LookUpUser(ctx, system.Runner(run), r.Name); err != nil { + return out, err + } else if back.Shell != r.Shell { + return out, fmt.Errorf("set %q's shell to %q and the user database says %q", + r.Name, r.Shell, back.Shell) + } + if out.Action == "unchanged" { + out.Action = "updated" + } + } + + if len(r.Groups) > 0 { + in, err := system.GroupsOf(ctx, system.Runner(run), r.Name) + if err != nil { + return out, err + } + already := map[string]bool{} + for _, g := range in { + already[g] = true + } + for _, want := range r.Groups { + if already[want] { + continue + } + if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil { + return out, err + } + if out.Action == "unchanged" { + out.Action = "updated" + } + } + } + return out, nil +} + +// own sets a path's owner, when one was declared. +// +// Looked up by name every time rather than cached: a user's numeric id is not stable across +// machines, and the whole reason this exists is that the same declaration lands on several. +func own(path, owner string) error { + if owner == "" { + return nil + } + found, err := osuser.Lookup(owner) + if err != nil { + return fmt.Errorf("%s should belong to %q and this machine has no such user: %w", + path, owner, err) + } + uid, err := strconv.Atoi(found.Uid) + if err != nil { + return err + } + gid, err := strconv.Atoi(found.Gid) + if err != nil { + return err + } + if err := os.Chown(path, uid, gid); err != nil { + return fmt.Errorf("cannot give %s to %q: %w", path, owner, err) + } + return nil +} + +// ownedBy reports whether a path already belongs to a user, so applying twice changes nothing. +func ownedBy(path, owner string) (bool, error) { + if owner == "" { + return true, nil + } + found, err := osuser.Lookup(owner) + if err != nil { + return false, nil + } + info, err := os.Stat(path) + if err != nil { + return false, err + } + uid, gid, ok := ownerOf(info) + if !ok { + return false, nil + } + return strconv.Itoa(uid) == found.Uid && strconv.Itoa(gid) == found.Gid, nil +} + +// ownAll gives a whole tree to a user, for an archive that was unpacked into it. +func ownAll(root, owner string) error { + if owner == "" { + return nil + } + return filepath.Walk(root, func(path string, _ os.FileInfo, err error) error { + if err != nil { + return err + } + return own(path, owner) + }) +} + +// ownerOf is the numeric owner of a file, where the platform reports one. +func ownerOf(info os.FileInfo) (uid, gid int, ok bool) { + return statOwner(info) +} diff --git a/internal/apply/user_unix.go b/internal/apply/user_unix.go new file mode 100644 index 0000000..fb6765d --- /dev/null +++ b/internal/apply/user_unix.go @@ -0,0 +1,18 @@ +//go:build unix + +package apply + +import ( + "os" + "syscall" +) + +// statOwner reads a file's numeric owner. Split out because the field is platform-specific and +// the rest of this package should not have to know that. +func statOwner(info os.FileInfo) (uid, gid int, ok bool) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, false + } + return int(stat.Uid), int(stat.Gid), true +} diff --git a/internal/apply/vocabulary_test.go b/internal/apply/vocabulary_test.go new file mode 100644 index 0000000..94e95ba --- /dev/null +++ b/internal/apply/vocabulary_test.go @@ -0,0 +1,245 @@ +package apply + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// The shapes added so that most of what a person installs is expressible. +// +// A shell, a chat client, a desktop are a package plus configuration in somebody's home, and a +// mesh with no user can manage /etc and nothing anybody looks at. + +func declare(t *testing.T, resources string) *declaration.Declaration { + t.Helper() + d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` + resources + `]}`)) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestAFileMayBeBytesRatherThanText(t *testing.T) { + // A wallpaper, a font, an icon. Stored as its own encoding it would be a wallpaper nothing + // can open. + dir := t.TempDir() + original := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff} + d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/wall.png","bytes":"`+ + base64.StdEncoding.EncodeToString(original)+`"}`) + + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, nil); err != nil { + t.Fatal(err) + } + on, err := os.ReadFile(dir + "/wall.png") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(on, original) { + t.Fatalf("the bytes did not survive: %x", on) + } +} + +func TestAFileSaysWhatIsInItExactlyOnce(t *testing.T) { + // Three ways of saying it and no precedence between them, so "what is in this file" is + // answerable by looking rather than by knowing which field wins. + _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[ + {"id":"f","type":"file","path":"/etc/x","content":"a","bytes":"YQ=="}]}`)) + if err == nil { + t.Fatal("a file that was both text and bytes was accepted") + } + if !strings.Contains(err.Error(), "exactly once") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestBytesThatAreNotBase64AreRefused(t *testing.T) { + dir := t.TempDir() + d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/x","bytes":"not base64!!"}`) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, nil) + if err == nil { + t.Fatal("a file carrying nonsense was written") + } + if _, statErr := os.Stat(dir + "/x"); statErr == nil { + t.Fatal("something was written before the failure") + } +} + +// A gzipped tar, and its digest, built here so the test does not depend on a fixture nobody can +// regenerate. +func anArchive(t *testing.T, files map[string]string) ([]byte, string) { + t.Helper() + var raw bytes.Buffer + zipped := gzip.NewWriter(&raw) + writer := tar.NewWriter(zipped) + for name, body := range files { + if err := writer.WriteHeader(&tar.Header{ + Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg, + }); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := zipped.Close(); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(raw.Bytes()) + return raw.Bytes(), "sha256:" + hex.EncodeToString(sum[:]) +} + +func serving(t *testing.T, body []byte) string { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + t.Cleanup(server.Close) + return server.URL + "/theme.tar.gz" +} + +func TestAnArchiveIsUnpacked(t *testing.T) { + body, digest := anArchive(t, map[string]string{ + "config/theme.conf": "dark", "config/icons/one.svg": "", + }) + dir := t.TempDir() + d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ + `","digest":"`+digest+`","path":"`+dir+`/theme"}`) + + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if !report.Changed() { + t.Fatal("nothing changed") + } + on, err := os.ReadFile(dir + "/theme/config/theme.conf") + if err != nil { + t.Fatal(err) + } + if string(on) != "dark" { + t.Fatalf("got %q", on) + } +} + +func TestAnArchiveThatIsNotWhatWasDeclaredIsRefusedBeforeAnythingIsWritten(t *testing.T) { + // The only thing making bytes from a network the mesh does not control safe to unpack is + // that they hash to what was declared. + body, _ := anArchive(t, map[string]string{"a": "b"}) + dir := t.TempDir() + d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ + `","digest":"sha256:`+strings.Repeat("ab", 32)+`","path":"`+dir+`/theme"}`) + + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, nil) + if err == nil { + t.Fatal("an archive that was not what was declared was unpacked") + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatal("something was written before the digest was checked") + } +} + +func TestAnArchiveCannotWriteOutsideWhereItWasUnpacked(t *testing.T) { + // The oldest bug in unpacking. Checked against the resolved root rather than by looking for + // "..", because there is more than one way to name a path that escapes. + body, digest := anArchive(t, map[string]string{"../../escaped": "no"}) + dir := t.TempDir() + d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ + `","digest":"`+digest+`","path":"`+dir+`/theme"}`) + + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, nil) + if err != nil && !strings.Contains(err.Error(), "outside") { + t.Fatalf("refused for the wrong reason: %v", err) + } + if _, statErr := os.Stat(dir + "/escaped"); statErr == nil { + t.Fatal("a file landed outside the directory it was unpacked into") + } + if err == nil { + t.Fatal("an escaping entry was accepted") + } +} + +func TestAnUnpackedArchiveIsNotFetchedAgainForNothing(t *testing.T) { + // The digest is the whole identity of an archive, so a matching record means the tree came + // from these exact bytes. Applying twice must not report work. + body, digest := anArchive(t, map[string]string{"a": "b"}) + dir := t.TempDir() + d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+ + `","digest":"`+digest+`","path":"`+dir+`/theme"}`) + + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + again, _, err := Apply(context.Background(), archHost(t), d, state, + store.OriginCarried, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if again.Changed() { + said, _ := json.Marshal(again) + t.Fatalf("the second apply did work: %s", said) + } +} + +func TestAnArchiveWithSomethingThatIsNotAFileIsRefused(t *testing.T) { + // A theme needing a symlink would otherwise arrive silently incomplete, and a device node in + // an archive is not something to unpack quietly onto a machine. + var raw bytes.Buffer + zipped := gzip.NewWriter(&raw) + writer := tar.NewWriter(zipped) + if err := writer.WriteHeader(&tar.Header{ + Name: "link", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd", Mode: 0o777, + }); err != nil { + t.Fatal(err) + } + writer.Close() + zipped.Close() + sum := sha256.Sum256(raw.Bytes()) + digest := "sha256:" + hex.EncodeToString(sum[:]) + + dir := t.TempDir() + d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, raw.Bytes())+ + `","digest":"`+digest+`","path":"`+dir+`/theme"}`) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil, nil) + if err == nil { + t.Fatal("a symlink was unpacked") + } + if !strings.Contains(err.Error(), "files and directories") { + t.Fatalf("refused for the wrong reason: %v", err) + } +} + +func TestAnArchiveMustBePinned(t *testing.T) { + _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[ + {"id":"t","type":"archive","source":"https://example.invalid/a.tgz","path":"/opt/t"}]}`)) + if err == nil { + t.Fatal("an unpinned archive was accepted") + } + if !strings.Contains(err.Error(), "digest") { + t.Fatalf("unhelpful refusal: %v", err) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 3b46d05..6b60c71 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -30,6 +30,17 @@ const ( TypePackage Type = "package" TypeContainer Type = "container" TypeAction Type = "action" + + // TypeUser is a login on the machine. Added because most of what a person actually installs + // is not a service: a shell, a terminal, a chat client, a desktop. All of those are a package + // plus configuration **in somebody's home**, and a mesh with no notion of a user can only + // manage /etc. + TypeUser Type = "user" + + // TypeArchive is a set of files fetched by digest and unpacked. A desktop theme is hundreds + // of files; inlining them would make every declaration enormous and rewrite the lot whenever + // one changed. + TypeArchive Type = "archive" ) // Resource is one thing that should be true of the machine. @@ -62,6 +73,9 @@ type Directory struct { Type Type `json:"type"` Path string `json:"path"` Mode string `json:"mode,omitempty"` + // Owner is the user this belongs to, by name. Absent means root, which is what everything + // managed was until users existed. + Owner string `json:"owner,omitempty"` } func (d *Directory) Identity() string { return d.ID } @@ -96,6 +110,16 @@ type File struct { // 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"` + + // Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon. + // + // A third way of saying what is in a file, and the three are exclusive. It would have been + // tempting to let Content carry base64 and add a flag, and then "what is in this file" would + // depend on a field somewhere else. + Bytes string `json:"bytes,omitempty"` + + // Owner is the user this belongs to, by name. Absent means root. + Owner string `json:"owner,omitempty"` } // Secret reports whether this file arrived sealed, which is what decides both that it must be @@ -111,14 +135,113 @@ 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 != "" { + var said []string + for name, value := range map[string]string{ + "content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes, + } { + if value != "" { + said = append(said, name) + } + } + if len(said) > 1 { + sort.Strings(said) 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") + ": a file says what is in it exactly once, and this says it as "+ + strings.Join(said, " and ")+ + " — otherwise nobody can tell by looking which one landed on the machine") } return append(problems, checkMode(where, f.Mode)...) } +// User is a login on the machine. +// +// The thing that makes a shell, a chat client or a desktop expressible at all: each is a package +// plus configuration in somebody's home, and until this the mesh could only own /etc. +// +// It also makes "zsh is my login shell" **declared state** rather than an action. `chsh` is a +// command, the link may not carry one (novox/hq ADR 0005), and a shell that could only be set by +// hand would be a shell the mesh cannot manage — which is most of the reason to manage a machine +// at all. +type User struct { + ID string `json:"id"` + Type Type `json:"type"` + Name string `json:"name"` + + // Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is + // there — the same rule Service.Boot follows, for the same reason: a field that always + // asserts cannot express "I do not care". + Shell string `json:"shell,omitempty"` + + // Groups this user must be in. Additive: the host puts the user in these and does not remove + // it from others, because a machine's own groups are not the mesh's to know about. + Groups []string `json:"groups,omitempty"` + + // Home directory. Absent means the system's default for a new user, and is not changed for + // one that exists — moving somebody's home is not something a declaration should do quietly. + Home string `json:"home,omitempty"` +} + +func (u *User) Identity() string { return u.ID } +func (u *User) Kind() Type { return TypeUser } +func (u *User) Target() string { return u.Name } + +func (u *User) validate(where string, _ bool) []string { + var problems []string + if u.Name == "" { + problems = append(problems, where+": a user needs a name") + } + if u.Shell != "" && !strings.HasPrefix(u.Shell, "/") { + problems = append(problems, where+ + ": a login shell is an absolute path, and "+u.Shell+" is not one") + } + if u.Home != "" && !strings.HasPrefix(u.Home, "/") { + problems = append(problems, where+": a home directory is an absolute path") + } + return problems +} + +// Archive is a set of files, fetched by digest and unpacked. +// +// For the case inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of +// files inlined would make every declaration enormous and rewrite all of it when one changed. +// +// **Pinned by digest, and the digest is checked before anything is unpacked.** The same discipline +// the bootstrap uses for images, and for the same reason — this is fetched over a network the +// mesh does not control, and a reference that can be made to point elsewhere is not a reference. +type Archive struct { + ID string `json:"id"` + Type Type `json:"type"` + // Source is where to fetch it from. + Source string `json:"source"` + // Digest is sha256 of the archive, as "sha256:". + Digest string `json:"digest"` + // Path is the directory it is unpacked into. + Path string `json:"path"` + // Owner is the user the unpacked files belong to. Absent means root. + Owner string `json:"owner,omitempty"` +} + +func (a *Archive) Identity() string { return a.ID } +func (a *Archive) Kind() Type { return TypeArchive } +func (a *Archive) Target() string { return a.Path } + +func (a *Archive) validate(where string, _ bool) []string { + var problems []string + if a.Source == "" { + problems = append(problems, where+": an archive needs somewhere to fetch it from") + } + if a.Path == "" { + problems = append(problems, where+": an archive needs somewhere to unpack into") + } + if !strings.HasPrefix(a.Digest, "sha256:") || len(a.Digest) != len("sha256:")+64 { + // Refused rather than fetched and trusted. Everything else pinned in this vocabulary is + // pinned by digest, and an archive that was not would be the one way in. + problems = append(problems, where+ + ": an archive is pinned by digest, as sha256:<64 hex characters>") + } + return problems +} + // Service is a unit the host puts into a state. It does not install the unit. // // Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped @@ -288,6 +411,10 @@ func newOf(t Type) Resource { return &Container{} case TypeAction: return &Action{} + case TypeUser: + return &User{} + case TypeArchive: + return &Archive{} } return nil } @@ -295,7 +422,8 @@ func newOf(t Type) Resource { // Vocabulary is every kind this host speaks. func Vocabulary() []Type { return []Type{ - TypeAction, TypeContainer, TypeDirectory, TypeFile, TypePackage, TypeService, + TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypePackage, + TypeService, TypeUser, } } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 08c7cb7..3ed10f7 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -67,9 +67,9 @@ func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) { func TestAnUnknownFieldIsRefused(t *testing.T) { // A field the host does not know is a thing the control plane believes it asked for. refusal := refusalFor(t, `{"declaration":1,"resources":[ - {"id":"conf","type":"file","path":"/etc/x","content":"a","owner":"root"} + {"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true} ]}`) - if !strings.Contains(strings.Join(refusal.Problems, "\n"), "owner") { + if !strings.Contains(strings.Join(refusal.Problems, "\n"), "immutable") { t.Errorf("the unknown field was not named: %v", refusal.Problems) } } @@ -240,16 +240,23 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { } } -func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) { - // novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them. - // Asserted so that removing one is a failing test rather than a discovery during a - // first-node install. +func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { + // Six of them the bootstrap uses (novox/hq 07-the-substrate.md), and removing one is a + // failing test rather than a discovery during a first-node install. + // + // Two were added on 2026-08-30 and the count is asserted precisely because adding one is a + // decision. `user` and `archive` exist because most of what a person installs is not a + // service: a shell, a chat client, a desktop are a package plus configuration **in + // somebody's home**, and a mesh with no user can only own /etc. `archive` is for the case + // inlining cannot serve — a theme is hundreds of files, and inlining them would rewrite all + // of them whenever one changed. speaks := map[Type]bool{} for _, t := range Vocabulary() { speaks[t] = true } for _, want := range []Type{ TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, + TypeUser, TypeArchive, } { if !speaks[want] { t.Errorf("the host no longer speaks %q", want) @@ -258,8 +265,8 @@ func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) { t.Errorf("%q is in the vocabulary and cannot be constructed", want) } } - if len(speaks) != 6 { - t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+ + if len(speaks) != 8 { + t.Errorf("the vocabulary is %d shapes rather than 8; every addition widens what a compromised "+ "control plane can express, so a change here is a decision: %s", len(speaks), vocabulary()) } diff --git a/internal/system/alpine.go b/internal/system/alpine.go index 41cee0b..5c66602 100644 --- a/internal/system/alpine.go +++ b/internal/system/alpine.go @@ -131,3 +131,40 @@ func errText(err error) string { } return err.Error() } + +// CreateUser makes a login with busybox adduser, whose flags are not useradd's. +// +// `-D` is "do not ask for a password", which is what makes it usable without a terminal. A login +// created this way has no password and cannot be logged into over the network with one, which is +// correct: what the mesh manages is what a login owns, never a way to become it. +func (alpine) CreateUser(ctx context.Context, run Runner, name, home, shell string) error { + args := []string{"-D"} + if home != "" { + args = append(args, "-h", home) + } + if shell != "" { + args = append(args, "-s", shell) + } + if _, err := run(ctx, "adduser", append(args, name)...); err != nil { + return fmt.Errorf("cannot create the user %q: %w", name, err) + } + return nil +} + +func (alpine) SetUserShell(ctx context.Context, run Runner, name, shell string) error { + // busybox has no usermod. `sed`-ing /etc/passwd is what the distribution's own tooling does, + // and chsh is the one command that exists for it everywhere. + if _, err := run(ctx, "chsh", "-s", shell, name); err != nil { + return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err) + } + return nil +} + +// AddUserToGroup uses addgroup, which on busybox takes the user and the group and is additive by +// construction — there is no form of it that replaces the set. +func (alpine) AddUserToGroup(ctx context.Context, run Runner, name, group string) error { + if _, err := run(ctx, "addgroup", name, group); err != nil { + return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err) + } + return nil +} diff --git a/internal/system/android.go b/internal/system/android.go index ec53363..b74e10b 100644 --- a/internal/system/android.go +++ b/internal/system/android.go @@ -80,3 +80,21 @@ func (a android) ServiceBoot(context.Context, Runner, string) (string, error) { func (a android) SetServiceBoot(context.Context, Runner, string, string) error { return fmt.Errorf("%w: service", ErrUnsupported) } + +// Users are one of the shapes this host refuses. +// +// Android's user database belongs to the framework and is not something an ordinary app may +// write. Refused with a reason rather than attempted, the same as package, service and container +// above — and the profile says so, so the control plane never sends one. +func (android) CreateUser(context.Context, Runner, string, string, string) error { + return fmt.Errorf("this host implements no users: Android's user database belongs to the " + + "framework and is not writable by an ordinary process") +} + +func (android) SetUserShell(context.Context, Runner, string, string) error { + return fmt.Errorf("this host implements no users") +} + +func (android) AddUserToGroup(context.Context, Runner, string, string) error { + return fmt.Errorf("this host implements no users") +} diff --git a/internal/system/arch.go b/internal/system/arch.go index b390c7d..880fef3 100644 --- a/internal/system/arch.go +++ b/internal/system/arch.go @@ -142,3 +142,38 @@ func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) e _, err := run(ctx, "systemctl", verb, unit) return err } + +// CreateUser makes a login with useradd. +// +// `--create-home` because a user whose home does not exist is a user nothing can be delivered +// to, and delivering a shell's configuration is most of why the mesh knows about users at all. +func (arch) CreateUser(ctx context.Context, run Runner, name, home, shell string) error { + args := []string{"--create-home"} + if home != "" { + args = append(args, "--home-dir", home) + } + if shell != "" { + args = append(args, "--shell", shell) + } + if _, err := run(ctx, "useradd", append(args, name)...); err != nil { + return fmt.Errorf("cannot create the user %q: %w", name, err) + } + return nil +} + +func (arch) SetUserShell(ctx context.Context, run Runner, name, shell string) error { + if _, err := run(ctx, "usermod", "--shell", shell, name); err != nil { + return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err) + } + return nil +} + +// AddUserToGroup appends, and `--append` is the whole point: without it usermod REPLACES the +// user's supplementary groups, so a declaration naming one group would silently remove every +// other — including the ones that make a login able to use a machine at all. +func (arch) AddUserToGroup(ctx context.Context, run Runner, name, group string) error { + if _, err := run(ctx, "usermod", "--append", "--groups", group, name); err != nil { + return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err) + } + return nil +} diff --git a/internal/system/system.go b/internal/system/system.go index c1edebb..1d5daff 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -60,6 +60,61 @@ type System interface { // ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot. ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error + + // CreateUser makes a login. Home and shell may be empty, meaning the system's own defaults — + // a declaration that says nothing about them must not impose an opinion. + CreateUser(ctx context.Context, run Runner, name, home, shell string) error + // SetUserShell changes an existing login's shell, which is what makes "zsh is my shell" + // declared state rather than a command the link may not carry. + SetUserShell(ctx context.Context, run Runner, name, shell string) error + // AddUserToGroup is additive and never removes. A machine's own groups are not the mesh's to + // know about, and a declaration that pruned them would take away what somebody set by hand. + AddUserToGroup(ctx context.Context, run Runner, name, group string) error +} + +// Login is what the machine's user database says about a login. +type Login struct { + Home string + Shell string +} + +// LookUpUser reads a login from the user database. +// +// Shared rather than per-system: `getent passwd` gives the same seven colon-separated fields +// everywhere this host runs, and a second implementation would be a second thing to get wrong in +// the same way. +// +// **Absent is an answer, an error is not.** A user database that cannot be read must not be +// reported as "no such user" — that is absence read as fact, the exact confusion this package +// takes trouble over elsewhere. `getent` exits 2 for "not found" and other codes for failures, so +// the two are distinguished rather than collapsed. +func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, error) { + out, err := run(ctx, "getent", "passwd", name) + if err != nil { + // getent's own convention: 2 means the key was not found, which is the only failure that + // means "no such user". + if strings.Contains(err.Error(), "exit status 2") { + return Login{}, false, nil + } + return Login{}, false, fmt.Errorf( + "the user database did not answer about %q, so nothing can be said about it: %w", + name, err) + } + fields := strings.Split(strings.TrimSpace(out), ":") + if len(fields) < 7 { + return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry", + strings.TrimSpace(out), name) + } + return Login{Home: fields[5], Shell: fields[6]}, true, nil +} + +// GroupsOf is every group a login is in. +func GroupsOf(ctx context.Context, run Runner, name string) ([]string, error) { + out, err := run(ctx, "id", "-nG", name) + if err != nil { + return nil, err + } + return strings.Fields(out), nil } // Supports reports whether this host can apply a shape. @@ -110,6 +165,7 @@ func everyShape() []declaration.Type { return []declaration.Type{ declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService, declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction, + declaration.TypeUser, declaration.TypeArchive, } } @@ -120,6 +176,10 @@ func everyShape() []declaration.Type { func portableShapes() []declaration.Type { return []declaration.Type{ declaration.TypeDirectory, declaration.TypeFile, declaration.TypeAction, + // An archive is a file that arrives in a bundle rather than in the declaration. It needs + // only a filesystem and a way to fetch, so a partial host can do it; a user needs a user + // database it is allowed to write, which it does not have. + declaration.TypeArchive, } } diff --git a/internal/system/system_test.go b/internal/system/system_test.go index 5c8a312..303a3ac 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -280,3 +280,68 @@ func TestAndroidsUnreachableAppliersFailLoudly(t *testing.T) { t.Errorf("android's service applier did not report it as unsupported: %v", err) } } + +func TestALoginThatIsNotThereIsAnAnswerAndABrokenDatabaseIsNot(t *testing.T) { + // The distinction this package takes trouble over everywhere else, applied to users. A user + // database that cannot be read must not be reported as "no such user" — absence read as + // fact is the fault the whole host exists to prevent. + notFound := func(context.Context, string, ...string) (string, error) { + return "", errors.New("exit status 2") + } + if _, exists, err := LookUpUser(context.Background(), notFound, "nobody"); err != nil { + t.Fatalf("a missing user was reported as a failure: %v", err) + } else if exists { + t.Fatal("a missing user was reported as present") + } + + broken := func(context.Context, string, ...string) (string, error) { + return "", errors.New("exit status 71: cannot read /etc/passwd") + } + if _, exists, err := LookUpUser(context.Background(), broken, "somebody"); err == nil { + t.Fatal("a broken user database was reported as an answer") + } else if exists { + t.Fatal("a broken user database reported a user as present") + } +} + +func TestALoginIsReadFromThePasswdEntry(t *testing.T) { + answering := func(context.Context, string, ...string) (string, error) { + return "worker:x:1001:1001:,,,:/home/worker:/usr/bin/zsh\n", nil + } + login, exists, err := LookUpUser(context.Background(), answering, "worker") + if err != nil || !exists { + t.Fatalf("exists=%v err=%v", exists, err) + } + if login.Home != "/home/worker" || login.Shell != "/usr/bin/zsh" { + t.Fatalf("got %+v", login) + } +} + +func TestSomethingThatIsNotAPasswdEntryIsRefused(t *testing.T) { + // Rather than read as a login with empty fields, which would have the host decide the shell + // differs and set it on every apply for ever. + nonsense := func(context.Context, string, ...string) (string, error) { + return "who knows\n", nil + } + if _, _, err := LookUpUser(context.Background(), nonsense, "worker"); err == nil { + t.Fatal("nonsense was read as a login") + } +} + +func TestAPartialHostRefusesUsersAndAllowsArchives(t *testing.T) { + // An archive needs a filesystem and a way to fetch; a user needs a user database this host is + // allowed to write, which Android does not have. + speaks := map[declaration.Type]bool{} + for _, shape := range (android{}).Shapes() { + speaks[shape] = true + } + if !speaks[declaration.TypeArchive] { + t.Error("a partial host refuses archives, which need only a filesystem") + } + if speaks[declaration.TypeUser] { + t.Error("a partial host claims to manage users") + } + if err := (android{}).CreateUser(context.Background(), nil, "a", "", ""); err == nil { + t.Error("a partial host created a user") + } +}