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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user