Files
mesh-host/internal/userlist/userlist.go
T
jschoubben 5906e18028 Node-engine: a composition, its codec, digest and keys come from the node-engine client; the candidate answer and its limits from the seat's contract; the declared packages are the engine's own (H-2, H-3, ADR 0634 §1; issue 1966)
The engine's Composed keeps the catalogue's Plan and the declaration types and goes through nodeengineclient's codec; the fingerprint key part is meshcatalogclient's. The candidate answer is nodeengine.CandidateAnswer; the have still holds it through an interim JSON bridge (link.interimAnswers) until the Have group moves.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:10:45 +01:00

550 lines
19 KiB
Go

// Package userlist is the bridge of novox/hq ADR 0547 §8: the node agent of the node holding mesh-broker writes the bus's
// user list alone, with no want, so a new account is in force on the bus without anybody's hand.
//
// **Why it exists.** The list reached the bus only through a want of the bus module, and ADR 0539 §15 bars the assigner
// from writing one: on 2026-10-25 it stood from 11:23 to 20:50 and every new account was refused until the operator put
// it back by hand (issue 1945). The bus module's entrypoint already reloads the server in place when the file changes;
// what was missing is a writer of the file that does not wait on a want.
//
// **The grants part's list alone** (ADR 0547 clauses 7, 8 and 11, ADR 0571 clause 8; issue 1126: the controller ended by
// its unassignment and its list is written by nobody). It watches the grants part's one key `user-list.sealed`: the list
// is the object the key names, read by its name, opened with this node's sealing key and checked against the key's
// digest, and written alone (mode 0600, apply.WriteAlone) only when the file's digest differs. A key that cannot be read
// or is deleted is said, the key last heard stands, and the list loaded stays; with no key ever heard nothing is
// written. The list a composition carries is never written.
//
// **Where it writes.** The file is the user-list resource (`bus-users`) of the bus module's composition as this node runs
// it (Env.Running: the agent's own record of what runs, its own composition or the declaration kept before the switch),
// never one read from a composition bucket: the controller that wrote `composed-<node>` ended (issue 1126), and the bus
// module on the bus node runs from what the agent holds. That is what tells the bus node it is one, and the file's
// place. The have of that module says what was written, which list it follows and this node's key, which the grants
// part seals to.
//
// **It watches, it never polls**: the grants part's key is watched, and what runs is looked at again when the agent says
// it changed, after every apply of the bus module and when asked (Again). It reads no want and writes none, moves no
// fingerprint, and touches nothing of the bus module but its user list.
package userlist
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
engcomp "github.com/novox/mesh-host/internal/compositions"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seal"
"github.com/novox/mesh-host/internal/agent"
)
// ResourceID is the id the composer gives the bus's user list (mesh-sdk catalogue.BusUsersID); in a composition it may
// carry its module before it, `<module>.bus-users`.
const ResourceID = "bus-users"
// Written is what the follower last did: the source it took the list from, the list's digest there, the file's digest
// on the machine after the write, how many users the list holds, when, and why a write failed.
type Written = delivery.UserListWritten
// Env is everything the follower needs of the node.
type Env struct {
Node string
// Running is the compositions this node runs now, as its agent holds them (agent.Agent.Everything): where the user
// list's file is is read from these alone (issue 1126). Nil runs nothing.
Running func() []engcomp.Composed
// OnDisk is what the resource's file holds on the machine now; nil when there is none.
OnDisk func(resource json.RawMessage) ([]byte, error)
// Write writes the user list's file resource of a module's composition alone, with body as its content, when it
// differs from the file on the machine, and answers the digest of the file there after it (`sha256:<hex>`) and
// whether it changed.
Write func(ctx context.Context, module string, resource json.RawMessage, body []byte) (digest string, changed bool, err error)
// Report says what was written, in the module's have.
Report func(ctx context.Context, module string, w Written)
// Grants watches the grants part's state (its key delivery.GrantsUserListKey); nil writes nothing.
Grants agent.Watcher
// Fetch reads an object of the grants part's bucket by its name; Open opens a box sealed to this node's key; Key is
// this node's sealing key's public half (SPKI PEM), said in the have so the grants part seals to it ("" unknown).
Fetch func(ctx context.Context, name string) ([]byte, error)
Open func(seal.Box) ([]byte, error)
Key func() string
// Kept is the grants part's key as last heard on this machine and its revision (nil when none), and Keep keeps it:
// what the follower follows while the key cannot be watched (ADR 0547 clause 8). Backoff is the wait before the
// try-th watch again.
Kept func() ([]byte, uint64)
Keep func(value []byte, rev uint64)
Backoff func(try int) time.Duration
Now func() time.Time
Say func(string)
}
type source struct {
module string
resource json.RawMessage
}
// Follower follows the grants part's user list into the file the bus module this node runs declares.
type Follower struct {
env Env
kick chan struct{}
// looks asks a look at what runs (RunsChanged): written only when the file it names moved.
looks chan struct{}
mu sync.Mutex
srcs map[string]source // by module, of what runs now
// cur is the source last taken, and written whether its write went through.
cur source
written bool
state Written
mod string
said map[string]bool
// grants is the grants part's key as last heard (nil when none).
grants *delivery.GrantsUserList
grantsRev uint64
// unwatched is why the grants part's key is not watched now; empty while it is, or where it need not be.
unwatched string
}
// New is a follower for a node.
func New(env Env) *Follower {
if env.Now == nil {
env.Now = time.Now
}
if env.Say == nil {
env.Say = func(string) {}
}
if env.Backoff == nil {
env.Backoff = func(int) time.Duration { return time.Minute }
}
f := &Follower{env: env, kick: make(chan struct{}, 1), looks: make(chan struct{}, 1), srcs: map[string]source{},
said: map[string]bool{}}
if env.Grants != nil && env.Kept != nil {
if raw, rev := env.Kept(); raw != nil {
if g, err := delivery.DecodeGrantsUserList(raw); err == nil {
f.grants, f.grantsRev = &g, rev
}
}
}
return f
}
// Again asks the follower to look at what runs and the file again: when what runs changed, after an apply of the bus
// module, and when a person asks. It never blocks.
func (f *Follower) Again() {
select {
case f.kick <- struct{}{}:
default:
}
}
// RunsChanged asks the follower to look at what this node runs again: the list is written again only when the file the
// bus module declares moved. It never blocks.
func (f *Follower) RunsChanged() {
select {
case f.looks <- struct{}{}:
default:
}
}
// State is the module whose list the follower writes ("" when nothing this node runs carries one) and what it last did.
func (f *Follower) State() (string, Written) {
f.mu.Lock()
defer f.mu.Unlock()
return f.mod, f.state
}
// Run follows the grants part's list until ctx ends.
func (f *Follower) Run(ctx context.Context) error {
f.RunsChanged() // the first look at what runs, at once
// The grants part's key, on the node whose compositions carry the list: the bus node alone is granted it (mesh-sdk
// buscompose.GrantsUserListFollows). Until its watch caught up nothing is written, so a new follower (one per link)
// never writes a list older than the key; a watch refused or ended is tried again on the backoff, and meanwhile the
// key last heard (kept on the machine) holds.
var grants <-chan agent.Update
var retry <-chan time.Time
opened, grantsCaught, tries := false, false, 0
failed := func(why string) {
grants, tries = nil, tries+1
retry = time.After(f.env.Backoff(tries))
f.mu.Lock()
f.unwatched = why
f.mu.Unlock()
f.sayOnce("the grants part's user-list key is not watched, tried again: " + why)
}
open := func() {
if grants != nil || retry != nil || f.env.Grants == nil || !f.busNode() {
return
}
opened = true
ch, err := f.env.Grants.Watch(ctx, delivery.GrantsUserListKey)
if err != nil {
failed(err.Error())
return
}
grants = ch
}
for {
again := false
select {
case <-ctx.Done():
return nil
case <-f.kick:
again = true
f.look()
case <-f.looks:
f.look()
case <-retry:
retry = nil
case u, ok := <-grants:
if !ok {
failed("its watch ended")
break
}
if u.Caught {
grantsCaught, tries = true, 0
f.mu.Lock()
f.unwatched = ""
f.mu.Unlock()
break
}
f.takeGrants(u)
}
open()
if opened && !grantsCaught && grants != nil {
continue // the switch is not heard yet: nothing is written before it is
}
f.mu.Lock()
if f.unwatched != f.state.Unwatched {
again = true // the have says whether the key is watched
}
f.mu.Unlock()
f.settle(ctx, again)
}
}
// busNode says something this node runs carries the bus's user list, or did.
func (f *Follower) busNode() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.cur.module != "" || len(f.srcs) > 0
}
// look reads what this node runs now for the bus's user list: each module whose composition carries it.
func (f *Follower) look() {
var running []engcomp.Composed
if f.env.Running != nil {
running = f.env.Running()
}
srcs := map[string]source{}
for _, c := range running {
if c.Node != "" && c.Node != f.env.Node {
continue
}
raw, _, _, ok := userList(c.Resources)
if !ok {
continue
}
module := c.Module
if module == "" {
module = agent.NodeModule
}
srcs[module] = source{module: module, resource: raw}
}
f.mu.Lock()
f.srcs = srcs
f.mu.Unlock()
}
// takeGrants keeps the grants part's key as heard. A key deleted, or a value it cannot read, is said and changes nothing:
// the key as last heard stands and the list loaded stays (novox/hq ADR 0571 clause 8, ADR 0547 clause 11; issue 1126).
func (f *Follower) takeGrants(u agent.Update) {
f.mu.Lock()
defer f.mu.Unlock()
if u.Deleted {
f.sayOnceLocked("the grants part's user-list key is deleted, so the list loaded is kept")
return
}
g, err := delivery.DecodeGrantsUserList(u.Value)
if err != nil {
f.sayOnceLocked("the grants part's user-list key cannot be read, so the list loaded is kept: " + err.Error())
return
}
f.grantsRev = u.Revision
f.grants = &g
f.keep(u.Value, u.Revision)
}
// keep keeps the key last heard on the machine (Env.Keep), so a follower that cannot watch it follows what it said.
func (f *Follower) keep(value []byte, rev uint64) {
if f.env.Keep != nil {
f.env.Keep(value, rev)
}
}
func (f *Follower) unwatchedNow() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.unwatched
}
func (f *Follower) key() string {
if f.env.Key == nil {
return ""
}
return f.env.Key()
}
// settle writes the grants part's list: when its key moved, or when asked.
func (f *Follower) settle(ctx context.Context, again bool) { f.settleGrants(ctx, again) }
// reportable says a have's user list says something new: a look again that found the file as it was says nothing.
func reportable(prev, w Written) bool {
return prev.Source != w.Source || prev.Revision != w.Revision || prev.Digest != w.Digest || prev.Error != w.Error ||
prev.Follows != w.Follows || prev.Key != w.Key || prev.Unwatched != w.Unwatched
}
// settleGrants writes the grants part's list, the object its key names, into the file the bus module's composition
// declares, made 0600 (ADR 0547 clause 7): when the key moved, or when asked. With no key heard it writes nothing and
// says why in the have.
func (f *Follower) settleGrants(ctx context.Context, again bool) {
f.mu.Lock()
var g delivery.GrantsUserList
heard := f.grants != nil
if heard {
g = *f.grants
}
rev := f.grantsRev
// What runs now: the module written before while it still carries the list, else the first by name; what was
// written before when nothing runs that carries it now (a look between two applies).
file := f.cur
if s, ok := f.srcs[f.cur.module]; ok {
file = s
} else if len(f.srcs) > 0 {
names := make([]string, 0, len(f.srcs))
for m := range f.srcs {
names = append(names, m)
}
sort.Strings(names)
file = f.srcs[names[0]]
}
if file.module == "" {
f.mu.Unlock()
return // nothing this node runs declares the file: not the bus node
}
moved := file.module != f.cur.module || pathOf(file.resource) != pathOf(f.cur.resource)
f.cur = file
if !again && !moved && f.state.Source != "" && f.state.Revision == rev && f.written {
f.mu.Unlock()
return
}
f.mu.Unlock()
w := Written{Source: delivery.GrantsUserListSource, Revision: rev, Wanted: g.Digest, At: f.env.Now().UTC(),
Follows: delivery.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()}
var err error
var changed bool
resource, rerr := mode0600(file.resource)
switch {
case rerr != nil:
err = fmt.Errorf("the bus's user-list file this node's composition declares cannot be read: %w", rerr)
case !heard:
err = fmt.Errorf("the grants part's user-list key is not heard yet; the list on the machine is kept")
case g.Digest == "":
err = fmt.Errorf("the grants part's key names no list yet; the list on the machine is kept")
case f.env.Fetch == nil || f.env.Open == nil:
err = fmt.Errorf("this engine cannot read or open the grants part's list")
case g.SealedTo != "" && w.Key != "" && g.SealedTo != delivery.SealingKeyID(w.Key):
err = fmt.Errorf("the grants part's list is sealed to another key than this node's (%s); the list on the machine is kept until it seals to this one", g.SealedTo)
}
var body []byte
if err == nil {
var object []byte
if object, err = f.env.Fetch(ctx, g.Object); err == nil {
body, err = delivery.OpenUserList(object, g.Digest, f.env.Open)
}
if err != nil {
err = fmt.Errorf("the grants part's list %s could not be had: %w", short(g.Digest), err)
}
}
if err == nil {
w.Users = Users(body)
if err = f.guard(resource, body); err != nil {
if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil {
w.Digest = Digest(disk)
}
}
}
if err == nil {
w.Digest, changed, err = f.env.Write(ctx, file.module, resource, body)
} else if w.Digest == "" && f.env.OnDisk != nil {
if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil {
w.Digest = Digest(disk)
}
}
if err != nil {
w.Error = err.Error()
}
f.mu.Lock()
prev := f.state
// The grants part's list is not handed to the vault: a handing written before stands (clause 9).
w.Handed = prev.Handed
f.mod, f.state = file.module, w
f.written = err == nil
f.mu.Unlock()
at := fmt.Sprintf("%s@%d", delivery.GrantsUserListSource, rev)
if err != nil {
f.sayOnce(fmt.Sprintf("the bus's user list of %s could not be written: %v", at, err))
} else if changed {
f.env.Say(fmt.Sprintf("the bus's user list from the grants part (%s, revision %d) is written alone (%d users, %s); the bus reloads it itself",
at, g.Revision, w.Users, short(w.Digest)))
}
if f.env.Report != nil && (changed || reportable(prev, w)) {
f.env.Report(ctx, file.module, w)
}
}
// Hold is c as the apply may write it (C3 of the switch's review): the list a composition carries is never written, so
// its user-list resource holds the list on the machine instead (made 0600), or is left out when the machine holds none.
// A list on the machine that cannot be read is an error: left out, the apply would remove the file as an orphan and the
// bus would refuse every connection at its next reload, so the bus module's apply waits instead. A composition without
// a user list is c unchanged.
func (f *Follower) Hold(c engcomp.Composed) (engcomp.Composed, error) {
out := c
out.Resources = nil
for _, raw := range c.Resources {
if r, _, _, ok := userList([]json.RawMessage{raw}); ok {
var held json.RawMessage
body, err := f.env.OnDisk(r)
if err != nil {
return c, fmt.Errorf("the user list on the machine could not be read, so the apply waits and the file stays: %w", err)
}
if body != nil {
var m map[string]any
if json.Unmarshal(r, &m) == nil {
delete(m, "digest")
delete(m, "size")
m["content"], m["mode"] = string(body), "0600"
held, _ = json.Marshal(m)
}
}
if held != nil {
out.Resources = append(out.Resources, held)
}
continue
}
out.Resources = append(out.Resources, raw)
}
return out, nil
}
// mode0600 is a user-list resource made mode 0600, whatever its composition said (ADR 0547 clause 7).
func mode0600(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("no resource")
}
var r map[string]any
if err := json.Unmarshal(raw, &r); err != nil {
return nil, err
}
r["mode"] = "0600"
return json.Marshal(r)
}
// guard refuses a list that would lock the mesh out of its own bus: one that drops more than half the users the file on
// the machine holds. A list that shrinks that far is a composer that read half the mesh, not half the mesh gone; it
// waits for a person, and the bus keeps the list it has. (A list without the controller's user is no longer refused:
// the controller ended, issue 1126.)
func (f *Follower) guard(resource json.RawMessage, body []byte) error {
names := Names(body)
if f.env.OnDisk == nil {
return nil
}
disk, err := f.env.OnDisk(resource)
if err != nil || disk == nil {
return nil
}
if had := Users(disk); had > 0 && len(names)*2 < had {
return fmt.Errorf("refused: the list holds %d users and the one on the machine %d; a list that drops more "+
"than half is not written, and the list on the machine is kept", len(names), had)
}
return nil
}
func (f *Follower) sayOnceLocked(line string) {
if !f.said[line] {
f.said[line] = true
f.env.Say(line)
}
}
func (f *Follower) sayOnce(line string) {
f.mu.Lock()
said := f.said[line]
f.said[line] = true
f.mu.Unlock()
if !said {
f.env.Say(line)
}
}
// userList is the bus's user list among a composition's resources: the resource, its content, and its digest when it
// is given by digest rather than content.
func userList(resources []json.RawMessage) (json.RawMessage, string, string, bool) {
for _, raw := range resources {
var head struct {
ID string `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
Digest string `json:"digest"`
}
if json.Unmarshal(raw, &head) != nil || head.Type != "file" {
continue
}
if head.ID == ResourceID || strings.HasSuffix(head.ID, "."+ResourceID) {
return raw, head.Content, head.Digest, true
}
}
return nil, "", "", false
}
// pathOf is where a file resource says its file is; "" when it says none.
func pathOf(raw json.RawMessage) string {
var head struct {
Path string `json:"path"`
}
_ = json.Unmarshal(raw, &head)
return head.Path
}
// Digest is content's digest as the have says it, `sha256:<hex>`.
func Digest(content []byte) string {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
}
// userEntry is one user of the list as the composer writes it (and the bus module's own tools read it).
var userEntry = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`)
// Users is how many users a list holds.
func Users(content []byte) int { return len(userEntry.FindAllIndex(content, -1)) }
// Names is the users a list holds, as it names them.
func Names(content []byte) []string {
var out []string
for _, m := range userEntry.FindAllSubmatch(content, -1) {
out = append(out, string(m[1]))
}
return out
}
func short(d string) string {
d = strings.TrimPrefix(d, "sha256:")
if len(d) > 12 {
d = d[:12]
}
return d
}