| 1 | package edge |
| 2 | |
| 3 | import ( |
| 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. |
| 22 | func 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. |
| 37 | func 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. |
| 65 | func 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. |
| 125 | func 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. |
| 145 | func 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. |
| 194 | func 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 | |