Files
felhom.eu/hub/internal/web/offsite.go
T
admin 7f11cfb36c hub v0.65.0 — PBS DR storage visibility (ep0 usage op) + Offsite tab split + dual dashboard gauges (R-5)
Makes PBS DR storage visible like the restic pool box (v0.64.0), differentiated. Scoping
correction: restic = subaccounts on the shared Hetzner Storage Box (Hetzner API); PBS DR =
the felhom-offsite PBS datastore on the ep0 endpoint VM (NO Hetzner API). Option A
(Viktor-ruled): a read-only `usage` op on the felhom-tenantsync ep0 forced command (twin of
fingerprint), polled by a new hub checker on the 15-min throttle. READ-ONLY throughout.

Phase-0 (gate PASSED): on ep0 (PBS 4.2.3), df -B1 --output=size,used,avail <datastore path>
yields bytes (39990112256/7627939840/... ~19%), read-only, existing sudo context, no admin token.

- scripts/felhom-tenantsync.sh -> v1.2.0: read-only `usage` short-circuit (df on the datastore
  path), no customer_id, no admin token, NO mutation. + a bash harness proving zero mutation.
- tenantsync.Client.Usage() + BoxUsage; unknown-op -> typed ErrUsageUnsupported (graceful).
- monitor.PBSDRBoxChecker: OffsiteBoxChecker clone over a usageReader seam; 15-min throttle,
  cached PBSBoxSnapshot, escalation-only pbsdr_box_fill on the "pbsdr-box" scope (operator only,
  no SaveEvent), recovery re-arm. Fill only. THREE states: ok / unavailable (ep0 <=v1.1.0,
  neutral no-alert) / degraded (exec failed, keep last).
- config: Alerting.PBSDRBoxFill{Warn,Crit}Percent (80/90); built with the tenantsync client,
  60s sweep, SetPBSDRBox. Hub deploy INDEPENDENT of the ep0 update (graceful degradation).
- web: /offsite splits into Restic + PBS DR hash tabs (endpoint cards under PBS DR); PBS panel;
  the single dashboard tile becomes two gauges (RESTIC pct.ratio, PBS DR pct / n/a).
- runbook offsite-endpoint.md 10: v1.2.0 update steps (no sudoers/authorized_keys change).

Tests: 10 Go + the harness; 3 red-proofs (usage mutation, escalation-only, unavailable-drives-band)
confirmed red then restored. go build/vet/test + bash -n + hub confirm gate all pass.
2026-07-17 21:13:30 +02:00

282 lines
9.5 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 restic pool-box aggregate panel (Restic tab)
pbsView := s.pbsdrBoxData() // R-5 v0.65.0 PBS-DR datastore panel (PBS DR tab)
data := map[string]interface{}{
"Endpoints": cards,
"HasEndpoints": len(cards) > 0,
"Peers": rows,
"OffsiteBox": boxView,
"OffsiteCusts": custRows,
"PBSBox": pbsView,
"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)
}