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 "context"
5 "crypto/tls"
6 "crypto/x509"
7 "errors"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httptest"
12 "reflect"
13 "strings"
14 "testing"
15 "time"
16
17 "golang.org/x/crypto/acme/autocert"
18)
19
20// steeringEdge is an Edge with the steering environment armed, built through
21// New so the derivation under test is the production one.
22func steeringEdge(t *testing.T, zone, appsDomain, fallback string) *Edge {
23 t.Helper()
24 t.Setenv("CARLOS_STEERING_ZONE_ID", zone)
25 t.Setenv("PLATFORM_APPS_DOMAIN", appsDomain)
26 e, err := New(openReg(t), nil)
27 if err != nil {
28 t.Fatal(err)
29 }
30 e.fallbackURL = fallback
31 return e
32}
33
34// TestSteeringProbeHostFromEnv: the hostname is derived only where the
35// deployment actually steers. A missing or malformed zone id — every box
36// today — leaves it empty, which is the whole inertness claim.
37func TestSteeringProbeHostFromEnv(t *testing.T) {
38 cases := []struct{ name, zone, domain, want string }{
39 {"nothing set (every deployment today)", "", "", ""},
40 {"apps domain alone is NOT the gate", "", "oncarlos.com", ""},
41 {"malformed zone id stays dormant", "/hostedzone/Z123", "oncarlos.com", ""},
42 {"armed", "Z0ABCDEF1234", "oncarlos.com", "edge.oncarlos.com"},
43 // The dotted form is the two-reads trap: it must normalize ONCE, so
44 // that the probe host and the wildcard parent cannot disagree. Covered
45 // end-to-end by TestSteeringArmsTheAppsDomainWildcardParent.
46 {"armed, case and dot normalized", "Z0ABCDEF1234", " OnCarlos.com. ", "edge.oncarlos.com"},
47 {"armed with no apps domain: nothing to build from", "Z0ABCDEF1234", "", ""},
48 }
49 for _, c := range cases {
50 t.Run(c.name, func(t *testing.T) {
51 t.Setenv("CARLOS_STEERING_ZONE_ID", c.zone)
52 t.Setenv("PLATFORM_APPS_DOMAIN", c.domain)
53 if got := steeringProbeHostFromEnv(); got != c.want {
54 t.Errorf("steeringProbeHostFromEnv() = %q, want %q", got, c.want)
55 }
56 })
57 }
58}
59
60// TestSteeringProbeAnswersAheadOfTheFallback is the health-probe gate itself:
61// on a steering deployment the check's exact request (GET / with the steering
62// hostname) is answered 200 even though a fallback URL is configured — the
63// 302 that fallback would otherwise return is what Route53 scores as healthy,
64// which would make the withdrawal decorative.
65func TestSteeringProbeAnswersAheadOfTheFallback(t *testing.T) {
66 e := steeringEdge(t, "Z0ABCDEF1234", "example.com", "https://marketing.example.net")
67
68 get := func(method, host, path string) *httptest.ResponseRecorder {
69 t.Helper()
70 r := httptest.NewRequest(method, "http://"+host+path, nil)
71 r.Host = host
72 w := httptest.NewRecorder()
73 e.ServeHTTP(w, r)
74 return w
75 }
76
77 w := get("GET", "edge.example.com", "/")
78 if w.Code != http.StatusOK {
79 t.Fatalf("GET / on the steering host = %d, want 200 (a --fallback edge must not 302 the health check)", w.Code)
80 }
81 if w.Body.String() != steeringProbeBody {
82 t.Errorf("body = %q, want %q", w.Body.String(), steeringProbeBody)
83 }
84 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
85 t.Errorf("Content-Type = %q, want text/plain", ct)
86 }
87 if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
88 t.Errorf("Cache-Control = %q, want no-store", cc)
89 }
90
91 // Port and trailing dot are both legal ways for a client to name it.
92 if w := get("GET", "EDGE.example.com.:443", "/"); w.Code != http.StatusOK {
93 t.Errorf("case/dot/port-qualified host = %d, want 200", w.Code)
94 }
95
96 // HEAD answers, with no body (the health check may use either).
97 if w := get("HEAD", "edge.example.com", "/"); w.Code != http.StatusOK || w.Body.Len() != 0 {
98 t.Errorf("HEAD / = %d body %q, want 200 with no body", w.Code, w.Body.String())
99 }
100
101 // The probe is an ANSWER, not a reserved hostname: everything else on
102 // that host takes the ordinary path (here: the fallback redirect).
103 for _, c := range []struct{ method, path string }{
104 {"GET", "/other"},
105 {"POST", "/"},
106 } {
107 if w := get(c.method, "edge.example.com", c.path); w.Code != http.StatusFound {
108 t.Errorf("%s %s on the steering host = %d, want the ordinary 302 fallback", c.method, c.path, w.Code)
109 }
110 }
111
112 // :80 answers the same way, for the same reason.
113 r := httptest.NewRequest("GET", "http://edge.example.com/", nil)
114 r.Host = "edge.example.com"
115 w80 := httptest.NewRecorder()
116 e.httpFallbackHandler().ServeHTTP(w80, r)
117 if w80.Code != http.StatusOK || w80.Body.String() != steeringProbeBody {
118 t.Errorf(":80 GET / = %d %q, want 200 %q", w80.Code, w80.Body.String(), steeringProbeBody)
119 }
120}
121
122// TestSteeringProbeInertWithoutSteering: with no steering zone the edge is
123// byte-identical to what it was — the steering hostname is just another
124// unknown host, redirected to the fallback on both listeners.
125func TestSteeringProbeInertWithoutSteering(t *testing.T) {
126 e := steeringEdge(t, "", "example.com", "https://marketing.example.net")
127 if e.steeringHost != "" {
128 t.Fatalf("steeringHost = %q on an unsteered deployment, want empty", e.steeringHost)
129 }
130 for _, h := range []http.Handler{e, e.httpFallbackHandler()} {
131 r := httptest.NewRequest("GET", "http://edge.example.com/", nil)
132 r.Host = "edge.example.com"
133 w := httptest.NewRecorder()
134 h.ServeHTTP(w, r)
135 if w.Code != http.StatusFound || w.Header().Get("Location") != "https://marketing.example.net" {
136 t.Errorf("unsteered edge: %d %q, want the unchanged 302 fallback", w.Code, w.Header().Get("Location"))
137 }
138 }
139}
140
141// TestSteeringArmsTheAppsDomainWildcardParent: the cert half. The health
142// check handshakes with SNI edge.<apps-domain>, one label SHALLOWER than the
143// per-account parents, so the apps domain itself must join the parent set —
144// and only when the deployment steers.
145func TestSteeringArmsTheAppsDomainWildcardParent(t *testing.T) {
146 cache := autocert.DirCache(t.TempDir())
147 t.Setenv("CARLOS_WILDCARD_DOMAINS", "")
148 // DOTTED on purpose: PLATFORM_APPS_DOMAIN with a trailing dot is legal
149 // input, newWildcardFromEnv's own Getenv does not trim it, and the parent
150 // must still come out as the name certFor can match. Two reads of the
151 // environment put the probe answer on edge.oncarlos.com and the parent on
152 // "oncarlos.com." — no cert, every health check failing.
153 t.Setenv("PLATFORM_APPS_DOMAIN", "oncarlos.com.")
154 t.Setenv("CARLOS_DNS_PROVIDER", "")
155 t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
156
157 e := &Edge{}
158 e.upstreams.Store(&map[string]upstream{})
159 w := newWildcardFromEnv(e, cache)
160 if w == nil {
161 t.Fatal("want a manager")
162 }
163 if len(w.staticDomains) != 0 {
164 t.Fatalf("unsteered staticDomains = %v, want none (the apps domain must not become a parent)", w.staticDomains)
165 }
166 if got := parentsFor(w.staticDomains, w.appsDomain, nil); len(got) != 0 {
167 t.Fatalf("unsteered parents = %v, want none", got)
168 }
169
170 // Armed through the SAME derivation production uses — not a hand-set
171 // field — so the normalization under test is the real one.
172 t.Setenv("CARLOS_STEERING_ZONE_ID", "Z0ABCDEF1234")
173 e.steeringHost = steeringProbeHostFromEnv()
174 if e.steeringHost != "edge.oncarlos.com" {
175 t.Fatalf("steeringHost = %q, want edge.oncarlos.com", e.steeringHost)
176 }
177 w = newWildcardFromEnv(e, cache)
178 if !reflect.DeepEqual(w.staticDomains, []string{"oncarlos.com"}) {
179 t.Fatalf("steered staticDomains = %v, want [oncarlos.com]", w.staticDomains)
180 }
181 // The parent has to cover the probe name exactly.
182 w.parents = parentsFor(w.staticDomains, w.appsDomain, nil)
183 cert, _ := selfSignedWildcard(t, "oncarlos.com", time.Now().Add(time.Hour))
184 w.certs["oncarlos.com"] = cert
185 if w.certFor("edge.oncarlos.com") == nil {
186 t.Error("the steering hostname must be covered by the apps-domain wildcard")
187 }
188}
189
190// TestSteeringProbeOverTLS is the end-to-end shape the Route53 health check
191// performs: a real handshake for edge.<apps-domain>, verified by name against
192// the issuing certificate, followed by GET / — the pair that has to answer
193// 200 for a withdrawal to mean anything.
194func TestSteeringProbeOverTLS(t *testing.T) {
195 e := steeringEdge(t, "Z0ABCDEF1234", "example.com", "https://marketing.example.net")
196
197 cert, _ := selfSignedWildcard(t, "example.com", time.Now().Add(time.Hour))
198 w := &wildcardManager{
199 parents: []string{"example.com"},
200 certs: map[string]*tls.Certificate{"example.com": cert},
201 }
202 srv := httptest.NewUnstartedServer(e)
203 srv.TLS = &tls.Config{GetCertificate: wrapGetCertificate(w, func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
204 // No per-host path in this test: if the wildcard does not cover the
205 // name, the handshake fails exactly as it would on a live box.
206 return nil, errors.New("no per-host certificate")
207 })}
208 srv.StartTLS()
209 t.Cleanup(srv.Close)
210
211 pool := x509.NewCertPool()
212 pool.AddCert(cert.Leaf)
213 client := &http.Client{
214 Timeout: 10 * time.Second,
215 Transport: &http.Transport{
216 TLSClientConfig: &tls.Config{RootCAs: pool},
217 DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
218 return net.Dial(network, srv.Listener.Addr().String())
219 },
220 },
221 }
222 resp, err := client.Get("https://edge.example.com/")
223 if err != nil {
224 t.Fatalf("handshake + GET against the steering hostname: %v", err)
225 }
226 defer resp.Body.Close()
227 body, _ := io.ReadAll(resp.Body)
228 if resp.StatusCode != http.StatusOK || string(body) != steeringProbeBody {
229 t.Fatalf("GET https://edge.example.com/ = %d %q, want 200 %q", resp.StatusCode, body, steeringProbeBody)
230 }
231}
232