diff --git a/documentation/runbooks/offsite-endpoint.md b/documentation/runbooks/offsite-endpoint.md index 5739336..4f1468e 100644 --- a/documentation/runbooks/offsite-endpoint.md +++ b/documentation/runbooks/offsite-endpoint.md @@ -393,3 +393,46 @@ before the hub ever used the surface. The §6-era orphan `root@pam!spike` token `/datastore/scratch` DatastoreAdmin ACL were removed in the same session (spike flag #1); after teardown the endpoint holds exactly the real `demo-felhom-01` tenancy. Hub v0.44.0 logs `PBS DR tenantsync enabled (endpoint 167.233.158.164:22, user felhom-peersync)` on start. + +## 11. The felhom-poke surface (agent-plane immediate-sync — Direction-2a, v0.59.0) + +The hub's THIRD forced-command surface: a **contentless UDP "sync now" nudge** to a registered +box's WireGuard /32, so a user-triggered agent-plane change (a pbsdr descriptor, a MinAgent floor) +lands in **seconds** instead of the ≤15-min report cycle. Proven end-to-end by +`documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md` (~0.42 s/poke; reserved there +for the agent plane). Same low-priv `felhom-peersync` user + REUSED endpoint address & pinned host +key, its OWN keypair + script — **peersync/tenantsync files untouched**. Script source of truth: +`scripts/felhom-poke.sh` (v1.0.0). Hub client: `hub/internal/poke`. + +**No sudoers grant** — unlike peersync/tenantsync, sending a datagram needs no privilege, so the +forced command runs as `felhom-peersync` directly (`command="/usr/local/bin/felhom-poke"`, no +`sudo`). The fixed poke port is **51822** — a shared cross-repo constant (`felhom-agent` +`internal/poke.Port`, this script's `POKE_PORT`, and the port the listener binds on the box's WG +/32); change it in all three or nowhere. + +On the build server (the hub's credential; mirror of §6/§10 — the PRIVATE key ends only in the +Secret): +```sh +ssh-keygen -t ed25519 -f agent-poke-ssh -N "" -C hub-poke@felhom +sudo kubectl -n felhom-system create secret generic agent-poke --from-file=key=agent-poke-ssh +# keep agent-poke-ssh.pub for the box step below, THEN: +shred -u agent-poke-ssh # the private key now exists ONLY in the Secret +``` +(The pinned host key + endpoint address are REUSED from the peersync env — no new hostkey entry. +`manifests/hub.yaml` mounts the Secret at `/etc/hub-secrets/agent-poke/key` via `POKE_SSH_KEY_FILE`; +absent Secret → the hub logs "agent-plane poke disabled".) + +On the box (ep0), as root — additive; the peersync `authorized_keys` lines are untouched: +```sh +tr -d '\r' < felhom-poke.sh > /tmp/fp && sh -n /tmp/fp +install -o root -g root -m 0755 /tmp/fp /usr/local/bin/felhom-poke && rm /tmp/fp +printf 'restrict,command="/usr/local/bin/felhom-poke" %s\n' "$(cat agent-poke-ssh.pub)" \ + >> /home/felhom-peersync/.ssh/authorized_keys +``` + +Verify (from the build server with the key before it is shredded — pin the host key from §6's on-box +read, never keyscan): `ssh -i agent-poke-ssh felhom-peersync@ 10.77.0.` → prints +`poke-fired`, and the box's agent journal logs `poke received → triggering an immediate +desired-state cycle`. A non-WG target (`ssh … 8.8.8.8`) → `refused non-WG target`, non-zero exit. +Hub v0.59.0 logs `agent-plane poke enabled (endpoint 167.233.158.164:22, user felhom-peersync)` on +start. diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 206c464..e338a5a 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,43 @@ # Felhom Hub — Changelog +## v0.59.0 — Direction-2a: agent-plane immediate-sync poke sender + ep0 felhom-poke surface (2026-07-16) + +Implements the AGENT-plane half of `documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md` +option (a): an operator agent-plane change (a pbsdr descriptor, a MinAgent floor) now nudges the box +in **seconds** via a CONTENTLESS UDP poke relayed hub → ep0 forced-command → wg0-origin → the agent's +poke listener (felhom-agent v0.89.0). The spike reserved this transport for the agent plane and +measured it at ~0.42 s/poke. Complements v0.58.0's `internal/intent` long-poll wait channel +(Direction-2b, the CONTROLLER plane): intent is customer-keyed and wakes the config puller; poke is +host-keyed and nudges the agent. Both are fire-and-forget; the 15-min report cycle stays the +guarantee. Pairs with felhom-agent v0.89.0 (the listener). + +- **`internal/poke` — the SSH poke sender + notifier.** Third structural sibling of + `internal/wgsync`/`internal/tenantsync`: a pinned-host-key (`ssh.FixedHostKey`, algorithm-pinned) + in-process SSH client that reuses the peersync endpoint + host key, its OWN forced-command key. + `Client.Poke(ctx, boxWGIP)` refuses any target outside `10.77.0.0/24` BEFORE dialing, then SSHes to + ep0's `felhom-poke` with the box's WG /32 as the command string (→ `$SSH_ORIGINAL_COMMAND`); the + forced command sends one empty UDP datagram to `:51822`. `Notifier.PokeHost`/`PokeAllHosts` + are fire-and-forget (detached goroutine, nil-receiver-safe) — a poke NEVER blocks or fails the + operator save; a missing peer / SSH error is logged and the report cycle reconciles. Tests: + resolved-IP send, no-peer/store-error no-send, nil no-op, `Poke` pre-dial non-WG refusal. +- **Wiring** (`internal/web/server.go`, `internal/web/pbsdr.go`, `internal/web/configs.go`, + `cmd/hub/main.go`): `Server.SetPoke`; `applyPBSDR` fires `PokeHost(host.HostID)` after each + generation-bumping descriptor save (disable, storage-id/re-enable change, fresh provision); + `handleSetArtifacts` (the MinAgent-floor / vouched-agent save) fires `PokeAllHosts()`. **Source note + (spec landmark vs source):** the artifact-manifest save does NOT itself bump per-host desired + generation — the agent self-update dispatches via signed-ops on the next report — so the fleet poke + there accelerates the next report cycle where the floor is applied, rather than delivering a + desired-state delta. The sender is env-configured (`POKE_SSH_KEY_FILE`, reusing + `WG_ENDPOINT_SSH_ADDR`/`_HOSTKEY`/user); absent Secret → "agent-plane poke disabled". +- **ep0 surface** (`scripts/felhom-poke.sh` v1.0.0 + `documentation/runbooks/offsite-endpoint.md` + §11): a NON-root (`felhom-peersync`, no sudoers grant — a datagram needs no privilege) + forced-command that validates `$SSH_ORIGINAL_COMMAND` to the WG /24 and sends one empty datagram + from wg0. Contentless + confined (the WG kernel independently refuses non-peer /32s — spike P1 + EKEYREJECTED). Port **51822** is a shared cross-repo constant. +- **`manifests/hub.yaml`:** `POKE_SSH_KEY_FILE` env + optional `Secret/agent-poke` mounted 0400 at + `/etc/hub-secrets/agent-poke/key` (the private key stored out-of-band, per §11). Bump the image tag + to `0.59.0` and sync. + ## v0.58.0 — Direction-2 immediate-sync: the hub→box "sync now" wait channel (2026-07-16) Implements option (b) of `documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md`: an diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 3e04f82..4b6f00e 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -24,6 +24,7 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/pbsdrheal" "gitea.dooplex.hu/admin/felhom-hub/internal/monitor" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" + "gitea.dooplex.hu/admin/felhom-hub/internal/poke" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/web" "gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync" @@ -425,6 +426,30 @@ func main() { } else { logger.Printf("[INFO] PBS DR tenantsync disabled (key or endpoint not configured)") } + + // Agent-plane immediate-sync SENDER (Direction-2a, v0.59.0; SPIKE-immediate-sync-transport): + // same endpoint + pinned host key + peersync user as wgsync/tenantsync, its OWN forced-command + // key (POKE_SSH_KEY_FILE, optional Secret). A poke is contentless + fire-and-forget; if this + // is unconfigured, an agent-plane save just bumps the generation and the box picks it up on its + // next ≤15-min report (no error to the operator). + pokeKeyFile := os.Getenv("POKE_SSH_KEY_FILE") + if wgAddr != "" && wgHostKey != "" && pokeKeyFile != "" { + keyPEM, err := os.ReadFile(pokeKeyFile) + if os.IsNotExist(err) { + logger.Printf("[INFO] agent-plane poke disabled (key %s not present)", pokeKeyFile) + } else if err != nil { + logger.Printf("[ERROR] agent-plane poke disabled: read key file %s: %v", pokeKeyFile, err) + } else if pokeClient, err := poke.New(poke.Config{ + Addr: wgAddr, User: wgUser, PrivateKey: keyPEM, HostKeyLine: wgHostKey, + }, logger); err != nil { + logger.Printf("[ERROR] agent-plane poke disabled: %v", err) + } else { + webServer.SetPoke(poke.NewNotifier(dataStore, pokeClient, logger)) + logger.Printf("[INFO] agent-plane poke enabled (endpoint %s, user %s)", wgAddr, wgUser) + } + } else { + logger.Printf("[INFO] agent-plane poke disabled (key or endpoint not configured)") + } } // PBS-DR self-heal reconciler (internal/pbsdrheal, from SPIKE-pbsdr-selfheal-2026-07-15). Re-arms diff --git a/hub/internal/poke/client.go b/hub/internal/poke/client.go new file mode 100644 index 0000000..f9a9ec4 --- /dev/null +++ b/hub/internal/poke/client.go @@ -0,0 +1,134 @@ +// Package poke is the agent-plane immediate-sync SENDER (Direction-2a, +// SPIKE-immediate-sync-transport-2026-07-16). It is the third structural sibling of +// internal/wgsync and internal/tenantsync: the hub holds a forced-command SSH credential to the +// offsite endpoint (ep0) and drives it; the endpoint stays a dumb, runbook-provisioned box. +// +// A poke is a CONTENTLESS "sync now" nudge: the hub SSHes to ep0's `felhom-poke` forced command +// with the target box's WireGuard /32 as the command string (→ SSH_ORIGINAL_COMMAND); the forced +// command validates it and sends ONE empty UDP datagram from wg0 to that /32:51822. The agent's +// poke listener (felhom-agent internal/poke) fires an immediate desired-state cycle. Measured +// ~0.42 s per fresh SSH session in the spike (target ≤2–3 s). +// +// It carries NO payload semantics, NO auth handshake, NO retry: a lost or forged poke costs at +// most one extra debounced tick, and the 15-min report cycle remains the guarantee. The transport +// is SSH with a PINNED host key (the wgsync posture — exact match or refuse, no insecure +// fallback); credential theft bounds the attacker to "make registered boxes tick", and WireGuard +// itself refuses to encrypt to any /32 no registered peer owns (spike P1 EKEYREJECTED). +package poke + +import ( + "bytes" + "context" + "fmt" + "log" + "net" + "net/netip" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +// wgSubnet confines every poke target to the WG /24 (defence in depth — the ep0 forced command +// validates independently, and the kernel refuses non-peer /32s). +var wgSubnet = netip.MustParsePrefix("10.77.0.0/24") + +// Config configures the SSH poke client. All values come from the deployment env / mounted Secret +// (operator infra — never a customer record). Addr + HostKeyLine mirror wgsync/tenantsync; the +// private key is the poke key's OWN (the ep0 authorized_keys line selects the forced command). +type Config struct { + Addr string // "host:22" + User string // "felhom-peersync" (same user; the key selects the forced command) + PrivateKey []byte // PEM private key (from the mounted Secret file) + HostKeyLine string // endpoint host pubkey, authorized_keys format (the wgsync pin) + Timeout time.Duration // default 10s (a poke must be quick or abandoned) +} + +// Client is a pinned-host-key SSH poke sender. Construct with New (parses keys up front). +type Client struct { + addr string + user string + signer ssh.Signer + hostKey ssh.PublicKey + timeout time.Duration + logger *log.Logger +} + +// New builds a Client, failing early on an unparsable key or host-key line. +func New(cfg Config, logger *log.Logger) (*Client, error) { + if cfg.Addr == "" || cfg.User == "" { + return nil, fmt.Errorf("poke: Addr and User are required") + } + signer, err := ssh.ParsePrivateKey(cfg.PrivateKey) + if err != nil { + return nil, fmt.Errorf("poke: parse private key: %w", err) + } + hostKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(cfg.HostKeyLine)) + if err != nil { + return nil, fmt.Errorf("poke: parse host key line: %w", err) + } + timeout := cfg.Timeout + if timeout == 0 { + timeout = 10 * time.Second + } + if logger == nil { + logger = log.Default() + } + return &Client{addr: cfg.Addr, user: cfg.User, signer: signer, hostKey: hostKey, timeout: timeout, logger: logger}, nil +} + +// Poke sends one contentless UDP nudge to boxWGIP via the ep0 forced command. boxWGIP is validated +// to be inside the WG /24 before any connection (the ep0 script validates again). The box IP is +// the SSH command string (the forced command reads $SSH_ORIGINAL_COMMAND). Returns an error on any +// failure — but the CALLER treats this fire-and-forget (log, never fail the operator save). +func (c *Client) Poke(ctx context.Context, boxWGIP string) error { + addr, err := netip.ParseAddr(boxWGIP) + if err != nil || !wgSubnet.Contains(addr) { + return fmt.Errorf("poke: refusing non-WG target %q", boxWGIP) + } + sshCfg := &ssh.ClientConfig{ + User: c.user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(c.signer)}, + HostKeyCallback: ssh.FixedHostKey(c.hostKey), + HostKeyAlgorithms: []string{c.hostKey.Type()}, // pin the algorithm (wgsync S1 finding) + Timeout: c.timeout, + } + dialer := net.Dialer{Timeout: c.timeout} + conn, err := dialer.DialContext(ctx, "tcp", c.addr) + if err != nil { + return fmt.Errorf("poke: dial %s: %w", c.addr, err) + } + if dl, ok := ctx.Deadline(); ok { + conn.SetDeadline(dl) + } else { + conn.SetDeadline(time.Now().Add(c.timeout)) + } + sconn, chans, reqs, err := ssh.NewClientConn(conn, c.addr, sshCfg) + if err != nil { + conn.Close() + return fmt.Errorf("poke: ssh handshake %s: %w", c.addr, err) + } + client := ssh.NewClient(sconn, chans, reqs) + defer client.Close() + conn.SetDeadline(time.Time{}) + if dl, ok := ctx.Deadline(); ok { + conn.SetDeadline(dl) + } + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("poke: session: %w", err) + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + // The forced command IGNORES this string for execution but reads it as $SSH_ORIGINAL_COMMAND — + // that is how the target box IP crosses to ep0. The command carries only the IP, nothing else. + if err := session.Run(boxWGIP); err != nil { + return fmt.Errorf("poke: remote felhom-poke failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String())) + } + c.logger.Printf("[INFO] poke: sync-poke delivered to %s via %s", boxWGIP, c.addr) + return nil +} diff --git a/hub/internal/poke/notifier.go b/hub/internal/poke/notifier.go new file mode 100644 index 0000000..9dd943c --- /dev/null +++ b/hub/internal/poke/notifier.go @@ -0,0 +1,100 @@ +package poke + +import ( + "context" + "log" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// peerStore is the WG-peer lookup seam (satisfied by *store.Store; tests fake it). +type peerStore interface { + GetWGPeerForHost(hostID string) (*store.WGPeer, error) + ListWGPeers() ([]store.WGPeer, error) +} + +// pokeSender is the SSH send seam (satisfied by *Client; tests fake it). +type pokeSender interface { + Poke(ctx context.Context, boxWGIP string) error +} + +// Notifier turns an operator-intent event (a host id, or "all hosts") into fire-and-forget pokes. +// Every public method returns immediately and does the SSH work on a detached goroutine, so a poke +// NEVER blocks or fails the hub save that triggered it — a lost poke is harmless (the 15-min cycle +// reconciles). nil *Notifier is a safe no-op (poke disabled / not configured). +type Notifier struct { + store peerStore + client pokeSender + timeout time.Duration + logger *log.Logger +} + +// NewNotifier builds a notifier. timeout bounds one poke (default 10s). +func NewNotifier(st peerStore, client pokeSender, logger *log.Logger) *Notifier { + if logger == nil { + logger = log.Default() + } + return &Notifier{store: st, client: client, timeout: 10 * time.Second, logger: logger} +} + +// PokeHost fires a poke to one host's box (async, fire-and-forget). Safe on a nil receiver. +func (n *Notifier) PokeHost(hostID string) { + if n == nil { + return + } + go func() { + if err := n.pokeHost(context.Background(), hostID); err != nil { + n.logger.Printf("[INFO] poke: host %s not nudged (%v) — the 15-min cycle still reconciles", hostID, err) + } + }() +} + +// PokeAllHosts fires a poke to EVERY host with a WG peer (async, fire-and-forget). Used by a +// fleet-wide agent-plane change (e.g. a MinAgent floor). Safe on a nil receiver. +func (n *Notifier) PokeAllHosts() { + if n == nil { + return + } + go func() { + peers, err := n.store.ListWGPeers() + if err != nil { + n.logger.Printf("[INFO] poke: fleet nudge skipped (peer list: %v) — the 15-min cycle still reconciles", err) + return + } + nudged := 0 + for _, p := range peers { + if p.HostID == "" || p.AssignedIP == "" { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), n.timeout) + if err := n.client.Poke(ctx, p.AssignedIP); err != nil { + n.logger.Printf("[INFO] poke: host %s (%s) not nudged (%v)", p.HostID, p.AssignedIP, err) + } else { + nudged++ + } + cancel() + } + n.logger.Printf("[INFO] poke: fleet nudge sent to %d/%d WG peers", nudged, len(peers)) + }() +} + +// pokeHost is the synchronous body (test seam). Resolves the host's WG /32 and sends one poke. +func (n *Notifier) pokeHost(ctx context.Context, hostID string) error { + peer, err := n.store.GetWGPeerForHost(hostID) + if err != nil { + return err + } + if peer == nil || peer.AssignedIP == "" { + return errNoPeer + } + tctx, cancel := context.WithTimeout(ctx, n.timeout) + defer cancel() + return n.client.Poke(tctx, peer.AssignedIP) +} + +type pokeErr string + +func (e pokeErr) Error() string { return string(e) } + +const errNoPeer = pokeErr("host has no WG peer") diff --git a/hub/internal/poke/notifier_test.go b/hub/internal/poke/notifier_test.go new file mode 100644 index 0000000..19efc8b --- /dev/null +++ b/hub/internal/poke/notifier_test.go @@ -0,0 +1,136 @@ +package poke + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "io" + "log" + "strings" + "sync" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "golang.org/x/crypto/ssh" +) + +func quietLogger() *log.Logger { return log.New(io.Discard, "", 0) } + +type fakePeerStore struct { + byHost map[string]*store.WGPeer + all []store.WGPeer + err error +} + +func (f *fakePeerStore) GetWGPeerForHost(hostID string) (*store.WGPeer, error) { + if f.err != nil { + return nil, f.err + } + return f.byHost[hostID], nil +} +func (f *fakePeerStore) ListWGPeers() ([]store.WGPeer, error) { + if f.err != nil { + return nil, f.err + } + return f.all, nil +} + +type fakeSender struct { + mu sync.Mutex + targets []string + err error +} + +func (f *fakeSender) Poke(_ context.Context, boxWGIP string) error { + f.mu.Lock() + f.targets = append(f.targets, boxWGIP) + f.mu.Unlock() + return f.err +} +func (f *fakeSender) got() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.targets...) +} + +// pokeHost resolves the host's WG /32 and sends the poke there — the address is looked up, never +// caller-supplied. +func TestPokeHost_SendsToResolvedWGIP(t *testing.T) { + st := &fakePeerStore{byHost: map[string]*store.WGPeer{ + "host-abc": {HostID: "host-abc", AssignedIP: "10.77.0.5"}, + }} + snd := &fakeSender{} + n := NewNotifier(st, snd, quietLogger()) + + if err := n.pokeHost(context.Background(), "host-abc"); err != nil { + t.Fatalf("pokeHost: %v", err) + } + got := snd.got() + if len(got) != 1 || got[0] != "10.77.0.5" { + t.Fatalf("sender targets = %v, want [10.77.0.5]", got) + } +} + +// A host with no WG peer is a no-op error the caller logs — never a send, never a save failure. +func TestPokeHost_NoPeerNoSend(t *testing.T) { + snd := &fakeSender{} + n := NewNotifier(&fakePeerStore{byHost: map[string]*store.WGPeer{}}, snd, quietLogger()) + if err := n.pokeHost(context.Background(), "unknown-host"); err == nil { + t.Fatal("pokeHost for a peerless host returned nil, want an error") + } + if len(snd.got()) != 0 { + t.Fatalf("sender was called for a peerless host: %v", snd.got()) + } +} + +// A store error propagates as an error (caller logs, never fails the save) and sends nothing. +func TestPokeHost_StoreErrorNoSend(t *testing.T) { + snd := &fakeSender{} + n := NewNotifier(&fakePeerStore{err: errors.New("db down")}, snd, quietLogger()) + if err := n.pokeHost(context.Background(), "host-abc"); err == nil { + t.Fatal("pokeHost on a store error returned nil") + } + if len(snd.got()) != 0 { + t.Fatalf("sender called despite store error: %v", snd.got()) + } +} + +// A nil *Notifier is a safe no-op (poke disabled) — the public entrypoints must not panic. +func TestNilNotifierIsNoOp(t *testing.T) { + var n *Notifier + n.PokeHost("h") // must not panic + n.PokeAllHosts() +} + +// Client.Poke REFUSES any target outside the WG /24 BEFORE any network I/O — the confinement the +// forced command and the WG kernel also enforce, checked here at the source. +func TestClientPoke_RefusesNonWGTarget(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatalf("signer: %v", err) + } + pemBlock, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("marshal key: %v", err) + } + hostLine := string(ssh.MarshalAuthorizedKey(signer.PublicKey())) + c, err := New(Config{ + Addr: "198.51.100.1:22", // TEST-NET-2 — must never actually be dialed + User: "felhom-peersync", + PrivateKey: pem.EncodeToMemory(pemBlock), + HostKeyLine: hostLine, + }, quietLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + err = c.Poke(context.Background(), "8.8.8.8") // public IP — outside 10.77.0.0/24 + if err == nil || !strings.Contains(err.Error(), "refusing non-WG target") { + t.Fatalf("Poke to a public IP: err = %v, want a pre-dial 'refusing non-WG target' refusal", err) + } +} diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 7e58049..d20c7b3 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -1000,6 +1000,12 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) { return } s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q", agentVer, goldenVer, minAgent) + // Agent-plane immediate-sync (Direction-2a, v0.59.0): a MinAgent-floor / vouched-agent change is + // a fleet-wide agent-plane intent shift. Fire-and-forget nudge every box so it re-reports at + // once (the self-update train's signed op / floor re-evaluation lands in seconds, not ≤15 min). + // The manifest write itself does not bump per-host desired generation; the poke only accelerates + // the next report where the floor is applied. Never blocks the save. + s.poke.PokeAllHosts() http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther) } diff --git a/hub/internal/web/pbsdr.go b/hub/internal/web/pbsdr.go index 574c753..e9a559b 100644 --- a/hub/internal/web/pbsdr.go +++ b/hub/internal/web/pbsdr.go @@ -25,6 +25,7 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-hub/internal/poke" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync" ) @@ -39,6 +40,11 @@ type tenancyProvisioner interface { // tier enabled returns an error (not configured on this hub); the form section still renders. func (s *Server) SetTenantSync(p tenancyProvisioner) { s.tenantsync = p } +// SetPoke wires the agent-plane immediate-sync sender (v0.59.0, Direction-2a). Without it, an +// agent-plane desired-state save just bumps the generation and the box picks it up on its next +// ≤15-min report — the poke only shortens that to seconds. Optional. +func (s *Server) SetPoke(p *poke.Notifier) { s.poke = p } + // pbsDRDescriptor is the NON-SECRET pbs_dr block in a host's desired_json. It NEVER carries the // token secret (that is host_pbs_secrets custody, consume-once). type pbsDRDescriptor struct { @@ -136,6 +142,7 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus return fmt.Errorf("pbsdr: desired-state write: %w", err) } s.logger.Printf("[INFO] pbsdr disabled for %s (host %s, gen %d; ep0 tenancy kept)", cfg.CustomerID, host.HostID, gen) + s.poke.PokeHost(host.HostID) // agent-plane immediate-sync (Direction-2a): land the change in seconds return nil } @@ -160,6 +167,7 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus return fmt.Errorf("pbsdr: desired-state write: %w", err) } s.logger.Printf("[INFO] pbsdr descriptor updated for %s (host %s, gen %d; tenancy unchanged)", cfg.CustomerID, host.HostID, gen) + s.poke.PokeHost(host.HostID) // agent-plane immediate-sync (Direction-2a): land the change in seconds return nil } @@ -171,6 +179,8 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus } if blocked != "" { s.logger.Printf("[INFO] pbsdr: DR tier ON for %s — waiting: %s", cfg.CustomerID, blocked) + } else { + s.poke.PokeHost(host.HostID) // freshly provisioned + generation bumped → nudge the box now } return nil } diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 973b580..4443c6f 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -19,6 +19,7 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/claim" "gitea.dooplex.hu/admin/felhom-hub/internal/gitea" "gitea.dooplex.hu/admin/felhom-hub/internal/intent" + "gitea.dooplex.hu/admin/felhom-hub/internal/poke" "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" "gitea.dooplex.hu/admin/felhom-hub/internal/semver" "gitea.dooplex.hu/admin/felhom-hub/internal/store" @@ -73,6 +74,13 @@ type Server struct { // no immediacy (bumps are no-ops; the 15-min cycle still reconciles). intentHub *intent.Hub + // poke (v0.59.0, Direction-2a agent-plane immediate-sync) fires a fire-and-forget UDP nudge to + // a host's box (via the ep0 forced command) when an AGENT-plane desired-state change is saved + // (a pbsdr descriptor, the MinAgent floor) so the box ticks in seconds instead of ≤15 min. nil + // = no immediacy (a no-op; the report cycle still reconciles). Its sibling intentHub handles + // the CONTROLLER plane (customer/app config) via the long-poll wait channel. + poke *poke.Notifier + sessions map[string]*hubSession sessionsMu sync.RWMutex } diff --git a/manifests/hub.yaml b/manifests/hub.yaml index acb8c09..fcc3b86 100644 --- a/manifests/hub.yaml +++ b/manifests/hub.yaml @@ -184,6 +184,13 @@ spec: # runbook offsite-endpoint.md §10). Optional: absent → the hub logs tenantsync disabled. - name: TENANTSYNC_SSH_KEY_FILE value: "/etc/hub-secrets/tenantsync/key" + # Agent-plane immediate-sync (Direction-2a, v0.59.0): the poke sender — same endpoint + + # pinned host key + peersync user as above, its OWN forced-command key from Secret/agent-poke + # (out-of-band; the ep0 authorized_keys line carries the PUBLIC half, command="felhom-poke"). + # Optional: absent → the hub logs the poke disabled; agent-plane saves still reconcile in + # ≤15 min. See documentation/runbooks/offsite-endpoint.md (poke section). + - name: POKE_SSH_KEY_FILE + value: "/etc/hub-secrets/agent-poke/key" # Offsite provisioning (SLICE 1+2): Hetzner Storage Box API token + the NUMERIC id of the # pool box, from the out-of-band Secret/storagebox (NOT committed). The token MUST be scoped # to the dedicated storage project — NEVER the shared-project token (it can touch ep0). @@ -222,6 +229,9 @@ spec: - name: tenantsync mountPath: /etc/hub-secrets/tenantsync readOnly: true + - name: agent-poke + mountPath: /etc/hub-secrets/agent-poke + readOnly: true livenessProbe: httpGet: path: /healthz @@ -259,6 +269,14 @@ spec: - key: key path: key mode: 0400 + - name: agent-poke + secret: + secretName: agent-poke + optional: true + items: + - key: key + path: key + mode: 0400 # ============================================================================= # SERVICE diff --git a/scripts/felhom-poke.sh b/scripts/felhom-poke.sh new file mode 100644 index 0000000..c2251fd --- /dev/null +++ b/scripts/felhom-poke.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# felhom-poke v1.0.0 — the offsite endpoint's agent-plane immediate-sync surface (Direction-2a, +# SPIKE-immediate-sync-transport-2026-07-16). The hub's THIRD forced-command surface on ep0 +# (alongside felhom-peersync and felhom-tenantsync). +# +# It sends ONE contentless UDP datagram from wg0 to a REGISTERED box's WireGuard /32 on the fixed +# poke port; the box's felhom-agent poke listener then runs an immediate desired-state cycle. This +# collapses a user-triggered agent-plane config change (a pbsdr descriptor, a MinAgent floor) from +# the 15-min report cycle to the spike-measured sub-second path. +# +# CONTRACT (do not weaken any of these): +# - CONTENTLESS: an EMPTY datagram — a poke means only "tick now". No payload, no secret, no +# version, no auth handshake. A forged or replayed poke costs at most one extra (debounced) +# tick on the box, and the report cycle remains the guarantee. +# - CONFINED: the target IP arrives as $SSH_ORIGINAL_COMMAND (the hub sets it as the SSH command; +# this forced command ignores it for execution and reads it only as data). It is validated to +# 10.77.0.0/24 here, AND the WireGuard kernel independently refuses to encrypt to any /32 no +# registered peer owns (EKEYREJECTED, spike P1) — so a poke can only ever reach a real box. +# - NON-ROOT: sending a datagram needs no privilege. Unlike peersync/tenantsync this surface has +# NO sudoers grant — the forced command runs as felhom-peersync directly. +# - ORIGINATION, NOT TRANSIT: the datagram leaves via wg0's own output path; it never touches the +# forward chain, so it needs no ip_forward and no nft rule (spike P1 §2). +# +# authorized_keys line on the box (PUBLIC key only; the private half lives in the hub k8s +# Secret/agent-poke, never here): +# restrict,command="/usr/local/bin/felhom-poke" ssh-ed25519 AAAA... hub-poke@felhom +set -eu + +POKE_PORT=51822 + +ip="${SSH_ORIGINAL_COMMAND:-}" +# Coarse then strict: WG /24 prefix, then a valid dotted octet (0-255) in it. Anything else refused. +case "$ip" in + 10.77.0.*) : ;; + *) echo "felhom-poke: refused non-WG target: '$ip'" >&2; exit 1 ;; +esac +echo "$ip" | grep -Eq '^10\.77\.0\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$' \ + || { echo "felhom-poke: malformed target: '$ip'" >&2; exit 1; } + +python3 - "$ip" "$POKE_PORT" <<'PY' +import socket, sys +ip, port = sys.argv[1], int(sys.argv[2]) +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +try: + s.sendto(b"", (ip, port)) # contentless: an empty datagram = "tick now" +finally: + s.close() +PY +echo "poke-fired"