package server import ( "context" "log/slog" "net/http" "strings" "git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname" ) // DomainAuthorizer answers the serving-authorization question behind // GET /domains/ask: does a servable placement exist at exactly this FQDN? // Implementations perform an exact-match lookup only — no ancestor or // wildcard reasoning (spec: domain-authorization). The FedWiki integration // supplies today's implementation; a future domains registry replaces it // behind this same interface without changing the HTTP contract. type DomainAuthorizer interface { AuthorizeDomain(ctx context.Context, fqdn string) (bool, error) } // DomainAskHandler implements the on-demand-TLS ask contract (e.g. Caddy's // on_demand_tls.ask, which appends ?domain= to the configured URL): // 200 authorizes certificate issuance for the queried name, any other // status refuses it. The endpoint is unauthenticated by design — // TLS-terminating proxies send no credentials — so deployments expose it // on an internal network only; existence of a name is not sensitive // (issued certificates are public in Certificate Transparency logs). // // The name's shape is checked before anything is looked up. The route is // unauthenticated, so a malformed parameter must cost a regexp rather than a // database round trip. A malformed name shares the empty parameter's 400 // rather than the 404 a well-formed-but-unserved name gets: both are // defects in the request itself, and neither says anything about which // names exist. Every other status stays as specified — 200 authorizes, // 404 refuses, 500 on a lookup error. // // The endpoint is called both by TLS-terminating proxies (Caddy's // on_demand_tls.ask, mid-handshake, no Accept header) and, occasionally, by // a person's browser following a stale or hand-edited link. tmpl renders the // malformed-request cases as a styled page for the latter (spec: // domain-authorization); the machine-facing contract — status code and // plain-text body — is unchanged for everyone else. tmpl may be nil (e.g. // in tests that only assert status codes), which always takes the // machine-facing branch. func DomainAskHandler(authorizer DomainAuthorizer, logger *slog.Logger, tmpl *SafeTemplates) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { domain := dnsname.Normalize(r.URL.Query().Get("domain")) if domain == "" { askMalformed(w, r, tmpl, "domain parameter required", "This link is missing its domain. Ask whoever gave you the link to include a domain, or contact your operator.") return } if msg := dnsname.ValidateExternalFQDN(domain, nil); msg != "" { askMalformed(w, r, tmpl, "domain parameter malformed", "This link's domain isn't valid. Ask whoever gave you the link to check it, or contact your operator.") return } ok, err := authorizer.AuthorizeDomain(r.Context(), domain) if err != nil { logger.Error("domain authorization check failed", slog.String("domain", domain), slog.Any("error", err)) http.Error(w, "authorization check failed", http.StatusInternalServerError) return } if !ok { http.Error(w, "domain not served here", http.StatusNotFound) return } w.WriteHeader(http.StatusOK) } } // askMalformed answers a malformed /domains/ask request: status 400 always // (the machine-facing contract never changes), with the body styled for a // browser and left as the original plain-text message for everyone else. A // browser is recognized by an HTML-accepting Accept header — the on-demand // TLS proxy that is this endpoint's primary caller sends none. func askMalformed(w http.ResponseWriter, r *http.Request, tmpl *SafeTemplates, machineMsg, humanMsg string) { if tmpl != nil && strings.Contains(r.Header.Get("Accept"), "text/html") { tmpl.RenderErrorPage(w, r, http.StatusBadRequest, humanMsg) return } http.Error(w, machineMsg, http.StatusBadRequest) }