hub: S2 API — box-facing WG registration + merge-at-read + hub-owned-key guard

POST /hosts/{id}/wg (per-host self-scoped; global = operator/DR path): bind /
re-key-in-place / adopt; generation bump + endpoint push ONLY on real change.
mergeWireguard injects the hub-owned block into served desired-state at READ
time (stored operator blob never touched; fail-safe unmerged on any error;
no-peer = byte-identical pass-through — existing golden test untouched+green).
handleAdminSetDesiredState rejects top-level wireguard (400). Admin DELETE of a
BOUND peer bumps the owning host. NEW golden desired-state-wireguard.golden.json
= the S3 cross-repo contract. Red-proofs a/b/c/d run + reverted.

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:44:40 +02:00
parent fcf84a0c5c
commit ba52005e61
4 changed files with 447 additions and 0 deletions
+17
View File
@@ -169,6 +169,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/restore-directive"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/restore-directive")
h.handleGetRestoreDirective(w, r, hostID)
// S2 offsite connectivity: box-facing WG pubkey registration (per-host key, self-scoped).
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/wg"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/wg")
h.handleRegisterHostWG(w, r, hostID)
// Desired-state serving (slice 10A) — per-host-key, self-scoped (a host reads only its own).
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/desired-state"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/desired-state")
@@ -911,6 +915,9 @@ func (h *Handler) handleGetDesiredState(w http.ResponseWriter, r *http.Request,
if strings.TrimSpace(desired) == "" {
desired = "{}"
}
// S2: merge the hub-OWNED wireguard block at read time (no peer → pass-through unchanged;
// the stored operator blob is never modified). See api/wg.go mergeWireguard.
desired = h.mergeWireguard(pathHostID, desired)
resp := map[string]interface{}{
"generation": host.DesiredGeneration,
"desired_state": json.RawMessage(desired), // opaque to the hub — agent owns the schema
@@ -1008,6 +1015,16 @@ func (h *Handler) handleAdminSetDesiredState(w http.ResponseWriter, r *http.Requ
http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
return
}
// S2: the wireguard block is HUB-owned, merged at read time — an operator copy-paste of a
// served desired-state must never write it back into the stored blob (it would go stale and
// shadow the live assignment). Reject at the door.
var topKeys map[string]json.RawMessage
if err := json.Unmarshal(body, &topKeys); err == nil {
if _, has := topKeys["wireguard"]; has {
http.Error(w, "wireguard is hub-owned; register via POST /hosts/{id}/wg", http.StatusBadRequest)
return
}
}
gen, err := h.store.SetHostDesired(pathHostID, body)
if err == sql.ErrNoRows {
http.Error(w, "Unknown host_id", http.StatusNotFound)
@@ -0,0 +1,33 @@
{
"generation": 5,
"desired_state": {
"guests": [
{
"vmid": 100,
"run": "running",
"spec": { "cores": 2, "memory_bytes": 2147483648, "disk_bytes": 21474836480 },
"description": "felhom: acme prod"
},
{
"vmid": 200,
"decommission": true
}
],
"pbs_namespace": "felhom-cust-acme",
"restore_directive": {
"mode": "guest_loss",
"archive": "local:backup/vzdump-lxc-200-2026_06_09-11_00_00.tar.zst",
"vmid": 200
},
"wireguard": {
"endpoint": {
"dns_name": "ep0.felhom.eu",
"wg_port": 443,
"server_pubkey": "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
"pbs_tunnel_ip": "10.77.0.1"
},
"pubkey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
"assigned_ip": "10.77.0.2/32"
}
}
}
+147
View File
@@ -198,6 +198,135 @@ func (h *Handler) handleAdminAddWGPeer(w http.ResponseWriter, r *http.Request) {
})
}
// handleRegisterHostWG — POST /hosts/{host_id}/wg (S2, doc 06 §3.3 steps 2-4). The box-facing
// registration: per-host key SELF-SCOPED (the handleGetDesiredState gate; the global key may
// register on any host — the operator/DR path). Binds the pubkey to the host (idempotent /
// re-key-in-place / adopt-unbound per the store), and ONLY on a real change bumps the host's
// desired_generation + pushes the peer list to the endpoint.
func (h *Handler) handleRegisterHostWG(w http.ResponseWriter, r *http.Request, pathHostID string) {
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if pathHostID == "" {
http.Error(w, "Missing host_id", http.StatusBadRequest)
return
}
if !isGlobal && authHostID != pathHostID {
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
var req struct {
Pubkey string `json:"pubkey"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
return
}
if err := validateWGPubkey(req.Pubkey); err != nil {
http.Error(w, "Invalid payload: "+err.Error(), http.StatusBadRequest)
return
}
host, err := h.store.GetHost(pathHostID)
if err != nil {
h.logger.Printf("[ERROR] wg register: host lookup %s: %v", pathHostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if host == nil {
http.Error(w, "Unknown host_id", http.StatusNotFound)
return
}
ip, changed, err := h.store.RegisterWGPeerForHost(pathHostID, req.Pubkey)
switch {
case err == store.ErrWGEndpointUnset:
http.Error(w, "wg endpoint not configured", http.StatusConflict)
return
case err == store.ErrWGPubkeyBoundElsewhere:
http.Error(w, "pubkey already registered elsewhere", http.StatusConflict)
return
case err == store.ErrWGSubnetExhausted:
http.Error(w, "tunnel subnet exhausted", http.StatusConflict)
return
case err != nil:
h.logger.Printf("[ERROR] wg register %s: %v", pathHostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
gen := host.DesiredGeneration
syncStatus := "unchanged"
if changed {
gen, err = h.store.BumpHostDesired(pathHostID)
if err != nil {
h.logger.Printf("[ERROR] wg register %s: generation bump: %v", pathHostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
syncStatus = h.syncAfterMutation(r.Context())
}
h.logger.Printf("[INFO] wg registered: host=%s pubkey=%s ip=%s/32 changed=%v gen=%d sync=%s",
pathHostID, req.Pubkey, ip, changed, gen, syncStatus)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"pubkey": req.Pubkey, "assigned_ip": ip + "/32", "existed": !changed,
"generation": gen, "sync": syncStatus,
})
}
// mergeWireguard injects the hub-OWNED wireguard block into a host's served desired-state (S2
// merge-at-read: the stored desired_json stays a pure operator blob; the WG assignment is hub
// state and is merged only on the way out). No peer → the blob passes through untouched (the
// golden pass-through contract). Any merge failure is fail-safe: log + serve UNMERGED — never
// break the agent's control channel over the WG add-on.
//
// Deliberately NOT wire fields (S3 agent constants derived from the design + pbs_tunnel_ip):
// client-side AllowedIPs, PersistentKeepalive=25, MTU 1420.
func (h *Handler) mergeWireguard(hostID, desired string) string {
peer, err := h.store.GetWGPeerForHost(hostID)
if err == sql.ErrNoRows {
return desired // no peer — byte-identical pass-through
}
if err != nil {
h.logger.Printf("[ERROR] wg merge %s: peer lookup: %v (serving unmerged)", hostID, err)
return desired
}
ep, err := h.store.GetWGEndpoint()
if err != nil {
h.logger.Printf("[ERROR] wg merge %s: peer exists but endpoint record unreadable: %v (serving unmerged)", hostID, err)
return desired
}
var doc map[string]interface{}
if err := json.Unmarshal([]byte(desired), &doc); err != nil {
h.logger.Printf("[ERROR] wg merge %s: stored desired_json unparsable: %v (serving unmerged)", hostID, err)
return desired
}
doc["wireguard"] = map[string]interface{}{
"endpoint": map[string]interface{}{
"dns_name": ep.DNSName,
"wg_port": ep.WGPort,
"server_pubkey": ep.ServerPubkey,
"pbs_tunnel_ip": ep.PBSTunnelIP,
},
"pubkey": peer.Pubkey,
"assigned_ip": peer.AssignedIP + "/32",
}
out, err := json.Marshal(doc)
if err != nil {
h.logger.Printf("[ERROR] wg merge %s: re-marshal: %v (serving unmerged)", hostID, err)
return desired
}
return string(out)
}
// handleAdminDeleteWGPeer — DELETE /admin/wg/peers, pubkey in the JSON body (never the URL).
// Unknown pubkey → 404 with NO sync (nothing changed). Known → delete + inline push: the pushed
// full list no longer contains the peer, so revocation lands with the push (Scenario B).
@@ -223,6 +352,17 @@ func (h *Handler) handleAdminDeleteWGPeer(w http.ResponseWriter, r *http.Request
http.Error(w, "Invalid payload: "+err.Error(), http.StatusBadRequest)
return
}
// S2: a BOUND peer's host must learn its block is gone — read the owner before deleting so
// the delete can bump that host's generation (an unbound S1 row bumps nothing).
ownerHostID := ""
if peers, err := h.store.ListWGPeers(); err == nil {
for _, p := range peers {
if p.Pubkey == req.Pubkey {
ownerHostID = p.HostID
break
}
}
}
err = h.store.RemoveWGPeer(req.Pubkey)
if err == sql.ErrNoRows {
http.Error(w, "Unknown pubkey", http.StatusNotFound)
@@ -233,6 +373,13 @@ func (h *Handler) handleAdminDeleteWGPeer(w http.ResponseWriter, r *http.Request
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if ownerHostID != "" {
if gen, err := h.store.BumpHostDesired(ownerHostID); err != nil {
h.logger.Printf("[ERROR] wg delete: generation bump for %s: %v", ownerHostID, err)
} else {
h.logger.Printf("[INFO] wg delete: host %s generation -> %d (peer unbound)", ownerHostID, gen)
}
}
syncStatus := h.syncAfterMutation(r.Context())
h.logger.Printf("[INFO] wg peer removed: %s (sync=%s)", req.Pubkey, syncStatus)
w.Header().Set("Content-Type", "application/json")
+250
View File
@@ -9,9 +9,13 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/wgsync"
)
// fakeWGSyncer counts SyncNow/Trigger calls; err scripts the SyncNow result.
@@ -190,6 +194,252 @@ func TestWGPeers_DeleteUnknown404NoSync(t *testing.T) {
}
}
// --- S2 Group B: registration endpoint + merge-at-read + admin-PUT rejection ---
// capturingPusher satisfies the wgsync push seam and records payload bytes — wiring a REAL
// wgsync.Reconciler over it lets API tests assert the actual pushed payload content.
type capturingPusher struct {
mu sync.Mutex
payloads [][]byte
}
func (c *capturingPusher) Push(ctx context.Context, payload []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
c.payloads = append(c.payloads, append([]byte(nil), payload...))
return nil
}
func (c *capturingPusher) count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.payloads)
}
func (c *capturingPusher) last() string {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.payloads) == 0 {
return ""
}
return string(c.payloads[len(c.payloads)-1])
}
// putGoldenEndpoint sets the endpoint record with the EXACT values of the S2 wireguard golden.
func putGoldenEndpoint(t *testing.T, h *Handler) {
t.Helper()
body := `{"dns_name":"ep0.felhom.eu","wg_port":443,"server_pubkey":"` + testPK(9) + `",` +
`"tunnel_subnet":"10.77.0.0/24","pbs_tunnel_ip":"10.77.0.1"}`
if rr := do(h, http.MethodPut, "/admin/wg/endpoint", globalKey, body); rr.Code != http.StatusOK {
t.Fatalf("PUT golden endpoint = %d: %s", rr.Code, rr.Body.String())
}
}
func TestRegisterHostWG_FullFlowWithPerHostKey(t *testing.T) {
h, st, _ := newTestHandler(t)
seedHost(t, st, "h1", "c1", "HKEY1")
putGoldenEndpoint(t, h)
pusher := &capturingPusher{}
h.SetWGSyncer(wgsync.NewReconciler(st, pusher, nil))
rr := do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(1)+`"}`)
if rr.Code != http.StatusOK {
t.Fatalf("register = %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
AssignedIP string `json:"assigned_ip"`
Existed bool `json:"existed"`
Generation int64 `json:"generation"`
Sync string `json:"sync"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.AssignedIP != "10.77.0.2/32" || resp.Existed || resp.Generation != 1 || resp.Sync != "ok" {
t.Errorf("resp = %+v, want .2/32 existed=false gen=1 sync=ok", resp)
}
if pusher.count() != 1 || !strings.Contains(pusher.last(), testPK(1)) {
t.Errorf("pushed payloads = %d, last = %s — want 1 push containing the pubkey", pusher.count(), pusher.last())
}
// Idempotent re-register: generation UNCHANGED, NO new push (both negatives).
rr = do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(1)+`"}`)
json.Unmarshal(rr.Body.Bytes(), &resp)
if rr.Code != http.StatusOK || !resp.Existed || resp.Generation != 1 || resp.Sync != "unchanged" {
t.Errorf("idempotent = %d %+v, want existed=true gen-still-1 sync=unchanged", rr.Code, resp)
}
if pusher.count() != 1 {
t.Errorf("push count after idempotent re-register = %d, want still 1", pusher.count())
}
// Re-key: pubkey swapped in place, ip kept, generation bumped, payload has P2 and NOT P1.
rr = do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(2)+`"}`)
json.Unmarshal(rr.Body.Bytes(), &resp)
if rr.Code != http.StatusOK || resp.AssignedIP != "10.77.0.2/32" || resp.Existed || resp.Generation != 2 {
t.Errorf("re-key = %d %+v, want ip kept, gen=2", rr.Code, resp)
}
if pusher.count() != 2 || !strings.Contains(pusher.last(), testPK(2)) || strings.Contains(pusher.last(), testPK(1)) {
t.Errorf("re-key payload = %s — must contain P2, not P1", pusher.last())
}
}
func TestRegisterHostWG_SelfScopeAndConflicts(t *testing.T) {
h, st, _ := newTestHandler(t)
seedHost(t, st, "h1", "c1", "HKEY1")
seedHost(t, st, "h2", "c2", "HKEY2")
fake := &fakeWGSyncer{}
h.SetWGSyncer(fake)
// c4: no endpoint record yet → 409, no allocation.
if rr := do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(1)+`"}`); rr.Code != http.StatusConflict {
t.Errorf("register without endpoint = %d, want 409", rr.Code)
}
putGoldenEndpoint(t, h)
// c1: h2's key on h1's path → 403, no row, no sync.
if rr := do(h, http.MethodPost, "/hosts/h1/wg", "HKEY2", `{"pubkey":"`+testPK(1)+`"}`); rr.Code != http.StatusForbidden {
t.Errorf("cross-host register = %d, want 403", rr.Code)
}
if peers, _ := st.ListWGPeers(); len(peers) != 0 {
t.Errorf("rows after 403 = %d, want 0", len(peers))
}
if fake.syncCalls != 0 {
t.Errorf("sync ran on refused register: %d", fake.syncCalls)
}
// no auth → 401
if rr := do(h, http.MethodPost, "/hosts/h1/wg", "", `{"pubkey":"`+testPK(1)+`"}`); rr.Code != http.StatusUnauthorized {
t.Errorf("no-auth register = %d, want 401", rr.Code)
}
// c3: unknown host (global key so the self-scope gate passes) → 404.
if rr := do(h, http.MethodPost, "/hosts/ghost/wg", globalKey, `{"pubkey":"`+testPK(1)+`"}`); rr.Code != http.StatusNotFound {
t.Errorf("unknown host = %d, want 404", rr.Code)
}
// c2: pubkey bound to h2 → h1 registering it → 409.
if rr := do(h, http.MethodPost, "/hosts/h2/wg", "HKEY2", `{"pubkey":"`+testPK(1)+`"}`); rr.Code != http.StatusOK {
t.Fatalf("h2 register = %d", rr.Code)
}
if rr := do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(1)+`"}`); rr.Code != http.StatusConflict {
t.Errorf("stealing h2's pubkey = %d, want 409", rr.Code)
}
// bad pubkey → 400
if rr := do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"nope"}`); rr.Code != http.StatusBadRequest {
t.Errorf("bad pubkey = %d, want 400", rr.Code)
}
}
func TestDesiredState_WireguardMergeMatchesGolden(t *testing.T) {
h, st, _ := newTestHandler(t)
seedHost(t, st, "h1", "c1", "HKEY1")
putGoldenEndpoint(t, h)
h.SetWGSyncer(&fakeWGSyncer{})
// Operator sets the EXISTING golden's desired_state (the operator blob half).
raw, err := os.ReadFile("testdata/desired-state.golden.json")
if err != nil {
t.Fatal(err)
}
var base struct {
DesiredState json.RawMessage `json:"desired_state"`
}
json.Unmarshal(raw, &base)
if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, string(base.DesiredState)); rr.Code != http.StatusOK {
t.Fatalf("admin-set: %d", rr.Code)
}
// Box registers → the served state must equal the NEW golden (base + wireguard block).
if rr := do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(1)+`"}`); rr.Code != http.StatusOK {
t.Fatalf("register: %d", rr.Code)
}
// This file is the S3 CROSS-REPO CONTRACT — the agent's testdata copy must stay
// byte-identical (the desired-state.golden.json duplication rule).
wgGolden, err := os.ReadFile("testdata/desired-state-wireguard.golden.json")
if err != nil {
t.Fatal(err)
}
var want struct {
DesiredState json.RawMessage `json:"desired_state"`
}
json.Unmarshal(wgGolden, &want)
rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
if rr.Code != 200 {
t.Fatalf("GET: %d", rr.Code)
}
var got struct {
Generation int64 `json:"generation"`
DesiredState json.RawMessage `json:"desired_state"`
}
json.Unmarshal(rr.Body.Bytes(), &got)
if got.Generation != 2 { // admin-set (1) + registration bump (2)
t.Errorf("generation = %d, want 2", got.Generation)
}
var wantAny, gotAny any
json.Unmarshal(want.DesiredState, &wantAny)
json.Unmarshal(got.DesiredState, &gotAny)
wb, _ := json.Marshal(wantAny)
gb, _ := json.Marshal(gotAny)
if string(wb) != string(gb) {
t.Errorf("served desired_state != wireguard golden:\n want: %s\n got: %s", wb, gb)
}
// Stored-blob purity: the raw stored desired_json is BYTE-identical to what the operator PUT.
host, _ := st.GetHost("h1")
if host.DesiredJSON != string(base.DesiredState) {
t.Errorf("stored desired_json was modified by the merge:\n stored: %s\n put: %s", host.DesiredJSON, base.DesiredState)
}
}
func TestAdminSetDesiredState_RejectsWireguardKey(t *testing.T) {
h, st, _ := newTestHandler(t)
seedHost(t, st, "h1", "c1", "HKEY1")
putGoldenEndpoint(t, h)
fake := `{"guests":[],"wireguard":{"pubkey":"FAKE","assigned_ip":"10.77.0.99/32"}}`
rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, fake)
if rr.Code != http.StatusBadRequest {
t.Fatalf("PUT with wireguard key = %d, want 400", rr.Code)
}
if !strings.Contains(rr.Body.String(), "hub-owned") {
t.Errorf("rejection message = %q, want the hub-owned hint", rr.Body.String())
}
// Follow-up GET must NOT serve the fake block (nothing was stored; no peer registered).
rr = do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
if strings.Contains(rr.Body.String(), "FAKE") || strings.Contains(rr.Body.String(), "wireguard") {
t.Errorf("GET serves a wireguard block that was never legitimately registered: %s", rr.Body.String())
}
// Generation untouched by the refused PUT.
host, _ := st.GetHost("h1")
if host.DesiredGeneration != 0 {
t.Errorf("generation moved on refused PUT: %d", host.DesiredGeneration)
}
}
func TestAdminDeleteWGPeer_BumpsOwnerOnlyForBoundPeers(t *testing.T) {
h, st, _ := newTestHandler(t)
seedHost(t, st, "h1", "c1", "HKEY1")
putGoldenEndpoint(t, h)
h.SetWGSyncer(&fakeWGSyncer{})
// Bound peer: register (gen 1) then admin-delete → gen 2, GET has no wireguard key.
do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(1)+`"}`)
rr := do(h, http.MethodDelete, "/admin/wg/peers", globalKey, `{"pubkey":"`+testPK(1)+`"}`)
if rr.Code != http.StatusOK {
t.Fatalf("delete bound = %d", rr.Code)
}
host, _ := st.GetHost("h1")
if host.DesiredGeneration != 2 {
t.Errorf("generation after bound delete = %d, want 2 (register+delete)", host.DesiredGeneration)
}
rr = do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
if strings.Contains(rr.Body.String(), "wireguard") {
t.Errorf("GET still serves wireguard after peer delete: %s", rr.Body.String())
}
// Unbound peer: admin add + delete → NO host generation movement (the negative).
do(h, http.MethodPost, "/admin/wg/peers", globalKey, `{"pubkey":"`+testPK(3)+`"}`)
do(h, http.MethodDelete, "/admin/wg/peers", globalKey, `{"pubkey":"`+testPK(3)+`"}`)
host, _ = st.GetHost("h1")
if host.DesiredGeneration != 2 {
t.Errorf("unbound add/delete moved h1's generation: %d, want still 2", host.DesiredGeneration)
}
}
func TestWGPeers_DeleteKnownRemovesAndSyncs(t *testing.T) {
h, st, _ := newTestHandler(t)
putTestEndpoint(t, h)