Merge pull request 'Judge a node's directories read-only before the walk refuses any (issue 358)' (#88) from fix/358-judge-directories-read-only into main
This commit was merged in pull request #88.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -116,6 +116,8 @@ reach, run as root at the machine.
|
||||
hand-over <dir> 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 <node> <dir> 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() {
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user