feat(hub): operator OOB peer + oob_peer_ip desired-state merge (H1 Part 1)

store.SetOperatorOOBPeer/GetOperatorOOBPeer (empty-host_id wg_peers row, explicit
/32, validated in-subnet/not-reserved/not-taken, last-write-wins rotation).
PUT/GET /admin/wg/operator-peer (global key). mergeWireguard adds oob_peer_ip when
an operator peer exists (absent = byte-identical). BumpAllHostGenerations forces
fleet re-fetch. Non-hollow tests both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 22:08:06 +02:00
parent a3ee93e97e
commit 0ec7555126
6 changed files with 342 additions and 1 deletions
+13
View File
@@ -1500,6 +1500,19 @@ func (s *Store) BumpHostDesired(hostID string) (int64, error) {
return gen, nil
}
// BumpAllHostGenerations advances EVERY host's desired_generation (TASK H1). Used when a FLEET-WIDE
// hub-owned served-state value changes — the operator OOB peer's /32 flows into every host's
// merge-at-read wireguard block (oob_peer_ip), so every agent must re-fetch + re-render. Returns the
// number of hosts bumped.
func (s *Store) BumpAllHostGenerations() (int64, error) {
res, err := s.db.Exec(`UPDATE hosts SET desired_generation = desired_generation + 1, updated_at = datetime('now')`)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
// SignedJob is one OPAQUE operator-signed destructive-op blob queued for a host (slice 10A). The
// hub stores + serves the bytes; it never forges, opens, or executes them (10B owns verify+run).
type SignedJob struct {
+103
View File
@@ -0,0 +1,103 @@
package store
import (
"database/sql"
"fmt"
"net/netip"
)
// OperatorOOBNote marks the single fleet-wide operator OOB peer among wg_peers (TASK H1). It is an
// UNBOUND peer (host_id ''), so it rides the existing empty-host_id support + the partial-unique
// idx_wg_peers_host (which only constrains non-empty host_id) without collision [OF-4]. It is pushed
// to the endpoint by peersync like any peer (so the endpoint accepts the operator's handshake), and
// its assigned_ip becomes each box's desired-state oob_peer_ip (rendered into wg-felhom AllowedIPs).
const OperatorOOBNote = "operator-oob"
// GetOperatorOOBPeer returns the fleet operator OOB peer, or (nil, nil) if none is registered.
func (s *Store) GetOperatorOOBPeer() (*WGPeer, error) {
var p WGPeer
err := s.db.QueryRow(
`SELECT pubkey, assigned_ip, host_id, note, created_at FROM wg_peers WHERE note = ? LIMIT 1`,
OperatorOOBNote).
Scan(&p.Pubkey, &p.AssignedIP, &p.HostID, &p.Note, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// SetOperatorOOBPeer registers/rotates THE operator OOB peer at an EXPLICIT tunnel address (so the
// endpoint's STATIC forward chain can hardcode the operator /32). Last-write-wins: any prior
// operator-oob row is replaced (key rotation / IP change). Validated: assignedIP is an IPv4 host
// address inside the endpoint's tunnel_subnet, not the network/broadcast/pbs_tunnel_ip, and not
// already taken by a DIFFERENT (customer) peer. host_id stays '' (unbound) so it is never counted as
// a customer host.
func (s *Store) SetOperatorOOBPeer(pubkey, assignedIP string) error {
ip, err := netip.ParseAddr(assignedIP)
if err != nil || !ip.Is4() {
return fmt.Errorf("operator oob: assigned_ip %q is not an IPv4 address", assignedIP)
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var subnet, pbsIP string
err = tx.QueryRow(`SELECT tunnel_subnet, pbs_tunnel_ip FROM wg_endpoints ORDER BY endpoint_id LIMIT 1`).
Scan(&subnet, &pbsIP)
if err == sql.ErrNoRows {
return ErrWGEndpointUnset
}
if err != nil {
return err
}
pfx, err := netip.ParsePrefix(subnet)
if err != nil {
return fmt.Errorf("operator oob: endpoint tunnel_subnet %q unparseable: %w", subnet, err)
}
if !pfx.Contains(ip) {
return fmt.Errorf("operator oob: assigned_ip %s is not inside tunnel_subnet %s", assignedIP, subnet)
}
if ip == pfx.Masked().Addr() || ip.String() == pbsIP || ip == lastAddr(pfx) {
return fmt.Errorf("operator oob: assigned_ip %s is a reserved address (network/broadcast/pbs)", assignedIP)
}
// The IP must be free OR already this operator peer's (idempotent re-set at the same IP).
var otherPubkey string
err = tx.QueryRow(`SELECT pubkey FROM wg_peers WHERE assigned_ip = ? AND note != ?`, assignedIP, OperatorOOBNote).
Scan(&otherPubkey)
if err == nil {
return fmt.Errorf("operator oob: assigned_ip %s is already taken by peer %s", assignedIP, shortKey(otherPubkey))
}
if err != sql.ErrNoRows {
return err
}
// The pubkey must not be bound to a host (an operator peer is never a customer key).
var boundHost string
err = tx.QueryRow(`SELECT host_id FROM wg_peers WHERE pubkey = ? AND host_id != ''`, pubkey).Scan(&boundHost)
if err == nil {
return fmt.Errorf("operator oob: pubkey is already bound to host %s — refusing", boundHost)
}
if err != sql.ErrNoRows {
return err
}
if _, err := tx.Exec(`DELETE FROM wg_peers WHERE note = ?`, OperatorOOBNote); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO wg_peers (pubkey, assigned_ip, host_id, note) VALUES (?, ?, '', ?)`,
pubkey, assignedIP, OperatorOOBNote); err != nil {
return err
}
return tx.Commit()
}
func shortKey(k string) string {
if len(k) > 12 {
return k[:12] + "…"
}
return k
}
+73
View File
@@ -0,0 +1,73 @@
package store
import "testing"
const opPubkey = "cdmN4U+fjR18zBk+SKoceJQyz9HgA9+hN8/FiKF1u0o="
func TestOperatorOOBPeer_RoundTripRotateAndUnbound(t *testing.T) {
s := newTestStore(t)
setTestEndpoint(t, s, "10.77.0.0/24", "10.77.0.1")
// none → (nil,nil)
if op, err := s.GetOperatorOOBPeer(); err != nil || op != nil {
t.Fatalf("no operator peer: got %+v / %v", op, err)
}
// register at an explicit /32
if err := s.SetOperatorOOBPeer(opPubkey, "10.77.0.250"); err != nil {
t.Fatalf("SetOperatorOOBPeer: %v", err)
}
op, err := s.GetOperatorOOBPeer()
if err != nil || op == nil {
t.Fatalf("GetOperatorOOBPeer: %+v / %v", op, err)
}
if op.AssignedIP != "10.77.0.250" || op.Pubkey != opPubkey {
t.Fatalf("operator peer = %+v", op)
}
if op.HostID != "" || op.Note != OperatorOOBNote {
t.Fatalf("operator peer must be UNBOUND (host_id '') + note operator-oob: %+v", op)
}
// it IS in the peer list (so peersync pushes it to the endpoint)
if peerCount(t, s) != 1 {
t.Fatalf("operator peer must appear in ListWGPeers")
}
// rotate: new key + new IP replaces the old (last-write-wins, still one operator row)
const opPubkey2 = "tQ6eJC9y8pSKtam44DaSrrtYBZYUvZG2z1stS6PsaD4="
if err := s.SetOperatorOOBPeer(opPubkey2, "10.77.0.249"); err != nil {
t.Fatalf("rotate: %v", err)
}
op, _ = s.GetOperatorOOBPeer()
if op.Pubkey != opPubkey2 || op.AssignedIP != "10.77.0.249" {
t.Fatalf("rotate did not replace: %+v", op)
}
if peerCount(t, s) != 1 {
t.Fatalf("rotate must keep exactly one operator peer, got %d", peerCount(t, s))
}
}
func TestOperatorOOBPeer_ValidationRejectsReservedAndTaken(t *testing.T) {
s := newTestStore(t)
setTestEndpoint(t, s, "10.77.0.0/24", "10.77.0.1")
for _, bad := range []struct{ ip, why string }{
{"10.77.0.1", "pbs_tunnel_ip"},
{"10.77.0.0", "network address"},
{"10.77.0.255", "broadcast"},
{"10.88.0.5", "outside subnet"},
{"not-an-ip", "not an ip"},
} {
if err := s.SetOperatorOOBPeer(opPubkey, bad.ip); err == nil {
t.Errorf("SetOperatorOOBPeer(%q) accepted but should reject (%s)", bad.ip, bad.why)
}
}
// a customer peer holds 10.77.0.2 → operator can't take it
if _, _, err := s.AddWGPeer("PCUST", "h1", ""); err != nil {
t.Fatalf("AddWGPeer: %v", err)
}
custIP, _ := s.GetWGPeerForHost("h1")
if err := s.SetOperatorOOBPeer(opPubkey, custIP.AssignedIP); err == nil {
t.Fatalf("operator peer must not take a customer's IP %s", custIP.AssignedIP)
}
}