Domain names become an allocatable resource with one authority. A new core module (schema `domains`, own migration stream between core and the integrations) owns claims — a DNS node plus its whole subtree, mutually disjoint: operator shared-domain roots, member claims carved from them, and bring-your-own names proven by TXT verification — and placements, which bind a name inside a claim to a provider slug and resource ref. Verification moves to the claim and decouples from creation. A member proves control of a domain once; afterwards every name inside it places instantly, wildcard-CNAME friendly, with no further DNS work. The claim workflow activates the claim and stops — it no longer creates a site — so the sites list offers a one-click create once a domain verifies. /domains/ask answers from placements and is registered by core rather than the FedWiki adapter; its HTTP contract is unchanged. A configured `domains-ask-fallback-url` forwards names the registry does not know to a legacy answerer, the strangler seam wiki.cafe's migration needs; a name the registry knows but has archived is refused locally. FedWiki's create saga reserves the name before the farm call, carrying a workflow-minted site id so retries are idempotent, and compensates on failure. Sync places only names it owns, never stealing a member's; lifecycle transitions and the retention purge maintain servability. An unconditional boot pass seeds operator roots, releases orphaned placements, and adopts pre-existing sites — grandfathering member-owned external domains shortest-name-first, and skipping name policy, so a live single-letter site cannot lose its certificate. Members manage domains at /domains: claims with verification status, DNS records including an optional wildcard row, check-now, cancel, release. Name policy (reserved, blocked, premium, plus a single-letter guard) is operator data; refusals collapse to a plain "unavailable" so the console never becomes an oracle for who holds what. BREAKING (pre-release): `fedwiki.custom_domain_verifications` and `sites.is_custom_domain` are dropped, the flag now derived from the placement's claim kind; resource key `fedwiki_custom_domains` migrates to the platform-owned `external_domain_claims`; running verify-custom-domain workflows must be terminated before deploy.
292 lines
11 KiB
Go
292 lines
11 KiB
Go
package domains
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
|
|
|
// errQuerier fails the ask lookup, standing in for a database outage: that
|
|
// error must surface (the handler answers 500) rather than being laundered
|
|
// into a refusal or a fallback call.
|
|
type errQuerier struct {
|
|
Querier
|
|
err error
|
|
}
|
|
|
|
func (e errQuerier) GetPlacementByFQDN(context.Context, string) (Placement, error) {
|
|
return Placement{}, e.err
|
|
}
|
|
|
|
// askFallbackServer records every request the legacy answerer receives, so
|
|
// tests can assert both what was forwarded and — for the local-refusal
|
|
// cases — that nothing was.
|
|
type askFallbackServer struct {
|
|
*httptest.Server
|
|
hits atomic.Int64
|
|
queries chan url.Values
|
|
}
|
|
|
|
func newAskFallbackServer(t *testing.T, status int) *askFallbackServer {
|
|
t.Helper()
|
|
s := &askFallbackServer{queries: make(chan url.Values, 8)}
|
|
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
s.hits.Add(1)
|
|
s.queries <- r.URL.Query()
|
|
w.WriteHeader(status)
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
return s
|
|
}
|
|
|
|
// placement builds a registry row for fqdn without going through the
|
|
// allocation API; only fqdn and servability matter to the authorizer.
|
|
func placement(fqdn string, servable bool) Placement {
|
|
return Placement{
|
|
Fqdn: fqdn,
|
|
ReversedLabels: dnsname.ReverseLabels(fqdn),
|
|
Provider: "testprovider",
|
|
ResourceRef: fqdn,
|
|
Servable: servable,
|
|
}
|
|
}
|
|
|
|
// TestRegistryAuthorizerWithoutFallback covers the three registry outcomes
|
|
// when no legacy answerer is configured (the default posture): servable row
|
|
// authorizes, unservable row refuses, missing row refuses.
|
|
func TestRegistryAuthorizerWithoutFallback(t *testing.T) {
|
|
q := &fakeQuerier{placements: []Placement{
|
|
placement("alice.example.test", true),
|
|
placement("archived.example.test", false),
|
|
}}
|
|
auth := NewRegistryAuthorizer(q, "", discardLogger())
|
|
|
|
tests := []struct {
|
|
name string
|
|
fqdn string
|
|
want bool
|
|
}{
|
|
{"servable placement authorized", "alice.example.test", true},
|
|
{"unservable placement refused", "archived.example.test", false},
|
|
{"registry miss refused", "nope.example.test", false},
|
|
{"child of a placement not implicitly authorized", "blog.alice.example.test", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := auth.AuthorizeDomain(context.Background(), tt.fqdn)
|
|
if err != nil {
|
|
t.Fatalf("AuthorizeDomain(%q) error = %v", tt.fqdn, err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("AuthorizeDomain(%q) = %v, want %v", tt.fqdn, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegistryAuthorizerLookupError checks that a real query failure is
|
|
// propagated: the handler turns it into a 500, which is distinguishable
|
|
// from a refusal and never reaches the fallback.
|
|
func TestRegistryAuthorizerLookupError(t *testing.T) {
|
|
fallback := newAskFallbackServer(t, http.StatusOK)
|
|
boom := errors.New("db down")
|
|
auth := NewRegistryAuthorizer(errQuerier{err: boom}, fallback.URL, discardLogger())
|
|
|
|
got, err := auth.AuthorizeDomain(context.Background(), "alice.example.test")
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("error = %v, want %v", err, boom)
|
|
}
|
|
if got {
|
|
t.Error("AuthorizeDomain authorized despite a lookup error")
|
|
}
|
|
if hits := fallback.hits.Load(); hits != 0 {
|
|
t.Errorf("fallback consulted %d times after a lookup error, want 0", hits)
|
|
}
|
|
}
|
|
|
|
// TestRegistryAuthorizerFallbackAuthorizesMiss covers the strangler seam's
|
|
// happy path and the request-building contract: the configured URL's path
|
|
// and pre-existing query survive, and `domain` is set through url.Values
|
|
// (exactly one value, overriding whatever the operator left there) rather
|
|
// than concatenated onto the URL.
|
|
func TestRegistryAuthorizerFallbackAuthorizesMiss(t *testing.T) {
|
|
fallback := newAskFallbackServer(t, http.StatusOK)
|
|
// A configured query carrying a literal "&" proves the encoding is a
|
|
// round-trip through url.Values, not string surgery.
|
|
configured := fallback.URL + "/ask?token=" + url.QueryEscape("a&b=c") + "&domain=stale.example.test"
|
|
auth := NewRegistryAuthorizer(&fakeQuerier{}, configured, discardLogger())
|
|
|
|
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
|
|
if err != nil {
|
|
t.Fatalf("AuthorizeDomain error = %v", err)
|
|
}
|
|
if !got {
|
|
t.Error("AuthorizeDomain refused a miss the fallback answered 200 for")
|
|
}
|
|
if hits := fallback.hits.Load(); hits != 1 {
|
|
t.Fatalf("fallback consulted %d times, want 1", hits)
|
|
}
|
|
query := <-fallback.queries
|
|
if len(query["domain"]) != 1 || query.Get("domain") != "legacy.example.test" {
|
|
t.Errorf("forwarded domain = %v, want exactly [legacy.example.test]", query["domain"])
|
|
}
|
|
if query.Get("token") != "a&b=c" {
|
|
t.Errorf("configured token param = %q, want %q", query.Get("token"), "a&b=c")
|
|
}
|
|
}
|
|
|
|
// TestRegistryAuthorizerFallbackFailsClosed covers every non-200 outcome of
|
|
// the forwarded request: refusal, timeout, and an unreachable answerer all
|
|
// refuse without surfacing an error.
|
|
func TestRegistryAuthorizerFallbackFailsClosed(t *testing.T) {
|
|
t.Run("non-200 refusal", func(t *testing.T) {
|
|
fallback := newAskFallbackServer(t, http.StatusNotFound)
|
|
auth := NewRegistryAuthorizer(&fakeQuerier{}, fallback.URL, discardLogger())
|
|
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
|
|
if err != nil || got {
|
|
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
|
|
}
|
|
if hits := fallback.hits.Load(); hits != 1 {
|
|
t.Errorf("fallback consulted %d times, want 1", hits)
|
|
}
|
|
})
|
|
|
|
t.Run("timeout", func(t *testing.T) {
|
|
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(500 * time.Millisecond)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(slow.Close)
|
|
auth := NewRegistryAuthorizer(&fakeQuerier{}, slow.URL, discardLogger())
|
|
// Shorten the production timeout so the test does not wait it out;
|
|
// the behaviour under test is what happens when it elapses.
|
|
auth.httpClient.Timeout = 20 * time.Millisecond
|
|
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
|
|
if err != nil || got {
|
|
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("unreachable", func(t *testing.T) {
|
|
dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
deadURL := dead.URL
|
|
dead.Close()
|
|
auth := NewRegistryAuthorizer(&fakeQuerier{}, deadURL, discardLogger())
|
|
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
|
|
if err != nil || got {
|
|
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed fallback URL disables forwarding", func(t *testing.T) {
|
|
auth := NewRegistryAuthorizer(&fakeQuerier{}, "not-a-url", discardLogger())
|
|
if auth.fallback != nil {
|
|
t.Fatal("malformed fallback URL was accepted")
|
|
}
|
|
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
|
|
if err != nil || got {
|
|
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRegistryAuthorizerMalformedURLIsNotLogged pins the secret-handling half
|
|
// of the malformed-URL path: the operator's configured value may embed
|
|
// userinfo credentials, so neither it nor url.Parse's error (which quotes its
|
|
// input verbatim) may reach the log. Only the parsed host survives.
|
|
func TestRegistryAuthorizerMalformedURLIsNotLogged(t *testing.T) {
|
|
const secret = "hunter2"
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantHost string // "" when no host can be salvaged
|
|
}{
|
|
// A control character defeats url.Parse entirely, so the error text
|
|
// is the leak vector and there is no host to report.
|
|
{"unparseable with credentials", "https://svc:" + secret + "@legacy.example.test/ask\n", ""},
|
|
// Parses, but "svc" becomes the scheme and the credentials land in
|
|
// Opaque — which is why the scheme is dropped too.
|
|
{"no scheme, credentials in opaque", "svc:" + secret + "@legacy.example.test/ask", ""},
|
|
// Scheme-relative: a real host survives, and the secret must not.
|
|
{"no scheme, host present", "//svc:" + secret + "@legacy.example.test/ask", "legacy.example.test"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var logged bytes.Buffer
|
|
auth := NewRegistryAuthorizer(&fakeQuerier{}, tt.url,
|
|
slog.New(slog.NewTextHandler(&logged, nil)))
|
|
if auth.fallback != nil {
|
|
t.Fatal("malformed fallback URL was accepted")
|
|
}
|
|
out := logged.String()
|
|
if out == "" {
|
|
t.Fatal("malformed fallback URL was not logged at all")
|
|
}
|
|
if strings.Contains(out, secret) {
|
|
t.Errorf("log leaked the configured credentials: %s", out)
|
|
}
|
|
if strings.Contains(out, "/ask") {
|
|
t.Errorf("log leaked the configured path: %s", out)
|
|
}
|
|
if tt.wantHost != "" && !strings.Contains(out, tt.wantHost) {
|
|
t.Errorf("log dropped the host %q, leaving nothing to diagnose: %s", tt.wantHost, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegistryAuthorizerNeverForwards pins the two cases where the fallback
|
|
// is configured but must not be consulted at all: a registry hit decides
|
|
// locally either way (an archived name must not be resurrected by the
|
|
// legacy answerer), and a name that is not a well-formed FQDN is refused
|
|
// before any outbound call, capping amplification from the unauthenticated
|
|
// route.
|
|
func TestRegistryAuthorizerNeverForwards(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fqdn string
|
|
want bool
|
|
}{
|
|
{"servable placement decided locally", "alice.example.test", true},
|
|
{"unservable placement never falls through", "archived.example.test", false},
|
|
{"single-label name is not a FQDN", "localhost", false},
|
|
{"empty name", "", false},
|
|
{"query-injection attempt is refused before dialing", "evil.example.test&domain=victim.example.test", false},
|
|
{"parameter-stuffed name is refused before dialing", "a.test?x=1", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fallback := newAskFallbackServer(t, http.StatusOK)
|
|
q := &fakeQuerier{placements: []Placement{
|
|
placement("alice.example.test", true),
|
|
placement("archived.example.test", false),
|
|
}}
|
|
auth := NewRegistryAuthorizer(q, fallback.URL, discardLogger())
|
|
|
|
got, err := auth.AuthorizeDomain(context.Background(), tt.fqdn)
|
|
if err != nil {
|
|
t.Fatalf("AuthorizeDomain(%q) error = %v", tt.fqdn, err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("AuthorizeDomain(%q) = %v, want %v", tt.fqdn, got, tt.want)
|
|
}
|
|
if hits := fallback.hits.Load(); hits != 0 {
|
|
t.Errorf("fallback consulted %d times for %q, want 0", hits, tt.fqdn)
|
|
}
|
|
})
|
|
}
|
|
}
|