package edge import ( "bytes" "html/template" "net/http" "strings" ) // The "not here YET" answer (#174): what an edge says for an unknown host // under one of this deployment's own domains. // // A host the platform just created is not servable everywhere at once. Its // home box starts serving it when the route lands in that box's registry; // every other edge learns where to backhaul it from the home box's edges file // (homemap.go), which reaches it seconds to half a minute later. A visitor // steered to one of those edges in that window used to get unknownHost's 404 // (or the fallback redirect): a dead end for an address that would have // worked a minute later. titogo hit it on every account's first sandbox: the // app's readiness probe asked the home edge, got a 200, and redirected the // browser to a different edge that did not know the host yet. // // So a host under a platform domain gets 503 + Retry-After and a page that // retries by itself, and a host anywhere else keeps the old answer exactly. // The edge cannot tell "created a moment ago" from "never existed" — no edge // knows about a host before its home box does — so a typo'd or retired // subdomain gets the retry page too. That is why the retries are bounded: // after pendingRetryWindow the page gives up and shows the not-found text, // or goes to the fallback URL on an edge configured with one. A retired // address therefore still lands where it always did, pendingRetryWindow // later. // // Platform domains are the ones the operator configured: the static wildcard // parents (CARLOS_WILDCARD_DOMAINS, which may be a tenant's parent the // operator set up, like go.tito.io) and the apps domain // (PLATFORM_APPS_DOMAIN), read once at start. A customer's own domain stays on // the plain 404, because a customer domain pointing at the edge before its // alias is attached is a configuration state, not propagation lag. // // The retry state (when this tab first saw the page, and when it last did) // lives in sessionStorage, and in window.name where sessionStorage throws // (cookies blocked, some embedded webviews). Both survive a reload in one // tab and nothing else. If neither works, the page cannot tell its first // load from its hundredth, so it gives up on the first: a visitor with no // storage sees the not-found text or the fallback at once, never an endless // reload. The reload itself is // location.replace, so a form POST that landed here is not resubmitted and // the waiting page does not fill the back button. // // Crawlers see 503 for as long as a retired platform subdomain is asked // for, so they never de-index it; noindex keeps it out of results // meanwhile. That is the accepted cost of not knowing "new" from "gone". // // The page never echoes the host or anything else from the request, so it // carries nothing a request could inject. What changes with the request is // the status line only. const ( // pendingRetryAfter is the Retry-After an API client gets, and the // page's own retry interval, in seconds. pendingRetryAfter = "5" // pendingRetryWindow is how long the page keeps retrying, in // milliseconds, measured from the visitor's first sight of it in this // tab. Propagation is at most about half a minute once the home box // serves the host; 90s leaves room for the home box itself still // converging. pendingRetryWindowMS = 90_000 // pendingEpisodeGapMS ends one episode of waiting. A tab that last saw // the page longer ago than this (because the host came up and it moved // on, say) starts a fresh 90s window the next time, rather than giving // up at once on a stale start time. Three retry intervals, so a slow // reload never splits one episode in two. pendingEpisodeGapMS = 15_000 ) // The page's words. Kept together so a copy change is one place. const ( pendingTitle = "Almost ready" pendingHeading = "This address is still being set up" pendingBody = "It usually takes less than a minute. This page will try again on its own." pendingNoscript = "Reload this page in a minute." goneHeading = "Nothing is answering at this address" goneBody = "Check the address and try again." ) var pendingPage = template.Must(template.New("pending").Parse(` {{.Title}}

{{.Heading}}

{{.Body}}

`)) // renderPendingPage renders the page once per edge: its only inputs are // constants and the fallback URL, which is fixed at start. func renderPendingPage(fallback string) []byte { var buf bytes.Buffer err := pendingPage.Execute(&buf, map[string]any{ "Title": pendingTitle, "Heading": pendingHeading, "Body": pendingBody, "Noscript": pendingNoscript, "GoneHeading": goneHeading, "GoneBody": goneBody, "WindowMS": pendingRetryWindowMS, "RetryMS": 5000, "GapMS": pendingEpisodeGapMS, "Fallback": fallback, }) if err != nil { // The template and its inputs are fixed at build time; a failure // here is a programming error, caught by the package's tests. panic(err) } return buf.Bytes() } // platformDomainsFromEnv reads the domains the pending answer covers: the // static wildcard parents and the apps domain, lowercased and deduplicated. func platformDomainsFromEnv(getenv func(string) string) []string { var out []string for _, d := range append(splitDomains(getenv("CARLOS_WILDCARD_DOMAINS")), splitDomains(getenv("PLATFORM_APPS_DOMAIN"))...) { d = strings.TrimSuffix(d, ".") if d != "" && !containsString(out, d) { out = append(out, d) } } return out } func containsString(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false } // setPlatformDomains arms the pending answer for hosts under domains, and // renders its page against the fallback URL, which must already be set. func (e *Edge) setPlatformDomains(domains []string) { e.platformDomains = domains e.pendingBody = renderPendingPage(e.fallbackURL) } // underPlatformDomain reports whether host sits strictly BELOW one of the // platform domains. The domain itself is excluded: an unrouted apex is an // operator's configuration, not a host waiting to propagate. // // Only a host in the form the route table keys on counts: lowercase, no // trailing dot. Routing matches r.Host byte for byte (lookupIn), so // "Acme.go.tito.io" or "acme.go.tito.io." misses even when acme.go.tito.io // is served, and telling that visitor "still being set up" for 90 seconds // would be wrong. It keeps the plain unknown-host answer it always had. func (e *Edge) underPlatformDomain(host string) bool { if host == "" || host != strings.ToLower(host) || strings.HasSuffix(host, ".") { return false } for _, d := range e.platformDomains { if strings.HasSuffix(host, "."+d) { return true } } return false } // pendingHost writes the 503 answer. HEAD gets the headers alone. func (e *Edge) pendingHost(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Type", "text/html; charset=utf-8") // no-store: this answer is about a moment, and a cache holding it would // keep a visitor on it after the host became servable. h.Set("Cache-Control", "no-store") h.Set("Retry-After", pendingRetryAfter) w.WriteHeader(http.StatusServiceUnavailable) if r.Method != http.MethodHead { w.Write(e.pendingBody) //nolint:errcheck } }