hub: S2 store — host-bound WG peers (register/re-key/adopt), one-per-host index, BumpHostDesired

allocateWGPeerTx extracted from addWGPeerOnce (behavior-neutral; S1 tests
unmodified+green). RegisterWGPeerForHost: idempotent / re-key-in-place-keep-ip /
adopt-unbound / ErrWGPubkeyBoundElsewhere. Partial unique index enforces one
bound peer per host. BumpHostDesired touches ONLY the generation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-04 00:39:24 +02:00
parent 740cc42734
commit fcf84a0c5c
3 changed files with 287 additions and 34 deletions
+23
View File
@@ -391,6 +391,8 @@ func (s *Store) migrate() error {
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
-- S2: one BOUND peer per host (partial index — S1's unbound admin/test rows unaffected).
CREATE UNIQUE INDEX IF NOT EXISTS idx_wg_peers_host ON wg_peers(host_id) WHERE host_id != '';
`)
if err != nil {
return err
@@ -1459,6 +1461,27 @@ func (s *Store) SetHostDesired(hostID string, desiredJSON []byte) (int64, error)
return gen, nil
}
// BumpHostDesired advances a host's desired_generation WITHOUT touching desired_json (S2). Used
// when hub-OWNED served state changes (the merge-at-read wireguard block) — the stored operator
// blob is not the thing that moved, so SetHostDesired (which replaces it) must not be used.
// Returns the new generation; sql.ErrNoRows for an unknown host.
func (s *Store) BumpHostDesired(hostID string) (int64, error) {
res, err := s.db.Exec(`
UPDATE hosts SET desired_generation = desired_generation + 1, updated_at = datetime('now')
WHERE host_id = ?`, hostID)
if err != nil {
return 0, err
}
if n, _ := res.RowsAffected(); n == 0 {
return 0, sql.ErrNoRows // unknown host
}
var gen int64
if err := s.db.QueryRow(`SELECT desired_generation FROM hosts WHERE host_id = ?`, hostID).Scan(&gen); err != nil {
return 0, err
}
return gen, 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 {