diff --git a/cmd/mesh-host/judge_directories.go b/cmd/mesh-host/judge_directories.go new file mode 100644 index 0000000..c8a9070 --- /dev/null +++ b/cmd/mesh-host/judge_directories.go @@ -0,0 +1,44 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/store" +) + +// judgeDirectories judges every directory of the declaration this node keeps, read-only (novox/hq issue 358): which +// the walk that follows no link another account could make would refuse. Nothing is made, chmodded or chowned. +func judgeDirectories(statePath string) (string, error) { + kept, err := store.ReadDeclared(store.DeclaredPath(statePath)) + if errors.Is(err, store.ErrNothingDeclared) { + return "", errors.New("this node keeps no declaration, so it is declared no directory") + } + if err != nil { + return "", err + } + d, err := declaration.Parse(kept.Declaration) + if err != nil { + return "", fmt.Errorf("the declaration this node keeps does not read: %w", err) + } + return apply.JudgeDirectories(d), nil +} + +// judgeDirectoriesFrom is judgeDirectories as the controller asks it over the link. +func judgeDirectoriesFrom(statePath string) link.JudgeDirectoriesFunc { + return func(string) (string, error) { return judgeDirectories(statePath) } +} + +// judgeDirectoriesCommand is `nox-mesh-host judge-directories` at this machine, as root: the same judgement, for a +// machine the controller cannot ask. +func judgeDirectoriesCommand(opts options) error { + said, err := judgeDirectories(opts.state) + if err != nil { + return err + } + fmt.Fprintln(opts.out, said) + return nil +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index c5e2fb0..71e835d 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -116,6 +116,8 @@ reach, run as root at the machine. hand-over hand a directory used as found to the mesh: the next apply gives it its declared owner and mode. The operator's line is nox node hand-over on the control-node; this is the same act for a machine with no mesh to reach + judge-directories which directories this node is declared the walk that follows no link another + account could make would refuse, read-only (novox/hq issue 358); run as root version --json machine-readable output @@ -244,6 +246,8 @@ func run(ctx context.Context, command string, opts options) error { switch command { case "hand-over": return handOverCommand(opts) + case "judge-directories": + return judgeDirectoriesCommand(opts) case "key": return keyCommand(opts) @@ -1212,8 +1216,11 @@ func runLink(ctx context.Context, opts options) error { } return setuidSearch.SearchNow(by), nil }, - Say: say, - Timeout: opts.timeout, + // A read-only judgement of the directories this node is declared, asked by the controller (novox/hq + // issue 358): which the walk that follows no link another account could make would refuse. + JudgeDirectories: judgeDirectoriesFrom(opts.state), + Say: say, + Timeout: opts.timeout, } worker := make(chan struct{}) go func() { diff --git a/internal/apply/homes_other.go b/internal/apply/homes_other.go index 9874386..cf5c92e 100644 --- a/internal/apply/homes_other.go +++ b/internal/apply/homes_other.go @@ -64,3 +64,10 @@ func giveHandedOver(path string, _ os.FileMode, _, _ int, _ bool) (os.FileMode, return 0, 0, 0, &PlacementRefusedError{Path: path, Why: "a handed-over directory is changed only through a " + "descriptor that followed no link, which this system cannot open"} } + +// judgeMeshDir refuses where there is no openat with O_NOFOLLOW (novox/hq issue 358). The node-engine runs on +// Linux. +func judgeMeshDir(path string) error { + return &PlacementRefusedError{Path: path, Why: "a mesh directory is judged only by a walk that follows no link " + + "another account could make, which this system cannot do"} +} diff --git a/internal/apply/judge_directories_test.go b/internal/apply/judge_directories_test.go new file mode 100644 index 0000000..950d50c --- /dev/null +++ b/internal/apply/judge_directories_test.go @@ -0,0 +1,72 @@ +//go:build linux + +package apply + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The read-only judgement of a node's directories (novox/hq issue 358): one line each — placed, would be refused +// naming the link and who could replace it, or not there yet (placed: an apply makes it) — and nothing made or +// changed. +func TestJudgeDirectoriesSaysWhatTheWalkWouldRefuseAndChangesNothing(t *testing.T) { + base := t.TempDir() + plain := filepath.Join(base, "plain") + if err := os.Mkdir(plain, 0o755); err != nil { + t.Fatal(err) + } + elsewhere := filepath.Join(base, "elsewhere", "state") + if err := os.MkdirAll(elsewhere, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(base, "elsewhere"), filepath.Join(base, "linked")); err != nil { + t.Fatal(err) + } + missing := filepath.Join(base, "missing", "deeper") + d := declare(t, `{"id":"a.plain","type":"directory","path":"`+plain+`","mode":"0700"},`+ + `{"id":"a.linked","type":"directory","path":"`+filepath.Join(base, "linked", "state")+`","mode":"0700"},`+ + `{"id":"a.missing","type":"directory","path":"`+missing+`","mode":"0700"}`) + + said := JudgeDirectories(d) + lines := strings.Split(said, "\n") + if len(lines) != 4 || !strings.HasPrefix(lines[0], "3 directories declared: 2 placed, 1 would be refused") { + t.Fatalf("the judgement: %q", said) + } + if lines[1] != plain+": placed" || lines[3] != missing+": placed" { + t.Errorf("a directory the walk reaches is not said placed: %q", said) + } + if !strings.Contains(lines[2], "would be refused") || !strings.Contains(lines[2], filepath.Join(base, "linked")) || + !strings.Contains(lines[2], "every account") { + t.Errorf("the linked directory is not said refused naming the link and who: %q", lines[2]) + } + if _, err := os.Stat(filepath.Join(base, "missing")); !os.IsNotExist(err) { + t.Error("the judgement made a directory") + } + for _, p := range []string{plain, elsewhere} { + if info, _ := os.Stat(p); info.Mode().Perm() != 0o755 { + t.Errorf("the judgement changed %s to %o", p, info.Mode().Perm()) + } + } +} + +// A link only root could have made is followed by the judgement as by the apply. +func TestJudgeDirectoriesFollowsARootOnlyLink(t *testing.T) { + wasDirs, wasLinks := couldReplace, linkReplacer + couldReplace = func(string, uint32, uint32, uint32) string { return "" } + linkReplacer = func(string, uint32) string { return "" } + t.Cleanup(func() { couldReplace, linkReplacer = wasDirs, wasLinks }) + base := t.TempDir() + if err := os.MkdirAll(filepath.Join(base, "real", "state"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(base, "run")); err != nil { + t.Fatal(err) + } + path := filepath.Join(base, "run", "state") + if said := JudgeDirectories(declare(t, `{"id":"a.s","type":"directory","path":"`+path+`"}`)); !strings.Contains(said, path+": placed") { + t.Fatalf("a directory behind a root-only link is not said placed: %q", said) + } +} diff --git a/internal/apply/mesh_dirs.go b/internal/apply/mesh_dirs.go new file mode 100644 index 0000000..adb8ffa --- /dev/null +++ b/internal/apply/mesh_dirs.go @@ -0,0 +1,125 @@ +package apply + +// A mesh directory outside a home is given its mode and owner only through a descriptor (novox/hq issue 358). +// +// Below a home every component is opened without following a link (ADR 0266, home_links.go), and a directory the +// operator handed to the mesh is changed through one descriptor opened from / with no link followed (issue 356). +// Every other directory the mesh owns — a module's state under /var/lib, a drop-in directory under /etc — was +// given its mode and owner by path, with chmod and chown, which follow a link anywhere in the path: whoever could +// turn a parent into a link between two applies directed root's chmod and chown at the link's target. +// +// The danger is a link another account can make or replace, not a link as such: /var/run is a link to /run on +// every node, and a disk is sometimes reached through one. So (the decision recorded on issue 358, 2026-10-11): +// +// - **the directory is opened component by component from /**, each with O_DIRECTORY|O_NOFOLLOW, and its mode +// and owner are changed with fchmod and fchown on that descriptor; a directory the walk makes is made in its +// parent's descriptor. A link put in place after any check is refused by the kernel at the moment of use; +// - **a link in a parent is followed only when root alone could have put it there**: the link is root's, and +// every directory from / down to the one holding it is root's and writable by no other account (no write for +// others, no write for a group other than root's). Its target is then walked the same way, from /; +// - **any other link refuses that directory**, naming the link and the account that could replace it; nothing +// is changed there, and the rest of the declaration applies; +// - **the directory itself is never a link** (issue 339, applyDirectory). +// +// A directory below a home keeps the walk of ADR 0266, which follows no link below the home at all. + +import ( + "fmt" + osuser "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// LinkInPathError is a mesh directory refused because a link in its path is one another account could replace. +type LinkInPathError struct { + // Path is the directory declared, Link the link in its path, Who the account that could replace it. + Path, Link, Who string +} + +func (e *LinkInPathError) Error() string { return e.Path + " " + e.why() } + +// why is the refusal without the path it refuses, as a PlacementRefusedError says it. +func (e *LinkInPathError) why() string { + return fmt.Sprintf("is reached through the link %s, which %s could replace: the node-engine runs as root and "+ + "follows a link to a directory it gives a mode and owner only when root alone could have made it (novox/hq "+ + "issue 358). Make %s a directory, or make it and every directory above it root's and writable by no other "+ + "account", e.Link, e.Who, e.Link) +} + +// couldReplace says who besides root could replace an entry of a directory with this owner, group and mode: "" +// when nobody can. A variable so a test, which runs below a directory every account may write (/tmp), can name +// which of its own directories count as root's alone. +var couldReplace = func(path string, uid, gid, mode uint32) string { + if uid != 0 { + return "the account " + accountNamed(uid) + " (it owns " + path + ")" + } + if mode&0o002 != 0 { + return "every account (" + path + " is writable by all)" + } + if mode&0o020 != 0 && gid != 0 { + return "the group " + groupNamed(gid) + " (it may write " + path + ")" + } + return "" +} + +// linkReplacer says who besides root owns a link: "" when root does. A variable for the same reason. +var linkReplacer = func(link string, uid uint32) string { + if uid != 0 { + return "the account " + accountNamed(uid) + " (it owns the link)" + } + return "" +} + +func accountNamed(uid uint32) string { + if u, err := osuser.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil { + return u.Username + } + return "with uid " + strconv.FormatUint(uint64(uid), 10) +} + +func groupNamed(gid uint32) string { + if g, err := osuser.LookupGroupId(strconv.FormatUint(uint64(gid), 10)); err == nil { + return g.Name + } + return "with gid " + strconv.FormatUint(uint64(gid), 10) +} + +// JudgeDirectories is the walk's judgement over every directory a declaration names, read-only (novox/hq issue +// 358): nothing is made, chmodded or chowned. One line per directory, in the declaration's order, after one line +// that counts them: placed, would be refused and why, refused already by the placement guard (issue 339), or +// below a home, where ADR 0266's walk judges it as before. +func JudgeDirectories(d *declaration.Declaration) string { + var lines []string + var placed, refused, guarded, homed int + for _, r := range d.Resources { + dir, ok := r.(*declaration.Directory) + if !ok { + continue + } + path := filepath.Clean(dir.Path) + if err := refusePath(path, placingDirectory, moduleOfID(dir.ID), dir.Owner); err != nil { + guarded++ + lines = append(lines, path+": refused already by the placement guard: "+err.Error()) + continue + } + if home := homeAbove(path); home != "" { + homed++ + lines = append(lines, path+": below the home "+home+", walked without following any link as before (ADR 0266)") + continue + } + if err := judgeMeshDir(path); err != nil { + refused++ + lines = append(lines, path+": would be refused: "+err.Error()) + continue + } + placed++ + lines = append(lines, path+": placed") + } + head := fmt.Sprintf("%d directories declared: %d placed, %d would be refused, %d refused already by the "+ + "placement guard, %d below a home (novox/hq issue 358; judged read-only, nothing changed)", + placed+refused+guarded+homed, placed, refused, guarded, homed) + return strings.Join(append([]string{head}, lines...), "\n") +} diff --git a/internal/apply/mesh_dirs_linux.go b/internal/apply/mesh_dirs_linux.go new file mode 100644 index 0000000..497ed8e --- /dev/null +++ b/internal/apply/mesh_dirs_linux.go @@ -0,0 +1,186 @@ +//go:build linux + +package apply + +// The descriptor half of mesh_dirs.go (novox/hq issue 358). + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +// maxLinksFollowed bounds the root-only links one walk follows, as the kernel bounds a lookup's (MAXSYMLINKS). +const maxLinksFollowed = 40 + +// errNotThereYet is a read-only walk meeting a component that does not exist: an apply would make it there. +var errNotThereYet = errors.New("not there yet") + +// meshWalk is one walk of a mesh directory's path from /. +type meshWalk struct { + // want is the directory as declared. + want string + // create makes a missing component, with mode, in its parent's descriptor; without it the walk stops there + // with errNotThereYet and makes nothing. + create bool + mode os.FileMode + // made is called with each directory the walk made and its descriptor. + made func(dir string, fd int) error + // Made is every directory the walk made, outermost first, and At the path the directory was reached at: + // want, or where the root-only links in its path led. + Made []string + At string +} + +// judgeMeshDir is openMeshDir's judgement and nothing else (novox/hq issue 358): the same walk, making nothing +// and changing nothing; nil when an apply would give the directory its mode and owner, also when a part of its +// path is not there yet (an apply makes it, as root, in a directory it reached). +func judgeMeshDir(path string) error { + w := &meshWalk{want: filepath.Clean(path)} + fd, err := w.open() + if errors.Is(err, errNotThereYet) { + return nil + } + if err != nil { + return err + } + unix.Close(fd) + return nil +} + +func (w *meshWalk) open() (int, error) { + if !filepath.IsAbs(w.want) { + return -1, fmt.Errorf("%s is not an absolute path", w.want) + } + p := w.want + for followed := 0; ; followed++ { + if followed > maxLinksFollowed { + return -1, &os.PathError{Op: "open", Path: w.want, Err: unix.ELOOP} + } + fd, next, err := w.fromRoot(p) + if err != nil { + return -1, err + } + if next == "" { + w.At = p + return fd, nil + } + p = next + } +} + +// fromRoot is one walk down p: the directory's descriptor, or the path to walk next when a root-only link was +// met (with -1), or the refusal. +func (w *meshWalk) fromRoot(p string) (int, string, error) { + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return -1, "", &os.PathError{Op: "open", Path: "/", Err: err} + } + // Who could replace an entry of the directories walked so far: once one could, every link below it is + // theirs to make, whoever owns the link. + who, err := replacerOf(fd, "/") + if err != nil { + unix.Close(fd) + return -1, "", err + } + at := "/" + var parts []string + if p != "/" { + parts = strings.Split(strings.TrimPrefix(p, "/"), "/") + } + const dirFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC + for i, part := range parts { + here := filepath.Join(at, part) + last := i == len(parts)-1 + next, err := unix.Openat(fd, part, dirFlags, 0) + if errors.Is(err, unix.ENOENT) { + if !w.create { + unix.Close(fd) + return -1, "", errNotThereYet + } + mkErr := unix.Mkdirat(fd, part, uint32(w.mode.Perm())) + if mkErr != nil && !errors.Is(mkErr, unix.EEXIST) { + unix.Close(fd) + return -1, "", &os.PathError{Op: "mkdir", Path: here, Err: mkErr} + } + next, err = unix.Openat(fd, part, dirFlags, 0) + if mkErr == nil && err == nil { + w.Made = append(w.Made, here) + if w.made != nil { + if gErr := w.made(here, next); gErr != nil { + unix.Close(next) + unix.Close(fd) + return -1, "", gErr + } + } + } + } + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) { + var st unix.Stat_t + if lerr := unix.Fstatat(fd, part, &st, unix.AT_SYMLINK_NOFOLLOW); lerr == nil && st.Mode&unix.S_IFMT == unix.S_IFLNK { + to, rerr := w.followable(fd, part, here, st, who, last) + unix.Close(fd) + if rerr != nil { + return -1, "", rerr + } + if !filepath.IsAbs(to) { + to = filepath.Join(at, to) + } + return -1, filepath.Join("/", to, filepath.Join(parts[i+1:]...)), nil + } + } + unix.Close(fd) + if err != nil { + switch { + case errors.Is(err, unix.ENOTDIR) && last: + return -1, "", fmt.Errorf("%s exists and is not a directory", here) + case errors.Is(err, unix.ENOTDIR): + return -1, "", fmt.Errorf("%s is not a directory, and %s lies below it", here, w.want) + } + return -1, "", &os.PathError{Op: "open", Path: here, Err: err} + } + fd = next + at = here + if who == "" { + if who, err = replacerOf(fd, here); err != nil { + unix.Close(fd) + return -1, "", err + } + } + } + return fd, "", nil +} + +// replacerOf is couldReplace for the directory open on fd. +func replacerOf(fd int, path string) (string, error) { + var st unix.Stat_t + if err := unix.Fstat(fd, &st); err != nil { + return "", &os.PathError{Op: "fstat", Path: path, Err: err} + } + return couldReplace(path, st.Uid, st.Gid, st.Mode&0o7777), nil +} + +// followable judges the link at here, held by the directory open on fd: its target when root alone could have +// made it, else the refusal. who is who could replace an entry of a directory above it ("" when nobody). +func (w *meshWalk) followable(fd int, name, here string, st unix.Stat_t, who string, last bool) (string, error) { + if last { + return "", &PlacementRefusedError{Path: w.want, Why: "it is a symbolic link, and the node-engine follows no " + + "link to a directory it owns"} + } + if who == "" { + who = linkReplacer(here, st.Uid) + } + if who != "" { + return "", &LinkInPathError{Path: w.want, Link: here, Who: who} + } + buf := make([]byte, unix.PathMax) + n, err := unix.Readlinkat(fd, name, buf) + if err != nil { + return "", &os.PathError{Op: "readlink", Path: here, Err: err} + } + return string(buf[:n]), nil +} diff --git a/internal/link/handover.go b/internal/link/handover.go index 1fc7f60..1d0dd2f 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -262,3 +262,61 @@ func SetuidSearchReply(check HandOverCheck, handle SetuidSearchFunc, reply strin type SetuidSearches interface { HearSetuidSearches(SetuidSearchFunc) } + +// A judgement of this node's directories asked over the bus (novox/hq issue 358). +// +// Before the node-engine gives a mesh directory its mode and owner only through a walk that follows no link +// another account could make, the operator is told which directories that walk would refuse on each node. The +// controller asks this node's engine on AskJudgeDirectoriesSubject, signed as a hand-over is and held to the +// same checks (believed), under a signing context of its own. The engine judges every directory of the +// declaration it keeps, read-only — nothing is made, chmodded or chowned — and answers one line per directory. + +// AskJudgeDirectoriesSubject is where the controller asks this node's engine to judge its directories. +func AskJudgeDirectoriesSubject(node string) string { + return "mesh.node." + node + ".ask.judge-directories" +} + +// JudgeDirectoriesContext is prefixed to a judgement ask's bytes before signing: never another ask's, nor a +// declaration's. +const JudgeDirectoriesContext = "novox-mesh judge-directories v1\n" + +// JudgeDirectoriesFunc judges this node's declared directories, read-only, asked by by: the words to answer with. +type JudgeDirectoriesFunc func(by string) (string, error) + +func refusedJudgement(why string) HandOverAnswer { + return HandOverAnswer{Refused: why + "; nothing was judged"} +} + +// AnswerJudgeDirectories is the engine's judgement of one ask's bytes: believed as a hand-over is, a path refused, +// and only then the handler. +func AnswerJudgeDirectories(check HandOverCheck, handle JudgeDirectoriesFunc, body []byte) HandOverAnswer { + if handle == nil { + return refusedJudgement("this engine judges no directory on an ask") + } + ask, refused := believed(check, JudgeDirectoriesContext, false, body) + if refused != "" { + return refusedJudgement(refused) + } + said, err := handle(ask.By) + if err != nil { + return refusedJudgement(err.Error()) + } + return HandOverAnswer{Said: said} +} + +// JudgeDirectoriesReply is what a judgement request is answered with, and whether to answer, as HandOverReply. +func JudgeDirectoriesReply(check HandOverCheck, handle JudgeDirectoriesFunc, reply string, body []byte) ([]byte, bool) { + if reply == "" { + return nil, false + } + answer, err := json.Marshal(AnswerJudgeDirectories(check, handle, body)) + if err != nil { + return nil, false + } + return answer, true +} + +// DirectoryJudgements is a link that hears the controller asking this node to judge its directories. Optional. +type DirectoryJudgements interface { + HearDirectoryJudgements(JudgeDirectoriesFunc) +} diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index 72d16f9..fd27d0f 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -64,7 +64,9 @@ type natsLink struct { handing *nats.Subscription // searching hears the controller's terminal asking for a fresh setuid search (novox/hq issue 361). searching *nats.Subscription - node string + // judging hears the controller asking for a read-only judgement of this node's directories (issue 358). + judging *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 arrived chan Declaration @@ -238,6 +240,25 @@ func (l *natsLink) HearSetuidSearches(handle SetuidSearchFunc) { } } +// HearDirectoryJudgements listens for the controller asking this node to judge its declared directories, +// read-only (novox/hq issue 358), on this machine's own subject, verified as a hand-over is +// (AnswerJudgeDirectories). A bus whose user list is older than the grant refuses the subscription, said by the +// client; the operator's line then says that nothing on this node answers. +func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) { + if handle == nil { + return + } + check := HandOverCheck{Signer: l.signer, Node: l.node} + judging, err := l.conn.Subscribe(AskJudgeDirectoriesSubject(l.node), func(msg *nats.Msg) { + if body, answer := JudgeDirectoriesReply(check, handle, msg.Reply, msg.Data); answer { + _ = msg.Respond(body) + } + }) + if err == nil { + l.judging = judging + } +} + 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 } @@ -255,6 +276,9 @@ func (l *natsLink) Close() { if l.searching != nil { _ = l.searching.Unsubscribe() } + if l.judging != nil { + _ = l.judging.Unsubscribe() + } if l.conn != nil { l.conn.Close() } diff --git a/internal/link/judge_directories_test.go b/internal/link/judge_directories_test.go new file mode 100644 index 0000000..6439266 --- /dev/null +++ b/internal/link/judge_directories_test.go @@ -0,0 +1,52 @@ +package link + +import ( + "strings" + "testing" + "time" +) + +// The subject and the signing context the controller holds the same words for (mesh-controller internal/link). +func TestTheDirectoryJudgementAskKeepsItsSubjectAndContext(t *testing.T) { + if AskJudgeDirectoriesSubject("laptop") != "mesh.node.laptop.ask.judge-directories" { + t.Fatalf("the subject is %q", AskJudgeDirectoriesSubject("laptop")) + } + if JudgeDirectoriesContext != "novox-mesh judge-directories v1\n" || JudgeDirectoriesContext == HandOverContext || + JudgeDirectoriesContext == SetuidSearchContext { + t.Fatalf("the signing context is %q", JudgeDirectoriesContext) + } +} + +// A judgement ask is believed as a hand-over is: signed by the mesh under its own context, for this node, +// unexpired, once, and naming no path. +func TestADirectoryJudgementAskIsBelievedOnlySignedForThisNodeAndOnce(t *testing.T) { + m, key := aMember(t) + now := time.Date(2026, 10, 12, 14, 0, 0, 0, time.UTC) + var asked []string + handle := func(by string) (string, error) { asked = append(asked, by); return "3 directories declared", nil } + check := HandOverCheck{Signer: m.Signer, Node: "n1", Now: func() time.Time { return now }, Nonces: &Nonces{}} + good := HandOverAsk{Node: "n1", By: "jo", Expires: now.Add(HandOverGood), Nonce: aNonce} + for name, body := range map[string][]byte{ + "a setuid search's signature": signedSearch(t, key, SetuidSearchContext, good), + "another node": signedSearch(t, key, JudgeDirectoriesContext, HandOverAsk{Node: "n2", By: "jo", Expires: good.Expires, Nonce: aNonce}), + "a path": signedSearch(t, key, JudgeDirectoriesContext, HandOverAsk{Node: "n1", Path: "/x", By: "jo", Expires: good.Expires, Nonce: aNonce}), + "expired": signedSearch(t, key, JudgeDirectoriesContext, HandOverAsk{Node: "n1", By: "jo", Expires: now, Nonce: aNonce}), + } { + if a := AnswerJudgeDirectories(check, handle, body); !strings.HasSuffix(a.Refused, "nothing was judged") || a.Said != "" { + t.Errorf("%s: %+v", name, a) + } + } + if len(asked) != 0 { + t.Fatalf("a refused ask reached the handler: %v", asked) + } + body := signedSearch(t, key, JudgeDirectoriesContext, good) + if a := AnswerJudgeDirectories(check, handle, body); a.Said != "3 directories declared" || len(asked) != 1 { + t.Fatalf("the good ask: %+v, %v", a, asked) + } + if a := AnswerJudgeDirectories(check, handle, body); !strings.Contains(a.Refused, "taken already") { + t.Fatalf("the same ask again: %+v", a) + } + if _, answer := JudgeDirectoriesReply(check, handle, "", body); answer { + t.Fatal("a request with no reply subject was answered") + } +} diff --git a/internal/link/queue.go b/internal/link/queue.go index c2a66dd..227dc1d 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -59,8 +59,11 @@ type Queue struct { // SetuidSearch throws away the last search for setuid programs and starts a full one, asked by the // controller's terminal over the link (novox/hq issue 361). Nil answers every such ask with a refusal. SetuidSearch SetuidSearchFunc - Say Announce - Timeout time.Duration + // 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 + Say Announce + Timeout time.Duration once sync.Once wake chan struct{} diff --git a/internal/link/run.go b/internal/link/run.go index e13135e..b61782e 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -248,6 +248,10 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou if s, ok := link.(SetuidSearches); ok { s.HearSetuidSearches(queue.SetuidSearch) } + // And asking for a read-only judgement of its directories (novox/hq issue 358). + if j, ok := link.(DirectoryJudgements); ok { + j.HearDirectoryJudgements(queue.JudgeDirectories) + } for { // Asked to stop — by standing aside, say — nothing further is taken in.