| 1 | package console |
| 2 | |
| 3 | // Instance catch-alls (domaincatchall.go): "*.<parent>" claimed through the |
| 4 | // domains door, serving each instance at <label>.<parent>. What matters, in |
| 5 | // the order it would hurt if lost: only a person, in the account that holds |
| 6 | // the parent, can make one; the deployment's own names can never be a |
| 7 | // parent; while one is live, no other account can claim a name it serves; |
| 8 | // detach never takes the first-route branch; and listing it reports only |
| 9 | // names that are provably this app's. |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "encoding/json" |
| 14 | "errors" |
| 15 | "net/http" |
| 16 | "net/url" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/domains" |
| 22 | "github.com/carlosframework/platform/internal/provision" |
| 23 | "github.com/carlosframework/platform/internal/status" |
| 24 | ) |
| 25 | |
| 26 | // catchallFixture is the testbed in miniature: account bab holds |
| 27 | // teams.example.com through another app, and titogo has instances under |
| 28 | // go.tito.io. |
| 29 | func catchallFixture(t *testing.T, code string) (*consoleFixture, string) { |
| 30 | t.Helper() |
| 31 | fx, member := instanceFixture(t, code) |
| 32 | noCatchallProof(fx) |
| 33 | enableInstances(t, fx, "titogo", titoManifest()) |
| 34 | if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{ |
| 35 | Host: "teams.example.com", Account: "bab", App: "site", |
| 36 | CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| 37 | }); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | return fx, member |
| 41 | } |
| 42 | |
| 43 | // noCatchallProof makes every ownership-proof lookup miss, so no test asks |
| 44 | // real DNS. proveCatchall publishes one for a parent and account. |
| 45 | func noCatchallProof(fx *consoleFixture) { |
| 46 | fx.srv.lookupTXT = func(context.Context, string) ([]string, error) { return nil, errors.New("no such record") } |
| 47 | } |
| 48 | |
| 49 | func proveCatchall(fx *consoleFixture, parent, account string) { |
| 50 | fx.srv.lookupTXT = func(_ context.Context, name string) ([]string, error) { |
| 51 | if name == domains.CatchallProofName(parent) { |
| 52 | return []string{"something else", domains.CatchallProofValue(account)}, nil |
| 53 | } |
| 54 | return nil, errors.New("no such record") |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func catchallReq(t *testing.T, fx *consoleFixture, token, body string) (int, string) { |
| 59 | t.Helper() |
| 60 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, body) |
| 61 | return code, out |
| 62 | } |
| 63 | |
| 64 | func TestInstanceCatchallAttachDetach(t *testing.T) { |
| 65 | fx, member := catchallFixture(t, "code-ca-attach") |
| 66 | code, out := catchallReq(t, fx, member, `{"host":"*.Teams.example.com"}`) |
| 67 | if code != http.StatusOK { |
| 68 | t.Fatalf("attach = %d %s", code, out) |
| 69 | } |
| 70 | var got struct { |
| 71 | Host string `json:"host"` |
| 72 | Created bool `json:"created"` |
| 73 | Instances bool `json:"instances"` |
| 74 | } |
| 75 | if err := json.Unmarshal([]byte(out), &got); err != nil || got.Host != "*.teams.example.com" || !got.Created || !got.Instances { |
| 76 | t.Fatalf("attach answer = %s (%v)", out, err) |
| 77 | } |
| 78 | c := claimOf(t, fx, "*.teams.example.com") |
| 79 | if !c.Instances || c.Account != "bab" || c.App != "titogo" || c.Route != "" || c.CreatedBy != "alice@keymail.dev" { |
| 80 | t.Fatalf("claim = %+v", *c) |
| 81 | } |
| 82 | if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK || !strings.Contains(out, `"created":false`) { |
| 83 | t.Fatalf("re-attach = %d %s, want an idempotent 200", code, out) |
| 84 | } |
| 85 | // Detach by the same name, and never through the first-route branch: |
| 86 | // a record at that host (impossible in practice) must survive. |
| 87 | code, _, out = rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/*.teams.example.com", member, "") |
| 88 | if code != http.StatusOK || !strings.Contains(out, `"removed":true`) { |
| 89 | t.Fatalf("detach = %d %s", code, out) |
| 90 | } |
| 91 | claimAbsent(t, fx, "*.teams.example.com") |
| 92 | claimOf(t, fx, "teams.example.com") // the parent is untouched |
| 93 | } |
| 94 | |
| 95 | func TestInstanceCatchallRefusals(t *testing.T) { |
| 96 | fx, member := catchallFixture(t, "code-ca-refuse") |
| 97 | fx.srv.cfg.DenyDomains = []string{"denied.test"} |
| 98 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 99 | fx.srv.cfg.TunnelHost = "hub.tunnels.example.org" |
| 100 | for _, c := range []struct { |
| 101 | what, body string |
| 102 | code int |
| 103 | msg string |
| 104 | }{ |
| 105 | {"a route alongside", `{"host":"*.teams.example.com","route":"go.tito.io"}`, 400, "takes no route"}, |
| 106 | {"wildcard alongside", `{"host":"*.teams.example.com","wildcard":true}`, 400, "takes no route"}, |
| 107 | {"unclaimed parent", `{"host":"*.free.example.com"}`, 409, "attach free.example.com to an app in this account first"}, |
| 108 | {"a deny entry", `{"host":"*.denied.test"}`, 400, "platform-managed"}, |
| 109 | {"under a deny entry", `{"host":"*.x.denied.test"}`, 400, "platform-managed"}, |
| 110 | {"the apps domain", `{"host":"*.oncarlos.test"}`, 400, "platform-managed"}, |
| 111 | {"an account's namespace", `{"host":"*.bab.oncarlos.test"}`, 400, "platform-managed"}, |
| 112 | {"above the tunnel host", `{"host":"*.tunnels.example.org"}`, 400, "platform-managed"}, |
| 113 | {"a public suffix", `{"host":"*.co.uk"}`, 400, "platform-managed"}, |
| 114 | {"the sidecar suffix", `{"host":"*.x.sidecar.internal"}`, 400, "platform-managed"}, |
| 115 | } { |
| 116 | code, out := catchallReq(t, fx, member, c.body) |
| 117 | if code != c.code || !strings.Contains(out, c.msg) { |
| 118 | t.Errorf("%s: %d %s, want %d containing %q", c.what, code, out, c.code, c.msg) |
| 119 | } |
| 120 | } |
| 121 | claimAbsent(t, fx, "*.teams.example.com") |
| 122 | |
| 123 | // The parent held by another account reads exactly as an unclaimed one. |
| 124 | if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{ |
| 125 | Host: "theirs.example.com", Account: "cdef", App: "widgets", CreatedAt: "t", |
| 126 | }); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | _, unclaimed := catchallReq(t, fx, member, `{"host":"*.free.example.com"}`) |
| 130 | _, theirs := catchallReq(t, fx, member, `{"host":"*.theirs.example.com"}`) |
| 131 | if strings.ReplaceAll(unclaimed, "free.", "") != strings.ReplaceAll(theirs, "theirs.", "") { |
| 132 | t.Fatalf("held elsewhere %q reads apart from unclaimed %q", theirs, unclaimed) |
| 133 | } |
| 134 | |
| 135 | // An app with no instance manifest has nothing to serve. |
| 136 | if _, err := fx.st.CreateAppIn(OpsAccountID, "plain", "paul@keymail.dev", time.Now()); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "plain"), member, `{"host":"*.teams.example.com"}`) |
| 140 | if code != http.StatusConflict || !strings.Contains(out, "has no instances") { |
| 141 | t.Fatalf("no manifest: %d %s", code, out) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // A person's act: the app's own credential cannot set or remove one. |
| 146 | func TestInstanceCatchallIsAPersonsAct(t *testing.T) { |
| 147 | fx, member := catchallFixture(t, "code-ca-person") |
| 148 | tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | if code, out := catchallReq(t, fx, tok, `{"host":"*.teams.example.com"}`); code != http.StatusUnauthorized { |
| 153 | t.Fatalf("app token attach = %d %s, want 401", code, out) |
| 154 | } |
| 155 | claimAbsent(t, fx, "*.teams.example.com") |
| 156 | if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK { |
| 157 | t.Fatalf("person attach = %d %s", code, out) |
| 158 | } |
| 159 | code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/*.teams.example.com", tok, "") |
| 160 | if code != http.StatusUnauthorized { |
| 161 | t.Fatalf("app token detach = %d %s, want 401", code, out) |
| 162 | } |
| 163 | claimOf(t, fx, "*.teams.example.com") |
| 164 | // The app token still attaches ordinary hostnames, including one-label |
| 165 | // names under its own account's catch-all. |
| 166 | if got := attachDomain(t, fx, tok, "acme.teams.example.com", "acme.go.tito.io"); !got.Created { |
| 167 | t.Fatalf("same-account attach under the catch-all = %+v", got) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // While a catch-all is live, another account cannot claim the name of one |
| 172 | // of its app's instances, and the refusal is the ordinary neutral 409. Labels |
| 173 | // with no instance stay open (an unproven parent claim must not block a |
| 174 | // whole domain), and a claim made before the catch-all keeps re-attaching. |
| 175 | func TestInstanceCatchallGuardsItsNames(t *testing.T) { |
| 176 | fx, member := catchallFixture(t, "code-ca-guard") |
| 177 | ctx := context.Background() |
| 178 | newInstance := func(label string) { |
| 179 | t.Helper() |
| 180 | if err := provision.CreateRecord(ctx, fx.rel, provision.Record{Schema: 1, Host: label + ".go.tito.io", Slug: label, |
| 181 | App: "titogo", Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | } |
| 185 | newInstance("acme") |
| 186 | now := time.Now() |
| 187 | if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | if _, err := fx.st.CreateAppIn(42, "widgets", "bob@keymail.dev", now); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | bob := cliToken(t, fx, "bob@keymail.dev", "code-ca-guard-bob") |
| 194 | attach := func(host string) (int, string) { |
| 195 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "widgets"), bob, attachBody(host, "w.example.net")) |
| 196 | return code, out |
| 197 | } |
| 198 | // Bob's claim from before the catch-all, on a label that later becomes |
| 199 | // an instance. |
| 200 | if code, out := attach("beta.teams.example.com"); code != http.StatusOK { |
| 201 | t.Fatalf("pre-catch-all attach = %d %s", code, out) |
| 202 | } |
| 203 | if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK { |
| 204 | t.Fatalf("catch-all attach = %d %s", code, out) |
| 205 | } |
| 206 | newInstance("beta") |
| 207 | |
| 208 | if code, out := attach("acme.teams.example.com"); code != http.StatusConflict || strings.TrimSpace(out) != `{"error":"hostname unavailable"}` { |
| 209 | t.Fatalf("foreign attach of a live instance's name = %d %s, want the neutral 409", code, out) |
| 210 | } |
| 211 | claimAbsent(t, fx, "acme.teams.example.com") |
| 212 | if code, out := attach("beta.teams.example.com"); code != http.StatusOK { |
| 213 | t.Fatalf("re-attach of bob's own older claim = %d %s, want 200", code, out) |
| 214 | } |
| 215 | // No instance at that label: not the catch-all's to hold. |
| 216 | if code, out := attach("free.teams.example.com"); code != http.StatusOK { |
| 217 | t.Fatalf("label with no instance = %d %s", code, out) |
| 218 | } |
| 219 | // Deeper names are not the catch-all's either. |
| 220 | if code, out := attach("x.acme.teams.example.com"); code != http.StatusOK { |
| 221 | t.Fatalf("deeper name = %d %s", code, out) |
| 222 | } |
| 223 | // Once the parent is released the catch-all serves nothing and holds |
| 224 | // nothing. |
| 225 | if _, err := domains.DetachClaim(ctx, fx.rel, "teams.example.com", "bab", "site"); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | if code, out := attach("acme.teams.example.com"); code != http.StatusOK { |
| 229 | t.Fatalf("after the parent went = %d %s", code, out) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | // The list reports the catch-all row and, beneath it, only the names that |
| 234 | // are provably this app's: one label under the parent, unclaimed, routed to |
| 235 | // that label under the app's own instance domain. |
| 236 | func TestInstanceCatchallListsItsDerivedNames(t *testing.T) { |
| 237 | fx, member := catchallFixture(t, "code-ca-list") |
| 238 | if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK { |
| 239 | t.Fatalf("attach = %d %s", code, out) |
| 240 | } |
| 241 | acct, err := fx.st.AccountBySqid("bab") |
| 242 | if err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | claimed := map[string]bool{"teams.example.com": true, "*.teams.example.com": true, "held.teams.example.com": true} |
| 246 | readings := map[string]status.AliasReading{ |
| 247 | "acme.teams.example.com": {Route: "acme.go.tito.io", DNSStatus: "pointing"}, |
| 248 | "held.teams.example.com": {Route: "held.go.tito.io"}, // a claim of its own |
| 249 | "beta.teams.example.com": {Route: "beta.elsewhere.example"}, // not this app's domain |
| 250 | "gamma.teams.example.com": {Route: "other.go.tito.io"}, // label does not match |
| 251 | "x.acme.teams.example.com": {Route: "x.acme.go.tito.io"}, // two labels deep |
| 252 | } |
| 253 | got := fx.srv.catchallDerived(context.Background(), acct, "titogo", "teams.example.com", claimed, readings) |
| 254 | if len(got) != 1 || got[0].Host != "acme.teams.example.com" || got[0].Route != "acme.go.tito.io" { |
| 255 | t.Fatalf("derived = %+v, want only acme", got) |
| 256 | } |
| 257 | |
| 258 | code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| 259 | if code != http.StatusOK { |
| 260 | t.Fatalf("list = %d %s", code, out) |
| 261 | } |
| 262 | var list struct { |
| 263 | Domains []struct { |
| 264 | Host string `json:"host"` |
| 265 | State string `json:"state"` |
| 266 | Instances bool `json:"instances"` |
| 267 | Guidance string `json:"guidance"` |
| 268 | } `json:"domains"` |
| 269 | } |
| 270 | if err := json.Unmarshal([]byte(out), &list); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | if len(list.Domains) != 1 || list.Domains[0].Host != "*.teams.example.com" || !list.Domains[0].Instances || |
| 274 | list.Domains[0].State != StateChecking || !strings.Contains(list.Domains[0].Guidance, "*.teams.example.com") { |
| 275 | t.Fatalf("list = %+v", list.Domains) |
| 276 | } |
| 277 | code, _, out = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo")+"/*.teams.example.com", member, "") |
| 278 | if code != http.StatusOK || !strings.Contains(out, `"instances":true`) { |
| 279 | t.Fatalf("get = %d %s", code, out) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // The Settings page attaches a catch-all through the ordinary form, shows |
| 284 | // it as its own row with a detach form, and detaches it by typing the name. |
| 285 | func TestAppDomainFormInstanceCatchall(t *testing.T) { |
| 286 | fx := newConsoleFixture(t) |
| 287 | noCatchallProof(fx) |
| 288 | c := signInPaul(t, fx) |
| 289 | csrf := sessionToken(t, fx, c) |
| 290 | ctx := context.Background() |
| 291 | if err := provision.PutManifest(ctx, fx.rel, fx.sqid, "kass", provision.Manifest{Schema: 1, App: "kass", Domain: "kass.example.net"}); err != nil { |
| 292 | t.Fatal(err) |
| 293 | } |
| 294 | if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{Host: "teams.example.com", Account: fx.sqid, App: "kass", Route: "kass.carlosapp.dev", CreatedAt: "t"}); err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | st, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {"*.teams.example.com"}}) |
| 298 | if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { |
| 299 | t.Fatalf("attach = %d -> %q:\n%s", st, loc, body) |
| 300 | } |
| 301 | if cl := claimOf(t, fx, "*.teams.example.com"); !cl.Instances || cl.App != "kass" || cl.CreatedBy == "" { |
| 302 | t.Fatalf("claim = %+v", *cl) |
| 303 | } |
| 304 | _, _, page := get(t, c, fx.u("/apps/kass/settings/domains")) |
| 305 | if !strings.Contains(page, "*.teams.example.com") || !strings.Contains(page, "instance catch-all") { |
| 306 | t.Fatalf("settings page does not show the catch-all:\n%s", page) |
| 307 | } |
| 308 | st, _, body = postForm(t, c, fx.u("/apps/kass/domains/detach"), url.Values{ |
| 309 | "csrf": {csrf}, "host": {"*.teams.example.com"}, "confirm": {"*.teams.example.com"}}) |
| 310 | if st != http.StatusSeeOther { |
| 311 | t.Fatalf("detach = %d:\n%s", st, body) |
| 312 | } |
| 313 | claimAbsent(t, fx, "*.teams.example.com") |
| 314 | claimOf(t, fx, "teams.example.com") |
| 315 | } |
| 316 | |
| 317 | // Two more reservations the guard makes: an instance under an instance |
| 318 | // domain the app no longer uses still keeps its name, and a parent proved |
| 319 | // in DNS reserves every label — a team's name is safe before the team |
| 320 | // exists. |
| 321 | func TestInstanceCatchallGuardReservations(t *testing.T) { |
| 322 | fx, member := catchallFixture(t, "code-ca-reserve") |
| 323 | ctx := context.Background() |
| 324 | // Created under an older instance domain than the manifest's go.tito.io. |
| 325 | if err := provision.CreateRecord(ctx, fx.rel, provision.Record{Schema: 1, Host: "olden.old.example.net", Slug: "olden", |
| 326 | App: "titogo", Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}); err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | if code, out := catchallReq(t, fx, member, `{"host":"*.teams.example.com"}`); code != http.StatusOK { |
| 330 | t.Fatalf("catch-all attach = %d %s", code, out) |
| 331 | } |
| 332 | now := time.Now() |
| 333 | if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | if _, err := fx.st.CreateAppIn(42, "widgets", "bob@keymail.dev", now); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | bob := cliToken(t, fx, "bob@keymail.dev", "code-ca-reserve-bob") |
| 340 | attach := func(host string) (int, string) { |
| 341 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "widgets"), bob, attachBody(host, "w.example.net")) |
| 342 | return code, out |
| 343 | } |
| 344 | if code, out := attach("olden.teams.example.com"); code != http.StatusConflict { |
| 345 | t.Fatalf("an old-domain instance's name = %d %s, want the neutral 409", code, out) |
| 346 | } |
| 347 | // Unproved: a label with no instance is open, and the list says so. |
| 348 | if code, out := attach("early.teams.example.com"); code != http.StatusOK { |
| 349 | t.Fatalf("unproved, no instance = %d %s, want 200", code, out) |
| 350 | } |
| 351 | _, _, list := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| 352 | if !strings.Contains(list, `"reserves":"instances"`) || !strings.Contains(list, domains.CatchallProofName("teams.example.com")) { |
| 353 | t.Fatalf("list does not say what is reserved, or name the proof record: %s", list) |
| 354 | } |
| 355 | // Proved: every label is the catch-all account's... |
| 356 | proveCatchall(fx, "teams.example.com", "bab") |
| 357 | if code, out := attach("later.teams.example.com"); code != http.StatusConflict || strings.TrimSpace(out) != `{"error":"hostname unavailable"}` { |
| 358 | t.Fatalf("proved, no instance = %d %s, want the neutral 409", code, out) |
| 359 | } |
| 360 | claimAbsent(t, fx, "later.teams.example.com") |
| 361 | // ...except a claim the caller already holds. |
| 362 | if code, out := attach("early.teams.example.com"); code != http.StatusOK { |
| 363 | t.Fatalf("re-attach of bob's own earlier claim = %d %s, want 200", code, out) |
| 364 | } |
| 365 | _, _, list = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| 366 | if !strings.Contains(list, `"reserves":"all"`) { |
| 367 | t.Fatalf("list after proof: %s", list) |
| 368 | } |
| 369 | // Another account's proof record does not prove this one. |
| 370 | proveCatchall(fx, "teams.example.com", "cdef") |
| 371 | if code, out := attach("other.teams.example.com"); code != http.StatusOK { |
| 372 | t.Fatalf("proof naming another account = %d %s, want 200", code, out) |
| 373 | } |
| 374 | } |
| 375 | |