From f81e6147aee1d48a5371f399388c9e87b8b2b0b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 09:42:29 +0100 Subject: [PATCH] Revert L3 (mesh-host, merge 0a4c35f): its node-engine removed every held module on novox, the bus server among them, at 08:54 CET on 2026-10-29 (issue 774) L3 lands again with two fixes: a held module is never removed, and the grants cover L3's new subjects. --- cmd/mesh-host/authority_test.go | 113 +-- cmd/mesh-host/engineseat.go | 277 ------- cmd/mesh-host/found.go | 47 +- cmd/mesh-host/found_test.go | 15 - cmd/mesh-host/libraries_test.go | 40 - cmd/mesh-host/main.go | 27 +- cmd/mesh-host/nodeagent.go | 56 +- cmd/mesh-host/nodeagent_integration_test.go | 3 +- cmd/mesh-host/trust.go | 317 -------- cmd/mesh-host/units_test.go | 59 -- cmd/mesh-host/whatruns_test.go | 37 - internal/accounts/setuid_cache.go | 11 - internal/link/access_act_test.go | 110 --- internal/link/engineseat.go | 382 ---------- internal/link/handacts.go | 769 -------------------- internal/link/handover.go | 20 +- internal/link/handover_test.go | 119 +-- internal/link/have_test.go | 5 +- internal/link/hearing_nats.go | 65 +- internal/link/record.go | 4 +- internal/link/run.go | 21 - internal/link/session.go | 54 -- internal/link/setuid_search_test.go | 78 -- internal/selfcompose/selfcompose.go | 77 -- internal/selfcompose/selfcompose_test.go | 27 - module.json | 6 - packaging/operator_keys_test.go | 9 - 27 files changed, 59 insertions(+), 2689 deletions(-) delete mode 100644 cmd/mesh-host/engineseat.go delete mode 100644 cmd/mesh-host/trust.go delete mode 100644 internal/link/engineseat.go delete mode 100644 internal/link/handacts.go diff --git a/cmd/mesh-host/authority_test.go b/cmd/mesh-host/authority_test.go index 84d8ea9..48ce708 100644 --- a/cmd/mesh-host/authority_test.go +++ b/cmd/mesh-host/authority_test.go @@ -1,27 +1,16 @@ package main import ( - "context" - "crypto/ed25519" - "crypto/x509" - "encoding/base64" "encoding/json" - "encoding/pem" "os" "path/filepath" "testing" - "time" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" - - "github.com/novox/mesh-host/internal/identity" ) // Issue 1126 (ADR 0555 §3): the mesh's own certificate a composition carries is kept for the machine's record, and the -// record's keys read it; a composition carrying none changes nothing. And (L3 s6, s7; ADR 0550 §2) the record says what -// the node trusts a mesh signature from, and the node keeps the certificate the vault signed for it. +// record's keys read it; a composition carrying none changes nothing. func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { state := filepath.Join(t.TempDir(), "state", "state.json") res := func(id, content string) json.RawMessage { @@ -37,105 +26,7 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil { t.Fatal(err) } - if got := recordKeys(state, nil)(); got.Authority != "CA-1" { + if got := recordKeys(state)(); got.Authority != "CA-1" { t.Errorf("the record's keys read the authority %q", got.Authority) } - - // L3 s6: before the vault's signers are read the node trusts the controller's key alone; once read, a message the - // vault signed is accepted and the record names both; once the lease is handed away the controller's key is refused - // and the record names the vault's alone. Each change tells the record's writer. - controllerPub, controller, _ := ed25519.GenerateKey(nil) - vaultPub, vault, _ := ed25519.GenerateKey(nil) - trust := newMeshTrust(controllerPub) - told := 0 - trust.onChange(func() { told++ }) - keys := recordKeys(state, trust.Trusts) - ctrlFP, vaultFP := delivery.SignerFingerprint(controllerPub), delivery.SignerFingerprint(vaultPub) - if got := keys().Trusts; !delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || delivery.TrustsKey(got, delivery.SignerVault, vaultFP) { - t.Errorf("before the signers are read the record trusts %v", got) - } - enrol, _ := delivery.SignContext(delivery.KindEnrolment) - msg := []byte("a reply") - if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err == nil { - t.Error("a vault-signed message is accepted before the vault's key is read") - } - vaultCA := testAuthority(t, vaultPub, vault) - keyValue, _ := json.Marshal(delivery.NewSignerKey(vaultPub, time.Now())) - fp, _ := delivery.AuthorityFingerprint(vaultCA) - authValue, _ := json.Marshal(delivery.SignerAuthority{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()}) - trust.heardSigner(delivery.SignersKeyKey, keyValue, false, func(string) {}) - trust.heardSigner(delivery.SignersAuthorityKey, authValue, false, func(string) {}) - if s, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err != nil || s.Name != delivery.SignerVault { - t.Errorf("a vault-signed message on a node that read signers: %+v, %v", s, err) - } - if got := keys().Trusts; !delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) || - trust.VaultAuthority() != vaultCA || told != 2 { - t.Errorf("after the signers are read the record trusts %v (told %d)", got, told) - } - handed, _ := json.Marshal(buscompose.LeaseHolder{HandedTo: "mesh-vault", HandedBy: "person.operator"}) - trust.heardLease(handed, true, nil) - if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil || trust.Controller() != nil { - t.Error("after the lease is handed away the controller's key is still trusted") - } - if got := keys().Trusts; delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) || told != 3 { - t.Errorf("after the lease is handed away the record trusts %v (told %d)", got, told) - } - trust.heardLease(handed, true, nil) - if told != 3 { - t.Error("a lease heard again unchanged wrote the record again") - } - // A signers' key naming another fingerprint is not trusted. - bad, _ := json.Marshal(delivery.SignerKey{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP}) - trust.heardSigner(delivery.SignersKeyKey, bad, false, func(string) {}) - if !delivery.TrustsKey(keys().Trusts, delivery.SignerVault, vaultFP) { - t.Error("a signers' key that does not check replaced the vault's") - } - - // L3 s7: the certificate the vault signs for this node's internal name and serving key is kept where the record reads - // it, and the record is written again; one signed by another authority is refused and nothing is kept. - serving, err := identity.GenerateServingKey() - if err != nil { - t.Fatal(err) - } - if err := identity.WriteServingKey(identity.ServingKeyPath(state), serving); err != nil { - t.Fatal(err) - } - servingPub, _ := base64.StdEncoding.DecodeString(serving.Public) - signedBy := func(ca string, key ed25519.PrivateKey) string { - parent, _ := delivery.ParseCertificate(ca) - der, err := x509.CreateCertificate(nil, delivery.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)), - parent, ed25519.PublicKey(servingPub), key) - if err != nil { - t.Fatal(err) - } - return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) - } - var asked []any - answer := signedBy(vaultCA, vault) - ask := func(_ context.Context, verb string, args any) (json.RawMessage, error) { - asked = append(asked, verb, args) - return json.Marshal(delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: "n1", Certificate: answer}) - } - written := 0 - if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err != nil { - t.Fatal(err) - } - if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != delivery.SignVerb { - t.Errorf("the vault's certificate: kept %v, written %d, asked %v", got.Certificate == answer, written, asked) - } - answer = signedBy(testAuthority(t, controllerPub, controller), controller) - if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err == nil || written != 1 { - t.Errorf("a certificate of another authority was kept: %v", err) - } -} - -// testAuthority is a self-signed authority certificate for key, as the vault makes its own. -func testAuthority(t *testing.T, pub ed25519.PublicKey, key ed25519.PrivateKey) string { - t.Helper() - tpl := delivery.AuthorityTemplate(pub, time.Now().Add(-time.Hour)) - der, err := x509.CreateCertificate(nil, tpl, tpl, pub, key) - if err != nil { - t.Fatal(err) - } - return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) } diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go deleted file mode 100644 index 4c11ece..0000000 --- a/cmd/mesh-host/engineseat.go +++ /dev/null @@ -1,277 +0,0 @@ -package main - -import ( - "bufio" - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync/atomic" - "syscall" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - meshasker "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" - "github.com/novox/mesh-host/internal/accounts" - "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/link" - "github.com/novox/mesh-host/internal/store" -) - -// The node seat node-engine's judgements on this machine (novox/hq ADR 0563): what a hand-over and a library's take -// would set and what the disk holds now, and whether this machine is root-free. - -// diskOf is what is on disk at a directory, as an act compares it when the operator's yes arrives: its owner and -// group, its mode, and which file it is. -func diskOf(info os.FileInfo) string { - owner := "unknown" - if uid, gid, ok := apply.OwnerOf(info); ok { - owner = fmt.Sprintf("%d:%d", uid, gid) - } - which := "" - if st, ok := info.Sys().(*syscall.Stat_t); ok { - which = fmt.Sprintf(" %v:%v", st.Dev, st.Ino) - } - return fmt.Sprintf("%s %o%s", owner, info.Mode().Perm(), which) -} - -// handOverJudgeFrom judges a hand-over through the node seat as the controller's ask is judged (judgeFound): the -// declared owner and mode it will set, and the disk as it is. -func handOverJudgeFrom(statePath string) func(string) (link.Judged, error) { - return func(path string) (link.Judged, error) { - found, info, err := judgeFound(statePath, path) - if err != nil { - return link.Judged{}, err - } - return link.Judged{Subject: path, Path: path, Sets: orUnsaid(found.Declared), Disk: diskOf(info), - Words: "A directory of " + moduleOf(found.ID) + " is used as found: the mesh left its owner and mode."}, nil - } -} - -func moduleOf(id string) string { - if at := strings.LastIndex(id, "."); at > 0 { - return id[:at] - } - return id -} - -// accessJudgeFrom judges the take act on a library through the node seat (ADR 0328 §7, ADR 0563 §4): declared on this -// machine, its home, and its root holding data the mesh has not taken; what the take sets, and the disk as it is. -func accessJudgeFrom(statePath string) func(string, string) (link.Judged, error) { - return func(act, access string) (link.Judged, error) { - d, err := declaredNow(statePath, nil) - if errors.Is(err, store.ErrNothingDeclared) { - return link.Judged{}, errors.New("this node keeps no declaration, so it is declared no library") - } - if err != nil { - return link.Judged{}, fmt.Errorf("what runs on this node does not read: %w", err) - } - for _, l := range d.Libraries { - if apply.LibraryAccess(l.Library) != access { - continue - } - if !l.Home { - return link.Judged{}, fmt.Errorf("this machine is not the home of the library %s, and a library is "+ - "taken on its home", l.Library) - } - ctx, cancel := context.WithTimeout(context.Background(), lookBound) - defer cancel() - if v := apply.JudgeLibrary(ctx, l); v.Kind != apply.VerdictWaitKeep { - return link.Judged{}, fmt.Errorf("the library %s does not wait for the operator's word to be kept "+ - "(it is judged %s), so there is nothing to take", l.Library, v.Kind) - } - info, err := os.Lstat(l.Root) - if err != nil { - return link.Judged{}, fmt.Errorf("the library %s's root cannot be read: %v", l.Library, err) - } - return link.Judged{Subject: access, Path: filepath.Clean(l.Root), - Sets: "the group " + l.Group + ", group write and setgid on everything under its root", - Disk: diskOf(info), Words: "The library " + l.Library + " holds data the mesh has not taken."}, nil - } - return link.Judged{}, fmt.Errorf("the access %s names no library declared on this machine", access) - } -} - -// askTakes asks the take of every library the look found waiting for the operator's word, once per library (ADR 0563 -// §4): asked by the engine itself, never a second time while one is open or after one was asked; access-act asks again -// by hand. -func askTakes(acts *link.HandActs, waiting []string, say func(string)) { - if acts == nil { - return - } - for _, access := range waiting { - if acts.AskedFor(link.ActAccess, access) { - continue - } - if _, err := acts.AskAccess(link.EngineSeat+"-"+acts.Node, link.ActTake, access, - "its root holds data outside its group", true); err != nil && say != nil { - say("the take of " + access + " could not be asked: " + err.Error()) - } - } -} - -// lastAccounts is the newest look at every account (the health loop's), which root-free judges from. -var lastAccounts atomic.Pointer[accounts.Statement] - -// rootFreshFor is how old the verdict root-free judges from may be (ADR 0563 §6, the controller's verdictFreshFor). -const rootFreshFor = 15 * time.Minute - -// judgeRootFree is this machine's root-free (ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most -// rootFreshFor old, that every account declared never to become root without a person cannot, at least one such -// account declared, and the login shell's execute not served here. Anything else is no pass, and says why. -func judgeRootFree(node string, st *accounts.Statement, now time.Time, served bool, servedWhy string) link.RootVerdict { - v := link.RootVerdict{Machine: node, Judged: now.UTC()} - var not []string - confined := "" - switch { - case st == nil: - not = append(not, "this machine's engine has judged no account yet") - case now.Sub(st.At) > rootFreshFor: - not = append(not, fmt.Sprintf("the engine's newest judgement of its accounts was made at %s, more than %d "+ - "minutes ago, and a verdict that old is not a verdict on now", st.At.UTC().Format(time.RFC3339), - int(rootFreshFor.Minutes()))) - default: - var named []string - for _, a := range st.Accounts { - if !a.Root { - continue - } - named = append(named, a.Name) - if a.State != accounts.Healthy { - not = append(not, "the account "+a.Name+" "+orNoneKnownWord(a.Reason)) - } - } - if len(named) == 0 { - not = append(not, "no account here is declared never to become root, so agents run as the operator's "+ - "account, which may become root") - } else if len(not) == 0 { - confined = "the account " + strings.Join(named, ", ") + " cannot become root without a person (judged " + - st.At.UTC().Format(time.RFC3339) + ")" - } - } - if served { - not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, which "+ - "can become root ("+servedWhy+")") - } - if len(not) > 0 { - v.Why = strings.Join(not, "; ") - return v - } - v.Free, v.Why = true, confined+"; the login shell's execute is not served here" - return v -} - -func orNoneKnownWord(s string) string { - if s == "" { - return "is not judged" - } - return s -} - -// settingFileSuffix is the resource the login shell's holder renders its setting execute into (ADR 0268: zsh's -// tools-conf, `execute=serve` or `execute=withhold`), read beside the bus's discovery. -const settingFileSuffix = ".tools-conf" - -// executeServedHere is whether the login shell's execute is served on this machine (ADR 0268), failing closed: served -// while the bus's discovery hears it answered here or could not be asked, or while the holder's setting file applied -// here says anything but withhold or cannot be read. why says which. -func executeServedHere(statePath string, heard func() (bool, error)) (bool, string) { - var why []string - if heard == nil { - why = append(why, "the bus could not be asked whether execute is answered here") - } else if h, err := heard(); err != nil { - why = append(why, "the bus could not be asked whether execute is answered here: "+err.Error()) - } else if h { - why = append(why, "the bus hears execute answered here") - } - known, err := store.Load(statePath) - if err != nil { - why = append(why, "what this machine applied could not be read: "+err.Error()) - } - for _, r := range known.Resources { - if r.Type != "file" || !strings.HasSuffix(r.ID, settingFileSuffix) { - continue - } - if value, err := settingIn(r.Target); err != nil { - why = append(why, moduleOf(r.ID)+"'s setting execute cannot be read: "+err.Error()) - } else if value != "withhold" { - why = append(why, moduleOf(r.ID)+"'s setting execute here is "+value) - } - } - return len(why) > 0, strings.Join(why, "; ") -} - -// settingIn is the value of execute in a setting file, "" when it says none. -func settingIn(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() - value := "" - lines := bufio.NewScanner(f) - for lines.Scan() { - t := strings.TrimSpace(lines.Text()) - if key, v, ok := strings.Cut(t, "="); ok && !strings.HasPrefix(t, "#") && strings.TrimSpace(key) == "execute" { - value = strings.TrimSpace(v) - } - } - if value == "" { - value = "not said" - } - return value, lines.Err() -} - -// keepWaiting is every library a look found waiting for the operator's word to keep it (ADR 0328 §8). -func keepWaiting(vs []link.ResourceHealth) []string { - var out []string - for _, v := range vs { - for _, w := range v.AccessWaits { - if w.Reason == link.WaitKeep { - out = append(out, w.Access) - } - } - } - return out -} - -// engineActs is the node seat this engine holds (ADR 0563): judged and performed by the same code as the controller's -// signed asks, its asks to the operator over the session's link, its acts kept beside the state. -func engineActs(opts options, node string, queue *link.Session, search *accounts.SetuidCache) *link.HandActs { - state := link.BusState{Session: queue, Node: node} - h := &link.HandActs{Node: node, State: state, - JudgeHandOver: handOverJudgeFrom(opts.state), HandOver: handOverFrom(opts.state), - JudgeAccess: accessJudgeFrom(opts.state), AccessAct: accessActFrom(opts.state, opts.timeout), - JudgeDirectories: judgeDirectoriesFrom(opts.state), SetuidSearch: queue.SetuidSearch, Searching: search.Running, - RootFree: func() link.RootVerdict { - served, why := executeServedHere(opts.state, func() (bool, error) { - return queue.HeardServed("node-login-shell", "execute") - }) - return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why) - }, - Say: queue.SayEngine} - asker := asks.EngineAsker(node) - h.Asker = &meshasker.Client{Module: asker, State: state, Publish: queue.PublishAsk, - Record: func(id string) (*stdio.StateEntry, error) { return queue.RouterRecord(asker + "." + id) }} - return h -} - -// reconcileEvery is how often the engine catches up its open asks from the router's records (a warrant heard while no -// link was open, an ask past its expiry): besides each link's opening. -const reconcileEvery = 5 * time.Minute - -func reconcileActs(ctx context.Context, h *link.HandActs) { - t := time.NewTicker(reconcileEvery) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - h.Reconcile() - } - } -} diff --git a/cmd/mesh-host/found.go b/cmd/mesh-host/found.go index eeebed0..48774d3 100644 --- a/cmd/mesh-host/found.go +++ b/cmd/mesh-host/found.go @@ -68,23 +68,14 @@ func foundDirectories(known store.State) []store.Applied { // known (novox/hq ADR 0272): typed on the control-node, from the operator's own account. The directory's // path is the resource's target beside the reason, never in it: the controller says a reason on the // operator's channel, which withholds a path (ADR 0234 §6), and keeps the target as evidence. -// -// From the lease write, heard as the engine hears it (meshTrust), the line has no answerer: the way out is the node -// seat's hand-over with the directory's path, which asks the operator on their channel (novox/hq ADR 0563 §5). func handOverLine() string { node, _ := foundNode.Load().(string) if node == "" { node = "" } - if t := foundTrust.Load(); t != nil && t.HandedAway() { - return fmt.Sprintf("`%s/node-engine.hand-over` with the directory's path, then your yes on your channel", node) - } return fmt.Sprintf("`nox node hand-over %s ` on the control-node", node) } -// foundTrust is what the engine trusts, whose lease decides the way out a directory used as found names. -var foundTrust atomic.Pointer[meshTrust] - // withFoundDirectories adds to a statement every directory used as found, as its module's resource of kind // directory, unhealthy: the reason names its owner and mode as they are now, what was declared, and the line // that hands it over. The path is the target, which the controller keeps as evidence. @@ -138,30 +129,16 @@ func foundNow() []store.Applied { // never follows a path it did not state: a hand-over is root giving a directory to an account at the next // apply, and a path that resolves elsewhere would give that elsewhere. func handOverFound(statePath, path, by string) (string, error) { - found, _, err := judgeFound(statePath, path) - if err != nil { - return "", err - } - if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil { - return "", err - } - return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+ - "then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil -} - -// judgeFound is the judgement of a hand-over's path, recording nothing: the directory this machine uses as found at -// that path, as the engine states it, and what is on disk there. -func judgeFound(statePath, path string) (store.Applied, os.FileInfo, error) { if !filepath.IsAbs(path) { - return store.Applied{}, nil, fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path) + return "", fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path) } if filepath.Clean(path) != path { - return store.Applied{}, nil, fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+ + return "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+ "separator); nothing was handed over", path) } known, err := store.Load(statePath) if err != nil { - return store.Applied{}, nil, err + return "", err } var found *store.Applied for _, r := range foundDirectories(known) { @@ -172,27 +149,31 @@ func judgeFound(statePath, path string) (store.Applied, os.FileInfo, error) { } } if found == nil { - return store.Applied{}, nil, errors.New(path + " is not a directory this machine uses as found (the module's condition names " + + return "", errors.New(path + " is not a directory this machine uses as found (the module's condition names " + "the ones that are); nothing was handed over") } info, err := os.Lstat(path) switch { case err != nil: - return store.Applied{}, nil, fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err) + return "", fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err) case info.Mode()&os.ModeSymlink != 0: - return store.Applied{}, nil, fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path) + return "", fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path) case !info.IsDir(): - return store.Applied{}, nil, fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path) + return "", fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path) } resolved, err := filepath.EvalSymlinks(path) if err != nil { - return store.Applied{}, nil, fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err) + return "", fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err) } if resolved != path { - return store.Applied{}, nil, fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+ + return "", fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+ "(novox/hq issue 339); nothing was handed over", path, resolved) } - return *found, info, nil + if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil { + return "", err + } + return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+ + "then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil } // handOverFrom is the engine's hand-over for the controller's ask: the state beside statePath, the path as asked. diff --git a/cmd/mesh-host/found_test.go b/cmd/mesh-host/found_test.go index f756bfc..4a13d2c 100644 --- a/cmd/mesh-host/found_test.go +++ b/cmd/mesh-host/found_test.go @@ -40,21 +40,6 @@ func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) { strings.Contains(r.Reason, "mesh-host") || strings.Contains(r.Reason, dir) { t.Fatalf("the way out is not the operator's line, or carries the path: %q", r.Reason) } - // ADR 0563 §5: from the lease write the remedy is the node seat's hand-over, answered on the operator's channel, and - // no longer the controller's terminal line; the lease is the one the engine hears. - handed := newMeshTrust(nil) - handed.handedAway = true - foundTrust.Store(handed) - if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason, - "`laptop/node-engine.hand-over`") || strings.Contains(r.Reason, "nox node hand-over") || strings.Contains(r.Reason, dir) { - t.Fatalf("after the lease write the way out is %q", r.Reason) - } - foundTrust.Store(newMeshTrust(nil)) - if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason, - "`nox node hand-over laptop `") { - t.Fatalf("while the controller holds its lease the way out is %q", r.Reason) - } - foundTrust.Store((*meshTrust)(nil)) foundNode.Store("") if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason, "`nox node hand-over `") { diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go index 10910c4..2f3aafb 100644 --- a/cmd/mesh-host/libraries_test.go +++ b/cmd/mesh-host/libraries_test.go @@ -8,8 +8,6 @@ import ( "testing" "time" - meshasker "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" @@ -55,42 +53,4 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) { if strings.Contains(media.Reason, dir) || len(books.AccessWaits) != 1 || books.AccessWaits[0].Reason != link.WaitDisk { t.Fatalf("a missing root does not wait for its disk, or a reason names a path: %+v %+v", books, media) } - // 774 (ADR 0563 §4): the library's home engine asks its take of the operator itself, once: no second ask while one is - // open, nor after it ended; and a library waiting for its disk is not asked. - t.Run("the home engine asks the take once", func(t *testing.T) { - var asked []asks.Ask - state := &link.MemState{} - h := &link.HandActs{Node: "n1", State: state, - JudgeAccess: func(act, access string) (link.Judged, error) { - return link.Judged{Subject: access, Path: root, Sets: "the group mesh-media", Disk: "0:0 755"}, nil - }, - AccessAct: func(link.AccessAct) (string, error) { return "taken", nil }} - h.Asker = &meshasker.Client{Module: asks.EngineAsker("n1"), State: state, - Publish: func(subject string, body any, id string) error { - if a, ok := body.(asks.Ask); ok { - asked = append(asked, a) - } - return nil - }} - waiting := keepWaiting(vs) - askTakes(h, waiting, nil) - askTakes(h, waiting, nil) - if len(waiting) != 1 || waiting[0] != "library:media" || len(asked) != 1 { - t.Fatalf("waiting %v asked %d times", waiting, len(asked)) - } - h.Heard(asks.Warrant{Ask: asked[0].ID, Asker: asks.EngineAsker("n1"), Outcome: asks.OutcomeExpired, At: time.Now()}) - askTakes(h, waiting, nil) - if len(asked) != 1 { - t.Fatalf("the take was asked again after its ask ended: %d", len(asked)) - } - // Kept in a key of its own (rev-l3 round 1): the acts gone from the record never make it ask again. - keys, _ := state.Keys("act.") - for _, k := range keys { - _ = state.Delete(k) - } - askTakes(h, waiting, nil) - if len(asked) != 1 { - t.Fatalf("the take was asked again with the acts gone: %d", len(asked)) - } - }) } diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 1b6289a..d8b3610 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1105,17 +1105,10 @@ func runLink(ctx context.Context, opts options) error { // the bus, answer what the controller asks of this machine, and say its facts: when a link opens, and when the // controller asks. proof := &proving{statePath: opts.state, version: runningVersion(), say: say} - // What this engine trusts a mesh signature from: the controller's key until its lease is handed away, and the vault's - // once read from its signers (novox/hq ADR 0550 §2). - trust := newMeshTrust(mine.Membership.Signer) - // And the way out a directory used as found names follows the lease it hears (novox/hq ADR 0563 §5). - foundTrust.Store(trust) queue := &link.Session{ Membership: membership, // And the public halves and certificate its record says (novox/hq ADR 0555 §3), read at each facts report. - Keys: recordKeys(opts.state, trust.Trusts), - // And the controller's key, read per signed ask: none once its lease is handed away (novox/hq ADR 0563 §7). - ControllerKey: trust.Controller, + Keys: recordKeys(opts.state), // And the first have this build writes is what proves it to the launcher (ADR 0539 §9, ADR 0227 §8). HaveWritten: proof.haveWritten, // And the operator's hand-over of a directory used as found, carried by the controller from the @@ -1136,10 +1129,6 @@ func runLink(ctx context.Context, opts options) error { Say: say, Timeout: opts.timeout, } - // And the node seat node-engine this engine holds (novox/hq ADR 0563): its verbs on every link, its own asks to the - // operator under node-engine-, and its acts kept beside its state. - queue.Acts = engineActs(opts, mine.Node, queue, setuidSearch) - go reconcileActs(ctx, queue.Acts) current := &agentNow{} setRunsHere(runsFromAgent(current)) factsOfNow := func(ctx context.Context, ag *agent.Agent) { @@ -1184,10 +1173,7 @@ func runLink(ctx context.Context, opts options) error { } // The node follows its wants: one module at a time from the composition its want names (novox/hq ADR 0539), and the // busy containers are asked for again soon (ADR 0360 §11). - // And what it trusts, followed on the bus, said in its record whenever it changes (ADR 0550 §2). - trust.onChange(func() { factsOfNow(aside, current.get()) }) - go followTrust(aside, queue, trust, say) - go nodeAgent(aside, opts, mine.Membership.Signer, trust, queue, sched, say, afterApply, current, factsOfNow) + go nodeAgent(aside, opts, mine.Membership.Signer, queue, sched, say, afterApply, current, factsOfNow) go busyPasses(aside, func() { if ag := current.get(); ag != nil { ag.Retry(busyModules()...) @@ -1384,7 +1370,6 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Session, if a := accountJudge; a != nil { as, accountsChanged := a.Look(ctx) accountSt = &as - lastAccounts.Store(&as) owed = owed || accountsChanged if accountsChanged { for _, v := range as.Accounts { @@ -1399,9 +1384,6 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Session, accessSt, accessesChanged := accesses.look(time.Now()) keeping.poke(ctx) owed = owed || accessesChanged - // And a library whose root holds data outside its group is asked of the operator by this engine, once (novox/hq - // ADR 0563 §4). - askTakes(queue.Acts, keepWaiting(accessSt), say) if accessesChanged { for _, v := range accessSt { if v.State != link.StateHealthy { @@ -2185,12 +2167,9 @@ func withMissing(st liveness.Statement) liveness.Statement { // recordKeys reads the public halves of the node's keys and the certificate the mesh issued for its internal name from // its state directory (novox/hq ADR 0555 §3): what the node's record says of them. A key that cannot be read is left // empty, never invented; the certificate is there only after the first declaration that carried it. -func recordKeys(state string, trusts func() []string) func() link.RecordKeys { +func recordKeys(state string) func() link.RecordKeys { return func() link.RecordKeys { var k link.RecordKeys - if trusts != nil { - k.Trusts = trusts() - } if id, err := identity.Load(identity.Path(state)); err == nil { k.TunnelKey = id.Overlay.Public } diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 667d18e..430be23 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -5,14 +5,12 @@ import ( "crypto/ed25519" "encoding/base64" "encoding/json" - "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" - "sync/atomic" "time" "git.novox.be/novox/mesh-sdk/go/compose/frombus" @@ -345,14 +343,13 @@ func resourcesOf(cs []delivery.Composed) []declaration.Resource { // controller's key the node was enrolled with (novox/hq ADR 0539 §3, §11); a node adopted later has it at its adoption. const operatorKeysFile = "operator-keys" -// trustedKeys are the keys a composition may be signed by: the controller's, which the node was enrolled with, while the -// controller holds its lease (ADR 0550 §2: trusted no more once it is handed away), and the operator's, read from -// operatorKeysFile each time it is asked, so a key that arrives while the agent runs is trusted at the next composition. -// A file that cannot be read trusts the controller's alone, and says so. -func trustedKeys(opts options, controllerNow func() ed25519.PublicKey) func() []ed25519.PublicKey { +// trustedKeys are the keys a composition may be signed by: the controller's, which the node was enrolled with, and the +// operator's, read from operatorKeysFile each time it is asked, so a key that arrives while the agent runs is trusted at +// the next composition. A file that cannot be read trusts the controller's alone, and says so. +func trustedKeys(opts options, controller ed25519.PublicKey) func() []ed25519.PublicKey { return func() []ed25519.PublicKey { keys := []ed25519.PublicKey{} - if controller := controllerNow(); len(controller) == ed25519.PublicKeySize { + if len(controller) == ed25519.PublicKeySize { keys = append(keys, controller) } raw, err := os.ReadFile(filepath.Join(filepath.Dir(opts.state), operatorKeysFile)) @@ -376,16 +373,13 @@ func trustedKeys(opts options, controllerNow func() ed25519.PublicKey) func() [] } // keptOnce reads the kept declaration once and splits it by module: its resources (the node's own are those that say -// none, ADR 0539 §9) and its filled paths. Once the read is retired (keptRetired) nothing is read. +// none, ADR 0539 §9) and its filled paths. func keptOnce(opts options, signer ed25519.PublicKey) func() (map[string][]json.RawMessage, map[string][]string) { var once sync.Once var by map[string][]json.RawMessage var filled map[string][]string return func() (map[string][]json.RawMessage, map[string][]string) { once.Do(func() { - if keptRetired.Load() { - return - } raw, err := store.LoadDeclared(store.DeclaredPath(opts.state), signer) if err != nil { return @@ -435,7 +429,7 @@ func splitKept(raw []byte) (map[string][]json.RawMessage, map[string][]string) { // nodeAgent runs the node agent under the session's links for as long as ctx lasts: it waits for a link, follows the // node's wants and compositions on it, and begins again with the next link. A watch it cannot make (a bucket this node's // account may not read) is said, and tried again with a growing wait, as the link is. -func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trust *meshTrust, session *link.Session, sched *apply.Scheduler, +func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, session *link.Session, sched *apply.Scheduler, say link.Announce, afterApply func(), current *agentNow, sayFacts func(ctx context.Context, ag *agent.Agent)) { wait := 2 * time.Second for ctx.Err() == nil { @@ -452,11 +446,6 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus return } var ag *agent.Agent - // The kept release-5 declaration is no longer read once every module the seat's placement puts here has a have of its - // own (ADR 0550 §2: its signature is the controller's, trusted no more after the lease write). - if retired, why := placedAllHave(linked, session, node); retired && !keptRetired.Swap(true) { - say("the kept declaration is no longer read: " + why) - } kept := keptOnce(opts, signer) // The mesh's own certificate the declaration kept from before carries, kept for the record before this machine // composes its own (ADR 0555 §3): without it a module that asks for a certificate would be stood in for. @@ -469,7 +458,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus } // The bus's user list, written alone from the newest composition of the bus module (ADR 0547 §8): on every // node, and a node whose compositions carry no list writes nothing. - users := userListFollower(opts, session, composedBucket, trustedKeys(opts, trust.Controller), say, func() { + users := userListFollower(opts, session, composedBucket, trustedKeys(opts, signer), say, func() { // This engine wrote the list, so the bus reloads its accounts and answers no JetStream request for a few // seconds: what failed in them is tried again once that is over, not at its next wait. time.AfterFunc(reloadSettles, func() { @@ -506,7 +495,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus } return out, nil }, - Trusted: trustedKeys(opts, trust.Controller), + Trusted: trustedKeys(opts, signer), Backup: backupNode(session), // What the filling refused before this apply may be named now: the provisioner's client is told to fill // again (ace, 2026-10-25 21:17). @@ -583,17 +572,11 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126) } }, - // And the vault's authority beside the controller's, and this node's certificate asked of the vault when a - // module here asks for one (ADR 0550 §2). - vaultAuthority: trust.VaultAuthority, - certify: func(ctx context.Context) error { - return certifyThisNode(ctx, opts.state, node, trust, session.AskVault, func() { sayFacts(ctx, ag) }) - }, } // And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into // its own key, `.node`, under candidates. shadowHooks := hooks - shadowHooks.refused, shadowHooks.certify = nil, nil + shadowHooks.refused = nil shadowEnv := composerEnv(session, node, say, shadowHooks) // And, after each answer, what runs of an own secret the answer says the vault holds another value of, handed to // the vault while the node applies the controller's compositions (issue 1126, cause C4; ownresync). @@ -742,19 +725,8 @@ func busyModules() []string { return out } -// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of -// its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9). -var keptRetired atomic.Bool - -// errKeptRetired is keptDeclaration's answer once the read is retired. -var errKeptRetired = errors.New("the kept declaration is read no more: every module placed on this node has a have of its own") - -// keptDeclaration is the declaration the node kept before the switch, verified with the mesh's key when it is given; -// once the read is retired, nothing is read. +// keptDeclaration is the declaration the node kept before the switch, verified with the mesh's key when it is given. func keptDeclaration(statePath string, signer ed25519.PublicKey) (*declaration.Declaration, error) { - if keptRetired.Load() { - return nil, errKeptRetired - } var raw []byte if len(signer) > 0 { var err error @@ -931,9 +903,6 @@ type composerHooks struct { unseal func(string) ([]byte, error) refused func(why string, held map[string]string) held func(module string) (delivery.Seals, []string, error) - // vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify. - vaultAuthority func() string - certify func(ctx context.Context) error } func selfComposer(ctx context.Context, session *link.Session, node string, say func(string), hooks composerHooks) (*selfcompose.Composer, string) { @@ -997,8 +966,7 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com Hear: session.Hear, // And a moved module's templates archive, in the artifact store, as every archive is fetched (issue 1126). Fetch: apply.Fetch, - Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, - VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify} + Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held} } // endWhenUnplaced calls stop once this node's placement key is deleted from the seat's placement (after the watch diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 852447b..b214352 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -159,8 +159,7 @@ func (b *itBed) start(trustOperator bool) (stop func()) { done := make(chan struct{}) go func() { defer close(done) - controller := b.controll.Public().(ed25519.PublicKey) - nodeAgent(ctx, b.opts, controller, newMeshTrust(controller), b.session, nil, b.say, func() {}, + nodeAgent(ctx, b.opts, b.controll.Public().(ed25519.PublicKey), b.session, nil, b.say, func() {}, &agentNow{}, func(context.Context, *agent.Agent) {}) }() return func() { cancel(); <-done } diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go deleted file mode 100644 index 2eadbc6..0000000 --- a/cmd/mesh-host/trust.go +++ /dev/null @@ -1,317 +0,0 @@ -package main - -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/buscompose" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" - - "github.com/novox/mesh-host/internal/identity" - "github.com/novox/mesh-host/internal/link" -) - -// **What this engine trusts a mesh signature from** (novox/hq ADR 0550 §2; design 47's L3 item d). Two signers: the -// controller's key, which the node was enrolled with (its membership's signer), and the vault's key, read from the vault's -// state `signers`, which only the vault may write: the bus grants are its anchor, as the wants' and the placement's (the -// lead's design limit, 2026-10-28). The controller's key is trusted until its lease is handed away, heard on the lease key -// (buscompose.LeaseHandedAway) and never on a clock; from then the vault's alone. What it trusts now the node states in its -// record (NodeRecord.Trusts), so the gate before the lease write reads every node's from the bus. -type meshTrust struct { - mu sync.Mutex - controller ed25519.PublicKey - handedAway bool - vault ed25519.PublicKey - authority string - // changed is told when what it trusts changed, so the record is written again. - changed func() -} - -// newMeshTrust trusts the controller's key alone until the vault's is read. -func newMeshTrust(controller ed25519.PublicKey) *meshTrust { - t := &meshTrust{} - if len(controller) == ed25519.PublicKeySize { - t.controller = controller - } - return t -} - -// onChange names who is told when what it trusts changed. -func (t *meshTrust) onChange(f func()) { - t.mu.Lock() - t.changed = f - t.mu.Unlock() -} - -// Controller is the controller's key while the controller holds its lease, and nil once the lease is handed away. -func (t *meshTrust) Controller() ed25519.PublicKey { - if t == nil { - return nil - } - t.mu.Lock() - defer t.mu.Unlock() - if t.handedAway { - return nil - } - return t.controller -} - -// HandedAway says the controller's lease was handed away, as heard on the lease key. -func (t *meshTrust) HandedAway() bool { - if t == nil { - return false - } - t.mu.Lock() - defer t.mu.Unlock() - return t.handedAway -} - -// Signers are the keys a mesh signature is verified against now: the controller's while it holds the lease, and the -// vault's once read. -func (t *meshTrust) Signers() []delivery.Signer { - t.mu.Lock() - defer t.mu.Unlock() - var out []delivery.Signer - if t.controller != nil && !t.handedAway { - out = append(out, delivery.Signer{Name: delivery.SignerController, Key: t.controller}) - } - if t.vault != nil { - out = append(out, delivery.Signer{Name: delivery.SignerVault, Key: t.vault}) - } - return out -} - -// Verify is the signer that signed msg as kind, or why none it trusts did. -func (t *meshTrust) Verify(kind string, msg, sig []byte) (delivery.Signer, error) { - context, err := delivery.SignContext(kind) - if err != nil { - return delivery.Signer{}, err - } - return delivery.VerifyBy(t.Signers(), context, msg, sig) -} - -// VaultAuthority is the vault's authority certificate as read from `signers`, or "". -func (t *meshTrust) VaultAuthority() string { - if t == nil { - return "" - } - t.mu.Lock() - defer t.mu.Unlock() - return t.authority -} - -// Trusts is what the record says it trusts: each signer's key and the vault's authority, by fingerprint. -func (t *meshTrust) Trusts() []string { - if t == nil { - return nil - } - authorities := map[string]string{} - if a := t.VaultAuthority(); a != "" { - authorities[delivery.SignerVault] = a - } - return delivery.Trusts(t.Signers(), authorities) -} - -// change applies f under the lock and tells who follows when Trusts differs after it. -func (t *meshTrust) change(f func()) { - before := strings.Join(t.Trusts(), "\n") - t.mu.Lock() - f() - changed := t.changed - t.mu.Unlock() - if strings.Join(t.Trusts(), "\n") != before && changed != nil { - changed() - } -} - -// heardSigner takes one key of the vault's `signers`: its key or its authority, refused (and said) when it does not -// check; a deleted key is no longer trusted. -func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link.Announce) { - switch key { - case delivery.SignersKeyKey: - var pub ed25519.PublicKey - if !deleted && len(value) > 0 { - k, err := delivery.DecodeSignerKey(value) - if err == nil { - pub, err = k.PublicKey() - } - if err != nil { - say("the vault's signing key in " + delivery.SignersRead + " is not trusted: " + err.Error()) - return - } - } - t.change(func() { t.vault = pub }) - case delivery.SignersAuthorityKey: - pem := "" - if !deleted && len(value) > 0 { - a, err := delivery.DecodeSignerAuthority(value) - if err != nil { - say("the vault's authority in " + delivery.SignersRead + " is not trusted: " + err.Error()) - return - } - pem = a.Certificate - } - t.change(func() { t.authority = pem }) - } -} - -// heardLease takes the controller's lease key as read: once handed away to a holder that is no controller, the -// controller's key is trusted no more. A lease that cannot be read changes nothing. -func (t *meshTrust) heardLease(raw []byte, found bool, readErr error) { - if readErr != nil { - return - } - handed, _ := buscompose.LeaseHandedAway(raw, found, nil) - t.change(func() { t.handedAway = handed }) -} - -// trustFollowAgain bounds the wait before a watch that could not be made is tried again on the same link: the vault may -// not serve `sign` yet, and until it does this node is not granted the reads. -var trustFollowAgain = 2 * time.Minute - -// followTrust follows the vault's `signers` and the controller's lease on every link, as watches (never a poll), for as -// long as ctx lasts. A watch that cannot be made is said once per reason and tried again. -func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, say link.Announce) { - said := "" - sayOnce := func(line string) { - if line != said { - said = line - if line != "" { - say(line) - } - } - } - for ctx.Err() == nil { - linked, cancel, err := session.WhenLinked(ctx) - if err != nil { - return - } - watching, stop := context.WithCancel(linked) - signers, serr := session.KVWatch(watching, delivery.SignersBucket, ">") - lease, lerr := session.KVWatch(watching, buscompose.LeaseBucket, buscompose.ControllerLeaseKey) - if serr != nil || lerr != nil { - stop() - sayOnce(fmt.Sprintf("what this node trusts a mesh signature from is not followed yet (the vault's signers: %v; "+ - "the controller's lease: %v): it trusts what it last read, and looks again in %s", serr, lerr, trustFollowAgain)) - signers, lease = nil, nil - } else { - sayOnce("") - } - for signers != nil || lease != nil { - select { - case u, ok := <-signers: - if !ok { - signers = nil - continue - } - if !u.Caught { - trust.heardSigner(u.Key, u.Value, u.Deleted, say) - } - case u, ok := <-lease: - if !ok { - lease = nil - continue - } - if !u.Caught { - trust.heardLease(u.Value, !u.Deleted && len(u.Value) > 0, nil) - } - } - } - stop() - select { - case <-linked.Done(): - case <-time.After(trustFollowAgain): - } - cancel() - } -} - -// placedAllHave says whether every module the seat mesh-placement places on node has a have of its own, read from the -// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose placement or haves -// cannot be read, keeps reading it. -func placedAllHave(ctx context.Context, session *link.Session, node string) (bool, string) { - key, err := delivery.PlacementKey(node) - if err != nil { - return false, err.Error() - } - h, err := session.KVHistory(ctx, delivery.PlacementBucket, key) - if err != nil || len(h) == 0 || h[len(h)-1].Deleted { - return false, "its placement is not read" - } - p, err := delivery.DecodePlacement(h[len(h)-1].Value) - if err != nil { - return false, err.Error() - } - return allHave(p, func(module string) (bool, error) { - _, ok, err := session.ReadHaveErr(ctx, module) - return ok, err - }) -} - -// allHave says whether has finds a have for every module p places, and why not. -func allHave(p delivery.Placement, has func(module string) (bool, error)) (bool, string) { - if len(p.Modules) == 0 { - return false, "the seat mesh-placement places no module here" - } - for _, m := range p.Modules { - ok, err := has(m.Module) - if err != nil { - return false, "the have of " + m.Module + " is not read: " + err.Error() - } - if !ok { - return false, m.Module + " has no have yet" - } - } - return true, fmt.Sprintf("each of the %d modules placed here has a have of its own", len(p.Modules)) -} - -// certifyThisNode asks the vault's `sign` for this node's own certificate (kind node-certificate, ADR 0550 §2), checks -// it is for this node's internal name and serving key and signed by the authority the answer names, which must be the -// vault's this node trusts, keeps it where the record reads it, and writes the record again (written). -func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust, - ask func(ctx context.Context, verb string, args any) (json.RawMessage, error), written func()) error { - serving, err := identity.LoadServingKey(identity.ServingKeyPath(state)) - if err != nil { - return err - } - key, err := base64.StdEncoding.DecodeString(serving.Public) - if err != nil || len(key) != ed25519.PublicKeySize { - return fmt.Errorf("this node's serving key is not an ed25519 key") - } - raw, err := ask(ctx, delivery.SignVerb, map[string]any{"kind": delivery.KindNodeCertificate, "node": node}) - if err != nil { - return err - } - var a delivery.SignAnswer - if err := json.Unmarshal(raw, &a); err != nil { - return fmt.Errorf("the vault's answer is not a signing: %w", err) - } - trusted := trust.VaultAuthority() - if trusted == "" { - return fmt.Errorf("this node has read no authority of the vault's to check the certificate against") - } - if err := delivery.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node), - ed25519.PublicKey(key), time.Now()); err != nil { - return err - } - path := identity.CertificatePath(state) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - if err := os.WriteFile(path, []byte(a.Certificate), 0o644); err != nil { - return err - } - if written != nil { - written() - } - return nil -} diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index bbbcf32..22ce465 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -1,9 +1,6 @@ package main import ( - "errors" - "os" - "path/filepath" "strings" "testing" "time" @@ -12,7 +9,6 @@ import ( "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/liveness" - "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/units" ) @@ -107,61 +103,6 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) { if got["claude-code.agent"] != "never" || got["openrazer.account"] != "" { t.Fatalf("root said: %v", got) } - // 774 (ADR 0563 §6): the node seat's root-free passes only on the engine's own positive verdict at most 15 minutes old - // with execute not served here; a stale, missing or negative verdict, or execute served, is no pass and says why. - t.Run("this machine's root-free", func(t *testing.T) { - agent := accounts.Verdict{Account: accounts.Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true}, - State: accounts.Healthy, Since: at} - free := &accounts.Statement{At: at, Accounts: []accounts.Verdict{agent}} - for _, row := range []struct { - name string - st *accounts.Statement - after time.Duration - served bool - free bool - why string - }{ - {name: "a positive verdict", st: free, after: 14 * time.Minute, free: true, why: "the account agent cannot become root"}, - {name: "a stale verdict", st: free, after: 16 * time.Minute, why: "more than 15 minutes ago"}, - {name: "no verdict", st: nil, why: "judged no account yet"}, - {name: "a way to root", st: as, why: "in the group docker, which grants root"}, - {name: "no agent account", st: &accounts.Statement{At: at}, why: "agents run as the operator's account"}, - {name: "execute served", st: free, served: true, why: "the login shell runs any command"}, - } { - v := judgeRootFree("ace", row.st, at.Add(row.after), row.served, "the bus hears execute answered here") - if v.Free != row.free || !strings.Contains(v.Why, row.why) || v.Machine != "ace" { - t.Errorf("%s: %+v", row.name, v) - } - } - // execute is served while the holder's setting file says anything but withhold, or the bus hears it. - dir := t.TempDir() - statePath, conf := filepath.Join(dir, "state.json"), filepath.Join(dir, "tools.conf") - if err := store.Save(statePath, store.State{Resources: []store.Applied{{ID: "zsh.tools-conf", Type: "file", Target: conf}}}); err != nil { - t.Fatal(err) - } - quiet := func() (bool, error) { return false, nil } - for _, row := range []struct { - name, setting string - heard func() (bool, error) - served bool - }{ - {name: "withheld and not heard", setting: "# the mesh's\nexecute=withhold\n", heard: quiet}, - {name: "served", setting: "execute=serve\n", heard: quiet, served: true}, - {name: "withheld and heard", setting: "execute=withhold\n", heard: func() (bool, error) { return true, nil }, served: true}, - {name: "the bus not asked", setting: "execute=withhold\n", heard: func() (bool, error) { return false, errors.New("no link") }, served: true}, - {name: "no setting file", heard: quiet, served: true}, - } { - _ = os.Remove(conf) - if row.setting != "" { - if err := os.WriteFile(conf, []byte(row.setting), 0o644); err != nil { - t.Fatal(err) - } - } - if served, why := executeServedHere(statePath, row.heard); served != row.served { - t.Errorf("%s: served %v (%s)", row.name, served, why) - } - } - }) } // A verdict waiting for the setuid search says how far it got, in the reason the root-free verb repeats and as diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 1d4601e..2ffd7c6 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -4,7 +4,6 @@ import ( "crypto/ed25519" "encoding/json" "errors" - "path/filepath" "strings" "testing" @@ -12,7 +11,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" - "github.com/novox/mesh-host/internal/store" ) func aFileOf(module, id string) json.RawMessage { @@ -61,41 +59,6 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { if err != nil || d.Provisioner != "from-agent" { t.Fatalf("declaredNow read %+v (%v), not what runs", d, err) } - - // L3 s9 (ADR 0550 §2): a node whose placed modules all have a have reads the kept declaration no more, though the - // file is there and readable and the controller's key is dropped; until then it reads it. - state := filepath.Join(t.TempDir(), "state", "state.json") - if err := store.SaveDeclared(store.DeclaredPath(state), store.Declared{Declaration: []byte(`{"declaration":1,` + - `"resources":[{"id":"zsh.rc","type":"file","module":"zsh","path":"/etc/zshrc","content":"x"}]}`)}); err != nil { - t.Fatal(err) - } - if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 { - t.Fatalf("before the read is retired: %+v, %v", d, err) - } - placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} - for _, row := range []struct { - name string - has map[string]bool - want bool - }{ - {"every placed module has a have", map[string]bool{"zsh": true, "lemurs": true}, true}, - {"one has none yet", map[string]bool{"zsh": true}, false}, - } { - if got, why := allHave(placed, func(m string) (bool, error) { return row.has[m], nil }); got != row.want { - t.Errorf("%s: %v (%s)", row.name, got, why) - } - } - if got, _ := allHave(delivery.Placement{Node: "n1"}, nil); got { - t.Error("a node with nothing placed retires the read") - } - keptRetired.Store(true) - defer keptRetired.Store(false) - if _, err := keptDeclaration(state, nil); !errors.Is(err, errKeptRetired) { - t.Errorf("after the read is retired: %v", err) - } - if by, filled := keptOnce(options{state: state}, nil)(); by != nil || filled != nil { - t.Errorf("after the read is retired the kept declaration was split: %v %v", by, filled) - } } // 1945/AC17 (unit): a resource the node's own composition shares with a running module's composition is declared once, diff --git a/internal/accounts/setuid_cache.go b/internal/accounts/setuid_cache.go index eadd8a0..777bbf5 100644 --- a/internal/accounts/setuid_cache.go +++ b/internal/accounts/setuid_cache.go @@ -240,17 +240,6 @@ func (c *SetuidCache) SearchAgain() { c.forgetProgress() } -// Running says whether a search walks now (novox/hq ADR 0563 §2: the node seat starts no second while one does). -// Nil c runs none. -func (c *SetuidCache) Running() bool { - if c == nil { - return false - } - c.mu.Lock() - defer c.mu.Unlock() - return c.running -} - // 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 diff --git a/internal/link/access_act_test.go b/internal/link/access_act_test.go index 82bf596..0bd38f6 100644 --- a/internal/link/access_act_test.go +++ b/internal/link/access_act_test.go @@ -6,10 +6,6 @@ import ( "strings" "testing" "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" ) // The take act's ask (novox/hq ADR 0325 §8): a hand-over's fields and the act's own in one signed object, under a @@ -72,112 +68,6 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { if a.Said != "started" || len(given) != 1 || given[0].Warrant != "w-1" || given[0].Path != "/srv/media/movies" { t.Fatalf("a warranted ask did not reach the act whole: %+v %+v", a, given) } - // 774 (ADR 0563 §3): a hand-over asked through the node seat is the engine's own ask, performed only on the - // operator's warrant to it: never on the call, not on a warrant for another digest, once on a warrant heard twice, - // and not when the disk changed since it was asked. - t.Run("the node seat's hand-over acts only on the operator's warrant", func(t *testing.T) { - for _, row := range []struct { - name string - warrant func(asked asks.Ask, w asks.Warrant) asks.Warrant - diskAfter string - twice bool - done int - state string - }{ - {name: "no warrant", done: 0, state: HandAsked}, - {name: "the operator's yes", warrant: yes, done: 1, state: HandDone}, - {name: "the yes heard twice", warrant: yes, twice: true, done: 1, state: HandDone}, - {name: "a warrant for another digest", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { - w = yes(a, w) - w.AskDigest = "sha256:another" - return w - }, done: 0, state: HandAsked}, - {name: "a no", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { - w = yes(a, w) - w.Option, w.Label, w.Level = "no", "No, leave it", asks.Acknowledge - return w - }, done: 0, state: HandRefused}, - {name: "the disk changed", warrant: yes, diskAfter: "0:0 700 9:2", done: 0, state: HandRefused}, - {name: "expired", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { - return asks.Warrant{Ask: a.ID, Asker: w.Asker, Outcome: asks.OutcomeExpired, At: w.At} - }, done: 0, state: HandRefused}, - } { - t.Run(row.name, func(t *testing.T) { - h, asked, done := anEngine(t) - disk := "1000:1000 755 9:1" - h.JudgeHandOver = func(path string) (Judged, error) { - return Judged{Subject: path, Path: path, Sets: "owner jo, group jo, mode 0750", Disk: disk, - Words: "A directory used as found."}, nil - } - if _, err := h.Call(EngineCall{Verb: ActHandOver, Caller: "shanks.node-tools", - Args: map[string]any{"path": "/srv/x", "why": "a test"}}); err != nil { - t.Fatal(err) - } - if len(*asked) != 1 || *done != 0 { - t.Fatalf("the call asked %d and performed %d", len(*asked), *done) - } - // The act is one key, and the open ask one key made by a create (ADR 0563 §3). - if acts, _ := h.State.Keys("act."); len(acts) != 1 || acts[0] != "act."+(*asked)[0].ID { - t.Fatalf("the act is kept as %v", acts) - } - if open, _ := h.State.Keys("open."); len(open) != 1 || !strings.HasPrefix(open[0], "open.hand-over.") { - t.Fatalf("the open ask is kept as %v", open) - } - if _, err := h.Call(EngineCall{Verb: ActHandOver, Caller: "x", Args: map[string]any{"path": "/srv/x"}}); err == nil { - t.Fatal("a second ask of the same path was asked while one is open") - } - if row.diskAfter != "" { - disk = row.diskAfter - } - if row.warrant != nil { - w := row.warrant((*asked)[0], asks.Warrant{Asker: asks.EngineAsker("n1"), At: time.Now()}) - h.Heard(w) - if row.twice { - h.Heard(w) - } - } - got, _ := h.Acts(0) - acts := got.(map[string]any)["acts"].([]HandAct) - if *done != row.done || len(acts) != 1 || acts[0].State != row.state { - t.Fatalf("performed %d (want %d), acts %+v (want %s)", *done, row.done, acts, row.state) - } - if open, _ := h.State.Keys("open."); (len(open) == 1) != (row.state == HandAsked) { - t.Fatalf("an ask %s leaves its open key as %v", row.state, open) - } - if row.state == HandDone && (acts[0].By != "operator" || acts[0].Via == "" || acts[0].Ask == "") { - t.Fatalf("the act does not record the warrant: %+v", acts[0]) - } - }) - } - }) -} - -// yes is the operator's yes to an ask, as the router writes it. -func yes(a asks.Ask, w asks.Warrant) asks.Warrant { - w.Ask, w.Outcome, w.Option, w.Label, w.Level = a.ID, asks.OutcomeChosen, "yes", "Yes", asks.Approve - w.By = &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"} - w.AskDigest, w.Proofs = a.Digest(), []string{"P1"} - return w -} - -// anEngine is a node seat with its own state in a temporary folder, a router that keeps what it was asked, and a -// hand-over that counts what it performed. -func anEngine(t *testing.T) (*HandActs, *[]asks.Ask, *int) { - t.Helper() - var asked []asks.Ask - done := 0 - state := &MemState{} - h := &HandActs{Node: "n1", State: state, - HandOver: func(path, by string) (string, error) { done++; return path + " handed over by " + by, nil }} - h.Asker = &asker.Client{Module: asks.EngineAsker("n1"), State: state, - Publish: func(subject string, body any, id string) error { - if a, ok := body.(asks.Ask); ok && subject == asks.AskSubject(asks.EngineAsker("n1")) { - asked = append(asked, a) - } - return nil - }, - Record: func(string) (*stdio.StateEntry, error) { return nil, nil }} - return h, &asked, &done } // The controller names an access by its declared resource id exactly, which may hold a `/`: it reaches the act, diff --git a/internal/link/engineseat.go b/internal/link/engineseat.go deleted file mode 100644 index 258e3f5..0000000 --- a/internal/link/engineseat.go +++ /dev/null @@ -1,382 +0,0 @@ -package link - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/micro" -) - -// The node seat node-engine, served on the engine's own link (novox/hq ADR 0563 §1). -// -// Each verb is answered on this node's subject alone, `mesh.seat.node-engine.tool..`, and on the same -// address naming its caller, `mesh.seat.node-engine.call...`, whose last token the bus lets each user -// publish with its own name alone (novox/hq issue 365): the caller is that token, and a call on the subject naming none is -// from an unproven caller, recorded so. The body is the call's arguments; the answer is {result, error, node}, as every -// tool's (mesh-tools node-tools). One service, `node-engine`, answers discovery with each verb's seat, tool and node. - -// EngineSeat is the seat. -const EngineSeat = catalogue.EngineSeat - -// EngineToolSubject is where a verb of this node's seat is asked naming no caller. -func EngineToolSubject(verb, node string) string { - return "mesh.seat." + EngineSeat + ".tool." + verb + "." + node -} - -// EngineCallPattern is where a verb of this node's seat is asked naming its caller, any caller. -func EngineCallPattern(verb, node string) string { - return "mesh.seat." + EngineSeat + ".call." + verb + "." + node + ".*" -} - -// EngineEventSubject is where the seat says one of its events about this node. -func EngineEventSubject(event, node string) string { - return "mesh.seat." + EngineSeat + ".event." + event + "." + node -} - -// EngineCall is one call of the seat: its verb, its caller as the bus proved it, and its arguments. -type EngineCall struct { - Verb string - Caller string - Args map[string]any -} - -// EngineFunc answers one call: its result, or why it is refused. -type EngineFunc func(EngineCall) (any, error) - -// CallerOfSubject is the caller a call's subject names for this node's verb: the last token of a caller-named subject, -// each `~` a dot, or stdio.Unproven on the subject that names none or on any other. -func CallerOfSubject(subject, verb, node string) string { - token, ok := strings.CutPrefix(subject, "mesh.seat."+EngineSeat+".call."+verb+"."+node+".") - if !ok || token == "" || strings.ContainsAny(token, ".*> ") { - return stdio.Unproven - } - return strings.ReplaceAll(token, "~", ".") -} - -// MaxEngineCall bounds a call's arguments: a path, an access and a few words. -const MaxEngineCall = 16 << 10 - -// AnswerEngineCall is the envelope one call is answered with: the verb's result, or its refusal in words. -func AnswerEngineCall(handle EngineFunc, verb, node, subject string, body []byte) []byte { - envelope := map[string]any{"node": node} - say := func() []byte { - raw, err := json.Marshal(envelope) - if err != nil { - raw, _ = json.Marshal(map[string]any{"node": node, "error": "the answer could not be written: " + err.Error()}) - } - return raw - } - args := map[string]any{} - switch { - case handle == nil: - envelope["error"] = "this engine serves no verb of " + EngineSeat - return say() - case len(body) > MaxEngineCall: - envelope["error"] = fmt.Sprintf("the call is %d bytes, more than a call of %s takes", len(body), EngineSeat) - return say() - case len(strings.TrimSpace(string(body))) > 0: - if err := json.Unmarshal(body, &args); err != nil { - envelope["error"] = "the call's arguments are not an object: " + err.Error() - return say() - } - } - result, err := handle(EngineCall{Verb: verb, Caller: CallerOfSubject(subject, verb, node), Args: args}) - if err != nil { - envelope["error"] = err.Error() - } else { - envelope["result"] = result - } - return say() -} - -// EngineSeats is a link that serves this node's seat. Optional, as Asked is. -type EngineSeats interface { - ServeEngineSeat(EngineFunc) -} - -// ServeEngineSeat answers every verb of the seat on this node's subjects, as the service node-engine, which says each -// verb's seat, tool and node to discovery (novox/hq ADR 0197). A bus whose user list is older than the grant refuses the -// subscriptions, said by the client; a caller then hears nobody answer. -func (l *natsLink) ServeEngineSeat(handle EngineFunc) { - if handle == nil || l.conn == nil { - return - } - svc, err := micro.AddService(l.conn, micro.Config{Name: EngineSeat, Version: "1.0.0", - Description: "the node seat node-engine of " + l.node + " (novox/hq ADR 0563)", QueueGroupDisabled: true}) - if err != nil { - return - } - for _, v := range catalogue.EngineVerbs() { - verb := v.Name - answer := micro.HandlerFunc(func(r micro.Request) { - _ = r.Respond(AnswerEngineCall(handle, verb, l.node, r.Subject(), r.Data())) - }) - meta := map[string]string{"seat": EngineSeat, "tool": verb, "node": l.node} - _ = svc.AddEndpoint(verb, answer, micro.WithEndpointSubject(EngineToolSubject(verb, l.node)), - micro.WithEndpointMetadata(meta)) - _ = svc.AddEndpoint(verb+"-called", answer, micro.WithEndpointSubject(EngineCallPattern(verb, l.node)), - micro.WithEndpointMetadata(meta)) - } - l.engine = svc -} - -// SayEngine says one of the seat's events about this node, on the events stream. -func (l *natsLink) SayEngine(event string, body any) error { - raw, err := json.Marshal(body) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _, err = l.js.Publish(EngineEventSubject(event, l.node), raw, nats.Context(ctx)) - return err -} - -// The engine asks the operator under its own asker name (asks.EngineAsker) on the router's seat (ADR 0259 §3, as ADR -// 0563 §3 changes it): its ask and cancel on the seat's work stream, its warrants heard on its own decided subject, and -// its asks' state read from the router's records under its own name. - -// RouterRecords is the router's bucket of asks, which each asker reads under its own name: the state `asks` of the -// operator-channel's holder, the messenger (mesh-catalog modules/messenger). -const RouterRecords = "messenger_asks" - -// OperatorAsks is a link that asks the operator as this node's engine. Optional. -type OperatorAsks interface { - PublishAsk(subject string, body any, id string) error - RouterRecord(key string) (*stdio.StateEntry, error) - HearWarrants(asker string, heard func(asks.Warrant)) error -} - -// PublishAsk publishes an ask or a cancel on the router's seat, once per id within the stream's duplicate window. -func (l *natsLink) PublishAsk(subject string, body any, id string) error { - raw, err := json.Marshal(body) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _, err = l.js.Publish(subject, raw, nats.MsgId(id), nats.Context(ctx)) - return err -} - -// RouterRecord reads one key of the router's records, `.`; nil when it holds none. -func (l *natsLink) RouterRecord(key string) (*stdio.StateEntry, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - msg, err := l.conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+RouterRecords+".$KV."+RouterRecords+"."+key, nil) - if err != nil { - return nil, err - } - if status := msg.Header.Get("Status"); status == "404" { - return nil, nil - } else if status != "" { - return nil, fmt.Errorf("the router's record %s could not be read: %s %s", key, status, msg.Header.Get("Description")) - } - return &stdio.StateEntry{Key: key, Value: msg.Data}, nil -} - -// HearWarrants hears the warrants for asker on its decided subject, each handed to heard as it arrives. -func (l *natsLink) HearWarrants(asker string, heard func(asks.Warrant)) error { - sub, err := l.conn.Subscribe(asks.DecidedSubject(asker), func(msg *nats.Msg) { - var w asks.Warrant - if json.Unmarshal(msg.Data, &w) == nil { - heard(w) - } - }) - if err != nil { - return err - } - l.warrants = sub - return nil -} - -// errNoAsking is a link that cannot ask the operator. -var errNoAsking = errors.New("the link open now cannot ask the operator") - -// discoveryQuiet is how long the engine waits after the last answer to the bus's discovery, and discoveryMost the -// longest it waits in all. -const ( - discoveryQuiet = 300 * time.Millisecond - discoveryMost = 2 * time.Second -) - -// HeardServed asks the bus's discovery (novox/hq ADR 0197) whether a seat's verb is answered on this machine: an -// endpoint naming the seat, the verb as its tool and this node. -func (l *natsLink) HeardServed(seat, verb string) (bool, error) { - inbox := l.conn.NewRespInbox() - sub, err := l.conn.SubscribeSync(inbox) - if err != nil { - return false, err - } - defer func() { _ = sub.Unsubscribe() }() - if err := l.conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil { - return false, fmt.Errorf("asking the bus who serves what: %w", err) - } - heard := false - deadline := time.Now().Add(discoveryMost) - for time.Now().Before(deadline) { - msg, err := sub.NextMsg(discoveryQuiet) - if err != nil { - break - } - var info micro.Info - if json.Unmarshal(msg.Data, &info) != nil { - continue - } - for _, e := range info.Endpoints { - node := e.Metadata["node"] - if node == "" { - node = info.ID - } - if e.Metadata["seat"] == seat && e.Metadata["tool"] == verb && node == l.node { - heard = true - } - } - } - return heard, nil -} - -// HeardServed is the link open now asking the bus's discovery whether a seat's verb is answered on this machine. -func (q *Session) HeardServed(seat, verb string) (bool, error) { - q.init() - q.mu.Lock() - bus := q.bus - q.mu.Unlock() - asker, ok := bus.(interface { - HeardServed(string, string) (bool, error) - }) - if bus == nil || !ok { - return false, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked) - } - return asker.HeardServed(seat, verb) -} - -// ---- the engine's acts in the bus's own storage (ADR 0563 §2, §3) ------------------------------------------------- - -// actsStore is this node's keys of ActsBucket over the link open now: bound, never created (the grants part creates -// it, as the node-engine's declared state). -func (l *natsLink) actsStore() (nats.KeyValue, error) { - kv, err := l.js.KeyValue(ActsBucket) - if err != nil { - return nil, fmt.Errorf("the node-engine's state %s cannot be bound (it is created from the node-engine's "+ - "manifest; asked again once it is): %w", ActsBucket, err) - } - return kv, nil -} - -// BusState is the engine's ActStore: its own node's keys of ActsBucket, `.`, over whichever link the session -// has open; with none open every read and write is refused, and nothing is kept anywhere else. -type BusState struct { - Session *Session - Node string -} - -func (b BusState) kv() (nats.KeyValue, error) { - b.Session.init() - b.Session.mu.Lock() - bus := b.Session.bus - b.Session.mu.Unlock() - l, ok := bus.(interface{ actsStore() (nats.KeyValue, error) }) - if bus == nil || !ok { - return nil, fmt.Errorf("%w: this machine has no link to the bus open now, where its acts are kept", ErrNotAsked) - } - return l.actsStore() -} - -func (b BusState) key(k string) string { return b.Node + "." + k } - -// changed is a write that lost its compare-and-set, as stdio.ErrChanged. -func changed(err error) error { - var api *nats.APIError - if errors.Is(err, nats.ErrKeyExists) || (errors.As(err, &api) && api.ErrorCode == nats.JSErrCodeStreamWrongLastSequence) { - return stdio.ErrChanged - } - return err -} - -func (b BusState) Get(key string) (*stdio.StateEntry, error) { - kv, err := b.kv() - if err != nil { - return nil, err - } - e, err := kv.Get(b.key(key)) - if errors.Is(err, nats.ErrKeyNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &stdio.StateEntry{Key: key, Value: e.Value(), Revision: e.Revision()}, nil -} - -func (b BusState) Create(key string, value any) (uint64, error) { - kv, err := b.kv() - if err != nil { - return 0, err - } - raw, err := json.Marshal(value) - if err != nil { - return 0, err - } - rev, err := kv.Create(b.key(key), raw) - return rev, changed(err) -} - -func (b BusState) Update(key string, value any, revision uint64) (uint64, error) { - kv, err := b.kv() - if err != nil { - return 0, err - } - raw, err := json.Marshal(value) - if err != nil { - return 0, err - } - rev, err := kv.Update(b.key(key), raw, revision) - return rev, changed(err) -} - -func (b BusState) Delete(key string) error { - kv, err := b.kv() - if err != nil { - return err - } - return kv.Delete(b.key(key)) -} - -// Keys are this node's keys under prefix, without the node: a watch of `.>` alone, the one filter the node's -// grant names, its values left out. -func (b BusState) Keys(prefix string) ([]string, error) { - kv, err := b.kv() - if err != nil { - return nil, err - } - w, err := kv.Watch(b.Node+".>", nats.MetaOnly(), nats.IgnoreDeletes()) - if err != nil { - return nil, err - } - defer func() { _ = w.Stop() }() - var out []string - for { - select { - case e := <-w.Updates(): - if e == nil { - sort.Strings(out) - return out, nil - } - if k := strings.TrimPrefix(e.Key(), b.Node+"."); strings.HasPrefix(k, prefix) { - out = append(out, k) - } - case <-time.After(10 * time.Second): - return nil, errors.New("the node-engine's acts could not be listed within 10s") - } - } -} diff --git a/internal/link/handacts.go b/internal/link/handacts.go deleted file mode 100644 index a411516..0000000 --- a/internal/link/handacts.go +++ /dev/null @@ -1,769 +0,0 @@ -package link - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "sort" - "strconv" - "strings" - "sync" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" -) - -// The node seat node-engine's acts (novox/hq ADR 0563). -// -// A hand-over and an access act are the engine's own asks, performed only on the warrant (§3): a call performs nothing. -// The engine judges every value as it does for the controller's signed ask, then refuses in words or asks the operator -// under its own asker name (asks.EngineAsker) at the level approve, binding the digest of the verb, the node, every -// argument and the owner, group and mode it will set. A warrant is claimed once by compare-and-set in the engine's own -// state (asker.Client.Take), held to the ask it answers, and to the act bound and the disk as judged, judged again now; -// only then is the act performed and recorded with the warrant. A decline, an expiry or a changed disk says refused and -// changes nothing. One ask is open per path or access. -// -// The read-only verbs need no warrant (§2): judge-directories changes nothing; setuid-search is bounded here, one at a -// time and at most one started in an hour. Every act, with who asked as the runtime proved it, is kept in the engine's own -// declared state acts (ActsBucket, created by the grants part), under its own node's keys alone, read by the verb acts. - -// The acts of the seat, as its verbs name them. -const ( - ActHandOver = "hand-over" - ActAccess = "access-act" - ActJudgeDirectories = "judge-directories" - ActSetuidSearch = "setuid-search" -) - -// The states of an act. -const ( - HandAsked = "asked" - HandDone = "done" - HandRefused = "refused" - HandAnswered = "answered" -) - -// SearchEvery is how often a setuid search may be started on a node (ADR 0563 §2): one in any hour. -const SearchEvery = time.Hour - -// askMargin is how long past an ask's expiry the router is given to say it ended, before the engine closes it itself. -const askMargin = 5 * time.Minute - -// ActsBucket is the node-engine's declared state acts (its manifest's state, ADR 0563 §2, §3), the bucket the grants -// part creates (mesh-sdk buscompose EngineActsBucket): keys `.act.`, `.open..` and -// `.ask.`, each written by this node's engine alone. -const ActsBucket = "mesh-host_acts" - -// Judged is what the engine judged of one act before it asks: what it is about, what it will set (the owner, group and -// mode, in words), and what the disk holds now (Disk), compared again when the warrant arrives. -type Judged struct { - // Subject is the path of a hand-over, or the access of an access act. - Subject string - // Path is the directory the act changes, as the engine states it. - Path string - // Sets is the owner, group and mode the act will set, in words; bound in the ask's digest. - Sets string - // Disk is what the engine read on disk when it judged: owner, group, mode and the file it is. - Disk string - // Words is what the operator is shown of it, without a path (ADR 0234 §6): the path is in the whole words alone. - Words string -} - -// HandAct is one act asked through the seat, as acts reads it. -type HandAct struct { - ID string `json:"id"` - Act string `json:"act"` - Subject string `json:"subject,omitempty"` - Caller string `json:"caller"` - Why string `json:"why,omitempty"` - At time.Time `json:"at"` - State string `json:"state"` - Said string `json:"said,omitempty"` - Ended time.Time `json:"ended,omitempty"` - // Ask is the operator's ask of a hand-over or an access act; Sets and Disk what was judged when it was asked. - Ask string `json:"ask,omitempty"` - Sets string `json:"sets,omitempty"` - Disk string `json:"disk,omitempty"` - Path string `json:"path,omitempty"` - // Expires is when the ask ends unanswered. - Expires time.Time `json:"expires,omitempty"` - // By, Via, Proofs are the operator's warrant, recorded with the act (ADR 0563 §3). - By string `json:"by,omitempty"` - Via string `json:"via,omitempty"` - Proofs []string `json:"proofs,omitempty"` - // Auto says the engine asked it itself, on finding a library's root holding data outside its group (§4). - Auto bool `json:"auto,omitempty"` -} - -// RootVerdict is the engine's answer to root-free (ADR 0563 §6), in the controller's words. -type RootVerdict struct { - Machine string `json:"machine"` - Free bool `json:"free"` - Why string `json:"why"` - Judged time.Time `json:"judged"` -} - -// HandActs is the engine's side of the seat. Every func is the engine's own judgement or act; nil refuses. -type HandActs struct { - Node string - // State is where the acts and the asks are kept: this node's keys of ActsBucket (BusState), each key one act. - State ActStore - // Asker asks the operator as asks.EngineAsker(Node), with State as its store. - Asker *asker.Client - // JudgeHandOver judges a hand-over's path, refusing in words; HandOver records it on the warrant. - JudgeHandOver func(path string) (Judged, error) - HandOver HandOverFunc - // JudgeAccess judges an access act on an access, refusing in words; AccessAct runs it on the warrant. - JudgeAccess func(act, access string) (Judged, error) - AccessAct AccessActFunc - // JudgeDirectories, SetuidSearch and Searching are the read-only verbs' and whether a search runs now. - JudgeDirectories JudgeDirectoriesFunc - SetuidSearch SetuidSearchFunc - Searching func() bool - // RootFree is this machine's root-free verdict now. - RootFree func() RootVerdict - // Say says one of the seat's events, asked, done or refused; nil says nothing. - Say func(event string, body any) - Now func() time.Time - // Nonce keeps two asks of one second apart; nil is random. - Nonce func() string - - mu sync.Mutex -} - -func (h *HandActs) now() time.Time { - if h.Now != nil { - return h.Now() - } - return time.Now() -} - -func (h *HandActs) nonce() string { - if h.Nonce != nil { - return h.Nonce() - } - var b [2]byte - _, _ = rand.Read(b[:]) - return hex.EncodeToString(b[:]) -} - -func (h *HandActs) say(event string, a HandAct) { - if h.Say != nil { - h.Say(event, map[string]any{"act": a.Act, "subject": a.Subject, "caller": a.Caller, "ask": a.Ask, - "state": a.State, "said": a.Said, "node": h.Node}) - } -} - -// Call is the seat's one entry: a verb, the caller as the runtime proved it, and its arguments. -func (h *HandActs) Call(c EngineCall) (any, error) { - arg := func(k string) string { s, _ := c.Args[k].(string); return strings.TrimSpace(s) } - switch c.Verb { - case ActHandOver: - return h.AskHandOver(c.Caller, arg("path"), arg("why"), false) - case ActAccess: - return h.AskAccess(c.Caller, arg("act"), arg("access"), arg("why"), false) - case ActJudgeDirectories: - return h.Judge(c.Caller) - case ActSetuidSearch: - return h.Search(c.Caller, arg("why")) - case "root-free": - if h.RootFree == nil { - return nil, errors.New("this engine judges no machine root-free") - } - return map[string]any{"machines": []RootVerdict{h.RootFree()}}, nil - case "acts": - if id := arg("id"); id != "" { - return h.Act(id) - } - limit, _ := strconv.Atoi(arg("limit")) - if f, ok := c.Args["limit"].(float64); ok { - limit = int(f) - } - return h.Acts(limit) - } - return nil, fmt.Errorf("the node seat %s serves no verb %q", EngineSeat, c.Verb) -} - -// ---- the record -------------------------------------------------------------------------------------------------- - -// ActStore is the engine's own state, this node's keys alone (BusState prefixes them with the node): each act one key, -// written by compare-and-set on its revision, an open ask one key made by a create. -type ActStore interface { - Get(key string) (*stdio.StateEntry, error) - Create(key string, value any) (uint64, error) - Update(key string, value any, revision uint64) (uint64, error) - Delete(key string) error - // Keys are the keys under prefix. - Keys(prefix string) ([]string, error) -} - -func actKey(id string) string { return "act." + id } - -// openKey is the one key an open ask of act on subject holds: made by a create, so a second ask of it is refused. -func openKey(act, subject string) string { - sum := sha256.Sum256([]byte(subject)) - return "open." + act + "." + hex.EncodeToString(sum[:12]) -} - -// read is every act kept, oldest first. -func (h *HandActs) read() ([]HandAct, error) { - keys, err := h.State.Keys("act.") - if err != nil { - return nil, fmt.Errorf("the engine's acts cannot be listed: %w", err) - } - var out []HandAct - for _, k := range keys { - a, _, err := h.readOne(k) - if err != nil { - return nil, err - } - if a != nil { - out = append(out, *a) - } - } - sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) - return out, nil -} - -func (h *HandActs) readOne(key string) (*HandAct, uint64, error) { - e, err := h.State.Get(key) - if err != nil || e == nil { - return nil, 0, err - } - var a HandAct - if err := json.Unmarshal(e.Value, &a); err != nil { - return nil, 0, fmt.Errorf("the engine's act %s cannot be read: %w", key, err) - } - return &a, e.Revision, nil -} - -// judgedKey keeps the node's judgements and refused searches, the last keptJudged of them: any caller may ask them, so -// they roll in one key and never add one (ADR 0563 §2: a wrong caller gains nothing, and fills no bucket). -const ( - judgedKey = "judged" - keptJudged = 50 -) - -// roll adds a to the rolling key by compare-and-set, the oldest falling out. -func (h *HandActs) roll(a HandAct) error { - for range 5 { - e, err := h.State.Get(judgedKey) - if err != nil { - return err - } - var kept []HandAct - if e != nil { - if err := json.Unmarshal(e.Value, &kept); err != nil { - kept = nil // an unreadable roll is started again, never a reason to refuse - } - } - kept = append(kept, a) - if len(kept) > keptJudged { - kept = kept[len(kept)-keptJudged:] - } - if e == nil { - _, err = h.State.Create(judgedKey, kept) - } else { - _, err = h.State.Update(judgedKey, kept, e.Revision) - } - if !errors.Is(err, stdio.ErrChanged) { - return err - } - } - return errors.New("the engine's judgements kept changing under its write") -} - -func (h *HandActs) rolled() ([]HandAct, error) { - e, err := h.State.Get(judgedKey) - if err != nil || e == nil { - return nil, err - } - var kept []HandAct - if err := json.Unmarshal(e.Value, &kept); err != nil { - return nil, fmt.Errorf("the engine's judgements cannot be read: %w", err) - } - return kept, nil -} - -func (h *HandActs) record(a HandAct) error { - _, err := h.State.Create(actKey(a.ID), a) - return err -} - -// end marks the open act of an ask ended, once, by compare-and-set on its revision, and frees its open key: false when -// it was not open. -func (h *HandActs) end(ask string, set func(*HandAct)) (HandAct, bool, error) { - for range 5 { - a, rev, err := h.readOne(actKey(ask)) - if err != nil || a == nil || a.State != HandAsked { - return HandAct{}, false, err - } - set(a) - a.Ended = h.now().UTC() - if _, err := h.State.Update(actKey(ask), *a, rev); errors.Is(err, stdio.ErrChanged) { - continue - } else if err != nil { - return HandAct{}, false, err - } - _ = h.State.Delete(openKey(a.Act, a.Subject)) - return *a, true, nil - } - return HandAct{}, false, errors.New("the engine's act " + ask + " kept changing under its write") -} - -// Act is the verb acts with an id: that one act. -func (h *HandActs) Act(id string) (any, error) { - a, _, err := h.readOne(actKey(id)) - if err != nil { - return nil, err - } - if a == nil { - return nil, fmt.Errorf("this engine keeps no act %q", id) - } - return map[string]any{"node": h.Node, "acts": []HandAct{*a}}, nil -} - -// Acts is the verb acts: every act kept, newest first, at most limit when given. -func (h *HandActs) Acts(limit int) (any, error) { - acts, err := h.read() - if err != nil { - return nil, err - } - judged, err := h.rolled() - if err != nil { - return nil, err - } - acts = append(acts, judged...) - sort.SliceStable(acts, func(i, j int) bool { return acts[i].At.Before(acts[j].At) }) - out := make([]HandAct, 0, len(acts)) - for i := len(acts) - 1; i >= 0; i-- { - out = append(out, acts[i]) - } - if limit > 0 && len(out) > limit { - out = out[:limit] - } - return map[string]any{"node": h.Node, "acts": out}, nil -} - -// ---- the read-only verbs (§2) ------------------------------------------------------------------------------------- - -// Judge is judge-directories: the engine's judgement, read-only, recorded with its caller. -func (h *HandActs) Judge(caller string) (any, error) { - if h.JudgeDirectories == nil { - return nil, errors.New("this engine judges no directory") - } - said, err := h.JudgeDirectories(caller) - a := HandAct{ID: h.id(ActJudgeDirectories), Act: ActJudgeDirectories, Caller: caller, At: h.now().UTC(), State: HandAnswered} - if err != nil { - a.State, a.Said = HandRefused, err.Error() - } - if rerr := h.roll(a); rerr != nil && err == nil { - return nil, rerr - } - if err != nil { - return nil, err - } - return map[string]any{"node": h.Node, "said": said}, nil -} - -// Search is setuid-search: refused while one runs and when one was started within the hour, naming when the next may -// start; otherwise the last search is thrown away and a full one starts, recorded with its caller. -func (h *HandActs) Search(caller, why string) (any, error) { - if h.SetuidSearch == nil { - return nil, errors.New("this engine keeps no search for setuid programs") - } - h.mu.Lock() - defer h.mu.Unlock() - now := h.now().UTC() - if h.Searching != nil && h.Searching() { - return nil, errors.New("a search for setuid programs runs on this machine now; no second is started while it does") - } - // The last start is a key of its own, claimed by compare-and-set before the search starts: no number of other acts, - // nor the record of acts, moves it (ADR 0563 §2). - if err := h.claimSearch(now, caller); err != nil { - return nil, err - } - said, err := h.SetuidSearch(caller) - a := HandAct{ID: h.id(ActSetuidSearch), Act: ActSetuidSearch, Caller: caller, Why: why, At: now, State: HandDone, Said: said} - if err != nil { - a.State, a.Said = HandRefused, err.Error() - } - // A search started is bounded to one an hour and kept as an act of its own; a refused one, which any caller may - // cause, rolls with the judgements. - record := h.record - if err != nil { - record = h.roll - } - if rerr := record(a); rerr != nil { - return nil, rerr - } - if err != nil { - return nil, err - } - return map[string]any{"node": h.Node, "said": said}, nil -} - -// searchKey keeps the last search's start on this node, and libKey each library's own take ask: keys of their own, which -// no other act's key moves, kept for the bucket's age (ninety days, the node-engine's manifest). -const searchKey = "search.last" - -func libKey(access string) string { - sum := sha256.Sum256([]byte(access)) - return "lib." + hex.EncodeToString(sum[:12]) + ".asked" -} - -// started is the last search's start as kept, and who asked it. -type started struct { - At time.Time `json:"at"` - By string `json:"by"` -} - -// claimSearch refuses a search within SearchEvery of the last start, naming when the next may start, and otherwise -// claims this one by compare-and-set: a second claim at the same moment loses. -func (h *HandActs) claimSearch(now time.Time, caller string) error { - e, err := h.State.Get(searchKey) - if err != nil { - return err - } - var rev uint64 - if e != nil { - var last started - if err := json.Unmarshal(e.Value, &last); err != nil { - return fmt.Errorf("the last search's start cannot be read, so none is started: %w", err) - } - if now.Sub(last.At) < SearchEvery { - return fmt.Errorf("a search was started at %s by %s, and one is started in any hour: the next may start "+ - "at %s", last.At.Format(time.RFC3339), last.By, last.At.Add(SearchEvery).Format(time.RFC3339)) - } - rev = e.Revision - } - claim := started{At: now, By: caller} - if rev == 0 { - _, err = h.State.Create(searchKey, claim) - } else { - _, err = h.State.Update(searchKey, claim, rev) - } - if errors.Is(err, stdio.ErrChanged) { - return errors.New("another search was started at this moment; no second is started") - } - return err -} - -// ---- the asks (§3, §4) -------------------------------------------------------------------------------------------- - -func (h *HandActs) id(act string) string { - return act + "-" + h.now().UTC().Format("20060102t150405") + "-" + h.nonce() -} - -// bound is the act an ask's yes binds: the verb, the node, every argument and what it will set (ADR 0563 §3). -func (h *HandActs) bound(a HandAct) asks.Act { - act := asks.Act{"verb": a.Act, "node": h.Node, "arg.subject": a.Subject, "arg.why": a.Why, "sets": a.Sets} - if a.Path != "" { - act["path"] = a.Path - } - return act -} - -// AskHandOver is hand-over: judged, refused in words or asked of the operator. auto is the engine asking by itself. -func (h *HandActs) AskHandOver(caller, path, why string, auto bool) (any, error) { - if h.JudgeHandOver == nil || h.HandOver == nil { - return nil, errors.New("this engine takes no hand-over") - } - j, err := h.JudgeHandOver(path) - if err != nil { - return nil, err - } - return h.ask(ActHandOver, caller, why, auto, j) -} - -// AskAccess is access-act: the act take on an access, judged on the library's home, refused or asked of the operator. -func (h *HandActs) AskAccess(caller, act, access, why string, auto bool) (any, error) { - if h.JudgeAccess == nil || h.AccessAct == nil { - return nil, errors.New("this engine runs no act on an accessed folder") - } - if act != ActTake { - return nil, fmt.Errorf("the act asked is %q, and the one act on an access is %q", act, ActTake) - } - j, err := h.JudgeAccess(act, access) - if err != nil { - return nil, err - } - return h.ask(ActAccess, caller, why, auto, j) -} - -// AskedFor says whether an act on subject was ever asked by the engine itself: a library's take is asked by itself once -// (ADR 0563 §4), and again only by hand. -func (h *HandActs) AskedFor(act, subject string) bool { - if act == ActAccess { - e, err := h.State.Get(libKey(subject)) - return err != nil || e != nil // unread is never a reason to ask again - } - acts, err := h.read() - if err != nil { - return true - } - for _, a := range acts { - if a.Act == act && a.Subject == subject && (a.Auto || a.State == HandAsked) { - return true - } - } - return false -} - -func (h *HandActs) ask(act, caller, why string, auto bool, j Judged) (any, error) { - if h.Asker == nil { - return nil, errors.New("this engine reaches no operator to ask: nothing was asked and nothing changed") - } - h.mu.Lock() - defer h.mu.Unlock() - now := h.now().UTC() - a := HandAct{ID: h.id(act), Act: act, Subject: j.Subject, Path: j.Path, Caller: caller, Why: why, At: now, - State: HandAsked, Sets: j.Sets, Disk: j.Disk, Expires: now.Add(asks.ApproveLasts - time.Minute), Auto: auto} - a.Ask = a.ID - // One ask open per path or access: its open key made by a create, which a second ask cannot make. - if _, err := h.State.Create(openKey(act, j.Subject), map[string]string{"ask": a.Ask}); errors.Is(err, stdio.ErrChanged) { - held := "another" - if e, _ := h.State.Get(openKey(act, j.Subject)); e != nil { - var o struct { - Ask string `json:"ask"` - } - if json.Unmarshal(e.Value, &o) == nil && o.Ask != "" { - held = o.Ask - } - } - return nil, fmt.Errorf("%s of %s is asked already: the ask %s waits for the operator's answer", act, j.Subject, held) - } else if err != nil { - return nil, err - } - if err := h.record(a); err != nil { - _ = h.State.Delete(openKey(act, j.Subject)) - return nil, err - } - bind, err := asks.ActDigest(h.bound(a)) - if err != nil { - return nil, err - } - headline, does, stays := "Hand a directory on "+h.Node+" to the mesh?", "The mesh gives it "+j.Sets+".", - "The directory stays as it was found." - if act == ActAccess { - headline, does, stays = "Keep the library "+strings.TrimPrefix(j.Subject, "library:")+" on "+h.Node+"?", - "The library's group, group write and setgid are set on everything under its root.", - "The library waits and nothing under its root changes." - } - if len([]rune(headline)) > asks.HeadlineLength { - headline = string([]rune(headline)[:asks.HeadlineLength]) - } - asked := asks.Ask{ID: a.Ask, Headline: headline, Who: asks.Operator, - Explanation: j.Words + " Your yes sets " + j.Sets + "; nothing changes until you answer.", - Whole: j.Words + " (" + j.Path + ") Your yes sets " + j.Sets + "; nothing changes until you answer.", - Details: "Asked by " + caller + " on " + h.Node + orWhy(why), - Options: []asks.Option{{ID: "yes", Label: "Yes", Does: does, Level: asks.Approve, Binds: bind}, - {ID: "no", Label: "No, leave it", Does: stays, Level: asks.Acknowledge}}, - Expires: a.Expires, OnExpiry: stays, About: EngineSeat + "-" + h.Node + "-" + act} - if err := h.Asker.Ask(asked); err != nil { - _, _, _ = h.end(a.Ask, func(x *HandAct) { x.State, x.Said = HandRefused, "the operator could not be asked: "+err.Error() }) - return nil, fmt.Errorf("the operator could not be asked, and nothing changed: %w", err) - } - // Once asked, a library's own take ask is kept's own take ask: once, in a key of its own (ADR 0563 §4). - if auto && act == ActAccess { - _, _ = h.State.Create(libKey(j.Subject), map[string]any{"ask": a.Ask, "at": now}) - } - h.say(HandAsked, a) - return map[string]any{"asked": a.Ask, "node": h.Node, "says": "the operator is asked on their channel; only " + - "their yes changes anything. acts shows where it stands."}, nil -} - -func orWhy(why string) string { - if why == "" { - return "" - } - return ": " + why -} - -// Warranted is the act on the operator's answer, handed once by asker.Client.Take after it held the warrant to the -// ask: a no is recorded refused; a yes is held to the act the option bound and the disk as judged, judged again now, -// and only then performed and recorded done with the warrant. -func (h *HandActs) Warranted(o asks.Option, w asks.Warrant) error { - a, _, err := h.readOne(actKey(w.Ask)) - if err != nil { - return err - } - if a == nil || a.State != HandAsked { - return fmt.Errorf("the warrant answers the ask %s, which is not open here: nothing is done", w.Ask) - } - refuse := func(why string) error { - ended, ok, err := h.end(w.Ask, func(x *HandAct) { x.State, x.Said = HandRefused, why; warrantOn(x, w) }) - if err == nil && ok { - h.say(HandRefused, ended) - } - return err - } - if o.ID != "yes" { - return refuse("the operator said no") - } - var j Judged - switch a.Act { - case ActHandOver: - j, err = h.JudgeHandOver(a.Subject) - case ActAccess: - j, err = h.JudgeAccess(ActTake, a.Subject) - default: - err = fmt.Errorf("the act %s is not performed on a warrant", a.Act) - } - if err != nil { - return refuse("judged again on the operator's yes, it is refused: " + err.Error()) - } - now := *a - now.Sets, now.Path = j.Sets, j.Path - if err := o.Performs(h.bound(now)); err != nil { - return refuse("what the yes bound is not what would be done now: " + err.Error()) - } - if j.Disk != a.Disk { - return refuse("the disk changed since the operator was asked (" + a.Disk + ", now " + j.Disk + "): ask again") - } - // Recorded with the way it came (ADR 0563 §5): the operator's yes to this engine's ask, not a terminal's line. - by := operatorOf(w) + ", to " + EngineSeat + "'s ask " + w.Ask - var said string - switch a.Act { - case ActHandOver: - said, err = h.HandOver(a.Subject, by) - case ActAccess: - said, err = h.AccessAct(AccessAct{Act: ActTake, Access: a.Subject, Path: j.Path, Warrant: w.Ask, By: by}) - } - if err != nil { - return refuse(err.Error()) - } - ended, ok, err := h.end(w.Ask, func(x *HandAct) { x.State, x.Said = HandDone, said; warrantOn(x, w) }) - if err == nil && ok { - h.say(HandDone, ended) - } - return err -} - -func warrantOn(a *HandAct, w asks.Warrant) { - a.Proofs = w.Proofs - if w.By != nil { - a.By, a.Via = w.By.Who, w.By.Kind - if w.By.Verified != "" { - a.Via += " (" + w.By.Verified + ")" - } - } -} - -func operatorOf(w asks.Warrant) string { - if w.By == nil { - return "the operator" - } - return "the " + w.By.Who + " via " + w.By.Kind -} - -// Heard is a warrant heard on the engine's decided subject: taken once (asker.Client.Take), and an ask that ended with -// no choice recorded refused. -func (h *HandActs) Heard(w asks.Warrant) { - if h.Asker == nil { - return - } - // A warrant the ask kept here does not hold to (another digest, another option, too late) ends nothing: it is no - // answer to the ask, which stays open for the operator's. - if handled, _ := h.Asker.Take(w, h.Warranted); handled == asker.Recorded { - why := "the ask ended " + string(w.Outcome) - if w.Words != "" { - why += ": " + w.Words - } - if ended, ok, _ := h.end(w.Ask, func(x *HandAct) { x.State, x.Said = HandRefused, why; warrantOn(x, w) }); ok { - h.say(HandRefused, ended) - } - } -} - -// Reconcile catches up what the engine missed while it was away: a warrant the router holds for an open ask is taken, -// and an ask past its expiry and a margin is closed refused, expired. -func (h *HandActs) Reconcile() { - if h.Asker == nil { - return - } - acts, err := h.read() - if err != nil { - return - } - for _, a := range acts { - if a.State != HandAsked || a.Ask == "" { - continue - } - if _, w, err := h.Asker.Where(a.Ask); err == nil && w != nil { - h.Heard(*w) - continue - } - if !a.Expires.IsZero() && h.now().After(a.Expires.Add(askMargin)) { - if ended, ok, _ := h.end(a.Ask, func(x *HandAct) { x.State, x.Said = HandRefused, "the ask expired unanswered" }); ok { - h.say(HandRefused, ended) - } - } - } -} - -// MemState is an ActStore in memory: an engine's acts while no bus is reached in a test. -type MemState struct { - mu sync.Mutex - keys map[string]stdio.StateEntry - top uint64 -} - -func (m *MemState) Get(key string) (*stdio.StateEntry, error) { - m.mu.Lock() - defer m.mu.Unlock() - if e, ok := m.keys[key]; ok { - return &e, nil - } - return nil, nil -} - -func (m *MemState) Create(key string, value any) (uint64, error) { return m.put(key, value, 0, true) } - -func (m *MemState) Update(key string, value any, revision uint64) (uint64, error) { - return m.put(key, value, revision, false) -} - -func (m *MemState) put(key string, value any, revision uint64, create bool) (uint64, error) { - raw, err := json.Marshal(value) - if err != nil { - return 0, err - } - m.mu.Lock() - defer m.mu.Unlock() - was, kept := m.keys[key] - if (create && kept) || (!create && (!kept || was.Revision != revision)) { - return 0, stdio.ErrChanged - } - if m.keys == nil { - m.keys = map[string]stdio.StateEntry{} - } - m.top++ - m.keys[key] = stdio.StateEntry{Key: key, Value: raw, Revision: m.top} - return m.top, nil -} - -func (m *MemState) Delete(key string) error { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.keys, key) - return nil -} - -func (m *MemState) Keys(prefix string) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - var out []string - for k := range m.keys { - if strings.HasPrefix(k, prefix) { - out = append(out, k) - } - } - sort.Strings(out) - return out, nil -} diff --git a/internal/link/handover.go b/internal/link/handover.go index e6103f0..1c5188b 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -76,13 +76,9 @@ const MaxHandOverBy = 200 // HandOverCheck is what an ask is held to: the mesh's key, this node's name, the clock, and the nonces taken. type HandOverCheck struct { Signer ed25519.PublicKey - // SignerNow, when set, is the controller's key as the engine trusts it at this ask, read per ask in place of Signer: - // nil once the controller's lease is handed away, which refuses every ask signed by its key (novox/hq ADR 0563 §7, - // ADR 0550 §2). - SignerNow func() ed25519.PublicKey - Node string - Now func() time.Time - Nonces *Nonces + Node string + Now func() time.Time + Nonces *Nonces } // Nonces are the nonces of asks taken, each kept until its ask expires: an ask is taken once. Kept in memory — @@ -161,14 +157,8 @@ func believedBytesWithin(check HandOverCheck, context string, withPath bool, bod if err := json.Unmarshal(body, &signed); err != nil { return HandOverAsk{}, nil, "the ask is not a signed ask: " + err.Error() } - signer := check.Signer - if check.SignerNow != nil { - if signer = check.SignerNow(); signer == nil { - return HandOverAsk{}, nil, "the controller's lease is handed away, and no ask signed by its key is taken" - } - } - if len(signer) != ed25519.PublicKeySize || - !ed25519.Verify(signer, append([]byte(context), signed.Ask...), signed.Signature) { + if len(check.Signer) != ed25519.PublicKeySize || + !ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) { return HandOverAsk{}, nil, "the ask is not signed by the mesh" } var ask HandOverAsk diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index a19d009..3442a33 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -9,9 +9,6 @@ import ( "strings" "testing" "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "github.com/nats-io/nats.go" ) // The hand-over's ask and answer hold these field names; the controller's side holds the same list @@ -148,21 +145,6 @@ func TestAHandOverAskIsBelievedOnlySignedForThisNodeUnexpiredAndOnce(t *testing. if strings.Join(given, "; ") != "/srv/notes by jo through mesh-cli on anchor; /refused by jo through mesh-cli on anchor" { t.Fatalf("the handler was given %v", given) } - // ADR 0563 §7, ADR 0550 §2: after the lease write the engine answers no ask signed by the controller's key. The key - // is read per ask from what the engine trusts now; nil (the lease handed away) refuses, and nothing is handed over. - trusted := m.Signer - following := HandOverCheck{Signer: m.Signer, SignerNow: func() ed25519.PublicKey { return trusted }, Node: m.Node, - Now: func() time.Time { return now }, Nonces: &Nonces{}} - before := len(given) - trusted = nil - late := signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now.Add(time.Minute); a.Nonce = strings.Repeat("cd", 16) })) - if a := AnswerHandOver(following, handle, late); !strings.Contains(a.Refused, "lease is handed away") || len(given) != before { - t.Fatalf("after the lease write an ask signed by the controller was answered %+v", a) - } - trusted = m.Signer - if a := AnswerHandOver(following, handle, late); a.Refused != "" || len(given) != before+1 { - t.Fatalf("while the controller holds the lease its ask was answered %+v", a) - } // A nonce is forgotten once its ask has expired, so the set does not grow for ever. now = now.Add(HandOverGood + time.Second) check.Nonces.take("ff", now.Add(time.Second), now) @@ -192,23 +174,17 @@ func TestAHandOverIsAnsweredOnlyWhenItCarriesAReply(t *testing.T) { // handingLink is a quiet link that hears hand-overs, keeping the handler it was given. type handingLink struct { *quietLink - given chan HandOverFunc - signer chan func() ed25519.PublicKey + given chan HandOverFunc } func (l handingLink) HearHandOvers(h HandOverFunc) { l.given <- h } -// FollowSigner keeps what the link is told to verify the controller's asks with. -func (l handingLink) FollowSigner(f func() ed25519.PublicKey) { l.signer <- f } - // **The link is given the queue's hand-over** when it is served: what the controller asks reaches the engine's // judgement, and nothing else. func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { m, _ := aMember(t) - l := handingLink{quietLink: newQuietLink(), given: make(chan HandOverFunc, 1), signer: make(chan func() ed25519.PublicKey, 1)} + l := handingLink{quietLink: newQuietLink(), given: make(chan HandOverFunc, 1)} q := &Session{Membership: m, Timeout: time.Second} - // The link verifies the controller's asks with the key the engine trusts now (ADR 0563 §7). - q.ControllerKey = func() ed25519.PublicKey { return nil } q.HandOver = func(path, by string) (string, error) { return "the queue's: " + path + " by " + by, nil } ctx, stop := context.WithCancel(context.Background()) served := make(chan error, 1) @@ -221,40 +197,8 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("the link was never given the queue's hand-over") } - select { - case f := <-l.signer: - if f() != nil { - t.Fatal("the link was given another key than the queue's") - } - case <-time.After(5 * time.Second): - t.Fatal("the link was never given the key the engine trusts now") - } stop() <-served - // 774 (ADR 0563 §1): a call of the node seat reaches its verb with its caller as the subject proves it, unproven on - // the subject that names none; a verb the seat does not serve is refused, and the answer is a tool's envelope. - t.Run("a call of the node seat reaches its verb with its proven caller", func(t *testing.T) { - h := &HandActs{Node: "n1", State: &MemState{}, - RootFree: func() RootVerdict { return RootVerdict{Machine: "n1", Free: true, Why: "judged"} }, - JudgeDirectories: func(by string) (string, error) { return "judged for " + by, nil }} - for _, row := range []struct{ verb, subject, want string }{ - {"judge-directories", "mesh.seat.node-engine.call.judge-directories.n1.shanks~node-tools", `"said":"judged for shanks.node-tools"`}, - {"judge-directories", EngineToolSubject("judge-directories", "n1"), `"said":"judged for unproven"`}, - {"judge-directories", "mesh.seat.node-engine.call.judge-directories.n2.shanks~node-tools", `"said":"judged for unproven"`}, - {"root-free", "mesh.seat.node-engine.call.root-free.n1.ace~messenger", `"free":true`}, - {"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`}, - } { - if got := string(AnswerEngineCall(h.Call, row.verb, "n1", row.subject, []byte(`{}`))); !strings.Contains(got, row.want) || - !strings.Contains(got, `"node":"n1"`) { - t.Errorf("%s on %s: %s", row.verb, row.subject, got) - } - } - acts, _ := h.Acts(0) - if got := acts.(map[string]any)["acts"].([]HandAct); len(got) != 3 || got[0].Caller != "unproven" || - got[2].Caller != "shanks.node-tools" || got[2].Act != ActJudgeDirectories { - t.Errorf("the judgements are recorded as %+v", got) - } - }) } // **Against a real server**: a signed request on the node's own subject is answered; one on another node's is @@ -293,63 +237,4 @@ func TestNatsAHandOverIsAskedOfTheNode(t *testing.T) { if len(handled) != 1 { t.Fatalf("handled %d asks; want only the one with a reply", len(handled)) } - // 774 (ADR 0563 §2, §3): the engine's acts are its node's keys of its declared state on the bus, each written by - // compare-and-set: a second create and a stale revision lose, and another node's keys are not this node's. - t.Run("the acts are kept in the bus's own storage under this node's keys", func(t *testing.T) { - _ = js.DeleteKeyValue(ActsBucket) - if _, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: ActsBucket}); err != nil { - t.Fatal(err) - } - q := &Session{Membership: m, Timeout: time.Second} - q.bus = l - mine, theirs := BusState{Session: q, Node: m.Node}, BusState{Session: q, Node: "another"} - rev, err := mine.Create("act.a-1", map[string]string{"state": "asked"}) - if err != nil { - t.Fatal(err) - } - if _, err := mine.Create("act.a-1", map[string]string{}); !errors.Is(err, stdio.ErrChanged) { - t.Errorf("a second create: %v", err) - } - if _, err := mine.Update("act.a-1", map[string]string{"state": "done"}, rev+7); !errors.Is(err, stdio.ErrChanged) { - t.Errorf("an update at a stale revision: %v", err) - } - if _, err := mine.Update("act.a-1", map[string]string{"state": "done"}, rev); err != nil { - t.Errorf("an update at its revision: %v", err) - } - _, _ = theirs.Create("act.b-1", map[string]string{}) - if keys, err := mine.Keys("act."); err != nil || len(keys) != 1 || keys[0] != "act.a-1" { - t.Errorf("this node's acts are %v, %v", keys, err) - } - if e, _ := js.KeyValue(ActsBucket); e != nil { - if got, err := e.Get(m.Node + ".act.a-1"); err != nil || !strings.Contains(string(got.Value()), "done") { - t.Errorf("the bucket holds %v, %v", got, err) - } - } - if err := mine.Delete("act.a-1"); err != nil { - t.Fatal(err) - } - if e, err := mine.Get("act.a-1"); err != nil || e != nil { - t.Errorf("a deleted act reads %v, %v", e, err) - } - }) - // 774 (ADR 0563 §1): the node seat is served on this node's subjects alone, naming its caller, and said to discovery. - t.Run("the node seat is served on this node and discovered", func(t *testing.T) { - l.ServeEngineSeat(func(c EngineCall) (any, error) { return c.Verb + " for " + c.Caller, nil }) - reply, err := conn.Request("mesh.seat.node-engine.call.acts."+m.Node+".shanks~node-tools", []byte(`{}`), 5*time.Second) - if err != nil || !strings.Contains(string(reply.Data), `"result":"acts for shanks.node-tools"`) { - t.Fatalf("the call answered %v, %v", reply, err) - } - if _, err := conn.Request(EngineToolSubject("acts", "another"), []byte(`{}`), 300*time.Millisecond); err == nil { - t.Fatal("another node's verb was answered here") - } - for seat, want := range map[string]bool{EngineSeat: true, "node-login-shell": false} { - verb := "acts" - if !want { - verb = "execute" - } - if heard, err := l.HeardServed(seat, verb); err != nil || heard != want { - t.Errorf("discovery hears %s.%s here: %v, %v", seat, verb, heard, err) - } - } - }) } diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 86f5a95..4280811 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -274,8 +274,7 @@ func TestTheReportIsSentFirstAndTheNodeKeyCarriesTheNodesFacts(t *testing.T) { // 1126 (ADR 0555 §3): with its keys, the node key carries the node's record: the profile's present capabilities and // networks, the outward links, the health's contract and the public keys; a report that changes none of it writes // nothing, a changed fact writes the key once, and a report without a profile or health leaves them standing. - keys := RecordKeys{TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", Certificate: "cert", - Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}} // and what it trusts (L3 s6, ADR 0550 §2) + keys := RecordKeys{TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", Certificate: "cert"} q.Keys = func() RecordKeys { return keys } rep.Profile = map[string]any{"capabilities": []any{map[string]any{"name": "systemd", "present": true}, map[string]any{"name": "docker", "present": false}, map[string]any{"name": "apt", "present": true}}, @@ -287,7 +286,7 @@ func TestTheReportIsSentFirstAndTheNodeKeyCarriesTheNodesFacts(t *testing.T) { t.Fatalf("the node key holds no record: %+v, %v", got, err) } wantRecord := delivery.NodeRecord{V: delivery.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", - Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, + Certificate: "cert", Capabilities: []string{"apt", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract} if !delivery.SameNodeRecord(*got.Record, wantRecord) { t.Errorf("record = %+v, want %+v", *got.Record, wantRecord) diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index 9bd0cde..da68fa2 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -6,11 +6,9 @@ import ( "errors" "fmt" "strings" - "sync" "time" "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/micro" ) // The host's link on the bus: one connection, and what is asked of this node over it. The node hears no declaration @@ -37,14 +35,11 @@ func natsURL(address string) string { // natsLink is this node's connection as a JetStream subscription. type natsLink struct { - // mu guards signerNow (FollowSigner). - mu sync.Mutex - signerNow func() ed25519.PublicKey - conn *nats.Conn - js nats.JetStreamContext - asking *nats.Subscription - asked chan struct{} - handing *nats.Subscription + conn *nats.Conn + js nats.JetStreamContext + asking *nats.Subscription + asked chan struct{} + handing *nats.Subscription // searching hears the controller's terminal asking for a fresh setuid search (novox/hq issue 361). searching *nats.Subscription // judging hears the controller asking for a read-only judgement of this node's directories (issue 358). @@ -53,10 +48,7 @@ type natsLink struct { acting *nats.Subscription // moving hears the controller asking to move a value sealed to this machine into the vault (issue 676). moving *nats.Subscription - // engine serves the node seat node-engine, and warrants hears the operator's answers to its asks (ADR 0563). - engine micro.Service - warrants *nats.Subscription - node string + 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 lost chan error @@ -149,21 +141,6 @@ func (l *natsLink) hearAsks() { } } -// FollowSigner has every signed ask heard on this link verified with the key the engine trusts at that ask, the -// controller's while it holds its lease and none from its hand-over (novox/hq ADR 0563 §7, ADR 0550 §2). -func (l *natsLink) FollowSigner(f func() ed25519.PublicKey) { - l.mu.Lock() - l.signerNow = f - l.mu.Unlock() -} - -// check is how a signed ask is believed on this link: the static membership signer until FollowSigner names the trust. -func (l *natsLink) check() HandOverCheck { - l.mu.Lock() - defer l.mu.Unlock() - return HandOverCheck{Signer: l.signer, SignerNow: l.signerNow, Node: l.node} -} - // HearHandOvers listens for the controller asking this node to hand a directory used as found to the mesh // (novox/hq issue 356): a request on this machine's own subject, answered once on the reply it carried. The // ask is verified with the mesh's key before anything is read out of it (AnswerHandOver), because a responder @@ -174,7 +151,7 @@ func (l *natsLink) HearHandOvers(handle HandOverFunc) { if handle == nil { return } - check := l.check() + check := HandOverCheck{Signer: l.signer, Node: l.node} handing, err := l.conn.Subscribe(AskHandOverSubject(l.node), func(msg *nats.Msg) { if body, answer := HandOverReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) @@ -193,7 +170,7 @@ func (l *natsLink) HearSetuidSearches(handle SetuidSearchFunc) { if handle == nil { return } - check := l.check() + 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) @@ -212,7 +189,7 @@ func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) { if handle == nil { return } - check := l.check() + 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) @@ -230,7 +207,7 @@ func (l *natsLink) HearAccessActs(handle AccessActFunc) { if handle == nil { return } - check := l.check() + check := HandOverCheck{Signer: l.signer, Node: l.node} acting, err := l.conn.Subscribe(AskAccessActSubject(l.node), func(msg *nats.Msg) { if body, answer := AccessActReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) @@ -248,7 +225,7 @@ func (l *natsLink) HearMoveSecrets(handle MoveSecretFunc) { if handle == nil { return } - check := l.check() + check := HandOverCheck{Signer: l.signer, Node: l.node} moving, err := l.conn.Subscribe(AskMoveSecretSubject(l.node), func(msg *nats.Msg) { if body, answer := MoveSecretReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) @@ -281,12 +258,6 @@ func (l *natsLink) Close() { if l.judging != nil { _ = l.judging.Unsubscribe() } - if l.engine != nil { - _ = l.engine.Stop() - } - if l.warrants != nil { - _ = l.warrants.Unsubscribe() - } if l.conn != nil { l.conn.Close() } @@ -330,12 +301,10 @@ func (l *natsLink) Ask(ctx context.Context, subject string, body []byte) ([]byte // What the queue asks of a live link beside Bus, each by a type assertion that fails quietly: said here // so a link that drops one does not build. var ( - _ Bus = (*natsLink)(nil) - _ HealthBus = (*natsLink)(nil) - _ HaveBus = (*natsLink)(nil) - _ ToolBus = (*natsLink)(nil) - _ Asker = (*natsLink)(nil) - _ Asked = (*natsLink)(nil) - _ EngineSeats = (*natsLink)(nil) - _ OperatorAsks = (*natsLink)(nil) + _ Bus = (*natsLink)(nil) + _ HealthBus = (*natsLink)(nil) + _ HaveBus = (*natsLink)(nil) + _ ToolBus = (*natsLink)(nil) + _ Asker = (*natsLink)(nil) + _ Asked = (*natsLink)(nil) ) diff --git a/internal/link/record.go b/internal/link/record.go index f176bab..1dc28a6 100644 --- a/internal/link/record.go +++ b/internal/link/record.go @@ -23,8 +23,6 @@ type RecordKeys struct { Certificate string // Authority is the mesh's own certificate as this machine last applied it (public). Authority string - // Trusts is what this machine trusts a mesh signature from now (delivery.Trusts, ADR 0550 §2). - Trusts []string } // recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that @@ -47,7 +45,7 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { return nil } rec := &delivery.NodeRecord{V: delivery.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, - ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts} + ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority} for _, c := range p.Capabilities { if c.Present && c.Name != "" { rec.Capabilities = append(rec.Capabilities, c.Name) diff --git a/internal/link/run.go b/internal/link/run.go index eac39ef..3d17258 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -7,8 +7,6 @@ import ( "errors" "fmt" "time" - - meshasks "git.novox.be/novox/mesh-sdk/go/asks" ) // Membership is what a node needs to reach its mesh again, held by the caller. @@ -187,12 +185,6 @@ func serve(ctx context.Context, link Link, m Membership, queue *Session, say Ann } // And the controller asking this node to hand a directory used as found to the mesh (novox/hq issue // 356), answered on the link the question came over. - // Every signed ask is verified with the key trusted at that ask, none after the lease write (ADR 0563 §7). - if f, ok := link.(interface { - FollowSigner(func() ed25519.PublicKey) - }); ok && queue.ControllerKey != nil { - f.FollowSigner(queue.ControllerKey) - } if h, ok := link.(HandOvers); ok { h.HearHandOvers(queue.HandOver) } @@ -208,19 +200,6 @@ func serve(ctx context.Context, link Link, m Membership, queue *Session, say Ann if s, ok := link.(AccessActs); ok { s.HearAccessActs(queue.AccessAct) } - // And its node seat, node-engine, with the operator's answers to its own asks (novox/hq ADR 0563): served on this - // link, and what was answered while no link was open caught up from the router's records. - if queue.Acts != nil { - if s, ok := link.(EngineSeats); ok { - s.ServeEngineSeat(queue.Acts.Call) - } - if a, ok := link.(OperatorAsks); ok { - if err := a.HearWarrants(meshasks.EngineAsker(m.Node), queue.Acts.Heard); err != nil { - say("cannot hear the operator's answers to this engine's asks: " + err.Error()) - } - go queue.Acts.Reconcile() - } - } // And asking to move a value sealed to this machine into the vault (design 57's V3, issue 676). if s, ok := link.(MoveSecrets); ok { s.HearMoveSecrets(queue.MoveSecret) diff --git a/internal/link/session.go b/internal/link/session.go index e33314d..31f8a5a 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -2,14 +2,12 @@ package link import ( "context" - "crypto/ed25519" "encoding/json" "errors" "fmt" "sync" "time" - stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -49,12 +47,6 @@ type Session struct { // MoveSecret moves one value sealed to this machine into the vault, asked by the controller during the move of // the secrets (design 57's V3, issue 676). Nil answers every such ask with a refusal. MoveSecret MoveSecretFunc - // ControllerKey is the controller's key as the engine trusts it now: nil once its lease is handed away (novox/hq - // ADR 0563 §7). Each link verifies every signed ask with it, read per ask; nil ControllerKey keeps the membership's. - ControllerKey func() ed25519.PublicKey - // Acts is the node seat node-engine this engine holds (novox/hq ADR 0563): served on every link, and its asks to the - // operator asked and heard over it. Nil serves no seat. - Acts *HandActs // Keys reads the node's public keys and its certificate for its record (ADR 0555 §3), at each facts report; nil writes // no record. Keys func() RecordKeys @@ -345,49 +337,3 @@ func (q *Session) AskSeat(ctx context.Context, seat, verb string, args any, with } return env.Result, nil } - -// PublishAsk publishes an ask or a cancel to the operator's router over the link open now (novox/hq ADR 0563 §3). -func (q *Session) PublishAsk(subject string, body any, id string) error { - asking, err := q.operatorAsks() - if err != nil { - return err - } - return asking.PublishAsk(subject, body, id) -} - -// RouterRecord reads one key of the router's records over the link open now. -func (q *Session) RouterRecord(key string) (*stdio.StateEntry, error) { - asking, err := q.operatorAsks() - if err != nil { - return nil, err - } - return asking.RouterRecord(key) -} - -// SayEngine says one of the node seat's events over the link open now; with none open it is not said. -func (q *Session) SayEngine(event string, body any) { - q.init() - q.mu.Lock() - bus := q.bus - q.mu.Unlock() - if sayer, ok := bus.(interface{ SayEngine(string, any) error }); ok { - if err := sayer.SayEngine(event, body); err != nil { - q.Say("could not say the node seat's " + event + ": " + err.Error()) - } - } -} - -func (q *Session) operatorAsks() (OperatorAsks, error) { - q.init() - q.mu.Lock() - bus := q.bus - q.mu.Unlock() - if bus == nil { - return nil, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked) - } - asking, ok := bus.(OperatorAsks) - if !ok { - return nil, errNoAsking - } - return asking, nil -} diff --git a/internal/link/setuid_search_test.go b/internal/link/setuid_search_test.go index 8b333e7..5e90229 100644 --- a/internal/link/setuid_search_test.go +++ b/internal/link/setuid_search_test.go @@ -57,82 +57,4 @@ func TestASetuidSearchAskIsBelievedOnlySignedForThisNodeAndOnce(t *testing.T) { if a := AnswerSetuidSearch(check, nil, body); a.Refused == "" { t.Fatalf("no handler: %+v", a) } - // 774 (ADR 0563 §2): the node seat's setuid-search starts none while one runs, at most one in any hour, naming when the - // next may start, and records who asked. - t.Run("the node seat's search is bounded", func(t *testing.T) { - at, running, started := now, false, 0 - h := &HandActs{Node: "n1", State: &MemState{}, Now: func() time.Time { return at }, - Searching: func() bool { return running }, - SetuidSearch: func(by string) (string, error) { started++; return "a new search starts", nil }} - for _, row := range []struct { - name string - after time.Duration - running bool - refused string - started int - }{ - {name: "one runs", running: true, refused: "runs on this machine now", started: 0}, - {name: "the first", started: 1}, - {name: "within the hour", after: 59 * time.Minute, refused: "the next may start at 2026-10-10T03:00:00Z", started: 1}, - {name: "after the hour", after: time.Hour + time.Second, started: 2}, - } { - at, running = now.Add(row.after), row.running - _, err := h.Call(EngineCall{Verb: ActSetuidSearch, Caller: "ace.node-tools"}) - if (row.refused == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), row.refused)) || started != row.started { - t.Errorf("%s: %v, %d started", row.name, err, started) - } - } - acts, _ := h.Acts(1) - if got := acts.(map[string]any)["acts"].([]HandAct); len(got) != 1 || got[0].Caller != "ace.node-tools" || got[0].State != HandDone { - t.Errorf("the last search is recorded as %+v", got) - } - // The bound is kept in a key of its own (rev-l3 round 1): two hundred judgements before a search, or the acts - // gone from the record, never let a second search start within the hour. - h.JudgeDirectories = func(string) (string, error) { return "judged", nil } - for range 200 { - if _, err := h.Call(EngineCall{Verb: ActJudgeDirectories, Caller: "ace.node-tools"}); err != nil { - t.Fatal(err) - } - } - at = now.Add(time.Hour + 2*time.Minute) - if _, err := h.Call(EngineCall{Verb: ActSetuidSearch, Caller: "ace.node-tools"}); err == nil || !strings.Contains(err.Error(), "the next may start") { - t.Errorf("a search after two hundred judgements: %v", err) - } - keys, _ := h.State.Keys("act.") - for _, k := range keys { - _ = h.State.Delete(k) - } - if _, err := h.Call(EngineCall{Verb: ActSetuidSearch, Caller: "ace.node-tools"}); err == nil || !strings.Contains(err.Error(), "the next may start") || started != 2 { - t.Errorf("a search with the acts gone: %v, %d started", err, started) - } - // rev-l3 round 2 (ADR 0563 §2, a wrong caller gains nothing): judgements and refused searches, which any caller - // may ask, keep one rolling key of the node's, so ten thousand of them write a bounded number of bytes and add no - // key to the bucket every node shares. - keysNow := func() (int, int) { - all, _ := h.State.Keys("") - size := 0 - for _, k := range all { - if e, _ := h.State.Get(k); e != nil { - size += len(e.Value) - } - } - return len(all), size - } - _, _ = h.Call(EngineCall{Verb: ActJudgeDirectories, Caller: "unproven"}) - keysBefore, _ := keysNow() - for i := range 10000 { - verb := ActJudgeDirectories - if i%10 == 0 { - verb = ActSetuidSearch - } - _, _ = h.Call(EngineCall{Verb: verb, Caller: "unproven"}) - } - if n, size := keysNow(); n != keysBefore || size > 64<<10 { - t.Errorf("ten thousand judgements and refused searches left %d keys (was %d) of %d bytes", n, keysBefore, size) - } - listed, _ := h.Acts(0) - if got := listed.(map[string]any)["acts"].([]HandAct); len(got) == 0 || got[0].Caller != "unproven" { - t.Errorf("acts does not show the latest judgement first: %d", len(got)) - } - }) } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 8a596e8..fb14265 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -12,9 +12,7 @@ package selfcompose import ( "context" - "crypto/ed25519" "crypto/sha256" - "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -30,7 +28,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/compose" "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/nats-io/nats.go" @@ -78,13 +75,6 @@ type Env struct { // Fetch reads an archive at a URL in the artifact store: a moved module's templates archive, whose texts its built // manifest carries (issue 1126). Read once per digest. Nil reads none, and a moved module does not compose. Fetch func(ctx context.Context, url string) ([]byte, error) - // VaultAuthority is the vault's authority as this node read it from the vault's `signers` (ADR 0550 §2), composed - // beside the controller's as the mesh's authority (frombus.SignersReader); "" or nil composes the controller's alone. - VaultAuthority func() string - // Certify asks the vault's `sign` for this node's own certificate, keeps it and writes the node's record again (ADR - // 0550 §2): asked only when a module placed here asks for a certificate and the record holds none from an authority - // the node trusts. Nil asks nothing, and such a module stands in, as before. - Certify func(ctx context.Context) error } // VaultSecretChanged is the vault's event that a secret changed: a name and its generation, never a value. @@ -109,8 +99,6 @@ type Composer struct { // sealedOwn is each own secret's sealed value as first handed, by module/name and generation: a value sealed again // is other bytes, so the one first had is kept while the generation stands, and the composition stays the same. sealedOwn map[string]ownSealed - // certified is the serving key a certificate was last asked for successfully, so it is asked once per key. - certified string said string refusal string // held is what the composer's last pass held: each module refused alone, and the node's own ("node") when it could @@ -513,8 +501,6 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { if c.env.Running != nil { running = c.env.Running() } - // Its own certificate, asked of the vault when a module here asks for one and the record holds none it trusts. - c.certifyIfWanted(ctx, r, own) scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running} missed := c.missed.Load() compositions, _, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps) @@ -760,69 +746,6 @@ func (c *Composer) Policy(ctx context.Context, module string) string { // reader is the composer's view of the bus as frombus reads it. type reader struct{ c *Composer } -// VaultAuthority is the vault's authority the node follows (frombus.SignersReader). -func (r reader) VaultAuthority(context.Context) string { - if r.c.env.VaultAuthority == nil { - return "" - } - return r.c.env.VaultAuthority() -} - -var _ frombus.SignersReader = reader{} - -// certificateWanted says whether a module the placement p puts on this node asks for a certificate for the machine's -// internal name (its manifest's `certificate`), read from the indexer's manifests; a manifest that cannot be read is -// taken as asking none, and the composition says what it misses. -func (c *Composer) certificateWanted(ctx context.Context, r reader, p delivery.Placement) bool { - for _, m := range p.Modules { - c.mu.Lock() - digest := c.modules[m.Module].Manifest - c.mu.Unlock() - if digest == "" { - continue - } - raw, err := r.Manifest(ctx, digest) - if err != nil { - continue - } - if parsed, err := catalogue.ParseManifestForInputs(raw); err == nil && parsed.Certificate != nil { - return true - } - } - return false -} - -// certifyIfWanted asks Certify once per serving key when a module placed here asks for a certificate and this node's -// record holds none for its internal name and serving key from an authority it trusts (the one it holds, or the vault's). -func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p delivery.Placement) { - if c.env.Certify == nil || !c.certificateWanted(ctx, r, p) { - return - } - c.mu.Lock() - rec, ok := c.records[c.env.Node] - asked := c.certified - c.mu.Unlock() - if !ok || rec.ServingKey == "" || asked == rec.ServingKey { - return - } - key, err := base64.StdEncoding.DecodeString(rec.ServingKey) - if err != nil || len(key) != ed25519.PublicKeySize { - return - } - trusted := []string{rec.Authority, r.VaultAuthority(ctx)} - if rec.Certificate != "" && delivery.CheckNodeCertificate(rec.Certificate, trusted, overlay.InternalName(c.env.Node), - ed25519.PublicKey(key), time.Now()) == nil { - return - } - if err := c.env.Certify(ctx); err != nil { - c.sayOnce(fmt.Sprintf("a module here asks for a certificate, and the vault's sign gave none: %v", err)) - return - } - c.mu.Lock() - c.certified = rec.ServingKey - c.mu.Unlock() -} - func (r reader) Placements(context.Context) (map[string]delivery.Placement, error) { r.c.mu.Lock() defer r.c.mu.Unlock() diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 62c1e89..06d1ce8 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -3,7 +3,6 @@ package selfcompose import ( "context" "crypto/sha256" - "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -1220,32 +1219,6 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } } b.mu.Unlock() - - // L3 s7 (ADR 0550 §2): the node asks the vault for its own certificate only when a module placed here asks for one - // and its record holds none from an authority it trusts; once per serving key. - certBus := newFakeBus() - certAsks := 0 - certEnv := certBus.env() - certEnv.Certify = func(context.Context) error { certAsks++; return nil } - certEnv.VaultAuthority = func() string { return "" } - cc := New(certEnv) - withCert := `{"module":"web","version":"1","certificate":{"into":"/etc/web/tls.crt"}}` - cdigest := delivery.ManifestDigest([]byte(withCert)) - cc.manifests[digest] = []byte(lemurs) - cc.manifests[cdigest] = []byte(withCert) - cc.modules = map[string]delivery.Module{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}} - cc.records["n1"] = delivery.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))} - onlyLemurs := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}}} - cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs) - if certAsks != 0 { - t.Errorf("no module asks for a certificate, and the vault was asked %d times", certAsks) - } - withWeb := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "web"}}} - cc.certifyIfWanted(context.Background(), reader{c: cc}, withWeb) - cc.certifyIfWanted(context.Background(), reader{c: cc}, withWeb) - if certAsks != 1 { - t.Errorf("a module asks for a certificate: the vault was asked %d times, want once", certAsks) - } } // patternBus is a bus whose every watch gets the updates of its bucket matching its pattern, as the bus's own watches diff --git a/module.json b/module.json index d10f063..8fd4fa6 100644 --- a/module.json +++ b/module.json @@ -17,12 +17,6 @@ "consumes": [ "restic.backed-up" ], - "state": [ - { - "name": "acts", - "ttl-seconds": 7776000 - } - ], "config": { "operator-signers": { "type": "string", diff --git a/packaging/operator_keys_test.go b/packaging/operator_keys_test.go index 6794b9e..5935047 100644 --- a/packaging/operator_keys_test.go +++ b/packaging/operator_keys_test.go @@ -68,13 +68,4 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) { if len(m.Consumes) != 1 || m.Consumes[0] != "restic.backed-up" { t.Fatalf("consumes %v", m.Consumes) } - // 774 (ADR 0563 §2, §3): the acts of the node seat and the engine's own asks are its declared state acts, the bucket - // mesh-host_acts the grants part creates and each node's engine writes under its own keys. - parsed, err := catalogue.ParseManifest(raw) - if err != nil { - t.Fatal(err) - } - if len(parsed.State) != 1 || parsed.State[0].Name != "acts" || parsed.State[0].TTLSeconds != 90*24*60*60 { - t.Fatalf("the manifest declares the state %+v", parsed.State) - } }