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)
}
}
+222 -25
View File
@@ -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 (165535)")
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)
}
+239 -5
View File
@@ -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,9 +88,12 @@ 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
`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
@@ -63,6 +103,19 @@ func TestOffsite_EndpointAndPeers(t *testing.T) {
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, "<td>2</td>") {
t.Error("ep0 card missing peer count 2")
}
if !strings.Contains(ep1Card, "<td>0</td>") {
t.Error("ep1 card missing peer count 0")
}
// The peer table's Endpoint column shows ep0 for both peers.
if got := strings.Count(html, "<td><code>ep0</code></td>"); 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, "<td><code>ep1</code></td>") {
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)
}
}
+15 -1
View File
@@ -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)
+149 -10
View File
@@ -21,25 +21,97 @@
</nav>
</header>
<h2 style="margin-bottom: 1rem;">Offsite connectivity</h2>
<h2 style="margin-bottom: 0.5rem;">Offsite connectivity</h2>
<p class="text-muted" style="margin: 0 0 1rem; font-size: 0.85em;">
Peer allocation and endpoint sync currently use the lowest endpoint id (ep0).
Per-endpoint allocation is a future work item.
</p>
{{if .HasEndpoint}}
<section class="card" style="margin-bottom: 1.5rem;">
<h3>Endpoint</h3>
<table class="detail-table">
<tr><th>Address</th><td><code>{{.Endpoint.DNSName}}:{{.Endpoint.WGPort}}</code> (WireGuard, UDP)</td></tr>
<tr><th>Server public key</th><td><code title="{{.Endpoint.ServerPubkey}}">{{.Endpoint.ServerPubkeyShort}}</code></td></tr>
<tr><th>Tunnel subnet</th><td><code>{{.Endpoint.TunnelSubnet}}</code></td></tr>
<tr><th>PBS tunnel address</th><td><code>{{.Endpoint.PBSTunnelIP}}:8007</code></td></tr>
{{if eq .Flash "endpoint_saved"}}
<div class="flash flash-success">Endpoint saved.</div>
{{end}}
{{if eq .Flash "endpoint_deleted"}}
<div class="flash flash-success">Endpoint deleted.</div>
{{end}}
{{if .HasEndpoints}}
{{range .Endpoints}}
<section class="card" style="margin-bottom: 1.5rem;"
data-endpoint-id="{{.EndpointID}}" data-dns-name="{{.DNSName}}" data-wg-port="{{.WGPort}}"
data-server-pubkey="{{.ServerPubkey}}" data-tunnel-subnet="{{.TunnelSubnet}}" data-pbs-tunnel-ip="{{.PBSTunnelIP}}">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h3 style="margin: 0;">Endpoint <code>{{.EndpointID}}</code></h3>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-outline btn-sm" onclick="epEdit('{{.EndpointID}}')">Edit</button>
<button type="button" class="btn btn-danger btn-sm" onclick="epDeleteConfirm('{{.EndpointID}}')">Remove&hellip;</button>
</div>
</div>
<table class="detail-table" style="margin-top: 0.75rem;">
<tr><th>Address</th><td><code>{{.DNSName}}:{{.WGPort}}</code> (WireGuard, UDP)</td></tr>
<tr><th>Server public key</th><td><code title="{{.ServerPubkey}}">{{.ServerPubkeyShort}}</code></td></tr>
<tr><th>Tunnel subnet</th><td><code>{{.TunnelSubnet}}</code></td></tr>
<tr><th>PBS tunnel address</th><td><code>{{.PBSTunnelIP}}:8007</code></td></tr>
<tr><th>Peers in subnet</th><td>{{.PeerCount}}</td></tr>
</table>
<div id="ep-delete-confirm-{{.EndpointID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); background: var(--crit-dim); border-radius: var(--radius); max-width: 44em;">
<p style="margin: 0 0 0.5rem; font-size: 0.85em;">
Deleting <code>{{.EndpointID}}</code> removes only the endpoint record — registered peers stay.
Deletion is refused while any peer is allocated in its subnet. Type the endpoint id to confirm:
</p>
<form method="POST" action="/offsite/endpoints/{{.EndpointID}}/delete" id="ep-delete-form-{{.EndpointID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="text" id="ep-delete-input-{{.EndpointID}}" placeholder="retype the endpoint id&hellip;" style="padding: 0.3em 0.5em; width: 12em;">
<button type="button" class="btn btn-danger btn-sm" onclick="epDeleteSubmit('{{.EndpointID}}')">Confirm &amp; delete</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('ep-delete-confirm-{{.EndpointID}}').style.display='none';">Cancel</button>
<span id="ep-delete-err-{{.EndpointID}}" style="font-size: 0.8em; color: var(--crit);"></span>
</form>
</div>
</section>
{{end}}
{{else}}
<section class="card" style="margin-bottom: 1.5rem;">
<h3>Endpoint</h3>
<p class="text-muted">Not configured. Register one via <code>PUT /api/v1/admin/wg/endpoint</code> (runbook: offsite-endpoint.md).</p>
<p class="text-muted">Not configured. Add one below (or via <code>PUT /api/v1/admin/wg/endpoint</code>, runbook: offsite-endpoint.md).</p>
</section>
{{end}}
<!-- Add / edit endpoint (v0.47.0). Plain form post — server-side validation is
authoritative; the JS layer only adds the pubkey-change type-to-confirm. -->
<section class="card" style="margin-bottom: 1.5rem;">
<h3 id="ep-form-title">Add endpoint</h3>
<form method="POST" action="/offsite/endpoints" id="ep-form" onsubmit="return epFormSubmitCheck()"
style="display: grid; grid-template-columns: auto 1fr; gap: 0.5rem; align-items: center; max-width: 44em; margin-top: 0.75rem;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<label style="font-size: 0.9em;">Endpoint id</label>
<input type="text" name="endpoint_id" id="ep-id" placeholder="ep1" style="padding: 0.3em 0.5em;">
<label style="font-size: 0.9em;">DNS name</label>
<input type="text" name="dns_name" id="ep-dns" placeholder="ep1.felhom.eu" style="padding: 0.3em 0.5em;">
<label style="font-size: 0.9em;">WG port</label>
<input type="number" name="wg_port" id="ep-port" min="1" max="65535" placeholder="443" style="padding: 0.3em 0.5em;">
<label style="font-size: 0.9em;">Server public key</label>
<input type="text" name="server_pubkey" id="ep-pubkey" placeholder="base64 WireGuard pubkey" style="padding: 0.3em 0.5em; font-family: var(--font-data);">
<label style="font-size: 0.9em;">Tunnel subnet</label>
<input type="text" name="tunnel_subnet" id="ep-subnet" placeholder="10.78.0.0/24" style="padding: 0.3em 0.5em;">
<label style="font-size: 0.9em;">PBS tunnel IP</label>
<input type="text" name="pbs_tunnel_ip" id="ep-pbsip" placeholder="10.78.0.1 (inside the subnet)" style="padding: 0.3em 0.5em;">
<span></span>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button type="submit" class="btn btn-sm">Save endpoint</button>
<button type="button" class="btn btn-sm btn-outline" id="ep-form-reset" style="display: none;" onclick="epFormReset()">Cancel edit</button>
</div>
</form>
<div id="ep-pubkey-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--warn); background: var(--warn-dim); border-radius: var(--radius); max-width: 44em;">
<p style="margin: 0 0 0.5rem; font-size: 0.85em;">
The server public key is CHANGING. Peers keep using the old key until they pull their
next desired-state (they converge on their own cycle — no push). Type the endpoint id to confirm:
</p>
<input type="text" id="ep-pubkey-confirm-input" placeholder="retype the endpoint id&hellip;" style="padding: 0.3em 0.5em; width: 12em;">
<button type="button" class="btn btn-sm" onclick="epPubkeyConfirmSubmit()">Confirm &amp; save</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('ep-pubkey-confirm').style.display='none';">Cancel</button>
<p id="ep-pubkey-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
{{if .Peers}}
<section class="card" style="padding: 0; overflow: hidden;">
<table class="data-table">
@@ -47,6 +119,7 @@
<tr>
<th>Public key</th>
<th>Assigned IP</th>
<th>Endpoint</th>
<th>Host</th>
<th>Note</th>
<th>Created</th>
@@ -57,6 +130,7 @@
<tr>
<td><code title="{{.Pubkey}}">{{.PubkeyShort}}</code></td>
<td><code>{{.AssignedIP}}</code></td>
<td>{{if .EndpointID}}<code>{{.EndpointID}}</code>{{else}}—{{end}}</td>
<td>{{if .HostID}}<a href="/hosts/{{.HostID}}">{{.HostID}}</a>{{else}}—{{end}}</td>
<td>{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
<td>{{.CreatedAt}}</td>
@@ -76,5 +150,70 @@
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
</footer>
</div>
<script>
// Endpoint management JS (v0.47.0). The server enforces every guard — this layer only
// fills the edit form from a card's data attributes and adds the pubkey-change confirm.
var epOriginalPubkey = null; // non-null = editing an existing endpoint
function epEdit(id) {
var card = document.querySelector('[data-endpoint-id="' + id + '"]');
if (!card) return;
document.getElementById('ep-form-title').textContent = 'Edit endpoint ' + id;
var f = {
'ep-id': 'data-endpoint-id', 'ep-dns': 'data-dns-name', 'ep-port': 'data-wg-port',
'ep-pubkey': 'data-server-pubkey', 'ep-subnet': 'data-tunnel-subnet', 'ep-pbsip': 'data-pbs-tunnel-ip'
};
for (var el in f) { document.getElementById(el).value = card.getAttribute(f[el]) || ''; }
// endpoint_id is the primary key — immutable while editing.
document.getElementById('ep-id').readOnly = true;
epOriginalPubkey = card.getAttribute('data-server-pubkey') || '';
document.getElementById('ep-form-reset').style.display = 'inline-block';
document.getElementById('ep-form').scrollIntoView({behavior: 'smooth', block: 'center'});
}
function epFormReset() {
document.getElementById('ep-form').reset();
document.getElementById('ep-form-title').textContent = 'Add endpoint';
document.getElementById('ep-id').readOnly = false;
document.getElementById('ep-id').value = '';
epOriginalPubkey = null;
document.getElementById('ep-form-reset').style.display = 'none';
document.getElementById('ep-pubkey-confirm').style.display = 'none';
}
function epFormSubmitCheck() {
// Editing + pubkey changed → intercept with the type-to-confirm (floor pattern).
if (epOriginalPubkey !== null && document.getElementById('ep-pubkey').value.trim() !== epOriginalPubkey) {
document.getElementById('ep-pubkey-confirm-input').value = '';
document.getElementById('ep-pubkey-confirm-err').textContent = '';
document.getElementById('ep-pubkey-confirm').style.display = 'block';
return false;
}
return true;
}
function epPubkeyConfirmSubmit() {
var typed = document.getElementById('ep-pubkey-confirm-input').value.trim();
var expected = document.getElementById('ep-id').value.trim();
var err = document.getElementById('ep-pubkey-confirm-err');
if (typed !== expected) { err.textContent = 'Confirmation does not match the endpoint id.'; return; }
document.getElementById('ep-form').submit();
}
function epDeleteConfirm(id) {
var box = document.getElementById('ep-delete-confirm-' + id);
document.getElementById('ep-delete-input-' + id).value = '';
document.getElementById('ep-delete-err-' + id).textContent = '';
box.style.display = 'block';
}
function epDeleteSubmit(id) {
var typed = document.getElementById('ep-delete-input-' + id).value.trim();
var err = document.getElementById('ep-delete-err-' + id);
if (typed !== id) { err.textContent = 'Confirmation does not match the endpoint id.'; return; }
document.getElementById('ep-delete-form-' + id).submit();
}
</script>
</body>
</html>