A warrant now carries the digest of the ask exactly as the router took it, so its asker acts only for the ask the person was shown, with what each option binds. The router refuses an ask whose approving option binds nothing (the SDK's Check). Its own unlink and relink asks bind the act they perform, and it performs that act only on a warrant for that ask. mesh-sdk moves to d4077b4.
264 lines
9.4 KiB
Go
264 lines
9.4 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 {
|
|
// 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)
|
|
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)
|
|
}
|
|
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":
|
|
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)
|
|
}
|
|
}
|
|
}
|