Files
felhom.eu/hub/internal/poke/client.go
T
admin eb227486d0 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).
2026-07-16 22:48:15 +02:00

135 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}