From fc3975d5355eb79ff597b8e85161438206b2381e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 17:00:41 +0200 Subject: [PATCH] WIP: judge an access by who writes it, and act on its path only on a warrant (issue 567) Paused: ADR 0325 replaces runs-as and the group grant. Kept for reuse: the bounded descriptor walks, the warrant-backed access act (share, create), the access wait and the missing-path waiting state. --- internal/apply/apply.go | 26 +++ internal/apply/grants.go | 9 +- internal/apply/missing.go | 122 +++++++++++ internal/apply/share.go | 86 ++++++++ internal/apply/share_linux.go | 325 ++++++++++++++++++++++++++++ internal/apply/share_other.go | 20 ++ internal/apply/writers.go | 84 +++++++ internal/declaration/declaration.go | 26 +++ internal/link/handover.go | 146 +++++++++++-- internal/link/hearing_nats.go | 23 ++ internal/link/messages.go | 63 ++++++ internal/link/queue.go | 4 + internal/link/run.go | 4 + internal/store/share_acts.go | 68 ++++++ 14 files changed, 990 insertions(+), 16 deletions(-) create mode 100644 internal/apply/missing.go create mode 100644 internal/apply/share.go create mode 100644 internal/apply/share_linux.go create mode 100644 internal/apply/share_other.go create mode 100644 internal/apply/writers.go create mode 100644 internal/store/share_acts.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 1ff9a5b..90c75b2 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -520,6 +520,8 @@ func ApplyMindingWindows( // created with, and what each account is asked to be in beside its user resources' groups — read before any // orphan goes, so one access going leaves an account in a group another still grants. in.grants = grantsOf(d.Resources, in.refused) + // Every accessed path missing below a folder, which no container mounts until it is made (novox/hq ADR 0324). + in.missing = missingAccesses(d.Resources, in.refused) in.containerGroups = containerGroups(d.Resources, in.grants) wanted.withGrants(d.Resources, in.grants) in.agentHomes = rootNeverHomes(d) @@ -809,6 +811,14 @@ func ApplyMindingWindows( if outcome.found != nil { known.KeepFound(resource.Identity(), origin, *outcome.found) } + // A step that waits for a missing accessed path has not completed: it gates its module as one that + // failed would (novox/hq ADR 0136, ADR 0324). + if c, ok := resource.(*declaration.Container); ok && c.RunOnce { + if module, ours := moduleOf(resource.Identity()); ours { + gated[module] = true + gatedWhy[module] = "a step this module declares waits for an accessed path to be made" + } + } report.Outcomes = append(report.Outcomes, outcome) log(fmt.Sprintf(" waiting %s (%s): %s", outcome.ID, outcome.Target, outcome.Detail)) continue @@ -1072,6 +1082,14 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru if err := refuseMounts(res, in.refused); err != nil { return begin(r), err } + // Not created over a missing accessed path, which the runtime would make as root (novox/hq ADR 0324). + if id := mountsMissing(res, in.missing); id != "" { + out := begin(r) + out.Action = "waiting" + out.Detail = "not created: it mounts the access " + id + ", whose path is not there yet; created once " + + "it is made (novox/hq ADR 0324)" + return out, nil + } } // Nothing below a home is touched through a link an account put there (novox/hq ADR 0266). // And nothing is unpacked or written into below an agent's home (agent_home_guard.go). @@ -1317,6 +1335,11 @@ func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, out := begin(r) info, err := os.Stat(r.Path) if errors.Is(err, os.ErrNotExist) { + // Below a folder that is there, it waits for the operator, and the rest applies (novox/hq ADR 0324). + if _, missing := missingAccess(r.Path); missing { + out.Action, out.Detail = "waiting", MissingWords(r.Path) + return out, nil + } return out, fmt.Errorf( "%s is not there, and the mesh does not own it — the operator must provide it. It is "+ "shared, pre-existing data (novox/hq ADR 0051): the host mounts it and creates "+ @@ -2166,6 +2189,9 @@ type inputs struct { containerGroups map[string][]int // handed is every group a record that went this apply handed to a resource still asking for it. handed Handed + // missing is every accessed path not there below a folder that is, by path, with its access's id (novox/hq + // ADR 0324): a container mounting it waits. + missing map[string]string } // fileDigest is what a file the container reads holds, by digest. diff --git a/internal/apply/grants.go b/internal/apply/grants.go index da702cf..d64eeb6 100644 --- a/internal/apply/grants.go +++ b/internal/apply/grants.go @@ -62,6 +62,10 @@ type PathGroup struct { Name string // Writable is whether the group's write bit is set on the path itself. Writable bool + // UID is the path's owner, and OwnerWritable whether the owner's write bit is set: a writer whose uid owns + // the path writes it as its owner (novox/hq ADR 0322 §2). + UID int + OwnerWritable bool } // Words names the group for a person: its name and number, or its number alone. @@ -79,11 +83,12 @@ var GroupOfPath = func(path string) (PathGroup, error) { if err != nil { return PathGroup{}, err } - _, gid, ok := statOwner(info) + uid, gid, ok := statOwner(info) if !ok { return PathGroup{}, fmt.Errorf("the group that owns %s cannot be read on this machine", path) } - g := PathGroup{GID: gid, Writable: info.Mode().Perm()&0o020 != 0} + g := PathGroup{GID: gid, Writable: info.Mode().Perm()&0o020 != 0, UID: uid, + OwnerWritable: info.Mode().Perm()&0o200 != 0} if found, err := user.LookupGroupId(strconv.Itoa(gid)); err == nil { g.Name = found.Name } diff --git a/internal/apply/missing.go b/internal/apply/missing.go new file mode 100644 index 0000000..75143db --- /dev/null +++ b/internal/apply/missing.go @@ -0,0 +1,122 @@ +package apply + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// A missing accessed path waits for the operator (novox/hq ADR 0324, issue 567). +// +// ADR 0051 refused an access whose path is not there, and the refusal failed the whole apply: one module's +// folder not made yet held every other module on the machine. The runtime must still not make it — a bind +// whose source is missing is made by it as root, with whatever mode it picks (04-ISSUES/026) — so the engine +// mounts nothing for the access: every container that mounts the path or below it is not created, and is said +// waiting with the access, while the rest of the declaration applies. The access is stated waiting on every look +// with a wait of kind access, and the operator's warrant for the create act makes the path, each level as its +// nearest existing ancestor is. Only when that ancestor is a directory: one that is a file or a link is refused +// as before, and one owned by a group that is never granted is stated unhealthy, with no wait. + +// MissingPath is an accessed path that is not there, and its nearest existing ancestor. +type MissingPath struct { + Ancestor string + // Levels are the names to make below Ancestor, outermost first. + Levels []string + // Info is the ancestor as lstat reads it: a link is not a directory. + Info os.FileInfo +} + +// NearestAncestor reads, for a path that is not there, its nearest existing ancestor by lstat. An error for a +// path that is there, one not stated exactly, or an ancestor that cannot be read. +func NearestAncestor(path string) (MissingPath, error) { + if err := shareablePath(path); err != nil { + return MissingPath{}, err + } + if _, err := os.Lstat(path); err == nil || !errors.Is(err, os.ErrNotExist) { + return MissingPath{}, fmt.Errorf("%s is there, or cannot be read: %v", path, err) + } + var levels []string + for p := path; p != "/"; p = filepath.Dir(p) { + levels = append([]string{filepath.Base(p)}, levels...) + parent := filepath.Dir(p) + info, err := os.Lstat(parent) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return MissingPath{}, err + } + return MissingPath{Ancestor: parent, Levels: levels, Info: info}, nil + } + return MissingPath{}, errors.New("no part of the path is there") +} + +// missingAccess is whether r's path is missing below an ancestor that is a directory: the access waits. +func missingAccess(path string) (MissingPath, bool) { + m, err := NearestAncestor(filepath.Clean(path)) + if err != nil || !m.Info.IsDir() { + return MissingPath{}, false + } + return m, true +} + +// missingAccesses is every declared access whose path is missing below a directory, by path, with its id: what +// no container mounts until it is made. A path the guard refuses is not read (issue 339). +func missingAccesses(resources []declaration.Resource, refused map[string]error) map[string]string { + out := map[string]string{} + for _, r := range resources { + a, ok := r.(*declaration.Access) + if !ok || refused[filepath.Clean(a.Path)] != nil { + continue + } + if _, missing := missingAccess(a.Path); missing { + out[filepath.Clean(a.Path)] = a.ID + } + } + return out +} + +// mountsMissing is the access a container waits for: one whose path it mounts, or mounts below, is missing. +func mountsMissing(c *declaration.Container, missing map[string]string) string { + for _, v := range c.Volumes { + src := mountSource(v) + if !strings.HasPrefix(src, "/") { + continue + } + for path, id := range missing { + if atOrBelow(filepath.Clean(src), path) { + return id + } + } + } + return "" +} + +// WaitingOnAccess is every container of the declaration that waits for a missing accessed path, read now: not +// created by the apply, so not judged alive or dead and not fired on a schedule — the access says why. +func WaitingOnAccess(d *declaration.Declaration) map[string]bool { + out := map[string]bool{} + if d == nil { + return out + } + missing := missingAccesses(d.Resources, refusedPlaces(d.Resources)) + if len(missing) == 0 { + return out + } + for _, r := range d.Resources { + if c, ok := r.(*declaration.Container); ok && mountsMissing(c, missing) != "" { + out[c.ID] = true + } + } + return out +} + +// MissingWords is the outcome of an access whose path is missing below a directory. +func MissingWords(path string) string { + return fmt.Sprintf("%s is not there yet, below a folder that is: nothing mounts it and the containers that do are "+ + "not created until it is made — on the operator's warrant, by the create act (novox/hq ADR 0324)", path) +} diff --git a/internal/apply/share.go b/internal/apply/share.go new file mode 100644 index 0000000..7ceb535 --- /dev/null +++ b/internal/apply/share.go @@ -0,0 +1,86 @@ +package apply + +import ( + "errors" + "fmt" + "os/user" + "path/filepath" + "strconv" + "time" +) + +// The share act on a read-write access's path (novox/hq ADR 0322 §6, issue 567). +// +// **The one change the mesh makes on an accessed path, and only on a warrant.** ADR 0312 §2 changes nothing on +// an accessed path. When the identities that write one path are more than one account, they share it only +// through its group: the group may write it, and every folder carries setgid, so a folder one of them makes keeps +// the group and the next can write in it too (§3). The operator lets that happen by approving a warrant for it +// (ADR 0259); the controller then asks this engine, signed, to run the act. The act adds group write to the path +// and every file and folder under it, and setgid to every folder: it changes no owner, no group and no other bit, +// follows no link, enters no other filesystem, and runs only for the path's own group, never a root group's. +// +// **Walked by descriptor.** The path is opened as a mesh directory is (meshWalk: no link another account could +// make, the path itself no link), and everything under it is reached from its parent's descriptor with +// O_NOFOLLOW: a link met is passed by, a folder whose device is not the path's is not entered, and an entry +// opened is checked to be the one that was looked at. A file is changed through its own descriptor, so a name +// swapped for a link between the look and the change changes nothing. + +// ShareLimits bound one walk under an access's path: how many entries it reads, and for how long. +type ShareLimits struct { + Entries int + Time time.Duration +} + +// ShareActLimits bound the act: a media library is millions of files, and an act that never ends is a machine +// that cannot say what it did. One stopped is asked again and goes on: what has the bits already is kept. +var ShareActLimits = ShareLimits{Entries: 5_000_000, Time: 30 * time.Minute} + +// ShareCounts is what one walk did or found. Counts only: never a path below the access. +type ShareCounts struct { + // Folders and Files are the entries changed; Kept the ones that had the bits already. + Folders, Files, Kept int + // Links are links passed by, Other entries neither a file nor a folder, Mounts folders on another + // filesystem not entered. + Links, Other, Mounts int + // Read is every entry looked at. + Read int +} + +// ErrShareBound is a walk that met its bound before it was through: what it did stays, what it did not reach is +// not known. +var ErrShareBound = errors.New("the walk met its bound") + +// SharedVerdict is the read-only judgement of a shared path (ADR 0322 §3). +type SharedVerdict struct { + // Shared is that the path may be written by its group and it and every folder under it carry setgid. + Shared bool + // Why is what keeps it from being shared, in words with no path. + Why string + Counts ShareCounts +} + +// ShareRefusal is why the act is never run for gid on a path whose group is pathGID, or nil. +func ShareRefusal(pathGID, gid int) error { + if gid != pathGID { + return fmt.Errorf("the share act runs for the path's own group, gid %d, and was asked for gid %d; nothing "+ + "was changed", pathGID, gid) + } + g := PathGroup{GID: pathGID} + if found, err := user.LookupGroupId(strconv.Itoa(pathGID)); err == nil { + g.Name = found.Name + } + if err := GrantRefusal(g); err != nil { + return fmt.Errorf("the share act never runs for this group: %w", err) + } + return nil +} + +// shareablePath refuses a path that is not stated exactly: absolute and clean, so `..` and a doubled separator +// never name it. +func shareablePath(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("%q is not an access's path as the engine states it (absolute, no `..`, no doubled or "+ + "trailing separator); nothing was changed", path) + } + return nil +} diff --git a/internal/apply/share_linux.go b/internal/apply/share_linux.go new file mode 100644 index 0000000..21adeea --- /dev/null +++ b/internal/apply/share_linux.go @@ -0,0 +1,325 @@ +//go:build linux + +package apply + +// The descriptor half of share.go (novox/hq ADR 0322 §3, §6). + +import ( + "errors" + "fmt" + "io" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// shareStats is how a walk reads an entry and an open descriptor: the machine's own calls, or a test's stand-in +// for a device it cannot mount. +type shareStats struct { + at func(dirfd int, name string, st *unix.Stat_t) error + fd func(fd int, st *unix.Stat_t) error +} + +var machineStats = shareStats{ + at: func(dirfd int, name string, st *unix.Stat_t) error { + return unix.Fstatat(dirfd, name, st, unix.AT_SYMLINK_NOFOLLOW) + }, + fd: unix.Fstat, +} + +// SharePath runs the share act on path for gid (ADR 0322 §6): group write on it and on every file and folder +// under it, setgid on every folder, nothing else. The path's group must be gid and never a root group's; a +// walk stopped at its bound returns what it did with ErrShareBound. +func SharePath(path string, gid int, limits ShareLimits) (ShareCounts, error) { + return sharePath(path, gid, limits, machineStats) +} + +func sharePath(path string, gid int, limits ShareLimits, stats shareStats) (ShareCounts, error) { + w, fd, err := openShared(path, limits, stats) + if err != nil { + return ShareCounts{}, err + } + w.change = true + var st unix.Stat_t + if err := stats.fd(fd, &st); err != nil { + unix.Close(fd) + return ShareCounts{}, fmt.Errorf("the path cannot be read: %v; nothing was changed", err) + } + if err := ShareRefusal(int(st.Gid), gid); err != nil { + unix.Close(fd) + return ShareCounts{}, err + } + err = w.dir(fd, st) + return w.counts, err +} + +// JudgeShared reads whether path is shared (ADR 0322 §3): its group may write it, and it and every folder under +// it carry setgid. Reads only, and stops at the first folder without setgid; one met its bound returns +// ErrShareBound, and the verdict is then not known. +func JudgeShared(path string, limits ShareLimits) (SharedVerdict, error) { + return judgeShared(path, limits, machineStats) +} + +func judgeShared(path string, limits ShareLimits, stats shareStats) (SharedVerdict, error) { + w, fd, err := openShared(path, limits, stats) + if err != nil { + return SharedVerdict{}, err + } + var st unix.Stat_t + if err := stats.fd(fd, &st); err != nil { + unix.Close(fd) + return SharedVerdict{}, err + } + if st.Mode&0o020 == 0 { + unix.Close(fd) + return SharedVerdict{Why: "its group cannot write it"}, nil + } + err = w.dir(fd, st) + v := SharedVerdict{Counts: w.counts} + switch { + case errors.Is(err, errNotShared): + v.Why = "a folder in it does not keep its group on new folders (no setgid)" + return v, nil + case err != nil: + return v, err + } + v.Shared = true + return v, nil +} + +// errNotShared stops a judgement at the first folder without setgid. +var errNotShared = errors.New("a folder without setgid") + +// shareWalk is one walk under an access's path, from the descriptor of the path itself. +type shareWalk struct { + // change is the act; without it the walk reads, and stops at the first folder without setgid. + change bool + dev uint64 + deadline time.Time + limit int + stats shareStats + counts ShareCounts +} + +// openShared opens path as a mesh directory is opened (no link another account could make, the path itself no +// link) and starts a walk from it on the path's own device. +func openShared(path string, limits ShareLimits, stats shareStats) (*shareWalk, int, error) { + if err := shareablePath(path); err != nil { + return nil, -1, err + } + mw := &meshWalk{want: path} + fd, err := mw.open() + if errors.Is(err, errNotThereYet) { + return nil, -1, errors.New("the path is not there; nothing was changed") + } + if err != nil { + return nil, -1, fmt.Errorf("the path is not walked: %v", err) + } + if mw.At != path { + unix.Close(fd) + return nil, -1, errors.New("the path is reached through a link, and the share act walks no path through " + + "one; nothing was changed") + } + var st unix.Stat_t + if err := stats.fd(fd, &st); err != nil { + unix.Close(fd) + return nil, -1, fmt.Errorf("the path cannot be read: %v; nothing was changed", err) + } + return &shareWalk{dev: uint64(st.Dev), deadline: time.Now().Add(limits.Time), limit: limits.Entries, + stats: stats}, fd, nil +} + +// read counts one entry, and says the bound when it is met. +func (w *shareWalk) read() error { + w.counts.Read++ + if w.counts.Read > w.limit || time.Now().After(w.deadline) { + return ErrShareBound + } + return nil +} + +// dir is one folder, open on fd, whose stat is st: its own bits, then every entry in it. It closes fd. +func (w *shareWalk) dir(fd int, st unix.Stat_t) error { + f := os.NewFile(uintptr(fd), "") + defer f.Close() + if err := w.read(); err != nil { + return err + } + mode := uint32(st.Mode) & 0o7777 + want := mode | 0o020 | unix.S_ISGID + switch { + case !w.change && mode&unix.S_ISGID == 0: + return errNotShared + case !w.change || want == mode: + w.counts.Kept++ + default: + if err := unix.Fchmod(fd, want); err != nil { + return fmt.Errorf("a folder's mode could not be set: %v", err) + } + w.counts.Folders++ + } + for { + names, err := f.Readdirnames(256) + for _, name := range names { + if err := w.entry(fd, name); err != nil { + return err + } + } + if errors.Is(err, io.EOF) || (err == nil && len(names) == 0) { + return nil + } + if err != nil { + return fmt.Errorf("a folder could not be read: %v", err) + } + } +} + +// entry is one name in the folder open on dirfd: a link passed by, a folder on this device walked, a file +// changed through its own descriptor, anything else left. +func (w *shareWalk) entry(dirfd int, name string) error { + var st unix.Stat_t + if err := w.stats.at(dirfd, name, &st); err != nil { + if errors.Is(err, unix.ENOENT) { + return nil + } + return fmt.Errorf("an entry could not be read: %v", err) + } + switch st.Mode & unix.S_IFMT { + case unix.S_IFLNK: + w.counts.Links++ + return nil + case unix.S_IFDIR, unix.S_IFREG: + default: + w.counts.Other++ + return nil + } + if uint64(st.Dev) != w.dev { + w.counts.Mounts++ + return nil + } + isDir := st.Mode&unix.S_IFMT == unix.S_IFDIR + if !isDir && !w.change { + // A judgement reads folders alone: setgid is a folder's. + return nil + } + flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NOCTTY | unix.O_NONBLOCK + if isDir { + flags |= unix.O_DIRECTORY + } + fd, err := unix.Openat(dirfd, name, flags, 0) + switch { + case errors.Is(err, unix.ENOENT): + return nil + case errors.Is(err, unix.ELOOP), errors.Is(err, unix.ENOTDIR): + // Swapped for a link, or for something else, after it was looked at: passed by. + w.counts.Links++ + return nil + case err != nil: + return fmt.Errorf("an entry could not be opened: %v", err) + } + var opened unix.Stat_t + if err := w.stats.fd(fd, &opened); err != nil { + unix.Close(fd) + return fmt.Errorf("an entry could not be read: %v", err) + } + if opened.Dev != st.Dev || opened.Ino != st.Ino || opened.Mode&unix.S_IFMT != st.Mode&unix.S_IFMT { + unix.Close(fd) + return errors.New("an entry changed while it was walked, and the act stopped there") + } + if isDir { + return w.dir(fd, opened) + } + defer unix.Close(fd) + if err := w.read(); err != nil { + return err + } + mode := uint32(opened.Mode) & 0o7777 + if mode&0o020 != 0 { + w.counts.Kept++ + return nil + } + if err := unix.Fchmod(fd, mode|0o020); err != nil { + return fmt.Errorf("a file's mode could not be set: %v", err) + } + w.counts.Files++ + return nil +} + +// CreatePath runs the create act on a missing accessed path (novox/hq ADR 0324): each missing level made from the +// nearest existing ancestor down, each with that ancestor's owner, group and mode, setgid included. No link is +// followed (the ancestor is reached as a mesh directory is, and each level is opened with O_NOFOLLOW), no level is +// made on another filesystem, no file is made, and nothing that was there is changed: a level that appears while +// the act runs stops it. How many levels it made. +func CreatePath(path string) (int, error) { + return createPath(path, machineStats) +} + +func createPath(path string, stats shareStats) (int, error) { + m, err := NearestAncestor(path) + if err != nil { + return 0, fmt.Errorf("the path cannot be made: %v; nothing was changed", err) + } + if !m.Info.IsDir() { + return 0, errors.New("its nearest existing ancestor is not a folder (a file or a link), and the create act " + + "makes a path only below a folder; nothing was changed") + } + mw := &meshWalk{want: m.Ancestor} + fd, err := mw.open() + if err != nil { + return 0, fmt.Errorf("the nearest existing ancestor is not walked: %v; nothing was changed", err) + } + if mw.At != m.Ancestor { + unix.Close(fd) + return 0, errors.New("the nearest existing ancestor is reached through a link, and the create act makes " + + "nothing through one; nothing was changed") + } + var anc unix.Stat_t + if err := stats.fd(fd, &anc); err != nil { + unix.Close(fd) + return 0, fmt.Errorf("the nearest existing ancestor cannot be read: %v; nothing was changed", err) + } + if err := ShareRefusal(int(anc.Gid), int(anc.Gid)); err != nil { + unix.Close(fd) + return 0, fmt.Errorf("the create act makes nothing as this ancestor's group: %w", err) + } + mode := uint32(anc.Mode) & 0o7777 + made := 0 + for _, name := range m.Levels { + // Made with no bits, so it is nobody's until it is the ancestor's. + if err := unix.Mkdirat(fd, name, 0); err != nil { + unix.Close(fd) + if errors.Is(err, unix.EEXIST) { + return made, errors.New("a level of the path appeared while the act ran, and the act changes " + + "nothing that is there; it stopped there") + } + return made, fmt.Errorf("a level could not be made: %v", err) + } + next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + unix.Close(fd) + if err != nil { + return made, fmt.Errorf("a level made could not be opened: %v", err) + } + fd = next + made++ + var st unix.Stat_t + if err := stats.fd(fd, &st); err != nil { + unix.Close(fd) + return made, fmt.Errorf("a level made could not be read: %v", err) + } + if uint64(st.Dev) != uint64(anc.Dev) { + unix.Close(fd) + return made, errors.New("a level made is on another filesystem than its ancestor, and the act stopped there") + } + if err := unix.Fchown(fd, int(anc.Uid), int(anc.Gid)); err != nil { + unix.Close(fd) + return made, fmt.Errorf("a level made could not be given its ancestor's owner: %v", err) + } + if err := unix.Fchmod(fd, mode); err != nil { + unix.Close(fd) + return made, fmt.Errorf("a level made could not be given its ancestor's mode: %v", err) + } + } + unix.Close(fd) + return made, nil +} diff --git a/internal/apply/share_other.go b/internal/apply/share_other.go new file mode 100644 index 0000000..7d6dd88 --- /dev/null +++ b/internal/apply/share_other.go @@ -0,0 +1,20 @@ +//go:build !linux + +package apply + +import "errors" + +// SharePath refuses where there is no openat with O_NOFOLLOW (novox/hq ADR 0322 §6). The node-engine runs on Linux. +func SharePath(path string, gid int, limits ShareLimits) (ShareCounts, error) { + return ShareCounts{}, errors.New("the share act walks only by descriptor, which this system cannot; nothing was changed") +} + +// JudgeShared cannot judge where there is no openat with O_NOFOLLOW. The node-engine runs on Linux. +func JudgeShared(path string, limits ShareLimits) (SharedVerdict, error) { + return SharedVerdict{}, errors.New("a shared path is judged only by descriptor, which this system cannot") +} + +// CreatePath refuses where there is no openat with O_NOFOLLOW (novox/hq ADR 0324). The node-engine runs on Linux. +func CreatePath(path string) (int, error) { + return 0, errors.New("the create act walks only by descriptor, which this system cannot; nothing was changed") +} diff --git a/internal/apply/writers.go b/internal/apply/writers.go new file mode 100644 index 0000000..e0572ce --- /dev/null +++ b/internal/apply/writers.go @@ -0,0 +1,84 @@ +package apply + +import ( + "os/user" + "strconv" + + "github.com/novox/mesh-host/internal/declaration" +) + +// An access is judged by who writes it (novox/hq ADR 0322 §2, issue 567). +// +// ADR 0312 judged a read-write access by its path's group alone: unhealthy whenever the group may not write. +// But image software that takes its identity from its settings (PUID, PGID) often owns the path it writes, and +// writes it as its owner whatever the group's bits. So the writers are named — each container of the grant with +// the identity it declares it runs as, and the grant's account with its own uid and primary gid — and the access +// is writable when every one of them may write the path: as its owner when its uid owns it and the owner may +// write, or through the group when the group may write (its own gid, or the group the grant gives it). A +// container that declares no runs-as is judged by the granted group alone, as before. + +// Writer is one identity that writes a read-write access's path. +type Writer struct { + // Who names it in words: "the container sonarr.app", "the account sonarr". + Who string + // Known is that UID and GID are known: a container's runs-as, an account the machine names. + Known bool + UID, GID int +} + +// AccountIDs is an account's uid and primary gid as this machine numbers them. A variable so a test can stand in +// for accounts it cannot make. +var AccountIDs = func(name string) (int, int, bool) { + u, err := user.Lookup(name) + if err != nil { + return 0, 0, false + } + uid, uerr := strconv.Atoi(u.Uid) + gid, gerr := strconv.Atoi(u.Gid) + return uid, gid, uerr == nil && gerr == nil +} + +// WritersOf is every writer of the read-write access a, read from the declaration it came in: each container the +// grant names, with its runs-as when it declares one, and the grant's account. +func WritersOf(resources []declaration.Resource, a *declaration.Access) []Writer { + if a.Grant == nil || a.Mode != declaration.AccessReadWrite { + return nil + } + runsAs := map[string]string{} + for _, r := range resources { + if c, ok := r.(*declaration.Container); ok { + runsAs[c.ID] = c.RunsAs + } + } + var out []Writer + for _, id := range a.Grant.Containers { + w := Writer{Who: "the container " + id} + if uid, gid, err := declaration.ParseRunsAs(runsAs[id]); runsAs[id] != "" && err == nil { + w.Known, w.UID, w.GID = true, uid, gid + } + out = append(out, w) + } + if a.Grant.Account != "" { + w := Writer{Who: "the account " + a.Grant.Account} + w.UID, w.GID, w.Known = AccountIDs(a.Grant.Account) + out = append(out, w) + } + return out +} + +// CannotWrite is every writer that may not write a path whose owner and group g describes: neither its owner +// with the owner's write bit, nor of a group that may write it. A group that may write is given to every writer by +// the grant (ADR 0312 §1), so then nobody is left out; the caller has refused a group that is never granted. +func CannotWrite(g PathGroup, writers []Writer) []Writer { + if g.Writable { + return nil + } + var out []Writer + for _, w := range writers { + if w.Known && w.UID == g.UID && g.OwnerWritable { + continue + } + out = append(out, w) + } + return out +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index e2f6d29..38ebd32 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -1207,6 +1207,25 @@ type Container struct { // Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its // timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more. Health *Health `json:"health,omitempty"` + + // RunsAs is the identity the container's code writes as, ":", resolved by the controller + // from the module's settings like any value (novox/hq ADR 0322 §1): image software that takes its + // identity from its environment (PUID and PGID) runs as neither the runtime's user nor anything the + // engine could read off the image. Only judged by: a read-write access is writable for it when its + // uid owns the path or its gid is the path's group (§2). Never passed to the runtime and not part of + // the spec, so declaring it recreates nothing. Absent: judged by the granted group alone (ADR 0312 §1). + RunsAs string `json:"runs-as,omitempty"` +} + +// ParseRunsAs reads a container's runs-as, ":", as two non-negative numbers. +func ParseRunsAs(s string) (uid, gid int, err error) { + u, g, ok := strings.Cut(s, ":") + uid, uerr := strconv.Atoi(u) + gid, gerr := strconv.Atoi(g) + if !ok || uerr != nil || gerr != nil || uid < 0 || gid < 0 || strings.ContainsAny(s, "+ ") { + return 0, 0, fmt.Errorf("runs-as is %q, and it is two non-negative numbers, \":\"", s) + } + return uid, gid, nil } func (c *Container) Identity() string { return c.ID } @@ -1289,6 +1308,13 @@ func (c *Container) validate(where string, _ bool) []string { problems = append(problems, where+": networks is for a container that keeps running; a step "+ "runs and exits, and joins nothing afterwards") } + // Judged as it arrived, after the controller resolved it (novox/hq ADR 0322 §1): a setting that was never + // given, or is not a number, is refused here, naming the container, not read as some identity. + if c.RunsAs != "" { + if _, _, err := ParseRunsAs(c.RunsAs); err != nil { + problems = append(problems, where+": container "+c.Name+": "+err.Error()) + } + } return append(problems, checkImage(where, c.Image)...) } diff --git a/internal/link/handover.go b/internal/link/handover.go index 1d0dd2f..e8886a1 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -136,20 +136,27 @@ func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandO // one short line naming who asked, and a path when withPath and none otherwise. The nonce is taken last, after // every other check passed. It answers the ask with who asked trimmed, or why it is refused. func believed(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, string) { + ask, _, refused := believedBytes(check, context, withPath, body) + return ask, refused +} + +// believedBytes is believed with the ask's bytes as signed, for an ask that carries more than a hand-over's +// fields: read out of them only once they are believed. +func believedBytes(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, []byte, string) { if len(body) > MaxHandOverAsk { - return HandOverAsk{}, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body)) + return HandOverAsk{}, nil, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body)) } var signed SignedHandOver if err := json.Unmarshal(body, &signed); err != nil { - return HandOverAsk{}, "the ask is not a signed ask: " + err.Error() + return HandOverAsk{}, nil, "the ask is not a signed ask: " + err.Error() } if len(check.Signer) != ed25519.PublicKeySize || !ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) { - return HandOverAsk{}, "the ask is not signed by the mesh" + return HandOverAsk{}, nil, "the ask is not signed by the mesh" } var ask HandOverAsk if err := json.Unmarshal(signed.Ask, &ask); err != nil { - return HandOverAsk{}, "the signed ask is not an ask: " + err.Error() + return HandOverAsk{}, nil, "the signed ask is not an ask: " + err.Error() } now := time.Now() if check.Now != nil { @@ -163,25 +170,25 @@ func believed(check HandOverCheck, context string, withPath bool, body []byte) ( nonce, nonceErr := hex.DecodeString(ask.Nonce) switch { case check.Node == "" || ask.Node != check.Node: - return HandOverAsk{}, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node) + return HandOverAsk{}, nil, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node) case ask.Expires.IsZero() || !now.Before(ask.Expires): - return HandOverAsk{}, "the ask has expired" + return HandOverAsk{}, nil, "the ask has expired" case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)): - return HandOverAsk{}, "the ask is good for longer than an ask may be" + return HandOverAsk{}, nil, "the ask is good for longer than an ask may be" case nonceErr != nil || len(nonce) < 16: - return HandOverAsk{}, "the ask carries no nonce of its own" + return HandOverAsk{}, nil, "the ask carries no nonce of its own" case ask.By == "": - return HandOverAsk{}, "the ask names nobody as asking" + return HandOverAsk{}, nil, "the ask names nobody as asking" case len(ask.By) > MaxHandOverBy || strings.ContainsAny(ask.By, "\r\n\x00"): - return HandOverAsk{}, "who asked is not one short line" + return HandOverAsk{}, nil, "who asked is not one short line" case withPath && ask.Path == "": - return HandOverAsk{}, "the ask names no directory" + return HandOverAsk{}, nil, "the ask names no directory" case !withPath && ask.Path != "": - return HandOverAsk{}, "the ask names a path, and this ask takes none" + return HandOverAsk{}, nil, "the ask names a path, and this ask takes none" case !nonces.take(ask.Nonce, ask.Expires, now): - return HandOverAsk{}, "the ask was taken already" + return HandOverAsk{}, nil, "the ask was taken already" } - return ask, "" + return ask, signed.Ask, "" } // HandOverReply is what a hand-over request is answered with, and whether to answer: a request with no reply @@ -320,3 +327,114 @@ func JudgeDirectoriesReply(check HandOverCheck, handle JudgeDirectoriesFunc, rep type DirectoryJudgements interface { HearDirectoryJudgements(JudgeDirectoriesFunc) } + +// An act on an accessed path asked over the bus (novox/hq ADR 0322 §6, ADR 0324, issue 567). +// +// An access waits for the operator when its writers are not one account and its path's group cannot write it or +// keep its group on new folders (ADR 0322), or when its path is not there (ADR 0324). The operator approves a +// warrant for the act that ends the wait (ADR 0259); the controller then asks this node's engine on +// AskAccessActSubject, signed as a hand-over is and held to the same checks (believed), under a signing context of +// its own, naming the act, the access, its path as the engine states it, the warrant and — for the share act — the +// path's group. The engine judges every value against what it was last declared and what is on disk, answers at +// once that the act started or ran, or why it is refused, and states the act on the access's verdict (Act). + +// AskAccessActSubject is where the controller asks this node's engine to run an act on one access's path. +func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" } + +// AccessActContext is prefixed to an act ask's bytes before signing: never another ask's, nor a declaration's. +const AccessActContext = "novox-mesh access-act v1\n" + +// The acts on an accessed path. +const ( + // ActShare adds group write to the path and everything under it, and setgid to every folder (ADR 0322 §6). + ActShare = "share" + // ActCreate makes a missing path, each level as its nearest existing ancestor is (ADR 0324). + ActCreate = "create" +) + +// AccessActAsk is an act's own fields, beside a hand-over's (node, path, by, expires, nonce): which act, the +// access by id, the warrant the operator approved, and for the share act the path's group by number. +type AccessActAsk struct { + Act string `json:"act"` + Access string `json:"access"` + Warrant string `json:"warrant"` + GID *int `json:"gid,omitempty"` +} + +// AccessAct is one act asked and believed, as the handler is given it. +type AccessAct struct { + Act, Access, Path, Warrant, By string + // GID is the path's group for the share act; -1 for the create act. + GID int +} + +// MaxActField bounds an access id and a warrant id. +const MaxActField = 200 + +// AccessActFunc judges and runs one act: the words to answer with, or the refusal. +type AccessActFunc func(AccessAct) (string, error) + +func refusedAct(why string) HandOverAnswer { + return HandOverAnswer{Refused: why + "; nothing was changed"} +} + +// AnswerAccessAct is the engine's judgement of one act ask's bytes: believed as a hand-over is, then the act, the +// access, the group and the warrant — **nothing is done without a warrant** — and only then the handler. +func AnswerAccessAct(check HandOverCheck, handle AccessActFunc, body []byte) HandOverAnswer { + if handle == nil { + return refusedAct("this engine runs no act on an accessed path") + } + ask, signed, refused := believedBytes(check, AccessActContext, true, body) + if refused != "" { + return refusedAct(refused) + } + var act AccessActAsk + if err := json.Unmarshal(signed, &act); err != nil { + return refusedAct("the signed ask is not an act's ask: " + err.Error()) + } + act.Access, act.Warrant = strings.TrimSpace(act.Access), strings.TrimSpace(act.Warrant) + switch { + case act.Warrant == "": + return refusedAct("the ask names no warrant, and an act on an accessed path runs only on one") + case act.Access == "": + return refusedAct("the ask names no access") + case act.Act == ActShare && (act.GID == nil || *act.GID < 0): + return refusedAct("the share act names no group") + case act.Act == ActCreate && act.GID != nil: + return refusedAct("the create act names no group: each level is made as its nearest existing ancestor is") + case act.Act != ActShare && act.Act != ActCreate: + return refusedAct(fmt.Sprintf("the ask names the act %q, and the acts are %q and %q", act.Act, ActShare, ActCreate)) + } + for _, v := range []string{act.Access, act.Warrant} { + if len(v) > MaxActField || strings.ContainsAny(v, "\r\n\x00/") { + return refusedAct("the ask's access or warrant is not one short name") + } + } + gid := -1 + if act.GID != nil { + gid = *act.GID + } + said, err := handle(AccessAct{Act: act.Act, Access: act.Access, Path: ask.Path, Warrant: act.Warrant, By: ask.By, + GID: gid}) + if err != nil { + return refusedAct(err.Error()) + } + return HandOverAnswer{Said: said} +} + +// AccessActReply is what an act request is answered with, and whether to answer, as HandOverReply. +func AccessActReply(check HandOverCheck, handle AccessActFunc, reply string, body []byte) ([]byte, bool) { + if reply == "" { + return nil, false + } + answer, err := json.Marshal(AnswerAccessAct(check, handle, body)) + if err != nil { + return nil, false + } + return answer, true +} + +// AccessActs is a link that hears the controller asking this node to run an act on an accessed path. Optional. +type AccessActs interface { + HearAccessActs(AccessActFunc) +} diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index fd27d0f..888dc2c 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -66,6 +66,8 @@ type natsLink struct { searching *nats.Subscription // judging hears the controller asking for a read-only judgement of this node's directories (issue 358). judging *nats.Subscription + // acting hears the controller asking for an act on an access's path (novox/hq ADR 0322 §6, ADR 0324). + acting *nats.Subscription node string // signer is the mesh's key, which a hand-over ask is verified with as a declaration is (issue 356). signer ed25519.PublicKey @@ -259,6 +261,24 @@ func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) { } } +// HearAccessActs listens for the controller asking this node to run an act on an access's path (novox/hq ADR +// 0322 §6, ADR 0324), on this machine's own subject, verified as a hand-over is (AnswerAccessAct). A bus whose user list is older +// than the grant refuses the subscription, said by the client; the controller then hears no answer. +func (l *natsLink) HearAccessActs(handle AccessActFunc) { + if handle == nil { + return + } + check := HandOverCheck{Signer: l.signer, Node: l.node} + acting, err := l.conn.Subscribe(AskAccessActSubject(l.node), func(msg *nats.Msg) { + if body, answer := AccessActReply(check, handle, msg.Reply, msg.Data); answer { + _ = msg.Respond(body) + } + }) + if err == nil { + l.acting = acting + } +} + func (l *natsLink) Declarations() <-chan Declaration { return l.arrived } func (l *natsLink) AskedToReport() <-chan struct{} { return l.asked } func (l *natsLink) Lost() <-chan error { return l.lost } @@ -276,6 +296,9 @@ func (l *natsLink) Close() { if l.searching != nil { _ = l.searching.Unsubscribe() } + if l.acting != nil { + _ = l.acting.Unsubscribe() + } if l.judging != nil { _ = l.judging.Unsubscribe() } diff --git a/internal/link/messages.go b/internal/link/messages.go index 3f84232..c03cbd9 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -258,6 +258,12 @@ const RootContract = 3 // know. const GrantContract = 4 +// RunsAsContract is the statement of a host that also reads a container's `runs-as` (novox/hq ADR 0322): it judges +// a read-write access by who writes it, states one not writable or not shared as waiting with a wait of kind access, +// and hears the share act. Like the fields before it, its presence is what tells the controller this host may be +// sent `runs-as`; an older host refuses the whole declaration for a field it does not know. +const RunsAsContract = 5 + // Health is one statement of every long-running resource's state on this machine (to-be 48 §4). Said in // every report, as an event on each change, and again every minute while anything is not healthy — so a // lost statement is not a lost fault. @@ -413,6 +419,63 @@ type ResourceHealth struct { // Waits is what a resource stated waiting waits for (novox/hq ADR 0283): each one own secret or one // setting of the module by name, the part, and what the operator gives, in words. Absent otherwise. Waits []health.Wait `json:"waits,omitempty"` + // AccessWaits is what an access stated waiting waits for (novox/hq ADR 0322 §5): the share act on it. Beside + // Waits rather than in it because mesh-sdk's health.Wait names a secret or a setting and nothing else; it + // moves there once that type has a kind. Absent otherwise. + AccessWaits []AccessWait `json:"access-waits,omitempty"` + // Act is the last act on an access's path (ADR 0322 §6, ADR 0324): which, running, done, stopped at its bound + // or failed, under which warrant. Absent on every other verdict, and on an access never acted on. + Act *AccessActRecord `json:"act,omitempty"` +} + +// WaitKindAccess is the kind of a wait on the share act of a read-write access (novox/hq ADR 0322 §5). +const WaitKindAccess = "access" + +// AccessWait is one access waiting for the operator to let its path's group write it: the access by module and +// id, the part, and what the operator gives, in words with no path (ADR 0234 §6). +type AccessWait struct { + // Kind is WaitKindAccess. + Kind string `json:"kind"` + Module string `json:"module"` + Access string `json:"access"` + Part string `json:"part"` + What string `json:"what"` +} + +// The states of an act on an accessed path. +const ( + ActRunning = "running" + ActDone = "done" + // ActStopped is a share act that met its bound in entries or time: what it changed stays, and what it did not + // reach is not changed. Asked again, it goes on. + ActStopped = "stopped" + ActFailed = "failed" +) + +// AccessActRecord is one act on an access's path, as the engine records it (novox/hq ADR 0322 §6, ADR 0324). +// Counts only: never a path. +type AccessActRecord struct { + // Act is ActShare or ActCreate. + Act string `json:"act"` + Access string `json:"access"` + Warrant string `json:"warrant"` + By string `json:"by"` + GID int `json:"gid"` + State string `json:"state"` + Started time.Time `json:"started"` + Ended time.Time `json:"ended,omitzero"` + // Made is how many levels the create act made. Folders and Files are how many the share act changed; Kept + // how many already had the bits; Links the links it passed by unfollowed; Other the entries it left that are + // neither a file nor a folder; Mounts the folders on another filesystem it did not enter. + Made int `json:"made,omitempty"` + Folders int `json:"folders,omitempty"` + Files int `json:"files,omitempty"` + Kept int `json:"kept,omitempty"` + Links int `json:"links,omitempty"` + Other int `json:"other,omitempty"` + Mounts int `json:"mounts,omitempty"` + // Why is what stopped or failed it, in words with no path. + Why string `json:"why,omitempty"` } // SetuidSearch is how far a running search for setuid programs got, as of its last checkpoint. Counts, times diff --git a/internal/link/queue.go b/internal/link/queue.go index 227dc1d..48287fb 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -62,6 +62,10 @@ type Queue struct { // JudgeDirectories judges this node's declared directories read-only, asked by the controller over the // link (novox/hq issue 358). Nil answers every such ask with a refusal. JudgeDirectories JudgeDirectoriesFunc + // AccessAct judges and runs an act on a read-write access's path — the share act, or making a missing + // path — asked by the controller on an approved warrant (novox/hq ADR 0322 §6, ADR 0324). Nil answers every + // such ask with a refusal. + AccessAct AccessActFunc Say Announce Timeout time.Duration diff --git a/internal/link/run.go b/internal/link/run.go index b61782e..8517117 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -252,6 +252,10 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou if j, ok := link.(DirectoryJudgements); ok { j.HearDirectoryJudgements(queue.JudgeDirectories) } + // And asking for an act on an access's path, on a warrant (novox/hq ADR 0322 §6, ADR 0324). + if s, ok := link.(AccessActs); ok { + s.HearAccessActs(queue.AccessAct) + } for { // Asked to stop — by standing aside, say — nothing further is taken in. diff --git a/internal/store/share_acts.go b/internal/store/share_acts.go new file mode 100644 index 0000000..f4cb1bc --- /dev/null +++ b/internal/store/share_acts.go @@ -0,0 +1,68 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// ShareActsName is the file, beside the node's state, of the last share act on each read-write access's path +// (novox/hq ADR 0322 §6): what was changed, under which warrant, by whom. Its own file for the reason the +// hand-overs have theirs: an act runs beside an apply, whose save would lose a record written into the state. +const ShareActsName = "share-acts.json" + +// ShareActsPath is that file beside the state at statePath. +func ShareActsPath(statePath string) string { + return filepath.Join(filepath.Dir(statePath), ShareActsName) +} + +// LoadShareActs reads the last act on each access, by access id, as the engine wrote it. None is an empty map; a +// file that cannot be read is an error. +func LoadShareActs(statePath string) (map[string]json.RawMessage, error) { + raw, err := os.ReadFile(ShareActsPath(statePath)) + if errors.Is(err, fs.ErrNotExist) { + return map[string]json.RawMessage{}, nil + } + if err != nil { + return nil, fmt.Errorf("reading the share acts: %w", err) + } + out := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("the share acts (%s) are unreadable: %w", ShareActsPath(statePath), err) + } + return out, nil +} + +// SaveShareAct records the last act on one access, atomically, beside every other access's. +func SaveShareAct(statePath, access string, act json.RawMessage) error { + all, err := LoadShareActs(statePath) + if err != nil { + return err + } + all[access] = act + raw, err := json.MarshalIndent(all, "", " ") + if err != nil { + return err + } + path := ShareActsPath(statePath) + tmp, err := os.CreateTemp(filepath.Dir(path), ".share-acts-*.json") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(append(raw, '\n')); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +}