diff --git a/hub/internal/store/wg.go b/hub/internal/store/wg.go index 6612362..f8148f7 100644 --- a/hub/internal/store/wg.go +++ b/hub/internal/store/wg.go @@ -45,7 +45,9 @@ type WGPeer struct { CreatedAt string // as stored (SQLite datetime text); display-only } -// SetWGEndpoint upserts the (single expected) endpoint record. +// SetWGEndpoint upserts an endpoint record. Multiple endpoints are legal since v0.47.0 +// (the /offsite management UI); allocation + sync still use the LOWEST endpoint_id only +// (GetWGEndpoint) — per-endpoint allocation is a future arc. func (s *Store) SetWGEndpoint(e *WGEndpoint) error { if e.EndpointID == "" { e.EndpointID = "ep0" @@ -75,6 +77,41 @@ func (s *Store) GetWGEndpoint() (*WGEndpoint, error) { return &e, nil } +// ListWGEndpoints returns every endpoint record ordered by endpoint_id (v0.47.0 — the +// /offsite management UI lists them all; the lowest id stays THE allocation/sync endpoint). +func (s *Store) ListWGEndpoints() ([]WGEndpoint, error) { + rows, err := s.db.Query(` + SELECT endpoint_id, dns_name, wg_port, server_pubkey, tunnel_subnet, pbs_tunnel_ip + FROM wg_endpoints ORDER BY endpoint_id`) + if err != nil { + return nil, err + } + defer rows.Close() + var eps []WGEndpoint + for rows.Next() { + var e WGEndpoint + if err := rows.Scan(&e.EndpointID, &e.DNSName, &e.WGPort, &e.ServerPubkey, &e.TunnelSubnet, &e.PBSTunnelIP); err != nil { + return nil, err + } + eps = append(eps, e) + } + return eps, rows.Err() +} + +// DeleteWGEndpoint removes an endpoint record. Plain delete — the peers-in-subnet guard +// lives in the web handler (where the refusal message is built); sql.ErrNoRows when the +// endpoint is unknown. +func (s *Store) DeleteWGEndpoint(endpointID string) error { + res, err := s.db.Exec(`DELETE FROM wg_endpoints WHERE endpoint_id = ?`, endpointID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + // AddWGPeer registers a peer pubkey and allocates the lowest free host address in the tunnel // subnet — skipping the network address, the endpoint's own pbs_tunnel_ip, and (v4) the // broadcast. Idempotent: an already-registered pubkey returns its existing IP with existed=true diff --git a/hub/internal/store/wg_endpoints_test.go b/hub/internal/store/wg_endpoints_test.go new file mode 100644 index 0000000..b87e0ee --- /dev/null +++ b/hub/internal/store/wg_endpoints_test.go @@ -0,0 +1,56 @@ +package store + +// Group D (hub v0.47.0 offsite multi-endpoint) — the endpoint list/delete store surface. +// GetWGEndpoint's LIMIT-1 lowest-id semantics stay untouched (allocation/sync contract). + +import ( + "database/sql" + "testing" +) + +func TestListWGEndpoints_OrderAndDelete(t *testing.T) { + s := newTestStore(t) + + // Empty → empty list, no error. + eps, err := s.ListWGEndpoints() + if err != nil || len(eps) != 0 { + t.Fatalf("empty list = %v / %v", eps, err) + } + + // Insert out of order → returned ordered by endpoint_id. + for _, id := range []string{"ep2", "ep0", "ep1"} { + if err := s.SetWGEndpoint(&WGEndpoint{ + EndpointID: id, DNSName: id + ".felhom.eu", WGPort: 443, + ServerPubkey: "pk-" + id, TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1", + }); err != nil { + t.Fatal(err) + } + } + eps, err = s.ListWGEndpoints() + if err != nil || len(eps) != 3 { + t.Fatalf("list = %d eps / %v, want 3", len(eps), err) + } + for i, want := range []string{"ep0", "ep1", "ep2"} { + if eps[i].EndpointID != want { + t.Errorf("eps[%d] = %s, want %s (id order)", i, eps[i].EndpointID, want) + } + } + + // GetWGEndpoint (the allocation/sync endpoint) still returns the LOWEST id. + ep, err := s.GetWGEndpoint() + if err != nil || ep.EndpointID != "ep0" { + t.Errorf("GetWGEndpoint = %+v / %v, want ep0 (lowest id wins)", ep, err) + } + + // Delete removes exactly the named row; unknown id → sql.ErrNoRows. + if err := s.DeleteWGEndpoint("ep1"); err != nil { + t.Fatalf("DeleteWGEndpoint: %v", err) + } + eps, _ = s.ListWGEndpoints() + if len(eps) != 2 || eps[0].EndpointID != "ep0" || eps[1].EndpointID != "ep2" { + t.Errorf("after delete = %+v, want [ep0 ep2]", eps) + } + if err := s.DeleteWGEndpoint("ghost"); err != sql.ErrNoRows { + t.Errorf("unknown delete = %v, want sql.ErrNoRows", err) + } +} diff --git a/hub/internal/web/offsite.go b/hub/internal/web/offsite.go index 15f2cdc..1e380ab 100644 --- a/hub/internal/web/offsite.go +++ b/hub/internal/web/offsite.go @@ -1,15 +1,26 @@ package web -// S2 offsite connectivity: the read-only /offsite registry page (doc 06 §8 S2 "hub UI shows the -// peer registry"). Read-only by design — add/remove stay on the admin API; UI mutations arrive -// with tunnel health (S3/S6). Peers render neutral (no health state exists yet — nothing to -// except on, so no status colors). +// Offsite connectivity: the /offsite registry page. Originally S2 read-only (doc 06 §8); +// since v0.47.0 the page also MANAGES wg_endpoints rows (add/edit/delete) — a deliberate +// posture change (operator decision, hub-UI reorganization task). Scope guard: management +// covers the ENDPOINT RECORDS only. Peer allocation, the wgsync reconciler push, and the +// desired-state merge all still use the LOWEST endpoint_id (GetWGEndpoint) — per-endpoint +// allocation is an explicitly deferred future arc (wg_peers.endpoint_id migration). import ( "database/sql" "net/http" + "net/netip" + "regexp" + "strconv" + "strings" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) +// validEndpointID matches the endpoint_id shape ("ep0" style — lowercase, digits, hyphens). +var validEndpointID = regexp.MustCompile(`^[a-z0-9-]+$`) + // truncateMiddle shortens a long opaque value (pubkeys) for table display, keeping both ends — // the full value always rides the title attribute. func truncateMiddle(s string, keep int) string { @@ -27,38 +38,94 @@ type offsitePeerRow struct { HostID string // "" = unbound Note string CreatedAt string + EndpointID string // derived: first endpoint (id order) whose subnet contains the IP; "" = none } -// handleOffsite renders the offsite registry: the endpoint card + the peer table. -func (s *Server) handleOffsite(w http.ResponseWriter, r *http.Request) { - data := map[string]interface{}{ - "HasEndpoint": false, - } +// offsiteEndpointCard is the per-endpoint view model. +type offsiteEndpointCard struct { + EndpointID string + DNSName string + WGPort int + ServerPubkey string + ServerPubkeyShort string + TunnelSubnet string + PBSTunnelIP string + PeerCount int +} - ep, err := s.store.GetWGEndpoint() - if err != nil && err != sql.ErrNoRows { - s.logger.Printf("[ERROR] offsite: endpoint read: %v", err) +// endpointForIP returns the id of the first endpoint (lowest endpoint_id — the slice is +// already id-ordered) whose tunnel subnet contains the peer IP; "" when none matches. +func endpointForIP(endpoints []store.WGEndpoint, ip string) string { + addr, err := netip.ParseAddr(ip) + if err != nil { + return "" + } + for _, ep := range endpoints { + prefix, err := netip.ParsePrefix(ep.TunnelSubnet) + if err != nil { + continue + } + if prefix.Contains(addr) { + return ep.EndpointID + } + } + return "" +} + +// peersInSubnet counts registered peers whose IP lies inside the given CIDR — the guard +// input for endpoint delete + subnet edit. A parse error counts as 0 matches for that peer. +func (s *Server) peersInSubnet(subnet string) (int, error) { + prefix, err := netip.ParsePrefix(subnet) + if err != nil { + return 0, err + } + peers, err := s.store.ListWGPeers() + if err != nil { + return 0, err + } + n := 0 + for _, p := range peers { + if addr, err := netip.ParseAddr(p.AssignedIP); err == nil && prefix.Contains(addr) { + n++ + } + } + return n, nil +} + +// handleOffsite renders the offsite registry: one card per endpoint + the peer table. +func (s *Server) handleOffsite(w http.ResponseWriter, r *http.Request) { + endpoints, err := s.store.ListWGEndpoints() + if err != nil { + s.logger.Printf("[ERROR] offsite: endpoint list: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } - if ep != nil { - data["HasEndpoint"] = true - data["Endpoint"] = map[string]interface{}{ - "DNSName": ep.DNSName, - "WGPort": ep.WGPort, - "ServerPubkey": ep.ServerPubkey, - "ServerPubkeyShort": truncateMiddle(ep.ServerPubkey, 10), - "TunnelSubnet": ep.TunnelSubnet, - "PBSTunnelIP": ep.PBSTunnelIP, - } - } - peers, err := s.store.ListWGPeers() if err != nil { s.logger.Printf("[ERROR] offsite: peer list: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } + + cards := make([]offsiteEndpointCard, 0, len(endpoints)) + for _, ep := range endpoints { + card := offsiteEndpointCard{ + EndpointID: ep.EndpointID, + DNSName: ep.DNSName, + WGPort: ep.WGPort, + ServerPubkey: ep.ServerPubkey, + ServerPubkeyShort: truncateMiddle(ep.ServerPubkey, 10), + TunnelSubnet: ep.TunnelSubnet, + PBSTunnelIP: ep.PBSTunnelIP, + } + for _, p := range peers { + if endpointForIP(endpoints, p.AssignedIP) == ep.EndpointID { + card.PeerCount++ + } + } + cards = append(cards, card) + } + rows := make([]offsitePeerRow, 0, len(peers)) for _, p := range peers { rows = append(rows, offsitePeerRow{ @@ -68,11 +135,141 @@ func (s *Server) handleOffsite(w http.ResponseWriter, r *http.Request) { HostID: p.HostID, Note: p.Note, CreatedAt: p.CreatedAt, + EndpointID: endpointForIP(endpoints, p.AssignedIP), }) } - data["Peers"] = rows + data := map[string]interface{}{ + "Endpoints": cards, + "HasEndpoints": len(cards) > 0, + "Peers": rows, + "CSRFToken": s.getCSRFToken(r), + "Flash": r.URL.Query().Get("flash"), + } if err := s.templates.ExecuteTemplate(w, "offsite.html", data); err != nil { s.logger.Printf("[ERROR] offsite.html template: %v", err) } } + +// handleOffsiteEndpointSave — POST /offsite/endpoints (v0.47.0): add or edit an endpoint +// record via SetWGEndpoint's existing upsert. Validation refuses with 400 and stores +// NOTHING; a tunnel_subnet change on an existing endpoint is refused with 409 while any +// peer is allocated inside the CURRENT subnet (their /32s would dangle). +func (s *Server) handleOffsiteEndpointSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + ep := store.WGEndpoint{ + EndpointID: strings.TrimSpace(r.FormValue("endpoint_id")), + DNSName: strings.TrimSpace(r.FormValue("dns_name")), + ServerPubkey: strings.TrimSpace(r.FormValue("server_pubkey")), + TunnelSubnet: strings.TrimSpace(r.FormValue("tunnel_subnet")), + PBSTunnelIP: strings.TrimSpace(r.FormValue("pbs_tunnel_ip")), + } + + refuse := func(msg string) { + s.logger.Printf("[WARN] offsite endpoint save refused (%s): %s", ep.EndpointID, msg) + http.Error(w, msg+" — nothing stored.", http.StatusBadRequest) + } + if !validEndpointID.MatchString(ep.EndpointID) { + refuse("Invalid endpoint id (use lowercase letters, digits, hyphens)") + return + } + if ep.DNSName == "" { + refuse("DNS name is required") + return + } + port, err := strconv.Atoi(strings.TrimSpace(r.FormValue("wg_port"))) + if err != nil || port < 1 || port > 65535 { + refuse("Invalid WireGuard port (1–65535)") + return + } + ep.WGPort = port + if ep.ServerPubkey == "" { + refuse("Server public key is required") + return + } + prefix, err := netip.ParsePrefix(ep.TunnelSubnet) + if err != nil { + refuse("Invalid tunnel subnet (CIDR, e.g. 10.77.0.0/24)") + return + } + pbsAddr, err := netip.ParseAddr(ep.PBSTunnelIP) + if err != nil || !prefix.Contains(pbsAddr) { + refuse("PBS tunnel address must be an IP inside the tunnel subnet") + return + } + + // Subnet-change guard on edit: peers allocated inside the CURRENT subnet pin it. + existing, err := s.store.ListWGEndpoints() + if err != nil { + s.logger.Printf("[ERROR] offsite endpoint save: list: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + for _, cur := range existing { + if cur.EndpointID != ep.EndpointID || cur.TunnelSubnet == ep.TunnelSubnet { + continue + } + n, err := s.peersInSubnet(cur.TunnelSubnet) + if err != nil { + s.logger.Printf("[ERROR] offsite endpoint save: peer scan: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if n > 0 { + s.logger.Printf("[WARN] offsite endpoint %s subnet change refused: %d peer(s) allocated in %s", ep.EndpointID, n, cur.TunnelSubnet) + http.Error(w, "Tunnel subnet unchanged: "+strconv.Itoa(n)+" peer(s) are allocated in the current subnet "+cur.TunnelSubnet+" — their addresses would dangle.", http.StatusConflict) + return + } + } + + if err := s.store.SetWGEndpoint(&ep); err != nil { + s.logger.Printf("[ERROR] offsite endpoint save %s: %v", ep.EndpointID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + s.logger.Printf("[INFO] offsite endpoint saved: %s (%s:%d, subnet %s)", ep.EndpointID, ep.DNSName, ep.WGPort, ep.TunnelSubnet) + http.Redirect(w, r, "/offsite?flash=endpoint_saved", http.StatusSeeOther) +} + +// handleOffsiteEndpointDelete — POST /offsite/endpoints/{id}/delete (v0.47.0). Refused +// with 409 while any peer's /32 lies inside the endpoint's subnet. +func (s *Server) handleOffsiteEndpointDelete(w http.ResponseWriter, r *http.Request, endpointID string) { + endpoints, err := s.store.ListWGEndpoints() + if err != nil { + s.logger.Printf("[ERROR] offsite endpoint delete: list: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + var target *store.WGEndpoint + for i := range endpoints { + if endpoints[i].EndpointID == endpointID { + target = &endpoints[i] + break + } + } + if target == nil { + http.NotFound(w, r) + return + } + n, err := s.peersInSubnet(target.TunnelSubnet) + if err != nil { + s.logger.Printf("[ERROR] offsite endpoint delete %s: peer scan: %v", endpointID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if n > 0 { + s.logger.Printf("[WARN] offsite endpoint delete refused: %s has %d peer(s) in %s", endpointID, n, target.TunnelSubnet) + http.Error(w, "Endpoint not deleted: "+strconv.Itoa(n)+" peer(s) are allocated in its subnet "+target.TunnelSubnet+".", http.StatusConflict) + return + } + if err := s.store.DeleteWGEndpoint(endpointID); err != nil && err != sql.ErrNoRows { + s.logger.Printf("[ERROR] offsite endpoint delete %s: %v", endpointID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + s.logger.Printf("[INFO] offsite endpoint deleted: %s", endpointID) + http.Redirect(w, r, "/offsite?flash=endpoint_deleted", http.StatusSeeOther) +} diff --git a/hub/internal/web/offsite_test.go b/hub/internal/web/offsite_test.go index ca945ec..7ff2901 100644 --- a/hub/internal/web/offsite_test.go +++ b/hub/internal/web/offsite_test.go @@ -1,10 +1,13 @@ package web -// Group C — the /offsite registry page renders in all three states (S2 Part 3). +// Group C (S2) + Group D (v0.47.0 multi-endpoint management) — the /offsite page. +// v0.47.0 amends the S2 render pins DELIBERATELY for the multi-card layout + Endpoint +// column; the pubkey-title and empty-/hosts/-href assertions are kept verbatim. import ( "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -21,6 +24,27 @@ func renderOffsite(t *testing.T, s *Server) string { return rr.Body.String() } +func postOffsiteEndpoint(t *testing.T, s *Server, form url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/offsite/endpoints", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + s.handleOffsiteEndpointSave(rr, req) + return rr +} + +// validEndpointForm returns a fully-valid add form; tests mutate single fields. +func validEndpointForm(id string) url.Values { + return url.Values{ + "endpoint_id": {id}, + "dns_name": {id + ".felhom.eu"}, + "wg_port": {"443"}, + "server_pubkey": {"CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk="}, + "tunnel_subnet": {"10.78.0.0/24"}, + "pbs_tunnel_ip": {"10.78.0.1"}, + } +} + func TestOffsite_NoEndpoint(t *testing.T) { s, _ := newTestServer(t) html := renderOffsite(t, s) @@ -30,17 +54,30 @@ func TestOffsite_NoEndpoint(t *testing.T) { if !strings.Contains(html, "No WireGuard peers registered") { t.Errorf("empty-peers state missing") } + // The add-endpoint form is offered even before the first endpoint exists. + if !strings.Contains(html, `action="/offsite/endpoints"`) { + t.Errorf("add-endpoint form missing") + } } +// E1 — two endpoint cards render (ids visible), per-endpoint peer counts derive from +// subnet membership, and the peer table's Endpoint column shows the containing endpoint. func TestOffsite_EndpointAndPeers(t *testing.T) { s, st := newTestServer(t) if err := st.SetWGEndpoint(&store.WGEndpoint{ - DNSName: "ep0.felhom.eu", WGPort: 443, + EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443, ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=", TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1", }); err != nil { t.Fatal(err) } + if err := st.SetWGEndpoint(&store.WGEndpoint{ + EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 51820, + ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=", + TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1", + }); err != nil { + t.Fatal(err) + } st.UpsertHost(&store.Host{HostID: "hv1", CustomerID: "c1", APIKey: "k"}) if _, _, err := st.RegisterWGPeerForHost("hv1", "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="); err != nil { t.Fatal(err) @@ -51,18 +88,34 @@ func TestOffsite_EndpointAndPeers(t *testing.T) { html := renderOffsite(t, s) for _, want := range []string{ - "ep0.felhom.eu:443", // endpoint card - "10.77.0.0/24", // subnet - "10.77.0.1:8007", // PBS tunnel addr - "10.77.0.2/32", // bound peer ip - "10.77.0.3/32", // unbound peer ip - `href="/hosts/hv1"`, // bound peer links to its host - "unbound-test", // note column + `data-endpoint-id="ep0"`, // ep0 card + `data-endpoint-id="ep1"`, // ep1 card + "ep0.felhom.eu:443", // ep0 address + "ep1.felhom.eu:51820", // ep1 address + "10.77.0.0/24", // ep0 subnet + "10.77.0.1:8007", // ep0 PBS tunnel addr + "10.77.0.2/32", // bound peer ip + "10.77.0.3/32", // unbound peer ip + `href="/hosts/hv1"`, // bound peer links to its host + "unbound-test", // note column } { if !strings.Contains(html, want) { t.Errorf("offsite page missing %q", want) } } + // Per-endpoint peer counts: both allocated peers live in ep0's subnet; ep1 has none. + ep0Card := html[strings.Index(html, `data-endpoint-id="ep0"`):strings.Index(html, `data-endpoint-id="ep1"`)] + ep1Card := html[strings.Index(html, `data-endpoint-id="ep1"`):] + if !strings.Contains(ep0Card, "2") { + t.Error("ep0 card missing peer count 2") + } + if !strings.Contains(ep1Card, "0") { + t.Error("ep1 card missing peer count 0") + } + // The peer table's Endpoint column shows ep0 for both peers. + if got := strings.Count(html, "ep0"); got != 2 { + t.Errorf("Endpoint column shows ep0 %d times, want 2", got) + } // Unbound peer renders an em-dash host cell, not a broken link. if strings.Contains(html, `href="/hosts/"`) { t.Error("unbound peer rendered an empty host link") @@ -72,3 +125,184 @@ func TestOffsite_EndpointAndPeers(t *testing.T) { t.Error("full pubkey missing from title attr") } } + +// E5 — a peer whose IP falls in NO endpoint's subnet renders Endpoint "—". +func TestOffsite_OrphanPeerEndpointColumn(t *testing.T) { + s, st := newTestServer(t) + if err := st.SetWGEndpoint(&store.WGEndpoint{ + EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443, + ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=", + TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1", + }); err != nil { + t.Fatal(err) + } + if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "orphan"); err != nil { + t.Fatal(err) // allocates 10.77.0.2 + } + // Replace ep0 with an endpoint on a DIFFERENT subnet → the peer's IP matches nothing. + if err := st.DeleteWGEndpoint("ep0"); err != nil { + t.Fatal(err) + } + if err := st.SetWGEndpoint(&store.WGEndpoint{ + EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 443, + ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=", + TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1", + }); err != nil { + t.Fatal(err) + } + + html := renderOffsite(t, s) + if !strings.Contains(html, "10.77.0.2/32") { + t.Fatal("orphan peer missing from the table") + } + if strings.Contains(html, "ep1") { + t.Error("orphan peer wrongly attributed to ep1 — must render em dash") + } +} + +// E2 — a valid POST upserts via SetWGEndpoint and redirects back to the page. +func TestOffsiteEndpointSave_Add(t *testing.T) { + s, st := newTestServer(t) + rr := postOffsiteEndpoint(t, s, validEndpointForm("ep1")) + if rr.Code != http.StatusSeeOther { + t.Fatalf("add = %d (%s), want 303", rr.Code, rr.Body.String()) + } + eps, err := st.ListWGEndpoints() + if err != nil || len(eps) != 1 { + t.Fatalf("endpoints after add = %v / %v, want 1", eps, err) + } + if eps[0].EndpointID != "ep1" || eps[0].DNSName != "ep1.felhom.eu" || eps[0].WGPort != 443 || + eps[0].TunnelSubnet != "10.78.0.0/24" || eps[0].PBSTunnelIP != "10.78.0.1" { + t.Errorf("stored endpoint = %+v", eps[0]) + } + // The new card renders. + if !strings.Contains(renderOffsite(t, s), `data-endpoint-id="ep1"`) { + t.Error("new endpoint card missing from the page") + } +} + +// E6 — every invalid form field → 400 and NOTHING stored. +func TestOffsiteEndpointSave_Validation(t *testing.T) { + s, st := newTestServer(t) + cases := []struct { + name string + field string + value string + }{ + {"bad cidr", "tunnel_subnet", "10.78.0.0/240"}, + {"not a cidr", "tunnel_subnet", "banana"}, + {"pbs ip outside subnet", "pbs_tunnel_ip", "10.99.0.1"}, + {"pbs ip garbage", "pbs_tunnel_ip", "not-an-ip"}, + {"port zero", "wg_port", "0"}, + {"port too big", "wg_port", "70000"}, + {"port garbage", "wg_port", "abc"}, + {"empty pubkey", "server_pubkey", ""}, + {"bad endpoint id", "endpoint_id", "EP 1!"}, + {"empty endpoint id", "endpoint_id", ""}, + {"empty dns", "dns_name", ""}, + } + for _, c := range cases { + form := validEndpointForm("ep1") + form.Set(c.field, c.value) + rr := postOffsiteEndpoint(t, s, form) + if rr.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d, want 400", c.name, rr.Code) + } + if eps, _ := st.ListWGEndpoints(); len(eps) != 0 { + t.Fatalf("%s: endpoint STORED despite invalid input: %+v", c.name, eps) + } + } +} + +// E3 — editing ep0's tunnel_subnet while peers are allocated inside the CURRENT subnet is +// refused with 409 and the stored subnet is UNCHANGED. +// RED-PROOF 4: removing the subnet-change guard makes this FAIL (subnet rewritten). +func TestOffsiteEndpointSave_SubnetChangeGuard(t *testing.T) { + s, st := newTestServer(t) + if err := st.SetWGEndpoint(&store.WGEndpoint{ + EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443, + ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=", + TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1", + }); err != nil { + t.Fatal(err) + } + if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "pin"); err != nil { + t.Fatal(err) // 10.77.0.2 — inside the current subnet + } + + form := validEndpointForm("ep0") + form.Set("tunnel_subnet", "10.99.0.0/24") + form.Set("pbs_tunnel_ip", "10.99.0.1") + rr := postOffsiteEndpoint(t, s, form) + if rr.Code != http.StatusConflict { + t.Fatalf("subnet change with peers = %d, want 409", rr.Code) + } + ep, err := st.GetWGEndpoint() + if err != nil { + t.Fatal(err) + } + if ep.TunnelSubnet != "10.77.0.0/24" { + t.Errorf("stored subnet = %s — the refused edit CHANGED it", ep.TunnelSubnet) + } + + // Non-subnet edits stay allowed while peers exist (e.g. a port move). + form = validEndpointForm("ep0") + form.Set("tunnel_subnet", "10.77.0.0/24") + form.Set("pbs_tunnel_ip", "10.77.0.1") + form.Set("wg_port", "51820") + if rr := postOffsiteEndpoint(t, s, form); rr.Code != http.StatusSeeOther { + t.Errorf("same-subnet edit = %d, want 303", rr.Code) + } + if ep, _ := st.GetWGEndpoint(); ep.WGPort != 51820 { + t.Errorf("port edit not stored: %+v", ep) + } +} + +// E4 — deleting an endpoint with peers allocated in its subnet is refused with 409 and the +// row survives; a peer-free endpoint deletes cleanly; an unknown one 404s. +// RED-PROOF 3: removing the peers-in-subnet guard makes this FAIL (endpoint deleted). +func TestOffsiteEndpointDelete_Guard(t *testing.T) { + s, st := newTestServer(t) + if err := st.SetWGEndpoint(&store.WGEndpoint{ + EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443, + ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=", + TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1", + }); err != nil { + t.Fatal(err) + } + if err := st.SetWGEndpoint(&store.WGEndpoint{ + EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 443, + ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=", + TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1", + }); err != nil { + t.Fatal(err) + } + if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "pin"); err != nil { + t.Fatal(err) // 10.77.0.2 — pins ep0 + } + + del := func(id string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/offsite/endpoints/"+id+"/delete", nil) + rr := httptest.NewRecorder() + s.handleOffsiteEndpointDelete(rr, req, id) + return rr + } + + if rr := del("ep0"); rr.Code != http.StatusConflict { + t.Fatalf("delete with peers = %d, want 409", rr.Code) + } + if eps, _ := st.ListWGEndpoints(); len(eps) != 2 { + t.Fatalf("endpoint count after refused delete = %d, want 2 (ep0 must survive)", len(eps)) + } + + if rr := del("ep1"); rr.Code != http.StatusSeeOther { + t.Errorf("peer-free delete = %d, want 303", rr.Code) + } + if eps, _ := st.ListWGEndpoints(); len(eps) != 1 || eps[0].EndpointID != "ep0" { + t.Errorf("endpoints after ep1 delete = %+v, want [ep0]", eps) + } + + if rr := del("ghost"); rr.Code != http.StatusNotFound { + t.Errorf("unknown endpoint delete = %d, want 404", rr.Code) + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index fbc2e90..f8222f0 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -248,9 +248,23 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(path, "/apps/"): appName := strings.TrimPrefix(path, "/apps/") s.handleAppDetail(w, r, appName) - // Offsite — read-only WG endpoint + peer registry (S2). Mutations stay on the admin API. + // Offsite — WG endpoint + peer registry (S2), plus endpoint management forms + // (v0.47.0; peer mutations stay on the admin API, allocation stays lowest-endpoint-id). case path == "/offsite": s.handleOffsite(w, r) + case path == "/offsite/endpoints": + if r.Method == http.MethodPost { + s.handleOffsiteEndpointSave(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case strings.HasPrefix(path, "/offsite/endpoints/") && strings.HasSuffix(path, "/delete"): + endpointID := strings.TrimSuffix(strings.TrimPrefix(path, "/offsite/endpoints/"), "/delete") + if r.Method == http.MethodPost { + s.handleOffsiteEndpointDelete(w, r, endpointID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } // Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions. case path == "/hosts" || path == "/hosts/": s.handleHostsList(w, r) diff --git a/hub/internal/web/templates/offsite.html b/hub/internal/web/templates/offsite.html index 358191b..c984bde 100644 --- a/hub/internal/web/templates/offsite.html +++ b/hub/internal/web/templates/offsite.html @@ -21,25 +21,97 @@ -

Offsite connectivity

+

Offsite connectivity

+

+ Peer allocation and endpoint sync currently use the lowest endpoint id (ep0). + Per-endpoint allocation is a future work item. +

- {{if .HasEndpoint}} -
-

Endpoint

- - - - - + {{if eq .Flash "endpoint_saved"}} +
Endpoint saved.
+ {{end}} + {{if eq .Flash "endpoint_deleted"}} +
Endpoint deleted.
+ {{end}} + + {{if .HasEndpoints}} + {{range .Endpoints}} +
+
+

Endpoint {{.EndpointID}}

+
+ + +
+
+
Address{{.Endpoint.DNSName}}:{{.Endpoint.WGPort}} (WireGuard, UDP)
Server public key{{.Endpoint.ServerPubkeyShort}}
Tunnel subnet{{.Endpoint.TunnelSubnet}}
PBS tunnel address{{.Endpoint.PBSTunnelIP}}:8007
+ + + + +
Address{{.DNSName}}:{{.WGPort}} (WireGuard, UDP)
Server public key{{.ServerPubkeyShort}}
Tunnel subnet{{.TunnelSubnet}}
PBS tunnel address{{.PBSTunnelIP}}:8007
Peers in subnet{{.PeerCount}}
+
+ {{end}} {{else}}

Endpoint

-

Not configured. Register one via PUT /api/v1/admin/wg/endpoint (runbook: offsite-endpoint.md).

+

Not configured. Add one below (or via PUT /api/v1/admin/wg/endpoint, runbook: offsite-endpoint.md).

{{end}} + +
+

Add endpoint

+
+ + + + + + + + + + + + + + +
+ + +
+
+ +
+ {{if .Peers}}
@@ -47,6 +119,7 @@ + @@ -57,6 +130,7 @@ + @@ -76,5 +150,70 @@ Felhom Hub {{hubVersion}} + +
Public key Assigned IPEndpoint Host Note Created
{{.PubkeyShort}} {{.AssignedIP}}{{if .EndpointID}}{{.EndpointID}}{{else}}—{{end}} {{if .HostID}}{{.HostID}}{{else}}—{{end}} {{if .Note}}{{.Note}}{{else}}—{{end}} {{.CreatedAt}}