A user, bytes, and an archive — because most of what people install is
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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "<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)
|
||||
}
|
||||
}
|
||||
@@ -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:<hex>".
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user