Files
mesh-host/cmd/mesh-host/logins.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

155 lines
5.3 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/link"
)
// loginsFile is where this engine keeps the logins it holds between runs (novox/hq ADR 0583 §1): beside its identity, readable by
// its owner alone, as the identity's membership password is.
func loginsFile(state string) string {
return filepath.Join(filepath.Dir(identity.Path(state)), "logins.json")
}
// loadLogins is the engine's logins: what it kept, with the membership's password as the first login's.
func loadLogins(state, node, first string) *link.Logins {
path := loginsFile(state)
var saved link.LoginsState
if raw, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(raw, &saved)
}
return link.NewLogins(node, first, saved, func(s link.LoginsState) error { return writeLogins(path, s) })
}
func writeLogins(path string, s link.LoginsState) error {
raw, err := json.Marshal(s)
if err != nil {
return err
}
tmp := path + ".new"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// loginsFollowAgain bounds the wait before a watch of node-logins that could not be made is tried again on the same link:
// the vault may not serve rotation yet, and until it does this node is not granted the read.
var loginsFollowAgain = 2 * time.Minute
// followLogins follows this node's key of the vault's node-logins on every link, as a watch (never a poll), for as long as ctx
// lasts, and hands each value to hear. A watch that cannot be made is said once and tried again.
func followLogins(ctx context.Context, session *link.Session, node string, hear func(delivery.NodeLogin), say link.Announce) {
said := ""
for ctx.Err() == nil {
linked, cancel, err := session.WhenLinked(ctx)
if err != nil {
return
}
watching, stop := context.WithCancel(linked)
updates, werr := session.KVWatch(watching, delivery.NodeLoginsBucket, node)
if werr != nil {
stop()
if line := "this node's login is not followed yet (" + werr.Error() + "): it dials as it last did, and looks again in " + loginsFollowAgain.String(); line != said {
said = line
say(line)
}
} else {
said = ""
for u := range updates {
if u.Caught || u.Deleted || len(u.Value) == 0 {
continue
}
nl, err := delivery.DecodeNodeLogin(u.Value)
if err != nil {
say("the key " + node + " of " + delivery.NodeLoginsRead + " is not read: " + err.Error())
continue
}
hear(nl)
}
stop()
}
select {
case <-linked.Done():
case <-time.After(loginsFollowAgain):
}
cancel()
}
}
// loginSecretModule is the module the vault names this engine's login values under: own/<node>/mesh-host/<login> (ADR 0583 §3,
// insight).
const loginSecretModule = "mesh-host"
// fetchLogin asks the vault, through its existing hand-out of a machine's own secrets, for the value of login, sealed to this
// node's key, and opens it with the sealing key; no new verb is asked for it.
func fetchLogin(queue *link.Session, statePath string) func(context.Context, string) (string, error) {
unseal := sealOpener(statePath)
return func(ctx context.Context, login string) (string, error) {
raw, err := queue.AskVault(ctx, "own", map[string]any{})
if err != nil {
return "", err
}
var a struct {
Own []struct {
Module string `json:"module"`
Name string `json:"name"`
Sealed json.RawMessage `json:"sealed"`
} `json:"own"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("the vault's own answer is not one: %w", err)
}
for _, o := range a.Own {
if o.Module != loginSecretModule || o.Name != login {
continue
}
sealed := string(o.Sealed)
var text string
if json.Unmarshal(o.Sealed, &text) == nil {
sealed = text
}
value, err := unseal(sealed)
if err != nil {
return "", fmt.Errorf("the value of %s cannot be opened with this node's sealing key: %w", login, err)
}
return string(value), nil
}
return "", fmt.Errorf("the vault hands this node no value for %s (own/%s/%s/%s)", login, queueNode(queue), loginSecretModule, login)
}
}
func queueNode(q *link.Session) string { return q.Membership.Node }
// newRotator is the engine's side of a rotation over this session (ADR 0583 §3-§6): the value fetched through the vault's own
// hand-out, the test-dial on a second connection, the confirmation over the new link, and a fall back carried in the health
// report.
func newRotator(queue *link.Session, statePath string, timeout time.Duration, say link.Announce) *link.Rotator {
r := &link.Rotator{Logins: queue.Membership.Logins, Say: say,
Fetch: fetchLogin(queue, statePath),
TestDial: func(ctx context.Context, login, password string) error {
return link.TestDial(ctx, queue.Membership, login, password, timeout)
},
Confirm: func(ctx context.Context) error {
login, _ := queue.Membership.Logins.Current()
if err := queue.WhenLinkedAs(ctx, login); err != nil {
return err
}
_, err := queue.AskVault(ctx, "node-account-confirm", map[string]any{})
return err
}}
r.Watch()
// The fall back is carried in this node's health report, from which the watchman raises node-account-fallback (ADR 0583 §6).
queue.Fallback = r.Fallback
return r
}