hub: offsite multi-endpoint management UI (v0.47.0 part 5)

- /offsite lists ALL wg_endpoints rows as cards (id, address, pubkey, subnet,
  PBS addr, peers-in-subnet count) + add/edit/delete forms
- store: ListWGEndpoints (id order) + DeleteWGEndpoint (plain delete; the
  peers-in-subnet guard lives in the handler where the refusal is built);
  SetWGEndpoint upsert reused, single-expected comment updated
- guards: subnet edit refused 409 while peers sit in the current subnet;
  endpoint delete refused 409 while peers sit in its subnet; full form
  validation (CIDR, pbs ip in subnet, port 1-65535, pubkey, id charset) -> 400
- peer table gains an Endpoint column (first id-ordered subnet match; em dash
  when none); pubkey-change edit gets a type-to-confirm noting pull-based
  convergence
- allocation/reconciler/desired-state STAY lowest-endpoint-id (page notes the
  deferral); GetWGEndpoint semantics untouched
- tests: E1-E6 incl. guard red-proofs; store list/delete test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vvz1NCu22p8dGkRCpeX9re
This commit is contained in:
2026-07-11 21:35:24 +02:00
parent 068427a729
commit 0daddcd1c4
6 changed files with 723 additions and 46 deletions
+38 -1
View File
@@ -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
+56
View File
@@ -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)
}
}