Files
felhom.eu/hub/internal/web/offsite.go
T
admin 0daddcd1c4 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
2026-07-11 21:35:24 +02:00

276 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
// 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 {
if len(s) <= 2*keep+1 {
return s
}
return s[:keep] + "…" + s[len(s)-keep:]
}
// offsitePeerRow is the per-peer view model.
type offsitePeerRow struct {
Pubkey string
PubkeyShort string
AssignedIP string // with /32
HostID string // "" = unbound
Note string
CreatedAt string
EndpointID string // derived: first endpoint (id order) whose subnet contains the IP; "" = none
}
// 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
}
// 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
}
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{
Pubkey: p.Pubkey,
PubkeyShort: truncateMiddle(p.Pubkey, 10),
AssignedIP: p.AssignedIP + "/32",
HostID: p.HostID,
Note: p.Note,
CreatedAt: p.CreatedAt,
EndpointID: endpointForIP(endpoints, p.AssignedIP),
})
}
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)
}