Files
felhom.eu/hub/internal/web/offsite.go
T
admin 4bb2df0dc4 hub v0.64.0 — offsite pool-box aggregate: fill, oversubscription, per-customer bars, operator alert (R-5)
The operator sees the shared pool box's real state on the hub: total box fill vs
capacity, Σ(shared soft quotas) vs capacity (the oversubscription ratio), per-customer
usage/quota bars, and a box-level operator alert (fill % + oversub ratio) on the existing
dispatcher's operator channel. Per-customer fill alerts already existed; the box-level
aggregate was the gap. READ-ONLY against Hetzner (GET only).

Phase-0 probe (gate PASSED): the live pool box 611714 returns capacity via
storage_box_type.size (1 TiB / bx11) and usage via a stats object (size/size_data/
size_snapshots), all bytes; our token reads it (200).

- hetznerapi: additive StorageBoxType + StorageBoxStats on StorageBox (no existing field/
  method changed); fake carries them + a GetBoxCalls counter; golden decode test.
- monitor.OffsiteBoxChecker: OffsiteChecker-sibling for the box; fetch-throttled (1 GET/
  15min), cached BoxSnapshot, escalation-only + recovery re-arm. FILL (used/capacity 80/90)
  + OVERSUB (Σ shared+enabled quotas / capacity, 2.0x) — independent. Σ from the ConfigJSON
  Descriptor (offsite.ReadDescriptor, new), never the report echo; dedicated+disabled
  excluded. Scope "pool-box" -> operator channel only, no SaveEvent. Failed fetch keeps the
  last snapshot degraded; missing data never becomes 0% and never transitions a band.
- config: Alerting.OffsiteBoxFill{Warn,Crit}Percent + OffsiteOversubWarnRatio (80/90/2.0
  defaults; thresholds pending Viktor's ruling). Constructed in the HETZNER_TOKEN branch,
  60s sweep, snapshot handed to the web server.
- web: Offsite-tab panel (fill bar, Σ+ratio, per-customer usage/quota rows) + a compact
  dashboard tile; reads the cached snapshot only, never fetches; nil -> "not configured".

Tests: 10 new + 4 red-proofs (throttle, Σ filter, escalation-only, failed-fetch honesty),
all confirmed red then restored. go build/vet/test all pass; hub confirm gate OK.
2026-07-17 20:15:34 +02:00

280 lines
9.4 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),
})
}
boxView, custRows := s.offsiteBoxData() // R-5 pool-box aggregate panel
data := map[string]interface{}{
"Endpoints": cards,
"HasEndpoints": len(cards) > 0,
"Peers": rows,
"OffsiteBox": boxView,
"OffsiteCusts": custRows,
"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)
}