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:
2026-08-30 03:22:38 +02:00
parent bc5b6e2143
commit c57087d75d
13 changed files with 1006 additions and 13 deletions
+40
View File
@@ -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"
+185
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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)
}
}
+155
View File
@@ -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)
}
+18
View File
@@ -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
}
+245
View File
@@ -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)
}
}