hub v0.59.0: Direction-2a agent-plane immediate-sync poke sender + ep0 felhom-poke surface

- internal/poke: pinned-host-key SSH poke sender (wgsync sibling) + fire-and-forget Notifier (PokeHost/PokeAllHosts). Poke refuses non-WG targets pre-dial; contentless via ep0 forced command to the box WG /32:51822.
- wiring: Server.SetPoke; applyPBSDR pokes the host after each descriptor gen-bump; handleSetArtifacts (MinAgent floor) pokes all hosts. main.go env POKE_SSH_KEY_FILE (reuses peersync endpoint/hostkey).
- scripts/felhom-poke.sh (non-root forced command) + offsite-endpoint.md §11; manifests/hub.yaml Secret/agent-poke + POKE_SSH_KEY_FILE (image tag bump follows the build).
This commit is contained in:
2026-07-16 22:48:15 +02:00
parent bdb65a80e8
commit eb227486d0
11 changed files with 567 additions and 0 deletions
@@ -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@<ep0> 10.77.0.<box>` → 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.
+38
View File
@@ -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 `<ip>: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
+25
View File
@@ -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
+134
View File
@@ -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 ≤23 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
}
+100
View File
@@ -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")
+136
View File
@@ -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)
}
}
+6
View File
@@ -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)
}
+10
View File
@@ -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
}
+8
View File
@@ -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
}
+18
View File
@@ -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
+49
View File
@@ -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"