diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 1ac7bb0..cb61b88 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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) diff --git a/hub/internal/api/testdata/desired-state-wireguard.golden.json b/hub/internal/api/testdata/desired-state-wireguard.golden.json new file mode 100644 index 0000000..436aac2 --- /dev/null +++ b/hub/internal/api/testdata/desired-state-wireguard.golden.json @@ -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" + } + } +} diff --git a/hub/internal/api/wg.go b/hub/internal/api/wg.go index 0afcb34..8bc5e88 100644 --- a/hub/internal/api/wg.go +++ b/hub/internal/api/wg.go @@ -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") diff --git a/hub/internal/api/wg_test.go b/hub/internal/api/wg_test.go index 11b92a1..c2b4e1a 100644 --- a/hub/internal/api/wg_test.go +++ b/hub/internal/api/wg_test.go @@ -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)