Report what an adopted node holds, its firewall and what is reachable, and speak unasked when that changes (hq ADR 0100)

This commit is contained in:
2026-09-22 17:22:31 +02:00
parent 3c90d155b3
commit 770f589401
11 changed files with 528 additions and 8 deletions
+99 -3
View File
@@ -20,6 +20,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"text/tabwriter"
"time"
@@ -27,10 +28,12 @@ import (
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/bundle"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/firewall"
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/inventory"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/profile"
"github.com/novox/mesh-host/internal/reachable"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
"github.com/novox/mesh-host/internal/upgrade"
@@ -437,6 +440,23 @@ func enrol(ctx context.Context, opts options) error {
return err
}
// An adopted node keeps the firewall it was found with (novox/hq ADR 0100), so a host that
// cannot speak that firewall must say so now — before the mesh records a node it could never
// open anything on.
if token.Adopted {
kind, name, err := firewall.Detect(ctx, apply.ExecRunner)
if err != nil {
return err
}
if kind == firewall.Unsupported {
return fmt.Errorf(
"this token joins this machine adopted, keeping the firewall found on it, and it is "+
"filtered by %s, which no host speaks yet. Nothing was enrolled", name)
}
fmt.Printf("joining adopted: what is on this machine is kept, and its firewall (%s) stays in force\n",
string(kind))
}
fmt.Printf("token for broker %s\n", token.Broker)
fmt.Printf(" pinned certificate %s\n", token.Fingerprint)
fmt.Printf(" signing key %s\n",
@@ -616,7 +636,22 @@ func runLink(ctx context.Context, opts options) error {
// new declarations; this holds the machine in the last one whether the link is up or not. A
// laptop shut for a week comes back and reconciles — it does not come back and ask what it is
// (novox/hq ADR 0004).
go holdTheMachine(ctx, opts, mine, say, sched)
// Reports a reconcile has to make unasked — what an adopted node holds changed, or its
// firewall did — go out over the link when it is up (novox/hq ADR 0100).
outbox := make(chan link.Report, 1)
watch := &adoptionWatch{}
applier = watch.noting(applier)
go holdTheMachine(ctx, opts, mine, say, sched, func(r link.Report) {
if !watch.changed(r) {
return
}
select {
case <-outbox:
// An older one nobody has published yet; this one says everything it did.
default:
}
outbox <- r
})
return link.HoldRoused(ctx, link.Membership{
Node: mine.Node,
@@ -624,7 +659,46 @@ func runLink(ctx context.Context, opts options) error {
Fingerprint: mine.Membership.Fingerprint,
Password: mine.Membership.Password,
Signer: mine.Membership.Signer,
}, applier, say, opts.timeout, rousedBySignal(ctx))
}, applier, say, opts.timeout, rousedBySignal(ctx), outbox)
}
// adoptionWatch remembers what the node last said about what it holds and its firewall, so a
// reconcile speaks unasked only when that changed.
type adoptionWatch struct {
mu sync.Mutex
last string
}
// fingerprint is what a report says about adoption: each hold and whether it changed, and the
// firewall.
func adoptionFingerprint(r link.Report) string {
parts := []string{"firewall=" + r.Firewall}
for _, h := range r.Held {
parts = append(parts, h.ID+"="+h.Changed)
}
sort.Strings(parts[1:])
return strings.Join(parts, "\n")
}
// changed records a report and says whether it differs from the last one that went out.
func (w *adoptionWatch) changed(r link.Report) bool {
w.mu.Lock()
defer w.mu.Unlock()
now := adoptionFingerprint(r)
if now == w.last {
return false
}
w.last = now
return true
}
// noting wraps the applier, so a report the link publishes after a delivery counts as said.
func (w *adoptionWatch) noting(apply link.Applier) link.Applier {
return func(ctx context.Context, raw, signature []byte) link.Report {
r := apply(ctx, raw, signature)
w.changed(r)
return r
}
}
// rousedBySignal is the machine telling this process that its link is probably stale.
@@ -672,7 +746,7 @@ func rousedBySignal(ctx context.Context) link.Roused {
const ReconcileEvery = 5 * time.Minute
func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, say link.Announce,
sched *apply.Scheduler) {
sched *apply.Scheduler, publish func(link.Report)) {
ticker := time.NewTicker(ReconcileEvery)
defer ticker.Stop()
@@ -695,6 +769,12 @@ func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, s
}
report := applyDeclared(ctx, opts, declared, sched)
// A reconcile is otherwise silent. On an adopted node it speaks when what it holds or
// its firewall changed, because that is how a predecessor still writing is caught
// (novox/hq ADR 0100); publish decides whether anything did.
if publish != nil && report.Refused == "" && (len(report.Held) > 0 || report.Firewall != "") {
publish(report)
}
switch {
case report.Refused != "":
say("what this node was last told no longer applies: " + report.Refused)
@@ -761,6 +841,22 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
}
report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw)}
// What this node found and holds, its firewall, and what is reachable on it — so an adopted
// node never reads as converged (novox/hq ADR 0100).
for _, h := range updated.Held {
report.Held = append(report.Held, link.Held{ID: h.ID, Module: h.Module, Kind: h.Kind,
Target: h.Target, Since: h.Since, Changed: h.Changed, Kept: h.Kept})
}
if declared.Adoption != nil {
if updated.Firewall != nil {
report.Firewall = updated.Firewall.Kind
}
reached, err := reachable.Collect(ctx, apply.ExecRunner)
if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: applied, and could not read what is reachable here: %v\n", err)
}
report.Reachable = reached
}
for _, change := range outcome.Outcomes {
// What is held is not what this machine owns: it was found, and is kept as it was until
// its module is taken (novox/hq ADR 0100).
+34
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
"testing"
"time"
@@ -135,3 +137,35 @@ func TestAFlagAfterAPositionalIsRead(t *testing.T) {
}
}
}
// Defends novox/hq ADR 0100: a reconcile on an adopted node speaks unasked only when what it holds
// or its firewall changed — which is how a predecessor still writing is caught, without a report
// every five minutes saying nothing new.
func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
w := &adoptionWatch{}
held := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page"}, {ID: "hello-web.server"}}}
if !w.changed(held) {
t.Fatal("the first report of a hold was not said")
}
again := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.server"}, {ID: "hello-web.page"}}}
if w.changed(again) {
t.Error("the same holds in another order were said again")
}
rewritten := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page", Changed: "rewritten"}, {ID: "hello-web.server"}}}
if !w.changed(rewritten) {
t.Error("a held file rewritten by something else was not said")
}
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held}) {
t.Error("a changed firewall was not said")
}
}
func TestWhatTheLinkPublishedCountsAsSaid(t *testing.T) {
w := &adoptionWatch{}
report := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "a"}}}
applier := w.noting(func(context.Context, []byte, []byte) link.Report { return report })
applier(context.Background(), nil, nil)
if w.changed(report) {
t.Error("a reconcile repeated what the link had just published")
}
}