| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "html/template" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // The "not here YET" answer (#174): what an edge says for an unknown host |
| 11 | // under one of this deployment's own domains. |
| 12 | // |
| 13 | // A host the platform just created is not servable everywhere at once. Its |
| 14 | // home box starts serving it when the route lands in that box's registry; |
| 15 | // every other edge learns where to backhaul it from the home box's edges file |
| 16 | // (homemap.go), which reaches it seconds to half a minute later. A visitor |
| 17 | // steered to one of those edges in that window used to get unknownHost's 404 |
| 18 | // (or the fallback redirect): a dead end for an address that would have |
| 19 | // worked a minute later. titogo hit it on every account's first sandbox: the |
| 20 | // app's readiness probe asked the home edge, got a 200, and redirected the |
| 21 | // browser to a different edge that did not know the host yet. |
| 22 | // |
| 23 | // So a host under a platform domain gets 503 + Retry-After and a page that |
| 24 | // retries by itself, and a host anywhere else keeps the old answer exactly. |
| 25 | // The edge cannot tell "created a moment ago" from "never existed" — no edge |
| 26 | // knows about a host before its home box does — so a typo'd or retired |
| 27 | // subdomain gets the retry page too. That is why the retries are bounded: |
| 28 | // after pendingRetryWindow the page gives up and shows the not-found text, |
| 29 | // or goes to the fallback URL on an edge configured with one. A retired |
| 30 | // address therefore still lands where it always did, pendingRetryWindow |
| 31 | // later. |
| 32 | // |
| 33 | // Platform domains are the ones the operator configured: the static wildcard |
| 34 | // parents (CARLOS_WILDCARD_DOMAINS, which may be a tenant's parent the |
| 35 | // operator set up, like go.tito.io) and the apps domain |
| 36 | // (PLATFORM_APPS_DOMAIN), read once at start. A customer's own domain stays on |
| 37 | // the plain 404, because a customer domain pointing at the edge before its |
| 38 | // alias is attached is a configuration state, not propagation lag. |
| 39 | // |
| 40 | // The retry state (when this tab first saw the page, and when it last did) |
| 41 | // lives in sessionStorage, and in window.name where sessionStorage throws |
| 42 | // (cookies blocked, some embedded webviews). Both survive a reload in one |
| 43 | // tab and nothing else. If neither works, the page cannot tell its first |
| 44 | // load from its hundredth, so it gives up on the first: a visitor with no |
| 45 | // storage sees the not-found text or the fallback at once, never an endless |
| 46 | // reload. The reload itself is |
| 47 | // location.replace, so a form POST that landed here is not resubmitted and |
| 48 | // the waiting page does not fill the back button. |
| 49 | // |
| 50 | // Crawlers see 503 for as long as a retired platform subdomain is asked |
| 51 | // for, so they never de-index it; noindex keeps it out of results |
| 52 | // meanwhile. That is the accepted cost of not knowing "new" from "gone". |
| 53 | // |
| 54 | // The page never echoes the host or anything else from the request, so it |
| 55 | // carries nothing a request could inject. What changes with the request is |
| 56 | // the status line only. |
| 57 | |
| 58 | const ( |
| 59 | // pendingRetryAfter is the Retry-After an API client gets, and the |
| 60 | // page's own retry interval, in seconds. |
| 61 | pendingRetryAfter = "5" |
| 62 | // pendingRetryWindow is how long the page keeps retrying, in |
| 63 | // milliseconds, measured from the visitor's first sight of it in this |
| 64 | // tab. Propagation is at most about half a minute once the home box |
| 65 | // serves the host; 90s leaves room for the home box itself still |
| 66 | // converging. |
| 67 | pendingRetryWindowMS = 90_000 |
| 68 | // pendingEpisodeGapMS ends one episode of waiting. A tab that last saw |
| 69 | // the page longer ago than this (because the host came up and it moved |
| 70 | // on, say) starts a fresh 90s window the next time, rather than giving |
| 71 | // up at once on a stale start time. Three retry intervals, so a slow |
| 72 | // reload never splits one episode in two. |
| 73 | pendingEpisodeGapMS = 15_000 |
| 74 | ) |
| 75 | |
| 76 | // The page's words. Kept together so a copy change is one place. |
| 77 | const ( |
| 78 | pendingTitle = "Almost ready" |
| 79 | pendingHeading = "This address is still being set up" |
| 80 | pendingBody = "It usually takes less than a minute. This page will try again on its own." |
| 81 | pendingNoscript = "Reload this page in a minute." |
| 82 | goneHeading = "Nothing is answering at this address" |
| 83 | goneBody = "Check the address and try again." |
| 84 | ) |
| 85 | |
| 86 | var pendingPage = template.Must(template.New("pending").Parse(`<!doctype html> |
| 87 | <html lang="en"> |
| 88 | <head> |
| 89 | <meta charset="utf-8"> |
| 90 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 91 | <meta name="robots" content="noindex"> |
| 92 | <title>{{.Title}}</title> |
| 93 | <style> |
| 94 | body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:20vh auto 0;padding:0 1.5rem;color:#222} |
| 95 | h1{font-size:1.25rem;font-weight:600;margin:0 0 .5rem} |
| 96 | p{margin:0;color:#555} |
| 97 | </style> |
| 98 | </head> |
| 99 | <body> |
| 100 | <h1 id="h">{{.Heading}}</h1> |
| 101 | <p id="b">{{.Body}}</p> |
| 102 | <noscript><p>{{.Noscript}}</p></noscript> |
| 103 | <script> |
| 104 | (function(){ |
| 105 | var k="carlos-pending:"+location.host, now=Date.now(), st=null; |
| 106 | function load(){ |
| 107 | try{return JSON.parse(sessionStorage.getItem(k))}catch(e){} |
| 108 | try{var w=JSON.parse(window.name);if(w&&w.k===k)return w}catch(e){} |
| 109 | return null; |
| 110 | } |
| 111 | function save(v){ |
| 112 | try{sessionStorage.setItem(k,JSON.stringify(v));return true}catch(e){} |
| 113 | try{v.k=k;window.name=JSON.stringify(v);return load()!==null}catch(e){} |
| 114 | return false; |
| 115 | } |
| 116 | st=load(); |
| 117 | if(!st||!(now-st.last<={{.GapMS}}))st={first:now}; |
| 118 | st.last=now; |
| 119 | if(now-st.first<{{.WindowMS}}&&save(st)){ |
| 120 | setTimeout(function(){location.replace(location.href)},{{.RetryMS}}); |
| 121 | return; |
| 122 | } |
| 123 | save({first:0,last:0}); |
| 124 | var f={{.Fallback}}; |
| 125 | if(f){location.replace(f);return} |
| 126 | document.title={{.GoneHeading}}; |
| 127 | document.getElementById("h").textContent={{.GoneHeading}}; |
| 128 | document.getElementById("b").textContent={{.GoneBody}}; |
| 129 | })(); |
| 130 | </script> |
| 131 | </body> |
| 132 | </html> |
| 133 | `)) |
| 134 | |
| 135 | // renderPendingPage renders the page once per edge: its only inputs are |
| 136 | // constants and the fallback URL, which is fixed at start. |
| 137 | func renderPendingPage(fallback string) []byte { |
| 138 | var buf bytes.Buffer |
| 139 | err := pendingPage.Execute(&buf, map[string]any{ |
| 140 | "Title": pendingTitle, |
| 141 | "Heading": pendingHeading, |
| 142 | "Body": pendingBody, |
| 143 | "Noscript": pendingNoscript, |
| 144 | "GoneHeading": goneHeading, |
| 145 | "GoneBody": goneBody, |
| 146 | "WindowMS": pendingRetryWindowMS, |
| 147 | "RetryMS": 5000, |
| 148 | "GapMS": pendingEpisodeGapMS, |
| 149 | "Fallback": fallback, |
| 150 | }) |
| 151 | if err != nil { |
| 152 | // The template and its inputs are fixed at build time; a failure |
| 153 | // here is a programming error, caught by the package's tests. |
| 154 | panic(err) |
| 155 | } |
| 156 | return buf.Bytes() |
| 157 | } |
| 158 | |
| 159 | // platformDomainsFromEnv reads the domains the pending answer covers: the |
| 160 | // static wildcard parents and the apps domain, lowercased and deduplicated. |
| 161 | func platformDomainsFromEnv(getenv func(string) string) []string { |
| 162 | var out []string |
| 163 | for _, d := range append(splitDomains(getenv("CARLOS_WILDCARD_DOMAINS")), |
| 164 | splitDomains(getenv("PLATFORM_APPS_DOMAIN"))...) { |
| 165 | d = strings.TrimSuffix(d, ".") |
| 166 | if d != "" && !containsString(out, d) { |
| 167 | out = append(out, d) |
| 168 | } |
| 169 | } |
| 170 | return out |
| 171 | } |
| 172 | |
| 173 | func containsString(list []string, s string) bool { |
| 174 | for _, v := range list { |
| 175 | if v == s { |
| 176 | return true |
| 177 | } |
| 178 | } |
| 179 | return false |
| 180 | } |
| 181 | |
| 182 | // setPlatformDomains arms the pending answer for hosts under domains, and |
| 183 | // renders its page against the fallback URL, which must already be set. |
| 184 | func (e *Edge) setPlatformDomains(domains []string) { |
| 185 | e.platformDomains = domains |
| 186 | e.pendingBody = renderPendingPage(e.fallbackURL) |
| 187 | } |
| 188 | |
| 189 | // underPlatformDomain reports whether host sits strictly BELOW one of the |
| 190 | // platform domains. The domain itself is excluded: an unrouted apex is an |
| 191 | // operator's configuration, not a host waiting to propagate. |
| 192 | // |
| 193 | // Only a host in the form the route table keys on counts: lowercase, no |
| 194 | // trailing dot. Routing matches r.Host byte for byte (lookupIn), so |
| 195 | // "Acme.go.tito.io" or "acme.go.tito.io." misses even when acme.go.tito.io |
| 196 | // is served, and telling that visitor "still being set up" for 90 seconds |
| 197 | // would be wrong. It keeps the plain unknown-host answer it always had. |
| 198 | func (e *Edge) underPlatformDomain(host string) bool { |
| 199 | if host == "" || host != strings.ToLower(host) || strings.HasSuffix(host, ".") { |
| 200 | return false |
| 201 | } |
| 202 | for _, d := range e.platformDomains { |
| 203 | if strings.HasSuffix(host, "."+d) { |
| 204 | return true |
| 205 | } |
| 206 | } |
| 207 | return false |
| 208 | } |
| 209 | |
| 210 | // pendingHost writes the 503 answer. HEAD gets the headers alone. |
| 211 | func (e *Edge) pendingHost(w http.ResponseWriter, r *http.Request) { |
| 212 | h := w.Header() |
| 213 | h.Set("Content-Type", "text/html; charset=utf-8") |
| 214 | // no-store: this answer is about a moment, and a cache holding it would |
| 215 | // keep a visitor on it after the host became servable. |
| 216 | h.Set("Cache-Control", "no-store") |
| 217 | h.Set("Retry-After", pendingRetryAfter) |
| 218 | w.WriteHeader(http.StatusServiceUnavailable) |
| 219 | if r.Method != http.MethodHead { |
| 220 | w.Write(e.pendingBody) //nolint:errcheck |
| 221 | } |
| 222 | } |
| 223 | |