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