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//mesh-host/ (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) } } }