Show how far the setuid search got, and start a fresh one when the terminal asks (hq issue 361)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestNatsAnEnrolmentWithNoReplyAddressIsNotAnswered (0.00s)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestNatsAnEnrolmentWithNoReplyAddressIsNotAnswered (0.00s)
mesh/delivery superseded: a newer head of the same pull request
The walk kept its progress every minute and nobody could read it: root-free said only that a search ran. The account verdict now carries the progress (counts, times, mount points; never a path) as fields and in its reason, outside what makes a verdict changed. A signed ask from the controller's terminal throws the last search away and starts a full one, after the operator removed by hand what it found.
This commit is contained in:
+34
-2
@@ -1199,8 +1199,15 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// And the operator's hand-over of a directory used as found, carried by the controller from the
|
||||
// terminal (novox/hq issue 356): judged and recorded here, applied by the next apply.
|
||||
HandOver: handOverFrom(opts.state),
|
||||
Say: say,
|
||||
Timeout: opts.timeout,
|
||||
// A fresh search for setuid programs, asked at the controller's terminal (novox/hq issue 361).
|
||||
SetuidSearch: func(by string) (string, error) {
|
||||
if setuidSearch == nil {
|
||||
return "", errors.New("this engine judges no account for root, so it keeps no search for setuid programs")
|
||||
}
|
||||
return setuidSearch.SearchNow(by), nil
|
||||
},
|
||||
Say: say,
|
||||
Timeout: opts.timeout,
|
||||
}
|
||||
worker := make(chan struct{})
|
||||
go func() {
|
||||
@@ -1668,11 +1675,36 @@ func withAccounts(h *link.Health, as *accounts.Statement) *link.Health {
|
||||
if v.Root {
|
||||
rh.Root = declaration.RootNever
|
||||
}
|
||||
// How far the setuid search got, while the verdict waits for it (novox/hq issue 361): in the reason the
|
||||
// root-free verb repeats, and as fields. Never in the journal's line, which says a change alone.
|
||||
if v.Search != nil {
|
||||
rh.Reason += "; " + v.Search.Words()
|
||||
rh.Search = searchAsReported(v.Search)
|
||||
}
|
||||
h.Resources = append(h.Resources, rh)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// searchAsReported is a search's progress as the statement carries it.
|
||||
func searchAsReported(sp *accounts.SearchProgress) *link.SetuidSearch {
|
||||
out := &link.SetuidSearch{Started: sp.Started.UTC(), Filesystems: sp.Filesystems, Done: sp.Done,
|
||||
Walking: sp.Walking, Visited: sp.Visited, Found: sp.Found}
|
||||
if !sp.Kept.IsZero() {
|
||||
kept := sp.Kept.UTC()
|
||||
out.Kept = &kept
|
||||
}
|
||||
for _, f := range sp.Finished {
|
||||
sf := link.SearchedFilesystem{Mount: f.Mount}
|
||||
if f.Took >= 0 {
|
||||
secs := int64(f.Took.Round(time.Second) / time.Second)
|
||||
sf.TookSeconds = &secs
|
||||
}
|
||||
out.Finished = append(out.Finished, sf)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// userOf is the account whose own service manager a unit is in (novox/hq ADR 0254), empty for the
|
||||
// machine's own manager.
|
||||
func userOf(scope, user string) string {
|
||||
|
||||
@@ -98,3 +98,25 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) {
|
||||
t.Fatalf("root said: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A verdict waiting for the setuid search says how far it got, in the reason the root-free verb repeats and as
|
||||
// fields (novox/hq issue 361); a mount point, never a path the walk visited or found.
|
||||
func TestTheStatementSaysHowFarTheSetuidSearchGot(t *testing.T) {
|
||||
at := time.Date(2026, 10, 10, 1, 40, 0, 0, time.UTC)
|
||||
sp := &accounts.SearchProgress{Started: at, Filesystems: 3, Done: 1, Walking: "/home", Visited: 42, Found: 2,
|
||||
Kept: at, Finished: []accounts.SearchedFilesystem{{Mount: "/", Took: 90 * time.Second}}}
|
||||
as := &accounts.Statement{At: at, Accounts: []accounts.Verdict{{Account: accounts.Account{Module: "claude-code",
|
||||
ID: "claude-code.agent", Name: "agent", Root: true}, State: accounts.Unknown,
|
||||
Reason: accounts.ReasonPending + ": the search for setuid programs, started 2026-10-10 01:27, has not finished yet",
|
||||
Since: at, Search: sp}}}
|
||||
h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as)
|
||||
r := h.Resources[0]
|
||||
if !strings.HasPrefix(r.Reason, accounts.ReasonPending) || !strings.Contains(r.Reason, "1 of 3 filesystems walked, walking /home, 42 entries visited") {
|
||||
t.Fatalf("the reason: %s", r.Reason)
|
||||
}
|
||||
s := r.Search
|
||||
if s == nil || s.Filesystems != 3 || s.Done != 1 || s.Walking != "/home" || s.Visited != 42 || s.Found != 2 ||
|
||||
s.Kept == nil || len(s.Finished) != 1 || s.Finished[0].Mount != "/" || *s.Finished[0].TookSeconds != 90 {
|
||||
t.Fatalf("the fields: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,10 @@ type Verdict struct {
|
||||
Reason string
|
||||
Since time.Time
|
||||
Streak int
|
||||
// Search is how far the setuid search got, while the verdict waits for it (novox/hq issue 361); nil
|
||||
// otherwise. Never part of Reason: it moves every minute, and a verdict that changed every minute would
|
||||
// be said in the journal every minute.
|
||||
Search *SearchProgress
|
||||
}
|
||||
|
||||
// Statement is one look at every account.
|
||||
@@ -219,14 +223,14 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) {
|
||||
st := Statement{At: now}
|
||||
changed := len(j.last.Accounts) != len(j.accounts)
|
||||
for _, a := range j.accounts {
|
||||
state, reason := j.judge(ctx, a)
|
||||
state, reason, search := j.judge(ctx, a)
|
||||
k := j.kept[a.ID]
|
||||
if k == nil || k.State != state || k.Reason != reason {
|
||||
changed = true
|
||||
k = &Verdict{Account: a, State: state, Reason: reason, Since: now}
|
||||
j.kept[a.ID] = k
|
||||
}
|
||||
k.Account = a
|
||||
k.Account, k.Search = a, search
|
||||
if state == Unhealthy {
|
||||
k.Streak++
|
||||
} else {
|
||||
@@ -238,8 +242,8 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) {
|
||||
return st, changed
|
||||
}
|
||||
|
||||
// judge is one account's verdict on one look.
|
||||
func (j *Judge) judge(ctx context.Context, a Account) (string, string) {
|
||||
// judge is one account's verdict on one look, and how far the setuid search got while it waits for one.
|
||||
func (j *Judge) judge(ctx context.Context, a Account) (string, string, *SearchProgress) {
|
||||
if a.Root {
|
||||
ways, err := j.reader.Escalation(ctx, a.Name, a.Secrets)
|
||||
var notJudged *SetuidNotJudged
|
||||
@@ -250,22 +254,26 @@ func (j *Judge) judge(ctx context.Context, a Account) (string, string) {
|
||||
if errors.As(err, ¬Judged) {
|
||||
reason += "; " + firstLine(err.Error())
|
||||
}
|
||||
return Unhealthy, reason
|
||||
var progress *SearchProgress
|
||||
if notJudged != nil {
|
||||
progress = notJudged.Progress
|
||||
}
|
||||
return Unhealthy, reason, progress
|
||||
case errors.As(err, ¬Judged):
|
||||
// The setuid search has no result yet: not judged, said as such — the same words on every look
|
||||
// until it has one, never a search that stalls the look (novox/hq ADR 0266).
|
||||
return Unknown, firstLine(err.Error())
|
||||
return Unknown, firstLine(err.Error()), notJudged.Progress
|
||||
case err != nil:
|
||||
return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error())
|
||||
return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error()), nil
|
||||
}
|
||||
if len(a.Groups) == 0 {
|
||||
// Declared for root alone: nothing of a session to read.
|
||||
return Healthy, ""
|
||||
return Healthy, "", nil
|
||||
}
|
||||
}
|
||||
in, err := j.reader.InDatabase(ctx, a.Name)
|
||||
if err != nil {
|
||||
return Unknown, "the user database could not be read: " + firstLine(err.Error())
|
||||
return Unknown, "the user database could not be read: " + firstLine(err.Error()), nil
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, g := range in {
|
||||
@@ -281,15 +289,15 @@ func (j *Judge) judge(ctx context.Context, a Account) (string, string) {
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return Unhealthy, fmt.Sprintf("not in the group %s: the apply has not put %s there; its outcome says why",
|
||||
strings.Join(missing, ", "), a.Name)
|
||||
strings.Join(missing, ", "), a.Name), nil
|
||||
}
|
||||
s, err := j.reader.Session(ctx, a.Name, inDB)
|
||||
if err != nil {
|
||||
return Unknown, "the account's own service manager could not be read: " + firstLine(err.Error())
|
||||
return Unknown, "the account's own service manager could not be read: " + firstLine(err.Error()), nil
|
||||
}
|
||||
if !s.Running {
|
||||
// Nobody is logged in, and the account does not linger: the next login takes every group.
|
||||
return Healthy, ""
|
||||
return Healthy, "", nil
|
||||
}
|
||||
var lacking []string
|
||||
for _, g := range inDB {
|
||||
@@ -299,9 +307,9 @@ func (j *Judge) judge(ctx context.Context, a Account) (string, string) {
|
||||
}
|
||||
if len(lacking) > 0 {
|
||||
return Unhealthy, fmt.Sprintf("%s: %s is in the group %s, and its running session began before it was; "+
|
||||
"log out of every session and in again, or reboot", ReasonRelogin, a.Name, strings.Join(lacking, ", "))
|
||||
"log out of every session and in again, or reboot", ReasonRelogin, a.Name, strings.Join(lacking, ", ")), nil
|
||||
}
|
||||
return Healthy, ""
|
||||
return Healthy, "", nil
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -55,11 +56,14 @@ type SetuidCache struct {
|
||||
loaded bool
|
||||
last *setuidResult // the last complete search
|
||||
unfinished *SetuidProgress // a walk that did not finish, to resume
|
||||
progress *SearchProgress // how far the running search got, as of its last checkpoint
|
||||
running bool
|
||||
startedAt time.Time // when the search running, or the last one, was started (a resumed one: its start)
|
||||
attemptAt time.Time // when the running or last search was started or resumed
|
||||
endedAt time.Time // when the last search ended, on the same clock
|
||||
again bool // asked to search again since the running or last one started
|
||||
gen int // counts SearchNow: a search started under an older one is thrown away
|
||||
cancel context.CancelFunc // ends the running search's walk
|
||||
startedAt time.Time // when the search running, or the last one, was started (a resumed one: its start)
|
||||
attemptAt time.Time // when the running or last search was started or resumed
|
||||
endedAt time.Time // when the last search ended, on the same clock
|
||||
again bool // asked to search again since the running or last one started
|
||||
failedAt time.Time
|
||||
failed error
|
||||
backoff time.Duration
|
||||
@@ -101,6 +105,84 @@ type SetuidNotJudged struct {
|
||||
Err error
|
||||
// Last is when the last complete search started, when there is one too old to judge.
|
||||
Last time.Time
|
||||
// Progress is how far the running search got, as of its last checkpoint: nil when none runs.
|
||||
Progress *SearchProgress
|
||||
}
|
||||
|
||||
// SearchProgress is how far a running search got, as of its last checkpoint, for anybody to read (novox/hq
|
||||
// issue 361): counts, times and mount points only. **Never a path the walk visited or found**: the cursor and
|
||||
// the files found are under accounts' homes as often as not, and stay in the engine's state and journal.
|
||||
type SearchProgress struct {
|
||||
// Started is when the search began, on the cache's clock.
|
||||
Started time.Time
|
||||
// Filesystems is how many it walks; Done how many it has walked to their end; Walking the mount point of
|
||||
// the one it walks now, empty when every one is walked.
|
||||
Filesystems, Done int
|
||||
Walking string
|
||||
// Visited is how many entries it has gone past; Found how many setuid- or setgid-root files it found so
|
||||
// far, before the package manager is asked about them.
|
||||
Visited int64
|
||||
Found int
|
||||
// Kept is when its progress was last kept, zero before the first time.
|
||||
Kept time.Time
|
||||
// Finished is every filesystem walked to its end, in order, and how long its walk took.
|
||||
Finished []SearchedFilesystem
|
||||
}
|
||||
|
||||
// SearchedFilesystem is one filesystem a search walked to its end. Took is negative when not known: walked by
|
||||
// an engine before it counted.
|
||||
type SearchedFilesystem struct {
|
||||
Mount string
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// progressOf is p as anybody may read it. Counts and mount points, no I/O.
|
||||
func progressOf(p *SetuidProgress, kept time.Time) *SearchProgress {
|
||||
sp := &SearchProgress{Started: p.Started, Filesystems: len(p.Mounts), Done: len(p.Done), Visited: p.Visited,
|
||||
Found: len(p.Found), Kept: kept}
|
||||
for i, m := range p.Done {
|
||||
took := time.Duration(-1)
|
||||
if i < len(p.Took) {
|
||||
took = p.Took[i]
|
||||
}
|
||||
sp.Finished = append(sp.Finished, SearchedFilesystem{Mount: m, Took: took})
|
||||
}
|
||||
for _, m := range p.Mounts {
|
||||
if !slices.Contains(p.Done, m) {
|
||||
sp.Walking = m
|
||||
break
|
||||
}
|
||||
}
|
||||
return sp
|
||||
}
|
||||
|
||||
// Words are the progress in one line, as the verdict says it beside "not judged yet".
|
||||
func (sp *SearchProgress) Words() string {
|
||||
if sp == nil {
|
||||
return ""
|
||||
}
|
||||
s := fmt.Sprintf("progress: %d of %d filesystems walked", sp.Done, sp.Filesystems)
|
||||
if sp.Walking != "" {
|
||||
s += ", walking " + sp.Walking
|
||||
}
|
||||
s += fmt.Sprintf(", %d entries visited, %d setuid- or setgid-root files found so far", sp.Visited, sp.Found)
|
||||
if sp.Kept.IsZero() {
|
||||
s += ", not kept yet"
|
||||
} else {
|
||||
s += ", kept " + sp.Kept.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
var took []string
|
||||
for _, f := range sp.Finished {
|
||||
if f.Took < 0 {
|
||||
took = append(took, f.Mount+" in a time not known")
|
||||
} else {
|
||||
took = append(took, f.Mount+" in "+f.Took.Round(time.Second).String())
|
||||
}
|
||||
}
|
||||
if len(took) > 0 {
|
||||
s += "; walked " + strings.Join(took, ", ")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (e *SetuidNotJudged) Error() string {
|
||||
@@ -152,6 +234,44 @@ func (c *SetuidCache) SearchAgain() {
|
||||
c.forgetProgress()
|
||||
}
|
||||
|
||||
// SearchNow throws away the last complete search, kept or in memory, and any walk under way or kept, and has
|
||||
// the next look start a full search at once, past AgainAfter and any back-off (novox/hq issue 361): the
|
||||
// operator changed by hand what the last one found, which no apply says. Until the new one completes the
|
||||
// verdict is "not judged yet". Asked at the controller's terminal alone (`nox node setuid-search <node>`): a
|
||||
// search can never make a machine free wrongly, but asked again and again it would keep a machine unjudged
|
||||
// and its disks busy. by is who asked, for the journal. Nil c has nothing to throw away.
|
||||
func (c *SetuidCache) SearchNow(by string) string {
|
||||
if c == nil {
|
||||
return "this engine keeps no search for setuid programs"
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.loaded = true // nothing kept is read after this: it is thrown away
|
||||
what := "no complete search was kept"
|
||||
if c.last != nil {
|
||||
what = "the complete search started " + c.last.Started.Local().Format("2006-01-02 15:04") + " is thrown away"
|
||||
}
|
||||
if c.running {
|
||||
what += ", and the search running since " + c.startedAt.Local().Format("2006-01-02 15:04") + " is ended"
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
}
|
||||
c.gen++
|
||||
c.last, c.unfinished, c.progress = nil, nil, nil
|
||||
c.failed, c.failedAt, c.backoff, c.again = nil, time.Time{}, 0, false
|
||||
c.forgetProgress()
|
||||
if c.Path != "" {
|
||||
if err := os.Remove(c.Path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
c.say("the search for setuid programs could not forget the complete search it kept: " + err.Error())
|
||||
}
|
||||
}
|
||||
said := "a new search for setuid programs starts at the next look, asked by " + by + ": " + what +
|
||||
"; until it completes, whether an account can become root through one is not judged"
|
||||
c.say(said)
|
||||
return said
|
||||
}
|
||||
|
||||
// SetuidSearch is one search: it carries p on to its end — walking what p has not walked, calling checkpoint
|
||||
// as it goes so its progress can be kept — and answers the setuid-root programs no package owns.
|
||||
type SetuidSearch func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error)
|
||||
@@ -199,7 +319,8 @@ func (c *SetuidCache) Look(now time.Time, mounts []string, search SetuidSearch)
|
||||
case fresh:
|
||||
return slices.Clone(c.last.Found), c.last.Started, nil
|
||||
case c.running:
|
||||
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: c.startedAt, Last: c.lastStarted()}
|
||||
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: c.startedAt, Last: c.lastStarted(),
|
||||
Progress: c.progress}
|
||||
case c.failed != nil:
|
||||
return nil, time.Time{}, &SetuidNotJudged{Since: c.failedAt, Err: c.failed, Last: c.lastStarted()}
|
||||
default:
|
||||
@@ -243,11 +364,17 @@ func (c *SetuidCache) start(now time.Time, mounts []string, search SetuidSearch)
|
||||
}
|
||||
c.running, c.startedAt, c.attemptAt, c.again = true, p.Started, now, false
|
||||
c.starts++
|
||||
// What a look says until the first checkpoint: where the kept walk was, or nothing walked.
|
||||
kept := p.Kept
|
||||
c.progress = progressOf(p, kept)
|
||||
if resumed {
|
||||
c.say(fmt.Sprintf("the search for setuid programs started %s carries on where it was (%d of %d "+
|
||||
"filesystems walked)", p.Started.Local().Format("2006-01-02 15:04"), len(p.Done), len(p.Mounts)))
|
||||
}
|
||||
began := time.Now()
|
||||
gen := c.gen
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.cancel = cancel
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
@@ -261,25 +388,50 @@ func (c *SetuidCache) start(now time.Time, mounts []string, search SetuidSearch)
|
||||
}
|
||||
if err == nil {
|
||||
unkept := false
|
||||
found, err = search(context.Background(), p, func() {
|
||||
found, err = search(ctx, p, func() {
|
||||
c.mu.Lock()
|
||||
superseded := c.again
|
||||
superseded := c.again || c.gen != gen
|
||||
c.mu.Unlock()
|
||||
if superseded {
|
||||
return // root acted since this walk started: its progress is not to be carried on
|
||||
if !superseded { // root acted since this walk started: its progress is not to be carried on
|
||||
at := time.Now()
|
||||
p.Kept = at
|
||||
if kerr := c.keepProgress(p); kerr != nil {
|
||||
p.Kept = kept // not kept: the file still says the time before
|
||||
if !unkept {
|
||||
unkept = true
|
||||
c.say("the search for setuid programs could not keep how far it got, and starts over " +
|
||||
"if the engine stops: " + kerr.Error())
|
||||
}
|
||||
} else {
|
||||
kept = at
|
||||
}
|
||||
}
|
||||
if kerr := c.keepProgress(p); kerr != nil && !unkept {
|
||||
unkept = true
|
||||
c.say("the search for setuid programs could not keep how far it got, and starts over if " +
|
||||
"the engine stops: " + kerr.Error())
|
||||
// What a look says from now: computed from p here, on the walk's own goroutine, never read
|
||||
// by a look while the walk changes it.
|
||||
sp := progressOf(p, kept)
|
||||
c.mu.Lock()
|
||||
if c.gen == gen {
|
||||
c.progress = sp
|
||||
}
|
||||
c.mu.Unlock()
|
||||
})
|
||||
}
|
||||
took := time.Since(began)
|
||||
cancel()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.running = false
|
||||
c.running, c.progress, c.cancel = false, nil, nil
|
||||
c.endedAt = now.Add(took)
|
||||
if c.gen != gen {
|
||||
// Thrown away by SearchNow while it ran: neither a result nor a failure, and nothing to carry on. The
|
||||
// next look starts the search asked for.
|
||||
c.endedAt = time.Time{}
|
||||
// A checkpoint that read its generation just before SearchNow may have kept this walk again.
|
||||
c.forgetProgress()
|
||||
c.say(fmt.Sprintf("the search for setuid programs started %s was ended, as asked (%s this time)",
|
||||
p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second)))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// The back-off counts from when it failed.
|
||||
c.failed, c.failedAt = err, c.endedAt
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How far a running search got is readable through the verdict (novox/hq issue 361): counts, times and mount
|
||||
// points, counted once across a resume, and never a path the walk visited or found.
|
||||
|
||||
// A walk stopped midway and resumed counts every entry once, as the walk that was never stopped does, and says
|
||||
// how long each filesystem took.
|
||||
func TestTheProgressCountsEachEntryOnceAcrossAResume(t *testing.T) {
|
||||
top := tree(t, "a/1!", "a/2", "a-b/3!", "b/c/4!", "b/c/5", "b/d!", "c/6!", "d/7!")
|
||||
other := tree(t, "x/1", "y!")
|
||||
whole := &SetuidProgress{Mounts: []string{top, other}}
|
||||
if err := mine().walk(t.Context(), whole, func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// top and the 14 entries below it, and other and its 3.
|
||||
if whole.Visited != 15+4 || len(whole.Took) != 2 || whole.Took[0] < 0 || whole.Walking != 0 {
|
||||
t.Fatalf("the whole walk: visited %d, took %v, walking %v", whole.Visited, whole.Took, whole.Walking)
|
||||
}
|
||||
|
||||
w := mine()
|
||||
w.every = 0
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
p := &SetuidProgress{Mounts: []string{top, other}}
|
||||
var kept []byte
|
||||
err := w.walk(ctx, p, func() {
|
||||
kept = mustJSON(t, p)
|
||||
if strings.HasSuffix(p.Cursor[top], "/b/c/4") {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("the walk was stopped: %v", err)
|
||||
}
|
||||
var resumed SetuidProgress
|
||||
fromJSON(t, kept, &resumed)
|
||||
if resumed.Visited == 0 || resumed.Visited >= whole.Visited || resumed.Walking < 0 {
|
||||
t.Fatalf("the kept walk counted %d of %d", resumed.Visited, whole.Visited)
|
||||
}
|
||||
if err := mine().walk(t.Context(), &resumed, func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resumed.Visited != whole.Visited || len(resumed.Took) != 2 {
|
||||
t.Fatalf("the resumed walk counted %d entries (the whole one %d), took %v", resumed.Visited,
|
||||
whole.Visited, resumed.Took)
|
||||
}
|
||||
}
|
||||
|
||||
// A walk kept before the times were counted has them not known, never shifted onto the next filesystem.
|
||||
func TestAWalkKeptWithoutTimesSaysThemNotKnown(t *testing.T) {
|
||||
top, other := tree(t, "a"), tree(t, "b")
|
||||
p := &SetuidProgress{Mounts: []string{top, other}, Done: Paths{top}}
|
||||
if err := mine().walk(t.Context(), p, func() {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Took) != 2 || p.Took[0] >= 0 || p.Took[1] < 0 {
|
||||
t.Fatalf("took %v", p.Took)
|
||||
}
|
||||
if w := progressOf(p, time.Time{}).Words(); !strings.Contains(w, top+" in a time not known") {
|
||||
t.Fatalf("the words: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
// A look while the search runs says how far it got at its last checkpoint, with no path it visited or found.
|
||||
func TestALookSaysHowFarTheRunningSearchGotAndNoPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := &SetuidCache{ProgressPath: filepath.Join(dir, SetuidProgressFile)}
|
||||
secret := "/home/someone/private/rootshell"
|
||||
checkpointed, release := make(chan struct{}), make(chan struct{})
|
||||
search := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
|
||||
p.Mounts = Paths{"/", "/home", "/boot"}
|
||||
p.Done, p.Took = Paths{"/"}, []time.Duration{90 * time.Minute}
|
||||
p.Cursor["/home"] = "/home/someone/private"
|
||||
p.Found = Paths{"/usr/bin/odd", secret}
|
||||
p.Visited = 1234567
|
||||
checkpoint()
|
||||
close(checkpointed)
|
||||
<-release
|
||||
return nil, nil
|
||||
}
|
||||
_, _, err := c.Look(at, root, search)
|
||||
nj := notJudged(t, err, true)
|
||||
if nj.Progress == nil || nj.Progress.Visited != 0 || nj.Progress.Walking != "/" {
|
||||
t.Fatalf("before the first checkpoint: %+v", nj.Progress)
|
||||
}
|
||||
<-checkpointed
|
||||
_, _, err = c.Look(at.Add(time.Minute), root, search)
|
||||
nj = notJudged(t, err, true)
|
||||
sp := nj.Progress
|
||||
if sp == nil || sp.Filesystems != 3 || sp.Done != 1 || sp.Walking != "/home" || sp.Visited != 1234567 ||
|
||||
sp.Found != 2 || sp.Kept.IsZero() || len(sp.Finished) != 1 || sp.Finished[0].Took != 90*time.Minute {
|
||||
t.Fatalf("the progress: %+v", sp)
|
||||
}
|
||||
words := sp.Words()
|
||||
for _, want := range []string{"1 of 3 filesystems walked", "walking /home", "1234567 entries visited",
|
||||
"2 setuid- or setgid-root files found so far", "kept " + sp.Kept.Local().Format("2006-01-02 15:04:05"),
|
||||
"walked / in 1h30m0s"} {
|
||||
if !strings.Contains(words, want) {
|
||||
t.Errorf("the words lack %q: %s", want, words)
|
||||
}
|
||||
}
|
||||
for _, leak := range []string{"someone", "private", "odd", "rootshell"} {
|
||||
if strings.Contains(words, leak) || strings.Contains(err.Error(), leak) {
|
||||
t.Errorf("a path leaks (%q): %s / %v", leak, words, err)
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
c.Wait()
|
||||
if _, _, err := c.Look(at.Add(2*time.Minute), root, search); err != nil {
|
||||
t.Fatalf("the complete search judges: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The progress moves every minute; the verdict does not change for it, so the journal says it once.
|
||||
type waiting struct{ progress *SearchProgress }
|
||||
|
||||
func (w *waiting) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
|
||||
func (w *waiting) Session(context.Context, string, []string) (Session, error) {
|
||||
return Session{}, nil
|
||||
}
|
||||
func (w *waiting) Escalation(context.Context, string, []string) ([]string, error) {
|
||||
return nil, &SetuidNotJudged{Pending: true, Since: at, Progress: w.progress}
|
||||
}
|
||||
|
||||
func TestTheProgressMovesWithoutChangingTheVerdict(t *testing.T) {
|
||||
r := &waiting{progress: &SearchProgress{Filesystems: 2, Visited: 10}}
|
||||
j := New(r)
|
||||
j.Set([]Account{{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true}})
|
||||
st, changed := j.Look(t.Context())
|
||||
if !changed || st.Accounts[0].State != Unknown || st.Accounts[0].Search.Visited != 10 {
|
||||
t.Fatalf("first look: changed %v, %+v", changed, st.Accounts[0])
|
||||
}
|
||||
r.progress = &SearchProgress{Filesystems: 2, Visited: 20}
|
||||
st, changed = j.Look(t.Context())
|
||||
if changed || st.Accounts[0].Search.Visited != 20 || strings.Contains(st.Accounts[0].Reason, "progress") {
|
||||
t.Fatalf("second look: changed %v, %+v", changed, st.Accounts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Asked at the terminal, a search starts afresh at the next look: the last complete one is thrown away, kept or
|
||||
// in memory, and a running one is ended and neither judges nor is carried on.
|
||||
func TestSearchNowThrowsAwayTheLastSearchAndStartsAFullOne(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := &SetuidCache{Path: filepath.Join(dir, SetuidSearchFile), ProgressPath: filepath.Join(dir, SetuidProgressFile)}
|
||||
g := newGate()
|
||||
g.found = []string{"/home/someone/rootshell"}
|
||||
c.Look(at, root, g.search)
|
||||
<-g.started
|
||||
g.finish(c)
|
||||
if found, _, err := c.Look(at.Add(time.Hour), root, g.search); err != nil || len(found) != 1 {
|
||||
t.Fatalf("the complete search judges: %v %v", found, err)
|
||||
}
|
||||
said := c.SearchNow("jo at the controller's terminal")
|
||||
if !strings.Contains(said, "thrown away") || strings.Contains(said, "someone") {
|
||||
t.Fatalf("said: %s", said)
|
||||
}
|
||||
if _, err := os.Stat(c.Path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("the kept search is still there: %v", err)
|
||||
}
|
||||
// The next look starts a full search at once, though the last ended only an hour ago.
|
||||
ended := make(chan error, 1)
|
||||
running := func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error) {
|
||||
if len(p.Done) != 0 || len(p.Cursor) != 0 {
|
||||
t.Errorf("not a full search: %+v", p)
|
||||
}
|
||||
checkpoint()
|
||||
<-ctx.Done()
|
||||
ended <- ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
_, _, err := c.Look(at.Add(time.Hour+time.Minute), root, running)
|
||||
notJudged(t, err, true)
|
||||
if starts(c) != 2 {
|
||||
t.Fatalf("searches started: %d", starts(c))
|
||||
}
|
||||
// Asked again while it runs: it is ended, judges nothing, fails nothing, and is not carried on.
|
||||
c.SearchNow("jo")
|
||||
if err := <-ended; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("the running search was not ended: %v", err)
|
||||
}
|
||||
c.Wait()
|
||||
if _, err := os.Stat(c.ProgressPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("the ended walk is still kept: %v", err)
|
||||
}
|
||||
g2 := newGate()
|
||||
_, _, err = c.Look(at.Add(time.Hour+2*time.Minute), root, g2.search)
|
||||
notJudged(t, err, true)
|
||||
<-g2.started
|
||||
g2.finish(c)
|
||||
if starts(c) != 3 {
|
||||
t.Fatalf("searches started: %d", starts(c))
|
||||
}
|
||||
if _, _, err := c.Look(at.Add(time.Hour+3*time.Minute), root, g2.search); err != nil {
|
||||
t.Fatalf("the new search judges: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,16 @@ type SetuidProgress struct {
|
||||
Cursor Cursors `json:"cursor"`
|
||||
// Found is every setuid- or setgid-root regular file found so far, before the package manager is asked.
|
||||
Found Paths `json:"found"`
|
||||
// Visited is how many entries the walk has gone past: each once, across a resume, as it is kept with the
|
||||
// cursor it matches.
|
||||
Visited int64 `json:"visited"`
|
||||
// Took is how long the walk spent in each of Done, in its order (negative: not known, a walk kept by an
|
||||
// engine before it counted); Walking how long it has spent in the filesystem it walks now. Time the
|
||||
// engine did not run is in neither.
|
||||
Took []time.Duration `json:"took"`
|
||||
Walking time.Duration `json:"walking"`
|
||||
// Kept is when this progress was kept, as the cache wrote it; zero before the first time.
|
||||
Kept time.Time `json:"kept"`
|
||||
}
|
||||
|
||||
// Paths are paths kept as their bytes. **Never as JSON strings**: encoding/json replaces bytes that are not
|
||||
@@ -120,6 +130,11 @@ func (w walker) walk(ctx context.Context, p *SetuidProgress, checkpoint func())
|
||||
if p.Cursor == nil {
|
||||
p.Cursor = Cursors{}
|
||||
}
|
||||
// One Took for each of Done: a walk kept before they were counted has them not known.
|
||||
for len(p.Took) < len(p.Done) {
|
||||
p.Took = append(p.Took, -1)
|
||||
}
|
||||
p.Took = p.Took[:len(p.Done)]
|
||||
last := time.Now()
|
||||
for _, m := range p.Mounts {
|
||||
if slices.Contains(p.Done, m) {
|
||||
@@ -129,6 +144,8 @@ func (w walker) walk(ctx context.Context, p *SetuidProgress, checkpoint func())
|
||||
return err
|
||||
}
|
||||
p.Done = append(p.Done, m)
|
||||
p.Took = append(p.Took, p.Walking)
|
||||
p.Walking = 0
|
||||
delete(p.Cursor, m)
|
||||
checkpoint()
|
||||
last = time.Now()
|
||||
@@ -166,6 +183,9 @@ func (w walker) walkMount(ctx context.Context, p *SetuidProgress, mount string,
|
||||
if c := p.Cursor[mount]; c != "" {
|
||||
cursor = components(c)
|
||||
}
|
||||
// How long it walks this filesystem, carried on from what a resumed walk had spent in it.
|
||||
began, before := time.Now(), p.Walking
|
||||
defer func() { p.Walking = before + time.Since(began) }()
|
||||
return filepath.WalkDir(mount, func(path string, d fs.DirEntry, err error) error {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
@@ -212,10 +232,13 @@ func (w walker) walkMount(ctx context.Context, p *SetuidProgress, mount string,
|
||||
}
|
||||
}
|
||||
// The cursor only moves on: the directories on the way back to it are visited again, and are before it.
|
||||
// Counted here alone, so an entry visited again on the way back to the cursor is counted once.
|
||||
if cursor == nil || walkOrder(at, cursor) > 0 {
|
||||
p.Cursor[mount], cursor = path, at
|
||||
p.Visited++
|
||||
}
|
||||
if time.Since(*last) >= w.every {
|
||||
p.Walking = before + time.Since(began)
|
||||
checkpoint()
|
||||
*last = time.Now()
|
||||
}
|
||||
|
||||
+96
-22
@@ -120,20 +120,36 @@ func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandO
|
||||
if handle == nil {
|
||||
return refusedAsk("this engine takes no hand-over")
|
||||
}
|
||||
ask, refused := believed(check, HandOverContext, true, body)
|
||||
if refused != "" {
|
||||
return refusedAsk(refused)
|
||||
}
|
||||
said, err := handle(ask.Path, ask.By)
|
||||
if err != nil {
|
||||
return HandOverAnswer{Refused: err.Error()}
|
||||
}
|
||||
return HandOverAnswer{Said: said}
|
||||
}
|
||||
|
||||
// believed is an ask's bytes held to what every signed ask is: signed by the mesh over context and its bytes,
|
||||
// for this node, unexpired and good no longer than an ask may be, with a nonce of its own taken once here, and
|
||||
// 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) {
|
||||
if len(body) > MaxHandOverAsk {
|
||||
return refusedAsk(fmt.Sprintf("the ask is %d bytes, more than a hand-over", len(body)))
|
||||
return HandOverAsk{}, 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 refusedAsk("the ask is not a signed hand-over: " + err.Error())
|
||||
return HandOverAsk{}, "the ask is not a signed ask: " + err.Error()
|
||||
}
|
||||
if len(check.Signer) != ed25519.PublicKeySize ||
|
||||
!ed25519.Verify(check.Signer, append([]byte(HandOverContext), signed.Ask...), signed.Signature) {
|
||||
return refusedAsk("the ask is not signed by the mesh")
|
||||
!ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) {
|
||||
return HandOverAsk{}, "the ask is not signed by the mesh"
|
||||
}
|
||||
var ask HandOverAsk
|
||||
if err := json.Unmarshal(signed.Ask, &ask); err != nil {
|
||||
return refusedAsk("the signed ask is not a hand-over: " + err.Error())
|
||||
return HandOverAsk{}, "the signed ask is not an ask: " + err.Error()
|
||||
}
|
||||
now := time.Now()
|
||||
if check.Now != nil {
|
||||
@@ -143,31 +159,29 @@ func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandO
|
||||
if nonces == nil {
|
||||
nonces = HandOverNonces
|
||||
}
|
||||
by := strings.TrimSpace(ask.By)
|
||||
ask.By = strings.TrimSpace(ask.By)
|
||||
nonce, nonceErr := hex.DecodeString(ask.Nonce)
|
||||
switch {
|
||||
case check.Node == "" || ask.Node != check.Node:
|
||||
return refusedAsk(fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node))
|
||||
return HandOverAsk{}, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node)
|
||||
case ask.Expires.IsZero() || !now.Before(ask.Expires):
|
||||
return refusedAsk("the ask has expired")
|
||||
return HandOverAsk{}, "the ask has expired"
|
||||
case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)):
|
||||
return refusedAsk("the ask is good for longer than an ask may be")
|
||||
return HandOverAsk{}, "the ask is good for longer than an ask may be"
|
||||
case nonceErr != nil || len(nonce) < 16:
|
||||
return refusedAsk("the ask carries no nonce of its own")
|
||||
case by == "":
|
||||
return refusedAsk("the ask names nobody as asking")
|
||||
case len(by) > MaxHandOverBy || strings.ContainsAny(by, "\r\n\x00"):
|
||||
return refusedAsk("who asked is not one short line")
|
||||
case ask.Path == "":
|
||||
return refusedAsk("the ask names no directory")
|
||||
return HandOverAsk{}, "the ask carries no nonce of its own"
|
||||
case ask.By == "":
|
||||
return HandOverAsk{}, "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"
|
||||
case withPath && ask.Path == "":
|
||||
return HandOverAsk{}, "the ask names no directory"
|
||||
case !withPath && ask.Path != "":
|
||||
return HandOverAsk{}, "the ask names a path, and this ask takes none"
|
||||
case !nonces.take(ask.Nonce, ask.Expires, now):
|
||||
return refusedAsk("the ask was taken already")
|
||||
return HandOverAsk{}, "the ask was taken already"
|
||||
}
|
||||
said, err := handle(ask.Path, by)
|
||||
if err != nil {
|
||||
return HandOverAnswer{Refused: err.Error()}
|
||||
}
|
||||
return HandOverAnswer{Said: said}
|
||||
return ask, ""
|
||||
}
|
||||
|
||||
// HandOverReply is what a hand-over request is answered with, and whether to answer: a request with no reply
|
||||
@@ -188,3 +202,63 @@ func HandOverReply(check HandOverCheck, handle HandOverFunc, reply string, body
|
||||
type HandOvers interface {
|
||||
HearHandOvers(HandOverFunc)
|
||||
}
|
||||
|
||||
// A search for setuid programs asked over the bus (novox/hq issue 361).
|
||||
//
|
||||
// The operator who changed by hand what the last search found — removed a setuid-root copy no package owns —
|
||||
// asks for a fresh one at the controller's terminal: `nox node setuid-search <node>` on the control-node. The
|
||||
// controller asks this node's engine on AskSetuidSearchSubject, signed as a hand-over is and held to the same
|
||||
// checks (believed), under a signing context of its own so neither ask's signature is the other's. The engine
|
||||
// throws away the last search and starts a full one; it never names a path in its answer.
|
||||
//
|
||||
// **At the terminal alone, never a verb** (decided in issue 361): a search can never make a machine free
|
||||
// wrongly — it judges only once complete — but one asked again and again would keep the machine unjudged and
|
||||
// its disks busy, and whoever may call a verb includes agents.
|
||||
|
||||
// AskSetuidSearchSubject is where the controller asks this node's engine for a fresh search.
|
||||
func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" }
|
||||
|
||||
// SetuidSearchContext is prefixed to a search ask's bytes before signing: never a hand-over's, nor a
|
||||
// declaration's.
|
||||
const SetuidSearchContext = "novox-mesh setuid-search v1\n"
|
||||
|
||||
// SetuidSearchFunc throws away the last search and starts a full one, asked by by: the words to answer with.
|
||||
type SetuidSearchFunc func(by string) (string, error)
|
||||
|
||||
func refusedSearch(why string) HandOverAnswer {
|
||||
return HandOverAnswer{Refused: why + "; no search was started"}
|
||||
}
|
||||
|
||||
// AnswerSetuidSearch is the engine's judgement of one search ask's bytes: believed as a hand-over is, a path
|
||||
// refused, and only then the handler.
|
||||
func AnswerSetuidSearch(check HandOverCheck, handle SetuidSearchFunc, body []byte) HandOverAnswer {
|
||||
if handle == nil {
|
||||
return refusedSearch("this engine searches for setuid programs on no ask")
|
||||
}
|
||||
ask, refused := believed(check, SetuidSearchContext, false, body)
|
||||
if refused != "" {
|
||||
return refusedSearch(refused)
|
||||
}
|
||||
said, err := handle(ask.By)
|
||||
if err != nil {
|
||||
return refusedSearch(err.Error())
|
||||
}
|
||||
return HandOverAnswer{Said: said}
|
||||
}
|
||||
|
||||
// SetuidSearchReply is what a search request is answered with, and whether to answer, as HandOverReply.
|
||||
func SetuidSearchReply(check HandOverCheck, handle SetuidSearchFunc, reply string, body []byte) ([]byte, bool) {
|
||||
if reply == "" {
|
||||
return nil, false
|
||||
}
|
||||
answer, err := json.Marshal(AnswerSetuidSearch(check, handle, body))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return answer, true
|
||||
}
|
||||
|
||||
// SetuidSearches is a link that hears the controller asking this node for a fresh search. Optional.
|
||||
type SetuidSearches interface {
|
||||
HearSetuidSearches(SetuidSearchFunc)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ type natsLink struct {
|
||||
asking *nats.Subscription
|
||||
asked chan struct{}
|
||||
handing *nats.Subscription
|
||||
node string
|
||||
// searching hears the controller's terminal asking for a fresh setuid search (novox/hq issue 361).
|
||||
searching *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
|
||||
@@ -217,6 +219,25 @@ func (l *natsLink) HearHandOvers(handle HandOverFunc) {
|
||||
}
|
||||
}
|
||||
|
||||
// HearSetuidSearches listens for the controller asking this node for a fresh search for setuid programs
|
||||
// (novox/hq issue 361), on this machine's own subject, verified as a hand-over is (AnswerSetuidSearch). 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) HearSetuidSearches(handle SetuidSearchFunc) {
|
||||
if handle == nil {
|
||||
return
|
||||
}
|
||||
check := HandOverCheck{Signer: l.signer, Node: l.node}
|
||||
searching, err := l.conn.Subscribe(AskSetuidSearchSubject(l.node), func(msg *nats.Msg) {
|
||||
if body, answer := SetuidSearchReply(check, handle, msg.Reply, msg.Data); answer {
|
||||
_ = msg.Respond(body)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
l.searching = searching
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -231,6 +252,9 @@ func (l *natsLink) Close() {
|
||||
if l.handing != nil {
|
||||
_ = l.handing.Unsubscribe()
|
||||
}
|
||||
if l.searching != nil {
|
||||
_ = l.searching.Unsubscribe()
|
||||
}
|
||||
if l.conn != nil {
|
||||
l.conn.Close()
|
||||
}
|
||||
|
||||
@@ -373,6 +373,31 @@ type ResourceHealth struct {
|
||||
// a person (novox/hq ADR 0266): healthy then also means the engine found no way for it to. Empty on
|
||||
// every other verdict, and on an account judged for its groups alone.
|
||||
Root string `json:"root,omitempty"`
|
||||
// Search is how far the setuid search got, on an account's verdict that waits for it (novox/hq issue
|
||||
// 361): Reason says the same in words. Absent otherwise.
|
||||
Search *SetuidSearch `json:"search,omitempty"`
|
||||
}
|
||||
|
||||
// SetuidSearch is how far a running search for setuid programs got, as of its last checkpoint. Counts, times
|
||||
// and mount points only: never a path the walk visited or found.
|
||||
type SetuidSearch struct {
|
||||
Started time.Time `json:"started"`
|
||||
Filesystems int `json:"filesystems"`
|
||||
Done int `json:"done"`
|
||||
// Walking is the mount point of the filesystem walked now; empty when every one is walked.
|
||||
Walking string `json:"walking,omitempty"`
|
||||
Visited int64 `json:"visited"`
|
||||
// Found is how many setuid- or setgid-root files it found so far, before the package manager is asked.
|
||||
Found int `json:"found"`
|
||||
// Kept is when its progress was last kept; absent before the first time.
|
||||
Kept *time.Time `json:"kept,omitempty"`
|
||||
Finished []SearchedFilesystem `json:"finished,omitempty"`
|
||||
}
|
||||
|
||||
// SearchedFilesystem is one filesystem a search walked to its end; TookSeconds is absent when not known.
|
||||
type SearchedFilesystem struct {
|
||||
Mount string `json:"mount"`
|
||||
TookSeconds *int64 `json:"took_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// HealthSaid is the health event: a machine's statement between its reports, on HealthSubject.
|
||||
|
||||
@@ -54,8 +54,11 @@ type Queue struct {
|
||||
// HandOver judges and records a directory used as found handed to the mesh by the operator, asked by
|
||||
// the controller over the link (novox/hq issue 356). Nil answers every such ask with a refusal.
|
||||
HandOver HandOverFunc
|
||||
Say Announce
|
||||
Timeout time.Duration
|
||||
// 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
|
||||
|
||||
once sync.Once
|
||||
wake chan struct{}
|
||||
|
||||
@@ -244,6 +244,10 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou
|
||||
if h, ok := link.(HandOvers); ok {
|
||||
h.HearHandOvers(queue.HandOver)
|
||||
}
|
||||
// And asking for a fresh search for setuid programs (novox/hq issue 361).
|
||||
if s, ok := link.(SetuidSearches); ok {
|
||||
s.HearSetuidSearches(queue.SetuidSearch)
|
||||
}
|
||||
|
||||
for {
|
||||
// Asked to stop — by standing aside, say — nothing further is taken in.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The subject and the signing context the controller holds the same words for (mesh-controller internal/link).
|
||||
func TestTheSetuidSearchAskKeepsItsSubjectAndContext(t *testing.T) {
|
||||
if AskSetuidSearchSubject("laptop") != "mesh.node.laptop.ask.setuid-search" {
|
||||
t.Fatalf("the subject is %q", AskSetuidSearchSubject("laptop"))
|
||||
}
|
||||
if SetuidSearchContext != "novox-mesh setuid-search v1\n" || SetuidSearchContext == HandOverContext {
|
||||
t.Fatalf("the signing context is %q", SetuidSearchContext)
|
||||
}
|
||||
}
|
||||
|
||||
func signedSearch(t *testing.T, key ed25519.PrivateKey, context string, ask HandOverAsk) []byte {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(ask)
|
||||
body, _ := json.Marshal(SignedHandOver{Ask: raw, Signature: ed25519.Sign(key, append([]byte(context), raw...))})
|
||||
return body
|
||||
}
|
||||
|
||||
// A search ask is believed as a hand-over is: signed by the mesh under its own context (a hand-over's signature
|
||||
// starts no search), for this node, unexpired, once, and naming no path.
|
||||
func TestASetuidSearchAskIsBelievedOnlySignedForThisNodeAndOnce(t *testing.T) {
|
||||
m, key := aMember(t)
|
||||
now := time.Date(2026, 10, 10, 2, 0, 0, 0, time.UTC)
|
||||
var asked []string
|
||||
handle := func(by string) (string, error) { asked = append(asked, by); return "a new search starts", 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 hand-over's signature": signedSearch(t, key, HandOverContext, good),
|
||||
"another node": signedSearch(t, key, SetuidSearchContext, HandOverAsk{Node: "n2", By: "jo", Expires: good.Expires, Nonce: aNonce}),
|
||||
"a path": signedSearch(t, key, SetuidSearchContext, HandOverAsk{Node: "n1", Path: "/x", By: "jo", Expires: good.Expires, Nonce: aNonce}),
|
||||
"expired": signedSearch(t, key, SetuidSearchContext, HandOverAsk{Node: "n1", By: "jo", Expires: now, Nonce: aNonce}),
|
||||
} {
|
||||
if a := AnswerSetuidSearch(check, handle, body); !strings.HasSuffix(a.Refused, "no search was started") || 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, SetuidSearchContext, good)
|
||||
if a := AnswerSetuidSearch(check, handle, body); a.Said != "a new search starts" || len(asked) != 1 || asked[0] != "jo" {
|
||||
t.Fatalf("the good ask: %+v, %v", a, asked)
|
||||
}
|
||||
if a := AnswerSetuidSearch(check, handle, body); !strings.Contains(a.Refused, "taken already") {
|
||||
t.Fatalf("the same ask again: %+v", a)
|
||||
}
|
||||
if a := AnswerSetuidSearch(check, nil, body); a.Refused == "" {
|
||||
t.Fatalf("no handler: %+v", a)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user