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
203 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|