v0.89.0: pbsdr self-grant (R-22) + escrow config live-reload + agent-plane poke listener (Direction-2a)

- pbsdr: on a 403 pre-check (non-default storage id, no ACL yet) self-grant via the root wrapper then re-read, instead of aborting before the grant — closes F4/R-22. Red-proof TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant.
- escrow preflight: late-bound CurrentPBSStorageID re-reads agent.json so a pbsdr-seeded pbs_storage_id flips the row green in-process (no restart). Red-proof TestEscrowPreflight_PBSStorageIDLiveReload.
- internal/poke: contentless UDP poke listener bound exclusively to the box WG /32 (port 51822), leading-edge debounced, fires the hub-loop out-of-band trigger for an immediate desired-state cycle. First slice of R-13. Red-proofs TestBindConfinement + TestDebounceCoalescesBurst.
This commit is contained in:
2026-07-16 22:47:22 +02:00
parent c040c180e9
commit a659e5dc09
11 changed files with 643 additions and 15 deletions
+197
View File
@@ -0,0 +1,197 @@
// Package poke is the agent-plane immediate-sync LISTENER (Direction-2a,
// SPIKE-immediate-sync-transport-2026-07-16). It receives a CONTENTLESS UDP "sync now" poke
// relayed hub → ep0 forced-command → wg0-origin → here, and fires ONE immediate desired-state
// cycle (the hub control loop's out-of-band report trigger). This collapses a user-triggered
// agent-plane config change (e.g. a pbsdr descriptor) from the 15-min report cycle to the
// spike-measured ~0.42 s path — while the 15-min cycle remains the guarantee (a lost poke is
// harmless by construction).
//
// The security posture is the spike's, unchanged:
// - BIND CONFINEMENT: the socket binds EXCLUSIVELY to the box's WireGuard /32 (10.77.0.x from
// registered.json). Never 0.0.0.0, never the LAN interface — a datagram to the LAN address
// reaches no socket. The only way in is over the tunnel, and WireGuard refuses to encrypt to a
// /32 no registered peer owns (spike P1 EKEYREJECTED), so a poke can only originate from ep0.
// - CONTENTLESS: the payload is ignored ENTIRELY — any datagram means only "tick now". A forged
// or replayed poke costs at most one extra debounced tick; it carries no config, no auth
// handshake, no version. There is nothing in a poke to trust.
// - DEBOUNCE: a burst of pokes within DebounceWindow yields ≤1 extra tick (leading-edge). The
// downstream trigger channel is itself cap-1 coalescing, so a tick already running collapses
// further pokes into one follow-up — never N queued.
package poke
import (
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"sync"
"time"
)
// Port is the FIXED UDP port the listener binds on the box's WG address and the hub/ep0
// forced-command targets. It is a shared cross-repo contract (documented in REUSE.md and the ep0
// felhom-poke forced-command): change it in one place → change it in all three.
const Port = 51822
// DebounceWindow coalesces a burst of pokes into a single immediate tick (leading-edge). Two
// distinct operator changes inside one window still converge in one tick because desired-state is
// level-triggered (the tick fetches the LATEST state, not a delta).
const DebounceWindow = 2 * time.Second
// rebindInterval is how long the listener waits before re-attempting a bind after WG is
// unregistered/down or a serve loop ends.
const rebindInterval = 5 * time.Second
// readBufSize bounds a single datagram read. A poke is contentless; the buffer only needs to be
// large enough to drain whatever a (harmless) sender emits.
const readBufSize = 512
// Listener binds the poke socket and fires the report trigger on each (debounced) poke.
type Listener struct {
// resolve returns the box's current WG address (ok=false until registered). Called before each
// bind attempt so a late registration / rare re-address is picked up on the next cycle.
resolve func() (netip.Addr, bool)
// trigger is the non-blocking nudge into the hub control loop's out-of-band report channel.
trigger func()
port int
window time.Duration
logger *slog.Logger
mu sync.Mutex
lastFire time.Time
local netip.AddrPort // set once bound (for tests / logging)
boundCh chan struct{} // closed once the first bind succeeds
now func() time.Time
}
// NewListener builds a listener. port<=0 uses the fixed Port. A nil trigger is a no-op (safe).
func NewListener(resolve func() (netip.Addr, bool), trigger func(), port int, logger *slog.Logger) *Listener {
if logger == nil {
logger = slog.Default()
}
if trigger == nil {
trigger = func() {}
}
if port <= 0 {
port = Port
}
return &Listener{
resolve: resolve, trigger: trigger, port: port,
window: DebounceWindow,
logger: logger,
boundCh: make(chan struct{}),
now: time.Now,
}
}
// onPoke applies the leading-edge debounce and fires the trigger at most once per window. Returns
// true iff it fired (the test hook for the debounce red-proof).
func (l *Listener) onPoke() bool {
l.mu.Lock()
now := l.now()
if !l.lastFire.IsZero() && now.Sub(l.lastFire) < l.window {
l.mu.Unlock()
return false
}
l.lastFire = now
l.mu.Unlock()
l.trigger()
return true
}
// Run resolves the WG address and serves pokes until ctx is cancelled, rebinding across a WG
// down/re-address. It NEVER returns nil early (the daemon treats a returned goroutine as a reason
// to exit) — only ctx cancellation ends it.
func (l *Listener) Run(ctx context.Context) error {
for {
if err := ctx.Err(); err != nil {
return err
}
addr, ok := l.resolve()
if !ok {
l.logger.Debug("poke: no WG address yet (box not registered) — will retry")
if !l.sleep(ctx, rebindInterval) {
return ctx.Err()
}
continue
}
if err := l.serve(ctx, addr); err != nil && ctx.Err() == nil {
l.logger.Warn("poke: listener stopped; will rebind", "err", err, "wg_addr", addr.String())
if !l.sleep(ctx, rebindInterval) {
return ctx.Err()
}
}
}
}
// serve binds EXCLUSIVELY to addr:port and delivers each datagram to onPoke until ctx or a read
// error ends it.
func (l *Listener) serve(ctx context.Context, addr netip.Addr) error {
bind := netip.AddrPortFrom(addr, uint16(l.port)).String()
var lc net.ListenConfig
pc, err := lc.ListenPacket(ctx, "udp", bind)
if err != nil {
return fmt.Errorf("bind %s: %w", bind, err)
}
defer pc.Close()
l.mu.Lock()
if ap, ok := netip.ParseAddrPort(pc.LocalAddr().String()); ok == nil {
l.local = ap
}
select {
case <-l.boundCh: // already closed
default:
close(l.boundCh)
}
l.mu.Unlock()
l.logger.Info("poke: listening for hub sync-pokes (WG-confined, contentless)", "addr", pc.LocalAddr().String())
// Unblock ReadFrom on shutdown.
go func() {
<-ctx.Done()
pc.Close()
}()
buf := make([]byte, readBufSize)
for {
_, from, err := pc.ReadFrom(buf)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("read: %w", err)
}
// Contentless: the payload is discarded. ANY datagram = "tick now".
if l.onPoke() {
l.logger.Info("poke received → triggering an immediate desired-state cycle", "from", from.String())
} else {
l.logger.Debug("poke received but coalesced within the debounce window", "from", from.String())
}
}
}
// BoundAddrPort waits (bounded by ctx) until the socket is bound and returns its address. Test
// helper; production never needs it.
func (l *Listener) BoundAddrPort(ctx context.Context) (netip.AddrPort, bool) {
select {
case <-l.boundCh:
l.mu.Lock()
defer l.mu.Unlock()
return l.local, true
case <-ctx.Done():
return netip.AddrPort{}, false
}
}
func (l *Listener) sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
+147
View File
@@ -0,0 +1,147 @@
package poke
import (
"context"
"io"
"log/slog"
"net"
"net/netip"
"sync/atomic"
"testing"
"time"
)
func discard() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
// waitFor polls cond up to d, returning true if it became true.
func waitFor(d time.Duration, cond func() bool) bool {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(2 * time.Millisecond)
}
return cond()
}
// TestBindConfinement is red-proof (a): the listener binds EXCLUSIVELY to the address the resolver
// returns (the box's WG /32), never the wildcard 0.0.0.0/::. A wildcard bind would ALSO accept the
// LAN address — the exact leak the confinement forbids. We assert the bound socket's local address
// IS the specific resolved address and is NOT unspecified, and that a datagram to it fires the
// trigger. RED-PROOF: a 0.0.0.0 bind makes BoundAddrPort().Addr().IsUnspecified() true → FAIL.
func TestBindConfinement(t *testing.T) {
want := netip.MustParseAddr("127.0.0.1") // stand-in for the WG /32 on a portable loopback
var fires atomic.Int32
l := NewListener(
func() (netip.Addr, bool) { return want, true },
func() { fires.Add(1) },
0, // OS-chosen port so the test never collides
discard(),
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go l.Run(ctx)
bctx, bcancel := context.WithTimeout(context.Background(), 3*time.Second)
defer bcancel()
bound, ok := l.BoundAddrPort(bctx)
if !ok {
t.Fatal("listener never bound")
}
if bound.Addr() != want {
t.Fatalf("bound to %s, want the specific WG addr %s (a wildcard bind would leak to the LAN)", bound.Addr(), want)
}
if bound.Addr().IsUnspecified() {
t.Fatal("listener bound to the wildcard address (0.0.0.0/::) — NOT confined to the WG /32")
}
// Functional: a datagram to the WG address fires exactly one trigger.
conn, err := net.Dial("udp", bound.String())
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte("")); err != nil { // contentless
t.Fatalf("write poke: %v", err)
}
if !waitFor(2*time.Second, func() bool { return fires.Load() >= 1 }) {
t.Fatal("a poke to the bound WG address did not fire the trigger")
}
}
// TestDebounceCoalescesBurst is red-proof (b): a burst of M pokes within DebounceWindow yields ≤1
// trigger; after the window elapses a further poke fires again. onPoke uses an injected clock so
// the test is deterministic. RED-PROOF: remove the leading-edge guard (fire every time) → M fires
// for M pokes → the "want 1" assertion FAILS.
func TestDebounceCoalescesBurst(t *testing.T) {
var fires atomic.Int32
l := NewListener(nil, func() { fires.Add(1) }, 0, discard())
base := time.Unix(1_700_000_000, 0)
var clock atomic.Int64
clock.Store(base.UnixNano())
l.now = func() time.Time { return time.Unix(0, clock.Load()) }
// A burst of 10 pokes at the SAME instant → exactly one fire.
const burst = 10
for i := 0; i < burst; i++ {
l.onPoke()
}
if got := fires.Load(); got != 1 {
t.Fatalf("burst of %d pokes fired %d triggers, want exactly 1 (debounce coalescing)", burst, got)
}
// Still inside the window → no additional fire.
clock.Store(base.Add(l.window - time.Millisecond).UnixNano())
l.onPoke()
if got := fires.Load(); got != 1 {
t.Fatalf("a poke inside the debounce window fired again (total %d), want still 1", got)
}
// Window elapsed → the next poke fires (immediacy is not lost after the window).
clock.Store(base.Add(l.window + time.Millisecond).UnixNano())
l.onPoke()
if got := fires.Load(); got != 2 {
t.Fatalf("a poke after the window did not fire (total %d), want 2", got)
}
}
// TestRunWaitsForRegistration: with no WG address yet, Run keeps retrying (never returns early,
// never binds) — a lost/absent poke path is harmless; the 15-min cycle still reconciles.
func TestRunWaitsForRegistration(t *testing.T) {
var resolved atomic.Bool
l := NewListener(
func() (netip.Addr, bool) {
if resolved.Load() {
return netip.MustParseAddr("127.0.0.1"), true
}
return netip.Addr{}, false
},
func() {}, 0, discard(),
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() { l.Run(ctx); close(done) }()
// Not bound while unregistered.
nb, nbcancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer nbcancel()
if _, ok := l.BoundAddrPort(nb); ok {
t.Fatal("listener bound before the box registered")
}
// Registration appears → it binds within a rebind cycle.
resolved.Store(true)
bctx, bcancel := context.WithTimeout(context.Background(), 3*rebindInterval)
defer bcancel()
if _, ok := l.BoundAddrPort(bctx); !ok {
t.Fatal("listener never bound after registration appeared")
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not exit on ctx cancel")
}
}