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.
107 lines
4.2 KiB
Go
107 lines
4.2 KiB
Go
// Package dnsname holds the DNS shape rules and normalization the console
|
|
// applies to every name it stores or serves: label/FQDN validation (RFC 1035
|
|
// §2.3.1 as amended by RFC 1123 §2.1, and the §2.3.4 length limit),
|
|
// normalization, and the label reversal the registry indexes subtrees by.
|
|
//
|
|
// It is a leaf: standard library only. internal/server and internal/domains
|
|
// both consume it, which is what lets internal/domains stay out of
|
|
// internal/server's import graph (internal/server imports internal/domains
|
|
// for Config wiring, so the reverse edge cannot exist).
|
|
//
|
|
// Validators return a user-facing message, or "" when the name is valid.
|
|
package dnsname
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// validLabel enforces the DNS label rules from RFC 1035 §2.3.1 (as amended
|
|
// by RFC 1123 §2.1): lowercase alphanumeric, may contain hyphens in the
|
|
// middle, must start and end with a letter or number. 1-63 chars.
|
|
//
|
|
// Callers must normalize input (see Normalize) before matching, as this
|
|
// pattern only matches lowercase.
|
|
var validLabel = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
|
|
|
// Normalize applies the console-wide name normalization: trim surrounding
|
|
// whitespace, lowercase, and strip one trailing dot (the root label of an
|
|
// absolute name). Every stored or compared name passes through this first.
|
|
func Normalize(name string) string {
|
|
return strings.TrimSuffix(strings.TrimSpace(strings.ToLower(name)), ".")
|
|
}
|
|
|
|
// ReverseLabels returns name with its labels in reverse order —
|
|
// "alice.wiki.cafe" becomes "cafe.wiki.alice". Reversed form turns a
|
|
// subtree into a string prefix, so descendant lookups become prefix scans
|
|
// over a text_pattern_ops index. Callers pass normalized names; the
|
|
// reversal itself does not normalize.
|
|
func ReverseLabels(name string) string {
|
|
labels := strings.Split(name, ".")
|
|
for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 {
|
|
labels[i], labels[j] = labels[j], labels[i]
|
|
}
|
|
return strings.Join(labels, ".")
|
|
}
|
|
|
|
// ValidateDNSLabel validates a single DNS label. It checks length before
|
|
// pattern to provide specific error messages per RFC 1035 §2.3.1.
|
|
func ValidateDNSLabel(name string) string {
|
|
if len(name) > 63 {
|
|
return "Site name exceeds the 63-character limit"
|
|
}
|
|
if !validLabel.MatchString(name) {
|
|
return "Site name may only contain lowercase letters, numbers, and hyphens, and must start and end with a letter or number"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ValidateFQDN checks that the assembled FQDN (label + "." + parent) does
|
|
// not exceed 253 characters per RFC 1035 §2.3.4. The 63-char label limit
|
|
// already prevents overflow for any reasonable parent domain; this is a
|
|
// defense-in-depth measure.
|
|
func ValidateFQDN(label, parent string) string {
|
|
// FQDN = label + "." + parent
|
|
fqdnLen := len(label) + 1 + len(parent)
|
|
if fqdnLen > 253 {
|
|
return fmt.Sprintf("The full domain name (%s.%s) exceeds the 253-character limit", label, parent)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ValidateExternalFQDN checks a member-supplied external (bring-your-own)
|
|
// domain name: a fully-qualified name of at least two labels, each
|
|
// satisfying the RFC 1035 §2.3.1 label rules (as amended by RFC 1123 §2.1),
|
|
// within the RFC 1035 §2.3.4 253-character presentation limit. Names equal
|
|
// to or inside any of operatorRoots are rejected — those are creatable only
|
|
// through the hosted path, where label policy and quota gating apply. Pass
|
|
// a nil or empty operatorRoots to check shape only. Callers must pass a
|
|
// normalized name (see Normalize).
|
|
func ValidateExternalFQDN(domain string, operatorRoots []string) string {
|
|
if domain == "" {
|
|
return "Domain is required"
|
|
}
|
|
if len(domain) > 253 {
|
|
return fmt.Sprintf("The domain name (%s) exceeds the 253-character limit", domain)
|
|
}
|
|
labels := strings.Split(domain, ".")
|
|
if len(labels) < 2 {
|
|
return "Enter a full domain name (e.g., wiki.example.org)"
|
|
}
|
|
for _, label := range labels {
|
|
if !validLabel.MatchString(label) {
|
|
return "Domain labels may only contain lowercase letters, numbers, and hyphens, and must start and end with a letter or number"
|
|
}
|
|
}
|
|
for _, root := range operatorRoots {
|
|
if root == "" {
|
|
continue
|
|
}
|
|
if domain == root || strings.HasSuffix(domain, "."+root) {
|
|
return fmt.Sprintf("Names under %s are created as regular sites, not custom domains", root)
|
|
}
|
|
}
|
|
return ""
|
|
}
|