mesh/delivery-group group feat/asks-answered-on-any-channel checking: 3 of 4 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
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: long-running resources without health: 70
248 lines
9.0 KiB
Go
248 lines
9.0 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"
|
|
"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.
|
|
type SelfAct struct {
|
|
Do string `json:"do"` // "unlink" or "link"
|
|
Kind string `json:"kind"`
|
|
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
switch rec.Act.Do {
|
|
case "unlink":
|
|
h.unlinkNow(rec.Act.Kind, "approved by the account itself")
|
|
case "link":
|
|
if rec.Act.Link != nil {
|
|
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}); 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 a kind's account: told first, by name, then every channel.
|
|
func (h *Holder) unlinkNow(kind, how string) {
|
|
ids := h.identitiesOf(kind)
|
|
if len(ids) == 0 || h.Asks == nil {
|
|
return
|
|
}
|
|
id := ids[0]
|
|
_ = 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.",
|
|
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
|
|
}
|
|
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.
|
|
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
|
|
}
|
|
h.work.Lock()
|
|
switch what {
|
|
case "unlink":
|
|
h.unlinkNow(kind, "at the router's machine, as root")
|
|
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 at the router's machine, as root")
|
|
}
|
|
}
|
|
h.work.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|