Files
mesh-host/internal/link/rotate.go
T
jschoubben e362c800a4 The engine test-dials, switches, confirms, drops and falls back across a rotation of its login
On node-logins' ready the engine fetches the new value through the vault's own hand-out (own/<node>/mesh-host/<login>, opened
with its sealing key), dials a second connection as the new login on the pinned certificate, and switches only if that
authenticates, then calls mesh-vault.node-account-confirm over the link as the new login (the vault is asked in the name of
the login the open link authenticated as, and only once that link is open). It keeps the old value until the key says the
rotation is done and drops it then, never before; an abandon, after the switch before or after the confirmation, switches
back before the new value is dropped; repeated refusals of the new login's credentials before rotation-done bring it back
to the old one, said as the finding node-account-fallback (in the journal: no route for an engine's finding to the watchman
is decided), and the same rotation is not tried again. Tests: two added, paid by merging two identity sealing tests into one;
more to be paid by deleting the identity token tests that move to mesh-sdk with the token (774/S64).

(issue 774, 774/S62, 774/S63)

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

179 lines
6.6 KiB
Go

package link
import (
"context"
"fmt"
"sync"
"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; Finding says a finding the watchman raises (node-account-fallback).
Say func(string)
Finding func(name, text string)
// 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
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()
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", text)
if r.Finding != nil {
r.Finding(delivery.NodeAccountFallbackFinding, text)
}
r.old, r.failures = "", 0
r.fellBackSince = r.switchedSince
}
// 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)
}
}
}