carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
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
58const (
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.
77const (
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
86var 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>
94body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:20vh auto 0;padding:0 1.5rem;color:#222}
95h1{font-size:1.25rem;font-weight:600;margin:0 0 .5rem}
96p{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.
137func 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.
161func 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
173func 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.
184func (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.
198func (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.
211func (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