0daddcd1c4
- /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
309 lines
11 KiB
Go
309 lines
11 KiB
Go
package web
|
|
|
|
// 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"
|
|
|
|
"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 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)
|
|
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")
|
|
}
|
|
// 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{
|
|
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)
|
|
}
|
|
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "unbound-test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
html := renderOffsite(t, s)
|
|
for _, want := range []string{
|
|
`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
|
|
"unbound-test", // note column
|
|
} {
|
|
if !strings.Contains(html, want) {
|
|
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")
|
|
}
|
|
// Full pubkey rides the title attribute (truncated display).
|
|
if !strings.Contains(html, `title="AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="`) {
|
|
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)
|
|
}
|
|
}
|