mesh/merge-gate fail: builds messenger, sudo → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: modules/messenge…
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 20
mesh/delivery-group group feat/asks-answered-on-any-channel rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
The review of 2026-10-09: the router read the absence of an agent-root condition as a pass, and its minute hung on a break-glass file, taking the work it already held. An approval now rests on the controller's verb root-free, asked live for the router's machine and the channel's, failing closed on every other answer, and a stale word on the kinds promises no verified sender. Break-glass runs in the minute, says on every channel what root did, and ends the router's own open questions. An ask offering an approval is answered only where it could be approved; a message says who asks, what each answer does and what expiry does. The router's own asks bind the account they are about; a link is checked against one per kind as it is made; an unlinked account does not come back by a code within a day; a fresh link is unlinked from the desk; no link begins while the desk cannot show its code, and a code is marked secret on every path. Links are bounded per account in the router's state; a warrant's message id cannot be foreseen. Moves to mesh-sdk 76902998 (canonical digests).
345 lines
13 KiB
Go
345 lines
13 KiB
Go
package main
|
|
|
|
// The router's own asks (novox/hq ADR 0259 §7, as revised after the security review of 2026-10-08): who the
|
|
// operator is on a channel changes only with the word of the account it is now.
|
|
//
|
|
// - **Unlinking is an approve ask, put to the account that is linked.** Whoever could unlink the
|
|
// operator's account without it could link their own in its place. The notice of the unlink reaches that
|
|
// account first, by name, and then every channel.
|
|
// - **A link within a day of an unlink waits for the replaced account's approval**, asked of it by name.
|
|
// - **When that account is lost with its phone**, the operator, as root at the router's machine, leaves a
|
|
// file in the router's directory — `unlink.<kind>` or `allow-link.<kind>`, owned by root — and the router
|
|
// does it, announced as done at the machine. A file of any other owner is refused and said: an agent
|
|
// without root cannot make one.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// SelfAsker is the name the router asks its own questions under.
|
|
const SelfAsker = "messenger"
|
|
|
|
// RelinkWaits is how long after an unlink a new link waits for the replaced account's approval.
|
|
const RelinkWaits = 24 * time.Hour
|
|
|
|
// SelfAct is what the router does on its own ask's warrant. It names the account it concerns: an option
|
|
// binds that account, so a warrant for unlinking one account never unlinks another linked in its place
|
|
// (the review of 2026-10-09).
|
|
type SelfAct struct {
|
|
Do string `json:"do"` // "unlink" or "link"
|
|
Kind string `json:"kind"`
|
|
Identity string `json:"identity"` // the account unlinked, or the account a link makes the operator's
|
|
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
|
|
}
|
|
|
|
// act is what an option of the router's own ask binds: its named fields (asks.ActDigest).
|
|
func (a SelfAct) act() asks.Act {
|
|
return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
|
|
}
|
|
|
|
// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it.
|
|
type Unlinked struct {
|
|
Identity Identity `json:"identity"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask.
|
|
func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
|
|
// Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone.
|
|
binds, err := asks.ActDigest(act.act())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range a.Options {
|
|
a.Options[i].Binds = binds
|
|
}
|
|
key := askKey(SelfAsker, a.ID)
|
|
now := h.Now()
|
|
rec := AskRecord{Asker: SelfAsker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{},
|
|
Only: &only, Act: &act, Record: "ask." + key}
|
|
for _, o := range a.Options {
|
|
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: only.Kind}
|
|
}
|
|
if h.Asks != nil {
|
|
rev, err := h.Asks.CreateAsk(key, rec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.keepAsk(key, rec, rev)
|
|
} else {
|
|
h.keepAsk(key, rec, 0)
|
|
}
|
|
r := Record{Key: rec.Record, Kind: "ask", Subject: SelfAsker, Severity: Urgent, Summary: a.Headline,
|
|
Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks",
|
|
Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key}
|
|
h.mu.Lock()
|
|
h.open[r.Key] = &r
|
|
h.mu.Unlock()
|
|
m := h.words(&r, "raised")
|
|
m.Actions = h.offers(&rec, only.Kind)
|
|
m.To = only.Identity
|
|
handle, err := h.showOnHandle(only.Kind, m)
|
|
if err == nil {
|
|
h.mu.Lock()
|
|
r.Sent[only.Kind] = handle
|
|
r.FirstSent = now
|
|
h.mu.Unlock()
|
|
}
|
|
h.persist(&r)
|
|
return err
|
|
}
|
|
|
|
// selfAct does what the router's own ask decided.
|
|
func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
|
|
if rec.Act == nil || w.Outcome != asks.OutcomeChosen {
|
|
return
|
|
}
|
|
o, err := w.For(SelfAsker, rec.Ask)
|
|
if err == nil {
|
|
err = o.Performs(rec.Act.act())
|
|
}
|
|
if err != nil {
|
|
h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err)
|
|
return
|
|
}
|
|
switch rec.Act.Do {
|
|
case "unlink":
|
|
if o.Level == asks.Acknowledge {
|
|
// Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked
|
|
// this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself.
|
|
id := h.identity(rec.Act.Kind, rec.Act.Identity)
|
|
if id == nil || !h.Now().Before(id.ApproveFrom) {
|
|
h.Logf("[messenger] an unlink chosen at the desk was not done: the account's first hour has passed")
|
|
h.announce(Message{Title: "An unlink chosen at the desk was not done",
|
|
Body: "The account's first hour had passed, so only the account itself, or root at the router's " +
|
|
"machine, can unlink it now.", Urgent: true})
|
|
return
|
|
}
|
|
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked")
|
|
return
|
|
}
|
|
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself")
|
|
case "link":
|
|
if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity {
|
|
h.linkNow(*rec.Act.Link, "approved by the account it replaces")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone.
|
|
func (h *Holder) Unlink(kind string) (map[string]any, error) {
|
|
h.work.Lock()
|
|
defer h.work.Unlock()
|
|
ids := h.identitiesOf(kind)
|
|
if len(ids) == 0 {
|
|
return map[string]any{"kind": kind, "linked": false, "asked": false}, nil
|
|
}
|
|
id := ids[0]
|
|
now := h.Now()
|
|
a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
|
|
Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+
|
|
"from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity),
|
|
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked",
|
|
Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}}
|
|
if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil {
|
|
return nil, err
|
|
}
|
|
h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked",
|
|
Body: fmt.Sprintf("It waits for the account itself (%s, number %s) to approve it within the hour. If you "+
|
|
"cannot answer there, you unlink it as root at the router's machine.", id.Display, id.Identity), Urgent: true})
|
|
return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil
|
|
}
|
|
|
|
// unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it
|
|
// is named, and nothing when another is linked now.
|
|
func (h *Holder) unlinkNow(kind, identity, how string) {
|
|
if h.Asks == nil {
|
|
return
|
|
}
|
|
id := h.identity(kind, identity)
|
|
if id == nil {
|
|
h.Logf("[messenger] the unlink of %s on %s was not done: that account is not linked", identity, kind)
|
|
return
|
|
}
|
|
_ = h.showOn(kind, Message{Title: "This account is no longer linked as you",
|
|
Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity})
|
|
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
|
|
h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err)
|
|
return
|
|
}
|
|
h.unlinked(kind, Unlinked{Identity: *id, At: h.Now()})
|
|
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
|
|
Body: fmt.Sprintf("Unlinked: %s (number %s), %s. A new link within a day waits for that account's approval, "+
|
|
"and that account itself can be linked again only after a day.", id.Display, id.Identity, how), Urgent: true})
|
|
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how})
|
|
}
|
|
|
|
// linkNow links an account as the operator's: acknowledges at once, approves after the hour.
|
|
func (h *Holder) linkNow(id Identity, how string) {
|
|
if h.Asks == nil {
|
|
return
|
|
}
|
|
// One account per kind, checked again as it is linked: a link approved, or allowed by root, while
|
|
// another account was linked meanwhile is not made (the review of 2026-10-09).
|
|
for _, other := range h.identitiesOf(id.Kind) {
|
|
if other.Identity != id.Identity {
|
|
h.Logf("[messenger] the link of %s on %s was not made: %s is linked there", id.Display, id.Kind, other.Display)
|
|
h.announce(Message{Title: "A " + Display(id.Kind) + " link was not made",
|
|
Body: fmt.Sprintf("%s (number %s) was not linked, %s: the account %s (number %s) is linked there now. "+
|
|
"One account is linked per channel.", id.Display, id.Identity, how, other.Display, other.Identity),
|
|
Urgent: true})
|
|
return
|
|
}
|
|
return // already linked
|
|
}
|
|
now := h.Now()
|
|
id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter)
|
|
if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil {
|
|
h.Logf("[messenger] the link of %s on %s could not be recorded: %v", id.Display, id.Kind, err)
|
|
return
|
|
}
|
|
_ = h.Asks.DeleteLink(id.Kind)
|
|
from := id.ApproveFrom.In(h.zone()).Format("15:04")
|
|
h.announce(Message{Title: "A " + Display(id.Kind) + " account is now linked as you",
|
|
Body: fmt.Sprintf("The %s account %s (number %s) is linked, %s. It can answer acknowledgements now, and "+
|
|
"approve from %s. If this was not you, unlink it before then.", Display(id.Kind), id.Display, id.Identity, how, from),
|
|
Urgent: true})
|
|
h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how})
|
|
}
|
|
|
|
// breakGlass does what root asked at the router's machine: a file `unlink.<kind>` or `allow-link.<kind>` in
|
|
// the router's directory, owned by root. Called every minute from Tick, which holds the work: it takes no
|
|
// lock of its own (the review of 2026-10-09 found it took the work again, and so hung the router's minute
|
|
// the first time root used it). Loud: what root did, or why nothing was done, is said on every channel, and
|
|
// every open question of the router's own ends, since root decided in its place.
|
|
func (h *Holder) breakGlass() {
|
|
if h.StateDir == "" {
|
|
return
|
|
}
|
|
for _, pattern := range []string{"unlink.*", "allow-link.*"} {
|
|
files, _ := filepath.Glob(filepath.Join(h.StateDir, pattern))
|
|
for _, f := range files {
|
|
name := filepath.Base(f)
|
|
what, kind, _ := strings.Cut(name, ".")
|
|
info, err := os.Lstat(f)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
owner := fileOwner(info)
|
|
_ = os.Remove(f)
|
|
if !info.Mode().IsRegular() || owner != 0 {
|
|
h.Logf("[messenger] %s was refused: it is not a file root made", name)
|
|
h.announce(Message{Title: "A request to change who is you was refused",
|
|
Body: "A file asking it was left at the router's machine by someone other than root. Nothing changed.",
|
|
Urgent: true})
|
|
continue
|
|
}
|
|
where := "at the router's machine"
|
|
if h.Node != "" {
|
|
where = "at " + h.Node
|
|
}
|
|
done := ""
|
|
switch what {
|
|
case "unlink":
|
|
if ids := h.identitiesOf(kind); len(ids) > 0 {
|
|
h.unlinkNow(kind, ids[0].Identity, where+", as root")
|
|
done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity)
|
|
} else {
|
|
done = "nothing was unlinked: no " + Display(kind) + " account is linked"
|
|
}
|
|
case "allow-link":
|
|
if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed {
|
|
h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed "+where+", as root")
|
|
done = fmt.Sprintf("the waiting link of %s (number %s) was allowed", p.Display, p.Identity)
|
|
} else {
|
|
done = "nothing was linked: no link with its code given waits on " + Display(kind)
|
|
}
|
|
}
|
|
ended := h.endSelfAsks("root decided " + where)
|
|
body := "Root " + where + " asked it: " + done + "."
|
|
if ended > 0 {
|
|
body += fmt.Sprintf(" %d open question(s) of the router's own were ended.", ended)
|
|
}
|
|
h.Logf("[messenger] break-glass %s: %s", name, done)
|
|
h.announce(Message{Title: "Root changed who is you", Body: body, Urgent: true})
|
|
}
|
|
}
|
|
}
|
|
|
|
// endSelfAsks ends every open ask of the router's own: root decided in their place.
|
|
func (h *Holder) endSelfAsks(why string) int {
|
|
h.mu.Lock()
|
|
var keys []string
|
|
for k, e := range h.asks {
|
|
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
h.mu.Unlock()
|
|
sort.Strings(keys)
|
|
n := 0
|
|
for _, k := range keys {
|
|
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, ""); err == nil {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root.
|
|
var fileOwner = func(info os.FileInfo) uint32 {
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
return st.Uid
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// unlinked keeps the last unlink of a kind, a day.
|
|
func (h *Holder) unlinked(kind string, u Unlinked) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.lastUnlink == nil {
|
|
h.lastUnlink = map[string]Unlinked{}
|
|
}
|
|
h.lastUnlink[kind] = u
|
|
if h.Asks != nil {
|
|
_ = h.Asks.PutUnlinked(kind, u)
|
|
}
|
|
}
|
|
|
|
// recentUnlink is the account unlinked on a kind less than a day ago, if any.
|
|
func (h *Holder) recentUnlink(kind string) *Unlinked {
|
|
h.mu.Lock()
|
|
u, ok := h.lastUnlink[kind]
|
|
h.mu.Unlock()
|
|
if !ok && h.Asks != nil {
|
|
if got, err := h.Asks.GetUnlinked(kind); err == nil && got != nil {
|
|
u, ok = *got, true
|
|
}
|
|
}
|
|
if !ok || h.Now().Sub(u.At) >= RelinkWaits {
|
|
return nil
|
|
}
|
|
return &u
|
|
}
|
|
|
|
// announceExcept says a message on every kind that can send but one.
|
|
func (h *Holder) announceExcept(kind string, m Message) {
|
|
if h.Bench == nil {
|
|
return
|
|
}
|
|
for _, k := range h.Bench.Kinds() {
|
|
if k.Kind != kind && h.Bench.Ready(k.Kind) == nil {
|
|
_ = h.showOn(k.Kind, m)
|
|
}
|
|
}
|
|
}
|