carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package domains
2
3// Proof of control for hostname claims (#215). Before this, the first account
4// to attach a hostname held it outright: a tenant could take over a name
5// whose DNS already pointed at the edge (and be served on it, with a
6// platform-issued certificate), or squat any name forever. A claim is now
7// pending until its writer proves control, and a pending claim is inert and
8// yields.
9
10import (
11 "context"
12 "encoding/json"
13 "errors"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/registry"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21var proofNow = time.Unix(1_800_000_000, 0).UTC()
22
23func proofs(answer bool, calls *int) func() bool {
24 return func() bool {
25 *calls++
26 return answer
27 }
28}
29
30// An unproven attach writes a reservation, and the reconcile pass derives
31// nothing from it — no alias, no prefix rows, no wildcard parent, no
32// catch-all — so the name is neither served nor issued a certificate. This
33// is the takeover: on main the claim was live the moment it was written.
34func TestPendingClaimIsNeitherRoutedNorCertified(t *testing.T) {
35 s := release.NewDirStore(t.TempDir())
36 reg := aliasRegWith(t, registry.Route{
37 Host: "web.bes.oncarlos.com", App: "web", Account: "bes", Kind: "instance", Status: "ready",
38 })
39 calls := 0
40 created, changed, pending, err := AttachClaimProven(context.Background(), s, Claim{
41 Host: "victim.example.com", Account: "bes", App: "web", Route: "web.bes.oncarlos.com",
42 }, proofNow, proofs(false, &calls))
43 if err != nil || !created || !changed || !pending {
44 t.Fatalf("unproven attach = created %v changed %v pending %v err %v, want a new pending claim", created, changed, pending, err)
45 }
46 c, err := GetClaim(context.Background(), s, "victim.example.com")
47 if err != nil {
48 t.Fatal(err)
49 }
50 if !c.Pending || c.PendingUntil != proofNow.Add(ClaimPendingTTL).Format(time.RFC3339) {
51 t.Fatalf("stored claim = %+v, want pending until now+%s", *c, ClaimPendingTTL)
52 }
53 // Capabilities and a prefix map set on a pending claim must stay inert too.
54 if _, err := SetClaimCapabilities(context.Background(), s, c.Host, "bes", "web", true, true); err != nil {
55 t.Fatal(err)
56 }
57 if _, err := SetClaimPaths(context.Background(), s, c.Host, "bes", "web", map[string]string{"x": "web.bes.oncarlos.com"}); err != nil {
58 t.Fatal(err)
59 }
60 if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
61 t.Fatal(err)
62 }
63 if as, _ := reg.ListAliases(); len(as) != 0 {
64 t.Fatalf("a pending claim materialized aliases %+v", as)
65 }
66 if ws, _ := reg.ListWildcardDomains(); len(ws) != 0 {
67 t.Fatalf("a pending claim derived wildcard parents %+v", ws)
68 }
69 if ps, _ := reg.ListPaths(); len(ps) != 0 {
70 t.Fatalf("a pending claim derived path rows %+v", ps)
71 }
72
73 // Proving it later is a re-attach by the same owner: the claim turns
74 // verified in place, and the next pass serves it.
75 created, changed, pending, err = AttachClaimProven(context.Background(), s, Claim{
76 Host: "victim.example.com", Account: "bes", App: "web", Route: "web.bes.oncarlos.com",
77 }, proofNow, proofs(true, &calls))
78 if err != nil || created || !changed || pending {
79 t.Fatalf("proven re-attach = created %v changed %v pending %v err %v, want a verifying write", created, changed, pending, err)
80 }
81 if c, _ := GetClaim(context.Background(), s, "victim.example.com"); c.Pending || c.PendingUntil != "" || !c.Wildcard {
82 t.Fatalf("verified claim = %+v, want pending cleared and capabilities kept", *c)
83 }
84 if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
85 t.Fatal(err)
86 }
87 if as, _ := reg.ListAliases(); len(as) == 0 {
88 t.Fatal("the verified claim materialized nothing")
89 }
90
91 // Once verified, a re-attach never asks again: a router that re-attaches
92 // every hostname on every pass must not cost a DNS lookup per hostname.
93 calls = 0
94 if _, changed, pending, err := AttachClaimProven(context.Background(), s, Claim{
95 Host: "victim.example.com", Account: "bes", App: "web", Route: "web.bes.oncarlos.com",
96 }, proofNow, proofs(false, &calls)); err != nil || changed || pending || calls != 0 {
97 t.Fatalf("re-attach of a verified claim = changed %v pending %v err %v after %d proofs, want a silent no-op", changed, pending, err, calls)
98 }
99}
100
101// A pending claim does not block the hostname's real owner: a caller who can
102// prove control supersedes it at any time, and an unproven caller can take
103// it only once it has run out. A VERIFIED claim yields to nobody, and the
104// refusal is the same ErrHostUnavailable either way.
105func TestPendingClaimYieldsToProofAndToExpiry(t *testing.T) {
106 ctx := context.Background()
107 s := release.NewDirStore(t.TempDir())
108 calls := 0
109 squat := Claim{Host: "shop.example.com", Account: "aaa", App: "squat", Route: "x.aaa.oncarlos.com"}
110 if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow, proofs(false, &calls)); err != nil {
111 t.Fatal(err)
112 }
113 if _, err := SetClaimCapabilities(ctx, s, squat.Host, "aaa", "squat", true, false); err != nil {
114 t.Fatal(err)
115 }
116
117 owner := Claim{Host: "shop.example.com", Account: "bbb", App: "shop", Route: "shop.bbb.oncarlos.com"}
118 if _, _, _, err := AttachClaimProven(ctx, s, owner, proofNow.Add(time.Hour), proofs(false, &calls)); !errors.Is(err, ErrHostUnavailable) {
119 t.Fatalf("unproven attach over a live reservation: err = %v, want ErrHostUnavailable", err)
120 }
121 created, changed, pending, err := AttachClaimProven(ctx, s, owner, proofNow.Add(time.Hour), proofs(true, &calls))
122 if err != nil || !created || !changed || pending {
123 t.Fatalf("proven attach over a reservation = created %v changed %v pending %v err %v, want a new verified claim", created, changed, pending, err)
124 }
125 c, _ := GetClaim(ctx, s, owner.Host)
126 if c.Account != "bbb" || c.App != "shop" || c.Pending || c.Wildcard {
127 t.Fatalf("superseding claim = %+v, want bbb/shop verified with nothing of the squatter's left", *c)
128 }
129
130 // The squatter, now facing a verified claim, gets the ordinary refusal,
131 // and no lookup is spent finding that out.
132 calls = 0
133 if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow.Add(2*time.Hour), proofs(true, &calls)); !errors.Is(err, ErrHostUnavailable) || calls != 0 {
134 t.Fatalf("attach over a verified claim: err = %v after %d proofs, want ErrHostUnavailable after none", err, calls)
135 }
136
137 // Expiry: an unproven reservation that has run out is free to anyone,
138 // even another unproven caller, whose own reservation starts fresh.
139 expired := Claim{Host: "old.example.com", Account: "aaa", App: "squat"}
140 if _, _, _, err := AttachClaimProven(ctx, s, expired, proofNow, proofs(false, &calls)); err != nil {
141 t.Fatal(err)
142 }
143 later := proofNow.Add(ClaimPendingTTL + time.Minute)
144 created, _, pending, err = AttachClaimProven(ctx, s, Claim{Host: "old.example.com", Account: "bbb", App: "shop"}, later, proofs(false, &calls))
145 if err != nil || !created || !pending {
146 t.Fatalf("unproven attach over an expired reservation = created %v pending %v err %v, want a fresh reservation", created, pending, err)
147 }
148 if c, _ := GetClaim(ctx, s, "old.example.com"); c.Account != "bbb" || c.PendingUntil != later.Add(ClaimPendingTTL).Format(time.RFC3339) {
149 t.Fatalf("claim after expiry = %+v, want bbb's reservation from now", *c)
150 }
151}
152
153// Claims written before #215 have no pending key. They are verified — that
154// is what they have been all along — so they keep serving with no rewrite.
155func TestClaimWithoutPendingKeyIsVerified(t *testing.T) {
156 s := release.NewDirStore(t.TempDir())
157 legacy := `{"schema":1,"host":"tickets.example.com","account":"bab","app":"kass","route":"canary/live","created_at":"2026-08-12T00:00:00Z","created_by":"paul@keymail.dev"}`
158 if _, err := s.Put(context.Background(), ClaimKey("tickets.example.com"), []byte(legacy), ""); err != nil {
159 t.Fatal(err)
160 }
161 reg := aliasRegWith(t, registry.Route{
162 Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
163 })
164 if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
165 t.Fatal(err)
166 }
167 if as, _ := reg.ListAliases(); len(as) != 1 {
168 t.Fatalf("legacy claim materialized %+v, want its alias", as)
169 }
170 // And a verified claim never grows the keys on the wire.
171 body, _ := json.Marshal(Claim{Schema: 1, Host: "a.example.com"})
172 var m map[string]any
173 _ = json.Unmarshal(body, &m)
174 if _, ok := m["pending"]; ok {
175 t.Fatalf("verified claim marshals a pending key: %s", body)
176 }
177 if _, ok := m["pending_until"]; ok {
178 t.Fatalf("verified claim marshals a pending_until key: %s", body)
179 }
180}
181
182// The token is account- and deployment-bound: a record published for one
183// account proves nothing for another, or for the same sqid on another
184// deployment.
185func TestChallengeTokenIsBound(t *testing.T) {
186 base := ChallengeToken("console.carloku.com", "bes", "shop.example.com")
187 for name, other := range map[string]string{
188 "another account": ChallengeToken("console.carloku.com", "bab", "shop.example.com"),
189 "another deployment": ChallengeToken("carlos.tito.io", "bes", "shop.example.com"),
190 "another host": ChallengeToken("console.carloku.com", "bes", "www.example.com"),
191 } {
192 if other == base {
193 t.Errorf("%s produces the same token %q", name, base)
194 }
195 }
196 if base != ChallengeToken("console.carloku.com", "bes", "shop.example.com") {
197 t.Error("token is not deterministic")
198 }
199 if got := ChallengeName("shop.example.com"); got != "_carlos-challenge.shop.example.com" {
200 t.Errorf("ChallengeName = %q", got)
201 }
202}
203
204// A pending parent is a reservation, not a holding: it cannot lend an
205// instance catch-all its one-label names, and a catch-all over it reads as
206// not live.
207func TestPendingParentHoldsNoInstanceCatchall(t *testing.T) {
208 ctx := context.Background()
209 s := release.NewDirStore(t.TempDir())
210 calls := 0
211 if _, _, _, err := AttachClaimProven(ctx, s, Claim{Host: "teams.example.com", Account: "bes", App: "web"}, time.Now(), proofs(false, &calls)); err != nil {
212 t.Fatal(err)
213 }
214 if _, err := AttachInstanceCatchall(ctx, s, "teams.example.com", "bes", "web", "", ""); !errors.Is(err, ErrParentNotHeld) {
215 t.Fatalf("catch-all over a pending parent: err = %v, want ErrParentNotHeld", err)
216 }
217}
218
219// A reservation is never renewed by its own account re-attaching (#215
220// review): `carlos deploy` re-attaches on every run, and renewal would let a
221// squatter hold a name forever without proving it.
222func TestPendingReservationIsNotRenewed(t *testing.T) {
223 ctx := context.Background()
224 s := release.NewDirStore(t.TempDir())
225 calls := 0
226 squat := Claim{Host: "shop.example.com", Account: "aaa", App: "squat"}
227 if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow, proofs(false, &calls)); err != nil {
228 t.Fatal(err)
229 }
230 first, _ := GetClaim(ctx, s, squat.Host)
231 for _, at := range []time.Duration{time.Hour, ClaimPendingTTL - time.Minute, ClaimPendingTTL + time.Hour} {
232 if _, _, _, err := AttachClaimProven(ctx, s, squat, proofNow.Add(at), proofs(false, &calls)); err != nil {
233 t.Fatal(err)
234 }
235 if c, _ := GetClaim(ctx, s, squat.Host); c.PendingUntil != first.PendingUntil {
236 t.Fatalf("re-attach at +%s moved pending_until %s -> %s", at, first.PendingUntil, c.PendingUntil)
237 }
238 }
239 // Past it, anyone may take the name.
240 if created, _, _, err := AttachClaimProven(ctx, s, Claim{Host: squat.Host, Account: "bbb", App: "shop"}, proofNow.Add(ClaimPendingTTL+2*time.Hour), proofs(false, &calls)); err != nil || !created {
241 t.Fatalf("attach after the reservation lapsed: created %v err %v", created, err)
242 }
243}
244