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
This commit is contained in:
@@ -45,7 +45,9 @@ type WGPeer struct {
|
||||
CreatedAt string // as stored (SQLite datetime text); display-only
|
||||
}
|
||||
|
||||
// SetWGEndpoint upserts the (single expected) endpoint record.
|
||||
// SetWGEndpoint upserts an endpoint record. Multiple endpoints are legal since v0.47.0
|
||||
// (the /offsite management UI); allocation + sync still use the LOWEST endpoint_id only
|
||||
// (GetWGEndpoint) — per-endpoint allocation is a future arc.
|
||||
func (s *Store) SetWGEndpoint(e *WGEndpoint) error {
|
||||
if e.EndpointID == "" {
|
||||
e.EndpointID = "ep0"
|
||||
@@ -75,6 +77,41 @@ func (s *Store) GetWGEndpoint() (*WGEndpoint, error) {
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// ListWGEndpoints returns every endpoint record ordered by endpoint_id (v0.47.0 — the
|
||||
// /offsite management UI lists them all; the lowest id stays THE allocation/sync endpoint).
|
||||
func (s *Store) ListWGEndpoints() ([]WGEndpoint, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT endpoint_id, dns_name, wg_port, server_pubkey, tunnel_subnet, pbs_tunnel_ip
|
||||
FROM wg_endpoints ORDER BY endpoint_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var eps []WGEndpoint
|
||||
for rows.Next() {
|
||||
var e WGEndpoint
|
||||
if err := rows.Scan(&e.EndpointID, &e.DNSName, &e.WGPort, &e.ServerPubkey, &e.TunnelSubnet, &e.PBSTunnelIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eps = append(eps, e)
|
||||
}
|
||||
return eps, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteWGEndpoint removes an endpoint record. Plain delete — the peers-in-subnet guard
|
||||
// lives in the web handler (where the refusal message is built); sql.ErrNoRows when the
|
||||
// endpoint is unknown.
|
||||
func (s *Store) DeleteWGEndpoint(endpointID string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM wg_endpoints WHERE endpoint_id = ?`, endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddWGPeer registers a peer pubkey and allocates the lowest free host address in the tunnel
|
||||
// subnet — skipping the network address, the endpoint's own pbs_tunnel_ip, and (v4) the
|
||||
// broadcast. Idempotent: an already-registered pubkey returns its existing IP with existed=true
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package store
|
||||
|
||||
// Group D (hub v0.47.0 offsite multi-endpoint) — the endpoint list/delete store surface.
|
||||
// GetWGEndpoint's LIMIT-1 lowest-id semantics stay untouched (allocation/sync contract).
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListWGEndpoints_OrderAndDelete(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
// Empty → empty list, no error.
|
||||
eps, err := s.ListWGEndpoints()
|
||||
if err != nil || len(eps) != 0 {
|
||||
t.Fatalf("empty list = %v / %v", eps, err)
|
||||
}
|
||||
|
||||
// Insert out of order → returned ordered by endpoint_id.
|
||||
for _, id := range []string{"ep2", "ep0", "ep1"} {
|
||||
if err := s.SetWGEndpoint(&WGEndpoint{
|
||||
EndpointID: id, DNSName: id + ".felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "pk-" + id, TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
eps, err = s.ListWGEndpoints()
|
||||
if err != nil || len(eps) != 3 {
|
||||
t.Fatalf("list = %d eps / %v, want 3", len(eps), err)
|
||||
}
|
||||
for i, want := range []string{"ep0", "ep1", "ep2"} {
|
||||
if eps[i].EndpointID != want {
|
||||
t.Errorf("eps[%d] = %s, want %s (id order)", i, eps[i].EndpointID, want)
|
||||
}
|
||||
}
|
||||
|
||||
// GetWGEndpoint (the allocation/sync endpoint) still returns the LOWEST id.
|
||||
ep, err := s.GetWGEndpoint()
|
||||
if err != nil || ep.EndpointID != "ep0" {
|
||||
t.Errorf("GetWGEndpoint = %+v / %v, want ep0 (lowest id wins)", ep, err)
|
||||
}
|
||||
|
||||
// Delete removes exactly the named row; unknown id → sql.ErrNoRows.
|
||||
if err := s.DeleteWGEndpoint("ep1"); err != nil {
|
||||
t.Fatalf("DeleteWGEndpoint: %v", err)
|
||||
}
|
||||
eps, _ = s.ListWGEndpoints()
|
||||
if len(eps) != 2 || eps[0].EndpointID != "ep0" || eps[1].EndpointID != "ep2" {
|
||||
t.Errorf("after delete = %+v, want [ep0 ep2]", eps)
|
||||
}
|
||||
if err := s.DeleteWGEndpoint("ghost"); err != sql.ErrNoRows {
|
||||
t.Errorf("unknown delete = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
+222
-25
@@ -1,15 +1,26 @@
|
||||
package web
|
||||
|
||||
// S2 offsite connectivity: the read-only /offsite registry page (doc 06 §8 S2 "hub UI shows the
|
||||
// peer registry"). Read-only by design — add/remove stay on the admin API; UI mutations arrive
|
||||
// with tunnel health (S3/S6). Peers render neutral (no health state exists yet — nothing to
|
||||
// except on, so no status colors).
|
||||
// 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 {
|
||||
@@ -27,38 +38,94 @@ type offsitePeerRow struct {
|
||||
HostID string // "" = unbound
|
||||
Note string
|
||||
CreatedAt string
|
||||
EndpointID string // derived: first endpoint (id order) whose subnet contains the IP; "" = none
|
||||
}
|
||||
|
||||
// handleOffsite renders the offsite registry: the endpoint card + the peer table.
|
||||
func (s *Server) handleOffsite(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"HasEndpoint": false,
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
ep, err := s.store.GetWGEndpoint()
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
s.logger.Printf("[ERROR] offsite: endpoint read: %v", err)
|
||||
// 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
|
||||
}
|
||||
if ep != nil {
|
||||
data["HasEndpoint"] = true
|
||||
data["Endpoint"] = map[string]interface{}{
|
||||
"DNSName": ep.DNSName,
|
||||
"WGPort": ep.WGPort,
|
||||
"ServerPubkey": ep.ServerPubkey,
|
||||
"ServerPubkeyShort": truncateMiddle(ep.ServerPubkey, 10),
|
||||
"TunnelSubnet": ep.TunnelSubnet,
|
||||
"PBSTunnelIP": ep.PBSTunnelIP,
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -68,11 +135,141 @@ func (s *Server) handleOffsite(w http.ResponseWriter, r *http.Request) {
|
||||
HostID: p.HostID,
|
||||
Note: p.Note,
|
||||
CreatedAt: p.CreatedAt,
|
||||
EndpointID: endpointForIP(endpoints, p.AssignedIP),
|
||||
})
|
||||
}
|
||||
data["Peers"] = rows
|
||||
|
||||
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 (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)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package web
|
||||
|
||||
// Group C — the /offsite registry page renders in all three states (S2 Part 3).
|
||||
// Group C (S2) + Group D (v0.47.0 multi-endpoint management) — the /offsite page.
|
||||
// v0.47.0 amends the S2 render pins DELIBERATELY for the multi-card layout + Endpoint
|
||||
// column; the pubkey-title and empty-/hosts/-href assertions are kept verbatim.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,6 +24,27 @@ func renderOffsite(t *testing.T, s *Server) string {
|
||||
return rr.Body.String()
|
||||
}
|
||||
|
||||
func postOffsiteEndpoint(t *testing.T, s *Server, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/offsite/endpoints", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleOffsiteEndpointSave(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// validEndpointForm returns a fully-valid add form; tests mutate single fields.
|
||||
func validEndpointForm(id string) url.Values {
|
||||
return url.Values{
|
||||
"endpoint_id": {id},
|
||||
"dns_name": {id + ".felhom.eu"},
|
||||
"wg_port": {"443"},
|
||||
"server_pubkey": {"CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk="},
|
||||
"tunnel_subnet": {"10.78.0.0/24"},
|
||||
"pbs_tunnel_ip": {"10.78.0.1"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOffsite_NoEndpoint(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
html := renderOffsite(t, s)
|
||||
@@ -30,17 +54,30 @@ func TestOffsite_NoEndpoint(t *testing.T) {
|
||||
if !strings.Contains(html, "No WireGuard peers registered") {
|
||||
t.Errorf("empty-peers state missing")
|
||||
}
|
||||
// The add-endpoint form is offered even before the first endpoint exists.
|
||||
if !strings.Contains(html, `action="/offsite/endpoints"`) {
|
||||
t.Errorf("add-endpoint form missing")
|
||||
}
|
||||
}
|
||||
|
||||
// E1 — two endpoint cards render (ids visible), per-endpoint peer counts derive from
|
||||
// subnet membership, and the peer table's Endpoint column shows the containing endpoint.
|
||||
func TestOffsite_EndpointAndPeers(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
DNSName: "ep0.felhom.eu", WGPort: 443,
|
||||
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
|
||||
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 51820,
|
||||
ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=",
|
||||
TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.UpsertHost(&store.Host{HostID: "hv1", CustomerID: "c1", APIKey: "k"})
|
||||
if _, _, err := st.RegisterWGPeerForHost("hv1", "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -51,9 +88,12 @@ func TestOffsite_EndpointAndPeers(t *testing.T) {
|
||||
|
||||
html := renderOffsite(t, s)
|
||||
for _, want := range []string{
|
||||
"ep0.felhom.eu:443", // endpoint card
|
||||
"10.77.0.0/24", // subnet
|
||||
"10.77.0.1:8007", // PBS tunnel addr
|
||||
`data-endpoint-id="ep0"`, // ep0 card
|
||||
`data-endpoint-id="ep1"`, // ep1 card
|
||||
"ep0.felhom.eu:443", // ep0 address
|
||||
"ep1.felhom.eu:51820", // ep1 address
|
||||
"10.77.0.0/24", // ep0 subnet
|
||||
"10.77.0.1:8007", // ep0 PBS tunnel addr
|
||||
"10.77.0.2/32", // bound peer ip
|
||||
"10.77.0.3/32", // unbound peer ip
|
||||
`href="/hosts/hv1"`, // bound peer links to its host
|
||||
@@ -63,6 +103,19 @@ func TestOffsite_EndpointAndPeers(t *testing.T) {
|
||||
t.Errorf("offsite page missing %q", want)
|
||||
}
|
||||
}
|
||||
// Per-endpoint peer counts: both allocated peers live in ep0's subnet; ep1 has none.
|
||||
ep0Card := html[strings.Index(html, `data-endpoint-id="ep0"`):strings.Index(html, `data-endpoint-id="ep1"`)]
|
||||
ep1Card := html[strings.Index(html, `data-endpoint-id="ep1"`):]
|
||||
if !strings.Contains(ep0Card, "<td>2</td>") {
|
||||
t.Error("ep0 card missing peer count 2")
|
||||
}
|
||||
if !strings.Contains(ep1Card, "<td>0</td>") {
|
||||
t.Error("ep1 card missing peer count 0")
|
||||
}
|
||||
// The peer table's Endpoint column shows ep0 for both peers.
|
||||
if got := strings.Count(html, "<td><code>ep0</code></td>"); got != 2 {
|
||||
t.Errorf("Endpoint column shows ep0 %d times, want 2", got)
|
||||
}
|
||||
// Unbound peer renders an em-dash host cell, not a broken link.
|
||||
if strings.Contains(html, `href="/hosts/"`) {
|
||||
t.Error("unbound peer rendered an empty host link")
|
||||
@@ -72,3 +125,184 @@ func TestOffsite_EndpointAndPeers(t *testing.T) {
|
||||
t.Error("full pubkey missing from title attr")
|
||||
}
|
||||
}
|
||||
|
||||
// E5 — a peer whose IP falls in NO endpoint's subnet renders Endpoint "—".
|
||||
func TestOffsite_OrphanPeerEndpointColumn(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
|
||||
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "orphan"); err != nil {
|
||||
t.Fatal(err) // allocates 10.77.0.2
|
||||
}
|
||||
// Replace ep0 with an endpoint on a DIFFERENT subnet → the peer's IP matches nothing.
|
||||
if err := st.DeleteWGEndpoint("ep0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=",
|
||||
TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
html := renderOffsite(t, s)
|
||||
if !strings.Contains(html, "10.77.0.2/32") {
|
||||
t.Fatal("orphan peer missing from the table")
|
||||
}
|
||||
if strings.Contains(html, "<td><code>ep1</code></td>") {
|
||||
t.Error("orphan peer wrongly attributed to ep1 — must render em dash")
|
||||
}
|
||||
}
|
||||
|
||||
// E2 — a valid POST upserts via SetWGEndpoint and redirects back to the page.
|
||||
func TestOffsiteEndpointSave_Add(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
rr := postOffsiteEndpoint(t, s, validEndpointForm("ep1"))
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("add = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||
}
|
||||
eps, err := st.ListWGEndpoints()
|
||||
if err != nil || len(eps) != 1 {
|
||||
t.Fatalf("endpoints after add = %v / %v, want 1", eps, err)
|
||||
}
|
||||
if eps[0].EndpointID != "ep1" || eps[0].DNSName != "ep1.felhom.eu" || eps[0].WGPort != 443 ||
|
||||
eps[0].TunnelSubnet != "10.78.0.0/24" || eps[0].PBSTunnelIP != "10.78.0.1" {
|
||||
t.Errorf("stored endpoint = %+v", eps[0])
|
||||
}
|
||||
// The new card renders.
|
||||
if !strings.Contains(renderOffsite(t, s), `data-endpoint-id="ep1"`) {
|
||||
t.Error("new endpoint card missing from the page")
|
||||
}
|
||||
}
|
||||
|
||||
// E6 — every invalid form field → 400 and NOTHING stored.
|
||||
func TestOffsiteEndpointSave_Validation(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
field string
|
||||
value string
|
||||
}{
|
||||
{"bad cidr", "tunnel_subnet", "10.78.0.0/240"},
|
||||
{"not a cidr", "tunnel_subnet", "banana"},
|
||||
{"pbs ip outside subnet", "pbs_tunnel_ip", "10.99.0.1"},
|
||||
{"pbs ip garbage", "pbs_tunnel_ip", "not-an-ip"},
|
||||
{"port zero", "wg_port", "0"},
|
||||
{"port too big", "wg_port", "70000"},
|
||||
{"port garbage", "wg_port", "abc"},
|
||||
{"empty pubkey", "server_pubkey", ""},
|
||||
{"bad endpoint id", "endpoint_id", "EP 1!"},
|
||||
{"empty endpoint id", "endpoint_id", ""},
|
||||
{"empty dns", "dns_name", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
form := validEndpointForm("ep1")
|
||||
form.Set(c.field, c.value)
|
||||
rr := postOffsiteEndpoint(t, s, form)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status = %d, want 400", c.name, rr.Code)
|
||||
}
|
||||
if eps, _ := st.ListWGEndpoints(); len(eps) != 0 {
|
||||
t.Fatalf("%s: endpoint STORED despite invalid input: %+v", c.name, eps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// E3 — editing ep0's tunnel_subnet while peers are allocated inside the CURRENT subnet is
|
||||
// refused with 409 and the stored subnet is UNCHANGED.
|
||||
// RED-PROOF 4: removing the subnet-change guard makes this FAIL (subnet rewritten).
|
||||
func TestOffsiteEndpointSave_SubnetChangeGuard(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
|
||||
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "pin"); err != nil {
|
||||
t.Fatal(err) // 10.77.0.2 — inside the current subnet
|
||||
}
|
||||
|
||||
form := validEndpointForm("ep0")
|
||||
form.Set("tunnel_subnet", "10.99.0.0/24")
|
||||
form.Set("pbs_tunnel_ip", "10.99.0.1")
|
||||
rr := postOffsiteEndpoint(t, s, form)
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("subnet change with peers = %d, want 409", rr.Code)
|
||||
}
|
||||
ep, err := st.GetWGEndpoint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ep.TunnelSubnet != "10.77.0.0/24" {
|
||||
t.Errorf("stored subnet = %s — the refused edit CHANGED it", ep.TunnelSubnet)
|
||||
}
|
||||
|
||||
// Non-subnet edits stay allowed while peers exist (e.g. a port move).
|
||||
form = validEndpointForm("ep0")
|
||||
form.Set("tunnel_subnet", "10.77.0.0/24")
|
||||
form.Set("pbs_tunnel_ip", "10.77.0.1")
|
||||
form.Set("wg_port", "51820")
|
||||
if rr := postOffsiteEndpoint(t, s, form); rr.Code != http.StatusSeeOther {
|
||||
t.Errorf("same-subnet edit = %d, want 303", rr.Code)
|
||||
}
|
||||
if ep, _ := st.GetWGEndpoint(); ep.WGPort != 51820 {
|
||||
t.Errorf("port edit not stored: %+v", ep)
|
||||
}
|
||||
}
|
||||
|
||||
// E4 — deleting an endpoint with peers allocated in its subnet is refused with 409 and the
|
||||
// row survives; a peer-free endpoint deletes cleanly; an unknown one 404s.
|
||||
// RED-PROOF 3: removing the peers-in-subnet guard makes this FAIL (endpoint deleted).
|
||||
func TestOffsiteEndpointDelete_Guard(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
|
||||
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=",
|
||||
TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "pin"); err != nil {
|
||||
t.Fatal(err) // 10.77.0.2 — pins ep0
|
||||
}
|
||||
|
||||
del := func(id string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/offsite/endpoints/"+id+"/delete", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleOffsiteEndpointDelete(rr, req, id)
|
||||
return rr
|
||||
}
|
||||
|
||||
if rr := del("ep0"); rr.Code != http.StatusConflict {
|
||||
t.Fatalf("delete with peers = %d, want 409", rr.Code)
|
||||
}
|
||||
if eps, _ := st.ListWGEndpoints(); len(eps) != 2 {
|
||||
t.Fatalf("endpoint count after refused delete = %d, want 2 (ep0 must survive)", len(eps))
|
||||
}
|
||||
|
||||
if rr := del("ep1"); rr.Code != http.StatusSeeOther {
|
||||
t.Errorf("peer-free delete = %d, want 303", rr.Code)
|
||||
}
|
||||
if eps, _ := st.ListWGEndpoints(); len(eps) != 1 || eps[0].EndpointID != "ep0" {
|
||||
t.Errorf("endpoints after ep1 delete = %+v, want [ep0]", eps)
|
||||
}
|
||||
|
||||
if rr := del("ghost"); rr.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown endpoint delete = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,9 +248,23 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case strings.HasPrefix(path, "/apps/"):
|
||||
appName := strings.TrimPrefix(path, "/apps/")
|
||||
s.handleAppDetail(w, r, appName)
|
||||
// Offsite — read-only WG endpoint + peer registry (S2). Mutations stay on the admin API.
|
||||
// Offsite — WG endpoint + peer registry (S2), plus endpoint management forms
|
||||
// (v0.47.0; peer mutations stay on the admin API, allocation stays lowest-endpoint-id).
|
||||
case path == "/offsite":
|
||||
s.handleOffsite(w, r)
|
||||
case path == "/offsite/endpoints":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleOffsiteEndpointSave(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/offsite/endpoints/") && strings.HasSuffix(path, "/delete"):
|
||||
endpointID := strings.TrimSuffix(strings.TrimPrefix(path, "/offsite/endpoints/"), "/delete")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleOffsiteEndpointDelete(w, r, endpointID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
|
||||
case path == "/hosts" || path == "/hosts/":
|
||||
s.handleHostsList(w, r)
|
||||
|
||||
@@ -21,25 +21,97 @@
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">Offsite connectivity</h2>
|
||||
<h2 style="margin-bottom: 0.5rem;">Offsite connectivity</h2>
|
||||
<p class="text-muted" style="margin: 0 0 1rem; font-size: 0.85em;">
|
||||
Peer allocation and endpoint sync currently use the lowest endpoint id (ep0).
|
||||
Per-endpoint allocation is a future work item.
|
||||
</p>
|
||||
|
||||
{{if .HasEndpoint}}
|
||||
<section class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3>Endpoint</h3>
|
||||
<table class="detail-table">
|
||||
<tr><th>Address</th><td><code>{{.Endpoint.DNSName}}:{{.Endpoint.WGPort}}</code> (WireGuard, UDP)</td></tr>
|
||||
<tr><th>Server public key</th><td><code title="{{.Endpoint.ServerPubkey}}">{{.Endpoint.ServerPubkeyShort}}</code></td></tr>
|
||||
<tr><th>Tunnel subnet</th><td><code>{{.Endpoint.TunnelSubnet}}</code></td></tr>
|
||||
<tr><th>PBS tunnel address</th><td><code>{{.Endpoint.PBSTunnelIP}}:8007</code></td></tr>
|
||||
{{if eq .Flash "endpoint_saved"}}
|
||||
<div class="flash flash-success">Endpoint saved.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "endpoint_deleted"}}
|
||||
<div class="flash flash-success">Endpoint deleted.</div>
|
||||
{{end}}
|
||||
|
||||
{{if .HasEndpoints}}
|
||||
{{range .Endpoints}}
|
||||
<section class="card" style="margin-bottom: 1.5rem;"
|
||||
data-endpoint-id="{{.EndpointID}}" data-dns-name="{{.DNSName}}" data-wg-port="{{.WGPort}}"
|
||||
data-server-pubkey="{{.ServerPubkey}}" data-tunnel-subnet="{{.TunnelSubnet}}" data-pbs-tunnel-ip="{{.PBSTunnelIP}}">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h3 style="margin: 0;">Endpoint <code>{{.EndpointID}}</code></h3>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="btn btn-outline btn-sm" onclick="epEdit('{{.EndpointID}}')">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="epDeleteConfirm('{{.EndpointID}}')">Remove…</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="detail-table" style="margin-top: 0.75rem;">
|
||||
<tr><th>Address</th><td><code>{{.DNSName}}:{{.WGPort}}</code> (WireGuard, UDP)</td></tr>
|
||||
<tr><th>Server public key</th><td><code title="{{.ServerPubkey}}">{{.ServerPubkeyShort}}</code></td></tr>
|
||||
<tr><th>Tunnel subnet</th><td><code>{{.TunnelSubnet}}</code></td></tr>
|
||||
<tr><th>PBS tunnel address</th><td><code>{{.PBSTunnelIP}}:8007</code></td></tr>
|
||||
<tr><th>Peers in subnet</th><td>{{.PeerCount}}</td></tr>
|
||||
</table>
|
||||
<div id="ep-delete-confirm-{{.EndpointID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); background: var(--crit-dim); border-radius: var(--radius); max-width: 44em;">
|
||||
<p style="margin: 0 0 0.5rem; font-size: 0.85em;">
|
||||
Deleting <code>{{.EndpointID}}</code> removes only the endpoint record — registered peers stay.
|
||||
Deletion is refused while any peer is allocated in its subnet. Type the endpoint id to confirm:
|
||||
</p>
|
||||
<form method="POST" action="/offsite/endpoints/{{.EndpointID}}/delete" id="ep-delete-form-{{.EndpointID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="text" id="ep-delete-input-{{.EndpointID}}" placeholder="retype the endpoint id…" style="padding: 0.3em 0.5em; width: 12em;">
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="epDeleteSubmit('{{.EndpointID}}')">Confirm & delete</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('ep-delete-confirm-{{.EndpointID}}').style.display='none';">Cancel</button>
|
||||
<span id="ep-delete-err-{{.EndpointID}}" style="font-size: 0.8em; color: var(--crit);"></span>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<section class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3>Endpoint</h3>
|
||||
<p class="text-muted">Not configured. Register one via <code>PUT /api/v1/admin/wg/endpoint</code> (runbook: offsite-endpoint.md).</p>
|
||||
<p class="text-muted">Not configured. Add one below (or via <code>PUT /api/v1/admin/wg/endpoint</code>, runbook: offsite-endpoint.md).</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Add / edit endpoint (v0.47.0). Plain form post — server-side validation is
|
||||
authoritative; the JS layer only adds the pubkey-change type-to-confirm. -->
|
||||
<section class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3 id="ep-form-title">Add endpoint</h3>
|
||||
<form method="POST" action="/offsite/endpoints" id="ep-form" onsubmit="return epFormSubmitCheck()"
|
||||
style="display: grid; grid-template-columns: auto 1fr; gap: 0.5rem; align-items: center; max-width: 44em; margin-top: 0.75rem;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<label style="font-size: 0.9em;">Endpoint id</label>
|
||||
<input type="text" name="endpoint_id" id="ep-id" placeholder="ep1" style="padding: 0.3em 0.5em;">
|
||||
<label style="font-size: 0.9em;">DNS name</label>
|
||||
<input type="text" name="dns_name" id="ep-dns" placeholder="ep1.felhom.eu" style="padding: 0.3em 0.5em;">
|
||||
<label style="font-size: 0.9em;">WG port</label>
|
||||
<input type="number" name="wg_port" id="ep-port" min="1" max="65535" placeholder="443" style="padding: 0.3em 0.5em;">
|
||||
<label style="font-size: 0.9em;">Server public key</label>
|
||||
<input type="text" name="server_pubkey" id="ep-pubkey" placeholder="base64 WireGuard pubkey" style="padding: 0.3em 0.5em; font-family: var(--font-data);">
|
||||
<label style="font-size: 0.9em;">Tunnel subnet</label>
|
||||
<input type="text" name="tunnel_subnet" id="ep-subnet" placeholder="10.78.0.0/24" style="padding: 0.3em 0.5em;">
|
||||
<label style="font-size: 0.9em;">PBS tunnel IP</label>
|
||||
<input type="text" name="pbs_tunnel_ip" id="ep-pbsip" placeholder="10.78.0.1 (inside the subnet)" style="padding: 0.3em 0.5em;">
|
||||
<span></span>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button type="submit" class="btn btn-sm">Save endpoint</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="ep-form-reset" style="display: none;" onclick="epFormReset()">Cancel edit</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="ep-pubkey-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--warn); background: var(--warn-dim); border-radius: var(--radius); max-width: 44em;">
|
||||
<p style="margin: 0 0 0.5rem; font-size: 0.85em;">
|
||||
The server public key is CHANGING. Peers keep using the old key until they pull their
|
||||
next desired-state (they converge on their own cycle — no push). Type the endpoint id to confirm:
|
||||
</p>
|
||||
<input type="text" id="ep-pubkey-confirm-input" placeholder="retype the endpoint id…" style="padding: 0.3em 0.5em; width: 12em;">
|
||||
<button type="button" class="btn btn-sm" onclick="epPubkeyConfirmSubmit()">Confirm & save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('ep-pubkey-confirm').style.display='none';">Cancel</button>
|
||||
<p id="ep-pubkey-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Peers}}
|
||||
<section class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="data-table">
|
||||
@@ -47,6 +119,7 @@
|
||||
<tr>
|
||||
<th>Public key</th>
|
||||
<th>Assigned IP</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Host</th>
|
||||
<th>Note</th>
|
||||
<th>Created</th>
|
||||
@@ -57,6 +130,7 @@
|
||||
<tr>
|
||||
<td><code title="{{.Pubkey}}">{{.PubkeyShort}}</code></td>
|
||||
<td><code>{{.AssignedIP}}</code></td>
|
||||
<td>{{if .EndpointID}}<code>{{.EndpointID}}</code>{{else}}—{{end}}</td>
|
||||
<td>{{if .HostID}}<a href="/hosts/{{.HostID}}">{{.HostID}}</a>{{else}}—{{end}}</td>
|
||||
<td>{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
@@ -76,5 +150,70 @@
|
||||
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Endpoint management JS (v0.47.0). The server enforces every guard — this layer only
|
||||
// fills the edit form from a card's data attributes and adds the pubkey-change confirm.
|
||||
var epOriginalPubkey = null; // non-null = editing an existing endpoint
|
||||
|
||||
function epEdit(id) {
|
||||
var card = document.querySelector('[data-endpoint-id="' + id + '"]');
|
||||
if (!card) return;
|
||||
document.getElementById('ep-form-title').textContent = 'Edit endpoint ' + id;
|
||||
var f = {
|
||||
'ep-id': 'data-endpoint-id', 'ep-dns': 'data-dns-name', 'ep-port': 'data-wg-port',
|
||||
'ep-pubkey': 'data-server-pubkey', 'ep-subnet': 'data-tunnel-subnet', 'ep-pbsip': 'data-pbs-tunnel-ip'
|
||||
};
|
||||
for (var el in f) { document.getElementById(el).value = card.getAttribute(f[el]) || ''; }
|
||||
// endpoint_id is the primary key — immutable while editing.
|
||||
document.getElementById('ep-id').readOnly = true;
|
||||
epOriginalPubkey = card.getAttribute('data-server-pubkey') || '';
|
||||
document.getElementById('ep-form-reset').style.display = 'inline-block';
|
||||
document.getElementById('ep-form').scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
|
||||
function epFormReset() {
|
||||
document.getElementById('ep-form').reset();
|
||||
document.getElementById('ep-form-title').textContent = 'Add endpoint';
|
||||
document.getElementById('ep-id').readOnly = false;
|
||||
document.getElementById('ep-id').value = '';
|
||||
epOriginalPubkey = null;
|
||||
document.getElementById('ep-form-reset').style.display = 'none';
|
||||
document.getElementById('ep-pubkey-confirm').style.display = 'none';
|
||||
}
|
||||
|
||||
function epFormSubmitCheck() {
|
||||
// Editing + pubkey changed → intercept with the type-to-confirm (floor pattern).
|
||||
if (epOriginalPubkey !== null && document.getElementById('ep-pubkey').value.trim() !== epOriginalPubkey) {
|
||||
document.getElementById('ep-pubkey-confirm-input').value = '';
|
||||
document.getElementById('ep-pubkey-confirm-err').textContent = '';
|
||||
document.getElementById('ep-pubkey-confirm').style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function epPubkeyConfirmSubmit() {
|
||||
var typed = document.getElementById('ep-pubkey-confirm-input').value.trim();
|
||||
var expected = document.getElementById('ep-id').value.trim();
|
||||
var err = document.getElementById('ep-pubkey-confirm-err');
|
||||
if (typed !== expected) { err.textContent = 'Confirmation does not match the endpoint id.'; return; }
|
||||
document.getElementById('ep-form').submit();
|
||||
}
|
||||
|
||||
function epDeleteConfirm(id) {
|
||||
var box = document.getElementById('ep-delete-confirm-' + id);
|
||||
document.getElementById('ep-delete-input-' + id).value = '';
|
||||
document.getElementById('ep-delete-err-' + id).textContent = '';
|
||||
box.style.display = 'block';
|
||||
}
|
||||
|
||||
function epDeleteSubmit(id) {
|
||||
var typed = document.getElementById('ep-delete-input-' + id).value.trim();
|
||||
var err = document.getElementById('ep-delete-err-' + id);
|
||||
if (typed !== id) { err.textContent = 'Confirmation does not match the endpoint id.'; return; }
|
||||
document.getElementById('ep-delete-form-' + id).submit();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user