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, "
ep0ep1+ Peer allocation and endpoint sync currently use the lowest endpoint id (ep0). + Per-endpoint allocation is a future work item. +
- {{if .HasEndpoint}} -| 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}} |
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).