7f11cfb36c
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.
282 lines
9.5 KiB
Go
282 lines
9.5 KiB
Go
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 (1–65535)")
|
||
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)
|
||
}
|