carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package 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
11import (
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.
29func 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.
45func noCatchallProof(fx *consoleFixture) {
46 fx.srv.lookupTXT = func(context.Context, string) ([]string, error) { return nil, errors.New("no such record") }
47}
48
49func 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
58func 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
64func 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
95func 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.
146func 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.
175func 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.
236func 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.
285func 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.
321func 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