Files
mesh-host/internal/link/rotate.go
T
jschoubben 12168ba4da The engine carries its login fall back in its health report, and says the enrolment fallback is the pre-lease path
A fall back to the old login during a rotation is now delivery.NodeHealth's Fallback (the node, the login fallen back from,
why, when), written with the node's health report, from which the watchman raises node-account-fallback; it is said no more
once the vault's key of the node moves on (blocker 7, ADR 0583 §6). mesh-host enrol's fall back to the controller's
enrolment subject says it is the path before the lease write, which the bus refuses once the lease is written. Tests: rows
in the existing rotation test; none added.

(issue 774)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:49:01 +01:00

203 lines
7.4 KiB
Go

package link
import (
"context"
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Rotator is the engine's side of a node's account rotation (novox/hq ADR 0583 §3-§6, issue 774). The vault begins a rotation
// and writes the node's key of node-logins: the login in use, the login rotating to and the stage. On that key's ready the
// engine fetches the new value, dials a second connection as the new login, and switches only if it authenticates; over the new
// connection it confirms. It keeps the old value until the vault says the rotation is done, switches back before the new value
// is dropped when the rotation is abandoned, and falls back to the old value after repeated authentication failures with the
// new one, saying so. Nothing here polls: every step answers a change of the key, or a failed dial.
type Rotator struct {
Logins *Logins
// Fetch opens the value of a login the vault hands this node as own/<node>/mesh-host/<login> (ADR 0583 §3, insight).
Fetch func(ctx context.Context, login string) (string, error)
// TestDial dials a second connection as login with password and answers whether it authenticated, closing it.
TestDial func(ctx context.Context, login, password string) error
// Confirm calls mesh-vault.node-account-confirm over the link as the login now in use, once it is open.
Confirm func(ctx context.Context) error
// Say is the engine's journal.
Say func(string)
// Now is the clock a fall back is dated by; time.Now when nil.
Now func() time.Time
// FailBound is how many authentication failures with the new login, before rotation-done, it falls back after; 3 when 0.
FailBound int
mu sync.Mutex
old string // the login switched away from, while the rotation is under way
failures int
// fellBack is the key's `since` a fallback was made for: the same rotation is not tried again.
fellBackSince string
// fallen is the fall back the node's health report carries (delivery.LoginFallback), from which the watchman raises
// node-account-fallback (ADR 0583 §6), until the vault's key of this node says anything new.
fallen *delivery.LoginFallback
switchedSince string
}
func (r *Rotator) say(f string, a ...any) {
if r.Say != nil {
r.Say(fmt.Sprintf(f, a...))
}
}
// Heard takes this node's key of node-logins as read.
func (r *Rotator) Heard(ctx context.Context, nl delivery.NodeLogin) {
r.mu.Lock()
defer r.mu.Unlock()
if r.fallen != nil && nl.Since.String() != r.fellBackSince {
r.fallen = nil // the vault moved on (abandoned, done, or another rotation begun): the fall back is no longer said
}
cur, _ := r.Logins.Current()
switch {
case nl.Rotating == "" && nl.Login == cur:
// Done (the switch happened and the old login is retired), or no rotation at all: the other login's value goes (§4),
// only now that the vault says so.
r.old, r.failures = "", 0
if other := delivery.OtherLogin(nl.Node, cur); other != "" {
if _, held := r.Logins.Value(other); held {
if err := r.Logins.Drop(other); err != nil {
r.say("the old login %s's value is not dropped: %v", other, err)
} else {
r.say("rotation-done: the value of %s is dropped, %s is the login in use", other, cur)
}
}
}
case nl.Rotating == "":
// Abandoned after the switch (before or after the confirmation), or the vault moved on: back to the login the vault keeps,
// before the value of the one it drops.
if _, held := r.Logins.Value(nl.Login); !held {
r.say("the vault keeps %s in use and this engine holds no value for it: it stays on %s", nl.Login, cur)
return
}
if err := r.Logins.Use(nl.Login); err != nil {
r.say("cannot switch back to %s: %v", nl.Login, err)
return
}
r.say("the rotation is abandoned: this engine dials as %s again", nl.Login)
r.old, r.failures = "", 0
if err := r.Logins.Drop(cur); err != nil {
r.say("the abandoned login %s's value is not dropped: %v", cur, err)
}
case nl.Login == cur && nl.Stage != delivery.RotationBegun:
if r.fellBackSince != "" && r.fellBackSince == nl.Since.String() {
return // fallen back for this rotation: not tried again until the vault begins another
}
r.switchTo(ctx, nl)
case nl.Rotating == cur && nl.Stage != delivery.RotationConfirmed:
r.confirm(ctx, nl.Rotating)
}
}
// switchTo fetches the new value, test-dials it and, only if it authenticates, switches and confirms (§3).
func (r *Rotator) switchTo(ctx context.Context, nl delivery.NodeLogin) {
next := nl.Rotating
password, held := r.Logins.Value(next)
if !held {
var err error
if password, err = r.Fetch(ctx, next); err != nil {
r.say("the value of %s is not fetched, so this node stays on %s: %v", next, nl.Login, err)
return
}
if err := r.Logins.Hold(next, password); err != nil {
r.say("the value of %s is not kept, so this node stays on %s: %v", next, nl.Login, err)
return
}
}
if err := r.TestDial(ctx, next, password); err != nil {
r.say("the test-dial as %s did not authenticate, so this node stays on %s: %v", next, nl.Login, err)
return
}
if err := r.Logins.Use(next); err != nil {
r.say("this node cannot switch to %s: %v", next, err)
return
}
r.old, r.failures, r.switchedSince = nl.Login, 0, nl.Since.String()
r.say("the test-dial as %s authenticated: this node dials as %s from now, and keeps the value of %s until the vault says the rotation is done",
next, next, nl.Login)
r.confirm(ctx, next)
}
func (r *Rotator) confirm(ctx context.Context, login string) {
if r.Confirm == nil {
return
}
if err := r.Confirm(ctx); err != nil {
r.say("node-account-confirm as %s is not answered yet (asked again when the key changes or the link opens): %v", login, err)
return
}
r.say("node-account-confirm as %s is answered", login)
}
// AuthFailed is told of each failed authentication as login: before rotation-done, repeated failures with the new login (the
// one switched to) bring the engine back to the old value, saying node-account-fallback (§6).
func (r *Rotator) AuthFailed(login string) {
r.mu.Lock()
defer r.mu.Unlock()
cur, _ := r.Logins.Current()
if r.old == "" || login != cur {
return
}
r.failures++
bound := r.FailBound
if bound == 0 {
bound = 3
}
if r.failures < bound {
return
}
if err := r.Logins.Use(r.old); err != nil {
r.say("cannot fall back to %s: %v", r.old, err)
return
}
text := fmt.Sprintf("%s failed to authenticate %d times before the vault said rotation-done: this node dials as %s again", login, r.failures, r.old)
r.say("%s: %s", delivery.NodeAccountFallbackFinding, text)
now := time.Now
if r.Now != nil {
now = r.Now
}
r.fallen = &delivery.LoginFallback{Node: r.Logins.node, From: login, Why: text, At: now().UTC()}
r.old, r.failures = "", 0
r.fellBackSince = r.switchedSince
}
// Fallback is the fall back the node's health report carries now, nil when there is none.
func (r *Rotator) Fallback() *delivery.LoginFallback {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if r.fallen == nil {
return nil
}
f := *r.fallen
return &f
}
// AuthOK is told of each successful connection as login: the failures with it are forgotten.
func (r *Rotator) AuthOK(login string) {
r.mu.Lock()
defer r.mu.Unlock()
if cur, _ := r.Logins.Current(); login == cur {
r.failures = 0
}
}
// Watch makes the engine's dials tell r how they went: every refusal of the login in use counts towards the fallback.
func (r *Rotator) Watch() {
r.Logins.OnDial = func(user string, authenticated bool) {
if authenticated {
r.AuthOK(user)
} else {
r.AuthFailed(user)
}
}
}