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
+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)
}