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.
166 lines
6.3 KiB
Go
166 lines
6.3 KiB
Go
package domains
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
|
|
)
|
|
|
|
// fallbackTimeout caps the outbound call to the legacy answerer. The ask
|
|
// endpoint sits on the TLS handshake path of a proxy that is already
|
|
// waiting on us, so a slow legacy answerer must fail closed quickly rather
|
|
// than stall certificate issuance.
|
|
const fallbackTimeout = 2 * time.Second
|
|
|
|
// fallbackBodyLimit is how much of the fallback's response body we drain
|
|
// before closing, so the connection can be reused. The contract carries no
|
|
// body — anything larger is discarded with the connection.
|
|
const fallbackBodyLimit = 4 << 10
|
|
|
|
// RegistryAuthorizer is the registry-backed server.DomainAuthorizer: a name
|
|
// is servable iff domains.placements holds a row at exactly that FQDN with
|
|
// servable = true. The interface is satisfied structurally — internal/server
|
|
// imports internal/domains for Config wiring, so the reverse edge cannot
|
|
// exist and this package never names the server interface (design D5, D6).
|
|
//
|
|
// The lookup is deliberately unconditional (GetPlacementByFQDN ignores
|
|
// servability) because the three outcomes are not two:
|
|
//
|
|
// - servable row → authorize;
|
|
// - unservable row → refuse LOCALLY, never consulting the fallback (an
|
|
// archived site's name must not be resurrected by a legacy answerer that
|
|
// has not caught up);
|
|
// - no row at all → a registry miss, the only case the strangler fallback
|
|
// may answer.
|
|
type RegistryAuthorizer struct {
|
|
q Querier
|
|
// fallback is the parsed domains-ask-fallback-url; nil disables
|
|
// forwarding (the default, and the posture after a malformed URL).
|
|
fallback *url.URL
|
|
httpClient *http.Client
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewRegistryAuthorizer builds the authorizer over the registry queries.
|
|
// fallbackURL is the optional legacy answerer (viper key
|
|
// domains-ask-fallback-url); empty disables forwarding. A malformed URL is
|
|
// logged and disables forwarding rather than failing the boot: the fallback
|
|
// is a migration-window convenience, and refusing unknown names is the
|
|
// safe posture. The configured value is never logged verbatim — see
|
|
// redactFallbackURL.
|
|
func NewRegistryAuthorizer(q Querier, fallbackURL string, logger *slog.Logger) *RegistryAuthorizer {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
a := &RegistryAuthorizer{
|
|
q: q,
|
|
httpClient: &http.Client{Timeout: fallbackTimeout},
|
|
logger: logger,
|
|
}
|
|
if fallbackURL == "" {
|
|
return a
|
|
}
|
|
parsed, err := url.Parse(fallbackURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
// Neither the raw value nor url.Parse's error may be logged: the
|
|
// configured URL can carry userinfo credentials
|
|
// (https://user:pass@host/ask), and the parse error quotes its input
|
|
// verbatim. reason carries the diagnosis the raw value would have.
|
|
reason := "missing scheme or host"
|
|
if err != nil {
|
|
reason = "not a parseable URL"
|
|
}
|
|
logger.Error("domains-ask-fallback-url is not a usable absolute URL; registry misses will be refused",
|
|
slog.String("url", redactFallbackURL(parsed)), slog.String("reason", reason))
|
|
return a
|
|
}
|
|
a.fallback = parsed
|
|
return a
|
|
}
|
|
|
|
// redactFallbackURL projects a misconfigured fallback URL onto the only
|
|
// field that cannot have absorbed a secret: Host, which url.Parse fills with
|
|
// host:port and nothing else. Userinfo, path, and query are dropped rather
|
|
// than masked, and so is the scheme — in a value that never split into
|
|
// scheme and host, the scheme is where a username lands ("user:pass@host"
|
|
// parses as scheme "user"). A value that yielded no host at all (including
|
|
// the nil url.Parse returns on failure) has nothing safe left to report.
|
|
func redactFallbackURL(u *url.URL) string {
|
|
if u == nil || u.Host == "" {
|
|
return "(no host)"
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
// AuthorizeDomain answers the serving-authorization question for exactly
|
|
// fqdn (the caller normalizes; see dnsname.Normalize). It implements the
|
|
// structural server.DomainAuthorizer contract.
|
|
func (a *RegistryAuthorizer) AuthorizeDomain(ctx context.Context, fqdn string) (bool, error) {
|
|
placement, err := a.q.GetPlacementByFQDN(ctx, fqdn)
|
|
if err == nil {
|
|
// A row decides locally, either way.
|
|
return placement.Servable, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return false, err
|
|
}
|
|
return a.askFallback(ctx, fqdn), nil
|
|
}
|
|
|
|
// askFallback forwards a registry miss to the configured legacy answerer,
|
|
// authorizing iff it answers 200. Every other outcome — no fallback, a name
|
|
// that is not a well-formed FQDN, a transport error, a timeout, a non-200 —
|
|
// refuses. Errors are swallowed rather than returned: a broken legacy
|
|
// answerer is a refusal, not a 500 on the ask endpoint.
|
|
func (a *RegistryAuthorizer) askFallback(ctx context.Context, fqdn string) bool {
|
|
if a.fallback == nil {
|
|
return false
|
|
}
|
|
// Shape-check before any outbound call: the ask route is
|
|
// unauthenticated, so this caps the amplification an arbitrary caller
|
|
// can aim at the legacy answerer (design D5).
|
|
if msg := dnsname.ValidateExternalFQDN(fqdn, nil); msg != "" {
|
|
a.logger.Debug("domain ask fallback skipped: name is not a well-formed FQDN",
|
|
slog.String("domain", fqdn), slog.String("reason", msg))
|
|
return false
|
|
}
|
|
|
|
// Copy the configured URL and set the parameter through url.Values, so
|
|
// the attacker-controllable name is escaped by the URL library and any
|
|
// query the operator configured on the fallback URL survives.
|
|
target := *a.fallback
|
|
query := target.Query()
|
|
query.Set("domain", fqdn)
|
|
target.RawQuery = query.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
|
if err != nil {
|
|
a.logger.Info("domain ask fallback request could not be built",
|
|
slog.String("domain", fqdn), slog.Any("error", err))
|
|
return false
|
|
}
|
|
resp, err := a.httpClient.Do(req)
|
|
if err != nil {
|
|
a.logger.Info("domain ask fallback unreachable; refusing",
|
|
slog.String("domain", fqdn), slog.Any("error", err))
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, fallbackBodyLimit))
|
|
if resp.StatusCode != http.StatusOK {
|
|
a.logger.Debug("domain ask fallback refused",
|
|
slog.String("domain", fqdn), slog.Int("status", resp.StatusCode))
|
|
return false
|
|
}
|
|
a.logger.Debug("domain ask fallback authorized a registry miss",
|
|
slog.String("domain", fqdn))
|
|
return true
|
|
}
|