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.
This commit is contained in:
2026-10-12 17:00:41 +02:00
parent dd9ab7927d
commit fc3975d535
14 changed files with 990 additions and 16 deletions
+26
View File
@@ -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.
+7 -2
View File
@@ -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
}
+122
View File
@@ -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)
}
+86
View File
@@ -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
}
+325
View File
@@ -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
}
+20
View File
@@ -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")
}
+84
View File
@@ -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
}
+26
View File
@@ -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, "<uid>:<gid>", 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, "<uid>:<gid>", 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, \"<uid>:<gid>\"", 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)...)
}
+132 -14
View File
@@ -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)
}
+23
View File
@@ -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()
}
+63
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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.
+68
View File
@@ -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)
}