diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index cb61b88..02309c5 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -1130,9 +1130,9 @@ var allowedEventTypes = map[string]bool{ "node_down": true, "node_recovered": true, // Hub-generated host-domain events (v0.7.0, slice 3) - "host_stale": true, - "host_down": true, - "host_recovered": true, + "host_stale": true, + "host_down": true, + "host_recovered": true, // Hub-generated host root-fs disk-pressure (v0.23.0) — distinct from the controller's GUEST disk_* "host_disk_warning": true, "host_disk_critical": true, diff --git a/hub/internal/api/wg_test.go b/hub/internal/api/wg_test.go index c2b4e1a..1bfcfb5 100644 --- a/hub/internal/api/wg_test.go +++ b/hub/internal/api/wg_test.go @@ -142,9 +142,9 @@ func TestWGPeers_BadPubkeyRejected(t *testing.T) { putTestEndpoint(t, h) bad := []string{ "not-base64", - base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 16)), // 24 chars - "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", // 44 chars, not base64 - base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 33)), // 44 chars but 33 bytes + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 16)), // 24 chars + "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", // 44 chars, not base64 + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 33)), // 44 chars but 33 bytes } for _, pk := range bad { rr := do(h, http.MethodPost, "/admin/wg/peers", globalKey, `{"pubkey":"`+pk+`"}`) diff --git a/hub/internal/store/wg.go b/hub/internal/store/wg.go index 01107a9..6612362 100644 --- a/hub/internal/store/wg.go +++ b/hub/internal/store/wg.go @@ -42,6 +42,7 @@ type WGPeer struct { AssignedIP string // bare IP — the /32 suffix is presentation, not storage HostID string Note string + CreatedAt string // as stored (SQLite datetime text); display-only } // SetWGEndpoint upserts the (single expected) endpoint record. @@ -318,7 +319,7 @@ func (s *Store) RemoveWGPeer(pubkey string) error { // ListWGPeers returns all registered peers ordered by assigned_ip — a deterministic order so // the sync payload bytes are stable for identical registries. func (s *Store) ListWGPeers() ([]WGPeer, error) { - rows, err := s.db.Query(`SELECT pubkey, assigned_ip, host_id, note FROM wg_peers ORDER BY assigned_ip`) + rows, err := s.db.Query(`SELECT pubkey, assigned_ip, host_id, note, created_at FROM wg_peers ORDER BY assigned_ip`) if err != nil { return nil, err } @@ -326,7 +327,7 @@ func (s *Store) ListWGPeers() ([]WGPeer, error) { var peers []WGPeer for rows.Next() { var p WGPeer - if err := rows.Scan(&p.Pubkey, &p.AssignedIP, &p.HostID, &p.Note); err != nil { + if err := rows.Scan(&p.Pubkey, &p.AssignedIP, &p.HostID, &p.Note, &p.CreatedAt); err != nil { return nil, err } peers = append(peers, p) diff --git a/hub/internal/web/offsite.go b/hub/internal/web/offsite.go new file mode 100644 index 0000000..15f2cdc --- /dev/null +++ b/hub/internal/web/offsite.go @@ -0,0 +1,78 @@ +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). + +import ( + "database/sql" + "net/http" +) + +// 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 +} + +// 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, + } + + ep, err := s.store.GetWGEndpoint() + if err != nil && err != sql.ErrNoRows { + s.logger.Printf("[ERROR] offsite: endpoint read: %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 + } + 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, + }) + } + data["Peers"] = rows + + if err := s.templates.ExecuteTemplate(w, "offsite.html", data); err != nil { + s.logger.Printf("[ERROR] offsite.html template: %v", err) + } +} diff --git a/hub/internal/web/offsite_test.go b/hub/internal/web/offsite_test.go new file mode 100644 index 0000000..ca945ec --- /dev/null +++ b/hub/internal/web/offsite_test.go @@ -0,0 +1,74 @@ +package web + +// Group C — the /offsite registry page renders in all three states (S2 Part 3). + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +func renderOffsite(t *testing.T, s *Server) string { + t.Helper() + rr := httptest.NewRecorder() + s.handleOffsite(rr, httptest.NewRequest(http.MethodGet, "/offsite", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("offsite render = %d", rr.Code) + } + return rr.Body.String() +} + +func TestOffsite_NoEndpoint(t *testing.T) { + s, _ := newTestServer(t) + html := renderOffsite(t, s) + if !strings.Contains(html, "Not configured") { + t.Errorf("no-endpoint state missing: %s", html[:200]) + } + if !strings.Contains(html, "No WireGuard peers registered") { + t.Errorf("empty-peers state missing") + } +} + +func TestOffsite_EndpointAndPeers(t *testing.T) { + s, st := newTestServer(t) + if err := st.SetWGEndpoint(&store.WGEndpoint{ + DNSName: "ep0.felhom.eu", WGPort: 443, + ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=", + TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.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) + } + if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "unbound-test"); err != nil { + t.Fatal(err) + } + + 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 + "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 + "unbound-test", // note column + } { + if !strings.Contains(html, want) { + t.Errorf("offsite page missing %q", want) + } + } + // 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") + } + // Full pubkey rides the title attribute (truncated display). + if !strings.Contains(html, `title="AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="`) { + t.Error("full pubkey missing from title attr") + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 1b676fe..f2e3065 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -241,6 +241,9 @@ 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. + case path == "/offsite": + s.handleOffsite(w, r) // Hosts — read-only fleet view (audit F-M1). GET only; no host actions. case path == "/hosts" || path == "/hosts/": s.handleHostsList(w, r) diff --git a/hub/internal/web/templates/app_detail.html b/hub/internal/web/templates/app_detail.html index e5917f2..8c350d9 100644 --- a/hub/internal/web/templates/app_detail.html +++ b/hub/internal/web/templates/app_detail.html @@ -17,6 +17,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/apps.html b/hub/internal/web/templates/apps.html index 03405b2..a492d75 100644 --- a/hub/internal/web/templates/apps.html +++ b/hub/internal/web/templates/apps.html @@ -16,6 +16,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html index 4f6ccb5..2b56e49 100644 --- a/hub/internal/web/templates/config_form.html +++ b/hub/internal/web/templates/config_form.html @@ -16,6 +16,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/configs.html b/hub/internal/web/templates/configs.html index 4acf67d..8e00693 100644 --- a/hub/internal/web/templates/configs.html +++ b/hub/internal/web/templates/configs.html @@ -16,6 +16,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html index 4e09820..904d420 100644 --- a/hub/internal/web/templates/configuration.html +++ b/hub/internal/web/templates/configuration.html @@ -16,6 +16,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index e5e75b0..be817f3 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -17,6 +17,7 @@ Customers Apps Hosts + Offsite Configuration ← All Customers diff --git a/hub/internal/web/templates/dashboard.html b/hub/internal/web/templates/dashboard.html index 1b91f02..10f7f37 100644 --- a/hub/internal/web/templates/dashboard.html +++ b/hub/internal/web/templates/dashboard.html @@ -17,6 +17,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/host_detail.html b/hub/internal/web/templates/host_detail.html index b473ece..b1c5be5 100644 --- a/hub/internal/web/templates/host_detail.html +++ b/hub/internal/web/templates/host_detail.html @@ -16,6 +16,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/hosts.html b/hub/internal/web/templates/hosts.html index a78f0ba..769c795 100644 --- a/hub/internal/web/templates/hosts.html +++ b/hub/internal/web/templates/hosts.html @@ -16,6 +16,7 @@ Customers Apps Hosts + Offsite Configuration diff --git a/hub/internal/web/templates/offsite.html b/hub/internal/web/templates/offsite.html new file mode 100644 index 0000000..358191b --- /dev/null +++ b/hub/internal/web/templates/offsite.html @@ -0,0 +1,80 @@ + + + + + + Offsite — Felhom Hub + + + + {{template "icon_sprite"}} +
+
+

Felhom Hub

+ +
+ +

Offsite connectivity

+ + {{if .HasEndpoint}} +
+

Endpoint

+ + + + + +
Address{{.Endpoint.DNSName}}:{{.Endpoint.WGPort}} (WireGuard, UDP)
Server public key{{.Endpoint.ServerPubkeyShort}}
Tunnel subnet{{.Endpoint.TunnelSubnet}}
PBS tunnel address{{.Endpoint.PBSTunnelIP}}:8007
+
+ {{else}} +
+

Endpoint

+

Not configured. Register one via PUT /api/v1/admin/wg/endpoint (runbook: offsite-endpoint.md).

+
+ {{end}} + + {{if .Peers}} +
+ + + + + + + + + + + + {{range .Peers}} + + + + + + + + {{end}} + +
Public keyAssigned IPHostNoteCreated
{{.PubkeyShort}}{{.AssignedIP}}{{if .HostID}}{{.HostID}}{{else}}—{{end}}{{if .Note}}{{.Note}}{{else}}—{{end}}{{.CreatedAt}}
+
+ {{else}} +
+

No WireGuard peers registered.

+

A peer appears here when a box registers via POST /hosts/<id>/wg or the operator adds one via the admin API.

+
+ {{end}} + + +
+ +