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// Proof of control at the /domains doors (#215, opt-in: CONSOLE_DOMAIN_PROOF), and the apps-domain
4// namespace rules that came with it. Before this, a custom hostname went to
5// whichever account attached it first: one whose DNS already pointed at the
6// edge was served for the claimant with a platform-issued certificate, and
7// any name could be held forever by someone who never controlled it. And
8// without CONSOLE_RESERVE_PLATFORM_DOMAINS, one account could attach names
9// inside another's <sqid>.<apps-domain> namespace.
10
11import (
12 "context"
13 "encoding/json"
14 "net"
15 "net/http"
16 "strings"
17 "testing"
18 "time"
19
20 "github.com/carlosframework/platform/internal/domains"
21 "github.com/carlosframework/platform/internal/provision"
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24)
25
26// noTXT is a zone with no proof in it.
27func noTXT(calls *int) func(context.Context, string) ([]string, error) {
28 return func(_ context.Context, name string) ([]string, error) {
29 *calls++
30 return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
31 }
32}
33
34// txtFor is a zone that carries exactly one account's proof for one host,
35// beside unrelated TXT noise.
36func txtFor(s *Server, account, host string) func(context.Context, string) ([]string, error) {
37 return func(_ context.Context, name string) ([]string, error) {
38 if name != domains.ChallengeName(host) {
39 return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
40 }
41 return []string{"v=spf1 -all", domains.ChallengeToken(s.challengeDeployment(), account, host)}, nil
42 }
43}
44
45type proofAttachResp struct {
46 Host string `json:"host"`
47 Route string `json:"route"`
48 Created bool `json:"created"`
49 Verified *bool `json:"verified"`
50 Verify dnsRecord `json:"verify"`
51}
52
53type proofListResp struct {
54 Domains []struct {
55 Host string `json:"host"`
56 State string `json:"state"`
57 Verified bool `json:"verified"`
58 Guidance string `json:"guidance"`
59 Records []dnsRecord `json:"records"`
60 } `json:"domains"`
61}
62
63// The audit's takeover, at the CLI door: a member attaches a hostname whose
64// DNS they do not control. On main this was a live claim — served the moment
65// a box reconciled it, certificate and all. Now it is stored pending, the
66// answer says exactly which record proves it, nothing is announced to the
67// fleet, and repeating the attach once the record exists verifies it.
68func TestAPIDomainAttachWithoutProofIsPending(t *testing.T) {
69 fx, member := instanceFixture(t, "code-dom-proof")
70 fx.srv.cfg.DomainProof = true
71 lookups := 0
72 fx.srv.lookupTXT = noTXT(&lookups)
73 const host = "victim.example.com"
74 before := serialValue(t, fx)
75
76 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io"))
77 if code != http.StatusOK {
78 t.Fatalf("attach = %d %s, want 200", code, out)
79 }
80 var got proofAttachResp
81 if err := json.Unmarshal([]byte(out), &got); err != nil {
82 t.Fatal(err)
83 }
84 wantToken := domains.ChallengeToken(fx.srv.challengeDeployment(), "bab", host)
85 if got.Verified == nil || *got.Verified || got.Verify.Type != "TXT" ||
86 got.Verify.Name != "_carlos-challenge."+host || got.Verify.Value != wantToken {
87 t.Fatalf("attach answer = %s, want verified false and the TXT record %s = %s", out, "_carlos-challenge."+host, wantToken)
88 }
89 if lookups != 1 {
90 t.Errorf("TXT lookups = %d, want 1", lookups)
91 }
92 if c := claimOf(t, fx, host); !c.Pending {
93 t.Fatalf("claim = %+v, want pending", *c)
94 }
95 if after := serialValue(t, fx); after != before {
96 t.Errorf("serial moved %q -> %q for a pending claim no box acts on", before, after)
97 }
98
99 // The list says the same thing, in both forms.
100 code, _, out = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
101 if code != http.StatusOK {
102 t.Fatalf("list = %d %s", code, out)
103 }
104 var list proofListResp
105 if err := json.Unmarshal([]byte(out), &list); err != nil {
106 t.Fatal(err)
107 }
108 if len(list.Domains) != 1 || list.Domains[0].State != StateUnverified || list.Domains[0].Verified {
109 t.Fatalf("list = %s, want one unverified row", out)
110 }
111 row := list.Domains[0]
112 found := false
113 for _, r := range row.Records {
114 if r.Type == "TXT" && r.Name == "_carlos-challenge."+host && r.Value == wantToken && r.Required {
115 found = true
116 }
117 }
118 if !found || !strings.Contains(row.Guidance, wantToken) {
119 t.Fatalf("list row = %+v, want the TXT record in records (required) and in guidance", row)
120 }
121
122 // Publish the record and attach again: verified, announced.
123 fx.srv.lookupTXT = txtFor(fx.srv, "bab", host)
124 code, _, out = rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io"))
125 if code != http.StatusOK {
126 t.Fatalf("re-attach = %d %s", code, out)
127 }
128 got = proofAttachResp{}
129 _ = json.Unmarshal([]byte(out), &got)
130 if got.Verified == nil || !*got.Verified || got.Verify.Name != "" {
131 t.Fatalf("re-attach answer = %s, want verified true and no verify record", out)
132 }
133 if c := claimOf(t, fx, host); c.Pending || c.PendingUntil != "" {
134 t.Fatalf("claim = %+v, want verified", *c)
135 }
136 if after := serialValue(t, fx); after == before {
137 t.Error("verifying the claim did not bump the serial")
138 }
139}
140
141// Another account's proof is not this account's proof: the token is
142// account-bound, so a record published for one account cannot be replayed.
143func TestAPIDomainAttachRefusesAnotherAccountsProof(t *testing.T) {
144 fx, member := instanceFixture(t, "code-dom-replay")
145 fx.srv.cfg.DomainProof = true
146 const host = "shop.example.com"
147 fx.srv.lookupTXT = txtFor(fx.srv, "cdef", host)
148 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io"))
149 if code != http.StatusOK {
150 t.Fatalf("attach = %d %s", code, out)
151 }
152 if c := claimOf(t, fx, host); !c.Pending {
153 t.Fatalf("claim = %+v, want pending: the TXT record proves account cdef, not bab", *c)
154 }
155}
156
157// Squatting: an unproven claim no longer holds a name against its owner. A
158// proven attach supersedes it, an unproven one meets the ordinary 409 — the
159// same bytes a verified holder produces, so a refusal still never says how
160// firmly a name is held.
161func TestAPIDomainProvenOwnerSupersedesSquatter(t *testing.T) {
162 fx, member := instanceFixture(t, "code-dom-squat")
163 fx.srv.cfg.DomainProof = true
164 ctx := context.Background()
165 const squatted, held = "shop.example.com", "held.example.com"
166 if _, _, _, err := domains.AttachClaimProven(ctx, fx.rel, domains.Claim{
167 Host: squatted, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com",
168 }, time.Now(), func() bool { return false }); err != nil {
169 t.Fatal(err)
170 }
171 if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
172 Host: held, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com",
173 }); err != nil {
174 t.Fatal(err)
175 }
176 lookups := 0
177 fx.srv.lookupTXT = noTXT(&lookups)
178 code1, _, body1 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io"))
179 code2, _, body2 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io"))
180 if code1 != http.StatusConflict || code1 != code2 || body1 != body2 {
181 t.Fatalf("unproven attach over a reservation = %d %q, over a verified claim = %d %q; want the same 409", code1, body1, code2, body2)
182 }
183
184 fx.srv.lookupTXT = txtFor(fx.srv, "bab", squatted)
185 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io"))
186 if code != http.StatusOK {
187 t.Fatalf("proven attach over a reservation = %d %s, want 200", code, out)
188 }
189 if c := claimOf(t, fx, squatted); c.Account != "bab" || c.App != "titogo" || c.Pending {
190 t.Fatalf("claim = %+v, want bab/titogo verified", *c)
191 }
192 // A verified claim is still first-writer-wins against proof: proving
193 // control of a name someone else already proved is a support case, not
194 // something the door decides.
195 fx.srv.lookupTXT = txtFor(fx.srv, "bab", held)
196 if code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io")); code != http.StatusConflict {
197 t.Fatalf("proven attach over a verified claim = %d, want 409", code)
198 }
199}
200
201// The first-route branch writes NO record for an unproven claim, because the
202// record is what turns the host into a real route; the attach that proves it
203// declares the record then.
204func TestAttachFirstRouteWaitsForProof(t *testing.T) {
205 fx, acct := attachFixture(t)
206 fx.srv.cfg.DomainProof = true
207 shipVersion(t, fx, "story", "v1")
208 seedChannel(t, fx, "story", "canary/live", "v1")
209 const host = "story.example.com"
210 lookups := 0
211 fx.srv.lookupTXT = noTXT(&lookups)
212 ops := &opStore{Store: fx.rel}
213 fx.srv.cfg.Releases = ops
214 res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
215 if ref != nil {
216 t.Fatalf("attach refused %+v", ref)
217 }
218 if !res.Pending || res.FirstRoute || res.Verify.Name != domains.ChallengeName(host) {
219 t.Fatalf("result = %+v, want pending, no first route, and the proof record", res)
220 }
221 keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story"))
222 if err != nil {
223 t.Fatal(err)
224 }
225 if len(keys) != 0 {
226 t.Fatalf("records for an unproven first route = %v, want none", keys)
227 }
228 if ops.opIndex("put "+release.SerialKey) >= 0 {
229 t.Errorf("ops %v: serial bumped for a pending claim", ops.ops)
230 }
231
232 fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host)
233 res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
234 if ref != nil {
235 t.Fatalf("re-attach refused %+v", ref)
236 }
237 if res.Pending || !res.FirstRoute {
238 t.Fatalf("result = %+v, want verified first route", res)
239 }
240 if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil {
241 t.Fatalf("record after proof: %v", err)
242 }
243}
244
245// Item 2 of #215: another account's <sqid>.<apps-domain> namespace is refused
246// whatever CONSOLE_RESERVE_PLATFORM_DOMAINS says, a first route may not be
247// minted under a namespace no account holds yet (that would order a
248// wildcard certificate for the invented label), and the account's own
249// namespace needs no TXT proof — there is no zone the member could publish
250// one in.
251func TestAppsDomainNamespacesBelongToTheirAccounts(t *testing.T) {
252 fx, acct := attachFixture(t)
253 fx.srv.cfg.DomainProof = true
254 if fx.srv.cfg.ReservePlatformDomains {
255 t.Fatal("fixture reserves platform domains; this test is about deployments that do not")
256 }
257 if err := fx.st.CreateAccount(42, "cdef", "Other", "sam@keymail.dev", time.Now()); err != nil {
258 t.Fatal(err)
259 }
260 shipVersion(t, fx, "story", "v1")
261 seedChannel(t, fx, "story", "canary/live", "v1")
262 lookups := 0
263 fx.srv.lookupTXT = noTXT(&lookups)
264
265 for _, host := range []string{"new.cdef.oncarlos.com", "cdef.oncarlos.com", "a.b.cdef.oncarlos.com"} {
266 _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
267 if ref == nil || ref.Status != http.StatusUnprocessableEntity || ref.Msg != "that hostname is platform-managed" {
268 t.Errorf("attach %s in another account's namespace = %+v, want the platform-managed refusal", host, ref)
269 }
270 if _, err := domains.GetClaim(context.Background(), fx.rel, host); err == nil {
271 t.Errorf("claim written for %s", host)
272 }
273 }
274 // The CLI door asks the same question.
275 if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) {
276 t.Error("domainHostDenied admits another account's namespace")
277 }
278
279 // An unheld sqid-shaped namespace: no first route.
280 if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "x.zzz.oncarlos.com", "", "", "paul@keymail.dev"); ref == nil {
281 t.Error("first route under an unheld namespace was accepted")
282 }
283
284 // The account's own namespace: attached and verified without a lookup.
285 own := "blog." + fx.sqid + ".oncarlos.com"
286 res, ref := fx.srv.attachDomain(context.Background(), acct, "story", own, "", "", "paul@keymail.dev")
287 if ref != nil {
288 t.Fatalf("attach in own namespace refused %+v", ref)
289 }
290 if res.Pending || !res.FirstRoute {
291 t.Errorf("own-namespace attach = %+v, want verified first route", res)
292 }
293 if lookups != 0 {
294 t.Errorf("TXT lookups = %d, want none: own namespace needs no proof", lookups)
295 }
296
297 // A name outside every namespace is unchanged on a self-hosted
298 // deployment: attachable (pending proof like any custom domain).
299 fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: "story." + fx.sqid + ".oncarlos.com",
300 Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"})
301 if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "www.oncarlos.com", "", "", "paul@keymail.dev"); ref != nil {
302 t.Errorf("alias attach of www.oncarlos.com refused %+v; self-hosted behaviour must not change", ref)
303 }
304}
305
306// serialValue reads control/serial, "" when absent.
307func serialValue(t *testing.T, fx *consoleFixture) string {
308 t.Helper()
309 b, _, err := fx.rel.Get(context.Background(), release.SerialKey)
310 if err != nil {
311 return ""
312 }
313 return string(b)
314}
315
316// With CONSOLE_DOMAIN_PROOF unset — the default — nothing about attach
317// changes (#215 keeps the 2026-08-26/27 ruling until a deployment opts in):
318// no TXT lookup is ever made, every claim is written verified and announced,
319// a first route declares its record at once, and the answer says verified.
320func TestDomainProofOffBehavesAsBefore(t *testing.T) {
321 fx, member := instanceFixture(t, "code-dom-proof-off")
322 if fx.srv.cfg.DomainProof {
323 t.Fatal("DomainProof is on by default")
324 }
325 lookups := 0
326 fx.srv.lookupTXT = noTXT(&lookups)
327 before := serialValue(t, fx)
328 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody("tickets.example.com", "go.tito.io"))
329 if code != http.StatusOK {
330 t.Fatalf("attach = %d %s", code, out)
331 }
332 var got proofAttachResp
333 _ = json.Unmarshal([]byte(out), &got)
334 if got.Verified == nil || !*got.Verified || got.Verify.Name != "" {
335 t.Fatalf("attach answer = %s, want verified true and no verify record", out)
336 }
337 if c := claimOf(t, fx, "tickets.example.com"); c.Pending || c.PendingUntil != "" {
338 t.Fatalf("claim = %+v, want verified", *c)
339 }
340 if serialValue(t, fx) == before {
341 t.Error("attach did not bump the serial")
342 }
343
344 afx, acct := attachFixture(t)
345 afx.srv.lookupTXT = noTXT(&lookups)
346 shipVersion(t, afx, "story", "v1")
347 seedChannel(t, afx, "story", "canary/live", "v1")
348 res, ref := afx.srv.attachDomain(context.Background(), acct, "story", "story.example.com", "", "", "paul@keymail.dev")
349 if ref != nil || res.Pending || !res.FirstRoute {
350 t.Fatalf("first-route attach = %+v / %+v, want a verified first route", res, ref)
351 }
352 if _, err := provision.GetRecord(context.Background(), afx.rel, afx.sqid, "story", "story.example.com"); err != nil {
353 t.Fatalf("record: %v", err)
354 }
355 if lookups != 0 {
356 t.Errorf("TXT lookups = %d with proof off, want none", lookups)
357 }
358}
359
360// A plain re-attach of one's own PENDING first-route claim (no route, no
361// kind — what `carlos domains attach host` sends) must look for the proof.
362// The own-first-route short-circuit used to answer it {created:false} with
363// no verified field, which the CLI reads as attached (#215 review).
364func TestAPIDomainPlainReattachVerifiesPendingFirstRoute(t *testing.T) {
365 fx, acct := attachFixture(t)
366 fx.srv.cfg.DomainProof = true
367 shipVersion(t, fx, "story", "v1")
368 seedChannel(t, fx, "story", "canary/live", "v1")
369 if _, err := fx.st.CreateAppIn(OpsAccountID, "story", "paul@keymail.dev", time.Now()); err != nil {
370 t.Fatal(err)
371 }
372 token := cliToken(t, fx, "paul@keymail.dev", "code-dom-plain-reattach")
373 const host = "story.example.com"
374 lookups := 0
375 fx.srv.lookupTXT = noTXT(&lookups)
376 if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "static", "", "paul@keymail.dev"); ref != nil {
377 t.Fatalf("seed attach refused %+v", ref)
378 }
379 if c := claimOf(t, fx, host); !c.Pending || c.Route != "" {
380 t.Fatalf("seed claim = %+v, want a pending first-route claim", *c)
381 }
382
383 post := func() proofAttachResp {
384 t.Helper()
385 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, fx.sqid, "story"), token, attachBody(host, ""))
386 if code != http.StatusOK {
387 t.Fatalf("plain re-attach = %d %s", code, out)
388 }
389 var got proofAttachResp
390 if err := json.Unmarshal([]byte(out), &got); err != nil {
391 t.Fatal(err)
392 }
393 return got
394 }
395 lookups = 0
396 if got := post(); got.Verified == nil || *got.Verified || lookups != 1 {
397 t.Fatalf("plain re-attach without proof = %+v after %d lookups, want verified false after one", got, lookups)
398 }
399 fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host)
400 if got := post(); got.Verified == nil || !*got.Verified || got.Route != host {
401 t.Fatalf("plain re-attach with proof = %+v, want verified first route", got)
402 }
403 if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil {
404 t.Fatalf("record after proof: %v", err)
405 }
406}
407
408// A deleted account's namespace stays reserved (#215 review): its tombstone
409// keeps the sqid from being reissued, and a name under it would still serve
410// through the apps-domain wildcard looking like that account's address.
411func TestDeletedAccountsNamespaceStaysReserved(t *testing.T) {
412 fx, _ := attachFixture(t)
413 if err := fx.st.CreateAccount(42, "cdef", "Gone", "sam@keymail.dev", time.Now()); err != nil {
414 t.Fatal(err)
415 }
416 if err := fx.st.DeleteAccount(42, time.Now()); err != nil {
417 t.Fatal(err)
418 }
419 if a, _ := fx.st.AccountBySqid("cdef"); a != nil {
420 t.Fatal("account not deleted")
421 }
422 if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) {
423 t.Error("a deleted account's namespace is attachable")
424 }
425}
426