| 1 | package 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 | |
| 10 | import ( |
| 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 | |
| 21 | var proofNow = time.Unix(1_800_000_000, 0).UTC() |
| 22 | |
| 23 | func 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. |
| 34 | func 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. |
| 105 | func 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. |
| 155 | func 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. |
| 185 | func 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. |
| 207 | func 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. |
| 222 | func 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 | |