carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Global hostname claims — the desired-state object behind attaching an
2// extra hostname to a route (#151). A claim lives at ClaimKey(host), the
3// SAME key regardless of which account or app is asking, so the bucket key
4// itself is the cross-account uniqueness gate: two accounts racing for the
5// same host cannot both win, because there is only ever one object to Put.
6// Later tasks derive registry rows from these claims; nothing here talks to
7// the registry.
8package domains
9
10import (
11 "context"
12 "crypto/sha256"
13 "encoding/hex"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "log"
18 "os"
19 "regexp"
20 "strings"
21 "time"
22
23 "github.com/carlosframework/platform/internal/release"
24)
25
26// ClaimsPrefix is where every hostname claim lives.
27const ClaimsPrefix = "domains/"
28
29// ClaimKey is the bucket key for host's claim.
30func ClaimKey(host string) string { return ClaimsPrefix + host + ".json" }
31
32// Claim is the wire shape at ClaimKey(Host) — the global record of who owns
33// an extra hostname attached to a route. Route is omitempty because a claim
34// can exist ahead of the route it will back (reserved but not yet wired);
35// CreatedBy is omitempty because a claim minted by a box-side process, not a
36// human door, has no operator to name.
37type Claim struct {
38 Acquisition string `json:"acq,omitempty"`
39 RecordIncarnation string `json:"incarnation,omitempty"`
40 OwnershipState string `json:"ownership_state,omitempty"`
41 Schema int `json:"schema"` // 1
42 Host string `json:"host"`
43 Account string `json:"account"`
44 App string `json:"app"`
45 Route string `json:"route,omitempty"`
46 CreatedAt string `json:"created_at"`
47 CreatedBy string `json:"created_by,omitempty"`
48 // Wildcard and Catchall are the woodstar-carloku branch's two claim
49 // capabilities (its migration spec §4-5 gives them meaning; nothing in
50 // this package acts on either). They are deliberately orthogonal:
51 // Wildcard opts the domain into a delegated-ACME wildcard cert (one
52 // *.<host> cert covering named aliases, no per-host CT-logged certs);
53 // Catchall derives the *.<host> catch-all alias entry to the primary
54 // route, and the attach surface refuses it without Wildcard (a
55 // catch-all nobody can serve TLS for is a config error, not a state).
56 // Both are declared here so a converge rewrite by this binary
57 // round-trips them instead of stripping fields it doesn't know — the
58 // additive-wire rule applied to writers, not just readers. Converge
59 // preserves only DECLARED fields (json.Unmarshal drops unknown ones),
60 // which is exactly why any future claim field must be added here in
61 // the same change that first writes it.
62 Wildcard bool `json:"wildcard,omitempty"`
63 Catchall bool `json:"catchall,omitempty"`
64 // Paths splits this hostname's traffic by leading URL segment: a bare
65 // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the
66 // route that serves it. Absent or empty means this host routes by
67 // hostname alone — the behaviour of every host today, and the reason this
68 // field is additive in the strongest sense: no row, no map, no change.
69 //
70 // This is a THIRD claim capability alongside Wildcard/Catchall, and it is
71 // declared here for the reason stated above them — converge preserves only
72 // DECLARED fields, so an undeclared field would be stripped by any binary
73 // that rewrote the claim.
74 //
75 // The writer is the tenant's own platform, publishing the whole map
76 // regenerated from its own database. Deltas were rejected deliberately: a
77 // lost delta desyncs the two systems silently, whereas a wholesale
78 // republish is always a full repair.
79 Paths map[string]string `json:"paths,omitempty"`
80 // Instances marks an instance catch-all: a claim whose Host is
81 // "*.<parent>" (InstanceCatchallHost) and which serves each of its
82 // app's instances at <label>.<parent>, where <label> is the instance
83 // host's own first label. The box derives one ordinary alias row per
84 // local instance (ReconcileAliases); nothing else about such a claim is
85 // used — it never names a Route, a Wildcard, a Catchall or Paths.
86 //
87 // Additive, and inert on a binary that predates it, by construction
88 // rather than by luck: such a binary decodes this claim as one with no
89 // Route (no alias), no Wildcard (no cert parent, no catch-all) and no
90 // Paths — the shape every pass already skips. No door accepts a "*" in
91 // a hostname (NormalizeAliasHost), so an older console cannot rewrite or
92 // detach it either. Declared here for the round-trip reason above.
93 Instances bool `json:"instances,omitempty"`
94 // Pending marks a claim whose writer has not yet proved control of the
95 // hostname (#215): no TXT record carrying ChallengeToken was found at
96 // ChallengeName(Host) when it was written. A pending claim is a
97 // reservation and nothing more. ReconcileAliases derives no row from it,
98 // so it is not routed and no certificate is ever ordered for it, and it
99 // does not hold the name against anyone who CAN prove control
100 // (AttachClaimProven supersedes it) or against anyone at all once
101 // PendingUntil has passed.
102 //
103 // The polarity is the additive-wire rule at work: every claim written
104 // before this field existed carries no "pending" key and therefore reads
105 // as verified, which is what those claims have been all along. The
106 // converse matters at roll time: a box running a binary from before this
107 // field would decode a pending claim as live and route it, so boxes roll
108 // before the console that starts writing them.
109 Pending bool `json:"pending,omitempty"`
110 // PendingUntil is when a pending claim stops reserving its hostname
111 // (RFC3339, UTC). Meaningless on a verified claim, and cleared when the
112 // claim is verified.
113 PendingUntil string `json:"pending_until,omitempty"`
114}
115
116// InstanceCatchallHost is the claim host of parent's instance catch-all:
117// "*.<parent>". Using the star as the key is what makes the catch-all
118// globally unique per parent through the same bucket key gate every other
119// claim uses, and no attach door can produce it (NormalizeAliasHost refuses
120// "*"), so it cannot collide with an ordinary claim.
121func InstanceCatchallHost(parent string) string { return "*." + parent }
122
123// ParseInstanceCatchallHost reports whether raw names an instance catch-all
124// ("*.<parent>", case and one trailing dot forgiven) and returns the
125// normalised parent. The parent is held to NormalizeAliasHost's rules, so it
126// has an interior dot: "*.dev" is never a catch-all.
127func ParseInstanceCatchallHost(raw string) (parent string, ok bool) {
128 rest, ok := strings.CutPrefix(strings.ToLower(raw), "*.")
129 if !ok {
130 return "", false
131 }
132 return NormalizeAliasHost(rest)
133}
134
135// instanceRecordsPrefix is where an app's instance records live:
136// provision.RecordsPrefix, spelled here for the import-cycle reason
137// hostLabelRe gives (internal/provision imports this package).
138func instanceRecordsPrefix(account, app string) string {
139 return release.AppPrefix(account, app) + "instances/"
140}
141
142// InstanceLabelCounts counts the app's instance records by the first label
143// of their host, from the record KEYS alone (one listing, no bodies): the
144// deployment-wide view of which labels the app's instances use, whatever
145// box serves them and whatever instance domain each was created under.
146// Replica records are counted too; they have labels of their own, so they
147// never inflate another instance's count.
148func InstanceLabelCounts(ctx context.Context, s release.Store, account, app string) (map[string]int, error) {
149 prefix := instanceRecordsPrefix(account, app)
150 keys, err := s.List(ctx, prefix)
151 if err != nil {
152 return nil, err
153 }
154 counts := map[string]int{}
155 for _, key := range keys {
156 host, ok := strings.CutSuffix(strings.TrimPrefix(key, prefix), ".json")
157 if !ok || host == "" || strings.Contains(host, "/") {
158 continue
159 }
160 label, _, _ := strings.Cut(host, ".")
161 counts[label]++
162 }
163 return counts, nil
164}
165
166// CatchallProofName and CatchallProofValue are the TXT record that proves an
167// account controls an instance catch-all's parent in DNS:
168// _carlos-catchall.<parent> = "carlos-catchall-account=<sqid>". The value
169// names the account, so one account's proof is no use to another — the same
170// shape as the mail ownership proof (internal/mail, #216). With it, the
171// catch-all reserves every one-label name under the parent at the attach
172// door, not only the names of instances that already exist.
173func CatchallProofName(parent string) string { return "_carlos-catchall." + parent }
174
175func CatchallProofValue(account string) string { return "carlos-catchall-account=" + account }
176
177// ErrParentNotHeld is returned by AttachInstanceCatchall when parent itself
178// is not claimed by the caller's account. One error for both "nobody holds
179// it" and "another account holds it", so this door says nothing the attach
180// door would not.
181var ErrParentNotHeld = errors.New("parent hostname not held by this account")
182
183// AttachInstanceCatchall writes parent's instance catch-all for account's
184// app, create-only by key like AttachClaim. The same app asking again is an
185// idempotent no-op; anyone else (another account, or another app of this
186// one) gets ErrHostUnavailable.
187//
188// The parent must be claimed by the SAME account, by any of its apps. With
189// wildcard DNS pointing at the edge, a catch-all is a claim on every
190// unclaimed name under parent at once, so it may only be made by whoever
191// already holds parent. The box re-checks this on every pass
192// (ReconcileAliases), so a catch-all whose parent claim is later released
193// goes inert rather than outliving it.
194//
195// The check and the write are two objects and not atomic. The race it
196// leaves is the parent claim being released between them, which the box
197// check answers.
198func claimRawAttachInstanceCatchall(ctx context.Context, s release.Store, parent, account, app, createdAt, createdBy string) (created bool, err error) {
199 apex, err := GetClaim(ctx, s, parent)
200 if errors.Is(err, os.ErrNotExist) {
201 return false, ErrParentNotHeld
202 } else if err != nil {
203 return false, err
204 }
205 if apex.Account != account || apex.Pending {
206 // A pending parent (#215) is a reservation, not a holding.
207 return false, ErrParentNotHeld
208 }
209 host := InstanceCatchallHost(parent)
210 body, err := json.Marshal(Claim{
211 Schema: 1, Host: host, Account: account, App: app,
212 CreatedAt: createdAt, CreatedBy: createdBy, Instances: true,
213 })
214 if err != nil {
215 return false, err
216 }
217 for attempt := 0; ; attempt++ {
218 cur, etag, err := getClaimETag(ctx, s, host)
219 if errors.Is(err, os.ErrNotExist) {
220 etag = ""
221 } else if err != nil {
222 return false, err
223 } else if cur.Account == account && cur.App == app && cur.Instances {
224 return false, nil
225 } else if cur.Instances && cur.Account == apex.Account {
226 // Live: its account still holds the parent. Ours to refuse.
227 return false, ErrHostUnavailable
228 } else if !cur.Instances {
229 return false, ErrHostUnavailable // not a catch-all: no door writes this key otherwise
230 }
231 // Absent, or a catch-all left behind by an account that no longer
232 // holds the parent. The second is inert everywhere already (the box
233 // and ForeignInstanceCatchall both re-check the parent), so the new
234 // holder of the parent replaces it rather than meeting a squat.
235 if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
236 return true, nil
237 } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
238 continue // lost a race — re-read and judge the winner
239 } else {
240 return false, err
241 }
242 }
243}
244
245// ForeignInstanceCatchall returns the live instance catch-all, held by an
246// account OTHER than account, whose parent is one label above host — or nil.
247// The attach doors use it to refuse such a host when the catch-all's app
248// actually runs an instance at that label (the console checks the record;
249// this package cannot import provision). Without that refusal another
250// account could claim a live instance's public name, and its exact claim
251// would beat the derived alias.
252//
253// Only one label under the parent, because that is all a catch-all serves.
254// Deeper names are ordinary names, open to anyone as they always were.
255//
256// "Live" is the box's rule: the catch-all's account still holds the parent.
257// A catch-all whose parent was released, or moved to another account,
258// serves nothing and is reported as nil.
259//
260// One GET (usually a 404), and a second only when another account's
261// catch-all sits over the name. A read failure is returned rather than
262// answered either way: the door turns it into a retryable error, never a
263// refusal that reads as "someone holds this" or a pass that skips the guard.
264func ForeignInstanceCatchall(ctx context.Context, s release.Store, host, account string) (*Claim, error) {
265 _, parent, ok := strings.Cut(host, ".")
266 if !ok || !strings.Contains(parent, ".") {
267 return nil, nil
268 }
269 c, err := GetClaim(ctx, s, InstanceCatchallHost(parent))
270 if errors.Is(err, os.ErrNotExist) {
271 return nil, nil
272 } else if err != nil {
273 return nil, err
274 }
275 if !c.Instances || c.Account == account {
276 return nil, nil
277 }
278 apex, err := GetClaim(ctx, s, parent)
279 if errors.Is(err, os.ErrNotExist) {
280 return nil, nil
281 } else if err != nil {
282 return nil, err
283 }
284 if apex.Account != c.Account || apex.Pending {
285 return nil, nil // a pending parent (#215) lends the catch-all nothing
286 }
287 return c, nil
288}
289
290// ClaimPendingTTL is how long an unproven claim reserves its hostname. Long
291// enough for a person to get a TXT record through a slow DNS provider, short
292// enough that a name claimed by someone who never could prove it comes free
293// on its own. It only ever blocks other UNPROVEN attaches: a writer who can
294// prove control supersedes a pending claim at any time.
295const ClaimPendingTTL = 7 * 24 * time.Hour
296
297// pendingLive reports whether c is pending and still reserving its hostname
298// at now. A pending claim with no readable expiry is hand-written or damaged,
299// and is treated as expired: the conservative direction for a reservation
300// nobody proved is to let it go.
301func (c Claim) pendingLive(now time.Time) bool {
302 if !c.Pending {
303 return false
304 }
305 until, err := time.Parse(time.RFC3339, c.PendingUntil)
306 return err == nil && now.Before(until)
307}
308
309// ChallengeLabel is the DNS label a claimant proves control under: a TXT
310// record at ChallengeName(host) carrying ChallengeToken. The name the
311// domain-attach spec reserved for this (2026-08-12 §2).
312const ChallengeLabel = "_carlos-challenge"
313
314// ChallengeName is where host's ownership TXT record lives.
315func ChallengeName(host string) string { return ChallengeLabel + "." + host }
316
317// ChallengeToken is the TXT value that proves account controls host on the
318// deployment named by deployment (the console's own hostname).
319//
320// Derived, not stored, and deliberately not secret. What the record proves
321// is that whoever controls host's DNS chose THIS account on THIS deployment,
322// and that only needs the value to differ per account and per deployment:
323// another account's token for the same host is a different string, so a
324// record published for one account can never be replayed by another, and a
325// record published for an account on one CARLOS deployment proves nothing on
326// another that happens to reuse the sqid. Knowing a token helps nobody who
327// cannot write the zone. Deriving it means there is nothing to look up
328// before a claim exists, which is what lets a claimant who has already
329// published the record attach and verify in one call, and lets a proven
330// claimant supersede someone else's pending reservation.
331func ChallengeToken(deployment, account, host string) string {
332 sum := sha256.Sum256([]byte("carlos-domain-challenge\x00" + deployment + "\x00" + account + "\x00" + host))
333 return "carlos-verify=" + hex.EncodeToString(sum[:16])
334}
335
336// ErrHostUnavailable is returned by AttachClaim when host is already claimed
337// by a different account or app. Deliberately neutral: it never names the
338// current holder, so a caller cannot use this door to discover who owns a
339// hostname they do not.
340var ErrHostUnavailable = errors.New("hostname unavailable")
341
342// getClaimETag reads and parses host's claim, carrying the etag it was read
343// under. Not-found stays os.ErrNotExist-wrapped. A schema newer than this
344// binary understands is refused rather than silently truncated by
345// json.Unmarshal — the GetManifestETag precedent (provision.go).
346func getClaimETag(ctx context.Context, s release.Store, host string) (*Claim, string, error) {
347 raw, etag, err := s.Get(ctx, ClaimKey(host))
348 if err != nil {
349 return nil, "", err
350 }
351 var c Claim
352 if err := json.Unmarshal(raw, &c); err != nil {
353 return nil, "", fmt.Errorf("domains: claim %s: %w", host, err)
354 }
355 if c.Schema > 1 {
356 return nil, "", fmt.Errorf("domains: claim %s: schema %d is newer than this binary understands — upgrade carlos", host, c.Schema)
357 }
358 return &c, etag, nil
359}
360
361// GetClaim reads host's claim. Not-found stays os.ErrNotExist-wrapped.
362func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error) {
363 c, _, err := getClaimETag(ctx, s, host)
364 return c, err
365}
366
367// AttachClaim writes host's global claim, create-only by key: the bucket
368// key IS the cross-account uniqueness gate. Same owner re-attaching is
369// idempotent (converging Route in place); any other owner gets
370// ErrHostUnavailable — deliberately neutral, never naming the holder.
371//
372// The claim it writes is VERIFIED: AttachClaim is AttachClaimProven with a
373// writer that needs no proof. That is right for callers whose authority over
374// the name was settled before they got here; a member's door asks for proof
375// through AttachClaimProven instead.
376//
377// created reports that this call brought the claim into existence; changed
378// reports that it WROTE — a create or a route converge, and false for the
379// pure no-op where the stored claim already said what the caller asked for.
380// The two are separate answers because they drive different things: created
381// is what the door tells the caller, changed is what the door audits and
382// bumps control/serial on. Callers that re-attach on a loop (titogo's router
383// re-attaches every hostname on each reload pass) make no-op attaches the
384// steady state, and a serial that moved for a call that wrote nothing would
385// send every box in the fleet looking for a change that isn't there.
386func AttachClaim(ctx context.Context, s release.Store, c Claim) (created, changed bool, err error) {
387 created, changed, _, err = AttachClaimProven(ctx, s, c, time.Now(), nil)
388 return created, changed, err
389}
390
391// AttachClaimProven is AttachClaim for a writer that may or may not control
392// host (#215). prove answers "does the caller control this hostname right
393// now?" — in practice a TXT lookup at ChallengeName(host) — and nil means
394// the caller's authority is already settled (a proof of true).
395//
396// prove is asked at most once, and only when the answer can change the
397// outcome: there is no claim yet, the caller's own claim is still pending,
398// or another owner's claim is pending. A re-attach of a verified claim this
399// caller already holds never asks, which is what keeps a router that
400// re-attaches every hostname on every pass from turning into a DNS lookup
401// per hostname per pass.
402//
403// The rules, in the order they apply:
404//
405// - no claim: written, pending unless proven;
406// - another owner's VERIFIED claim: ErrHostUnavailable, exactly as before;
407// - another owner's PENDING claim: superseded by a proven caller at any
408// time, and by anyone once it has expired; otherwise ErrHostUnavailable,
409// byte-identical to the verified case;
410// - the caller's own claim: converged — a pending one becomes verified
411// once proven (its expiry is never pushed back) — and Route repointed.
412//
413// Verification is one-way and sticky: nothing here ever turns a verified
414// claim back into a pending one. pending reports the state of the claim as
415// stored after the call (false whenever err is non-nil).
416func AttachClaimProven(ctx context.Context, s release.Store, c Claim, now time.Time, prove func() bool) (created, changed, pending bool, err error) {
417 c.Schema = 1
418 c.Pending, c.PendingUntil = false, ""
419 asked, proven := false, true
420 isProven := func() bool {
421 if prove == nil {
422 return true
423 }
424 if !asked {
425 asked, proven = true, prove()
426 }
427 return proven
428 }
429 // fresh is the caller's claim as a brand-new object: verified when
430 // proven, otherwise a reservation that runs out ClaimPendingTTL from now.
431 fresh := func() Claim {
432 out := c
433 if !isProven() {
434 out.Pending = true
435 out.PendingUntil = now.UTC().Add(ClaimPendingTTL).Format(time.RFC3339)
436 }
437 return out
438 }
439 for attempt := 0; ; attempt++ {
440 cur, etag, err := getClaimETag(ctx, s, c.Host)
441 if errors.Is(err, os.ErrNotExist) {
442 next := fresh()
443 body, err := json.Marshal(next)
444 if err != nil {
445 return false, false, false, err
446 }
447 if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil {
448 return true, true, next.Pending, nil
449 } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
450 continue // lost the create race — re-read and judge the winner
451 } else {
452 return false, false, false, err
453 }
454 } else if err != nil {
455 return false, false, false, err
456 }
457 if cur.Account != c.Account || cur.App != c.App {
458 // A verified claim is the holder's, full stop. A pending one is
459 // only a reservation: it yields to a caller who can prove
460 // control, and to anyone once it has run out. Both refusals are
461 // the same ErrHostUnavailable, so the answer still never says
462 // whether the name is held, or how firmly.
463 if !cur.Pending || (cur.pendingLive(now) && !isProven()) {
464 return false, false, false, ErrHostUnavailable
465 }
466 next := fresh()
467 body, err := json.Marshal(next)
468 if err != nil {
469 return false, false, false, err
470 }
471 if _, err := s.Put(ctx, ClaimKey(c.Host), body, etag); err == nil {
472 // From the caller's side this IS a new claim: nothing of the
473 // previous holder's survives it — not its route, its
474 // capabilities, or its prefix map.
475 return true, true, next.Pending, nil
476 } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
477 continue
478 } else {
479 return false, false, false, err
480 }
481 }
482 // Converge repoints Route and settles the pending state, and nothing
483 // else: the stored claim keeps its CreatedAt (the claim's age) and
484 // Wildcard (TLS opt-in state a route repoint has no business
485 // clearing). Writing the caller's whole claim here would strip both.
486 next := *cur
487 next.Route = c.Route
488 // A reservation is never renewed: PendingUntil stays where the first
489 // attach put it, so whoever reserved a name has ClaimPendingTTL from
490 // then to prove it, however often they re-attach (and `carlos
491 // deploy` re-attaches on every run). Proof still verifies an expired
492 // reservation of one's own, as long as nobody has taken it meanwhile.
493 if cur.Pending && isProven() {
494 next.Pending, next.PendingUntil = false, ""
495 }
496 if next.Route == cur.Route && next.Pending == cur.Pending && next.PendingUntil == cur.PendingUntil {
497 return false, false, cur.Pending, nil // nothing to write: this call is a no-op
498 }
499 nbody, err := json.Marshal(next)
500 if err != nil {
501 return false, false, false, err
502 }
503 if _, err := s.Put(ctx, ClaimKey(c.Host), nbody, etag); err == nil {
504 return false, true, next.Pending, nil // not a create, but a real write
505 } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
506 continue // converge race — one retry, then surface
507 } else {
508 return false, false, false, err
509 }
510 }
511}
512
513// DetachClaim removes host's claim, but only for the owner that holds it:
514// a mismatched account/app is quietly ignored (removed=false, nil error)
515// rather than erroring, the same neutrality AttachClaim's ErrHostUnavailable
516// observes — a caller must not learn who the real owner is by probing
517// detach. An already-absent claim is likewise quiet: detach is idempotent.
518//
519// An instance catch-all is deleted only at the revision this call read
520// (release.DeleteIfMatch). It is the one claim that can change hands in
521// place — AttachInstanceCatchall replaces a dead one for the parent's new
522// holder — so an old owner's detach that read its own claim must not land
523// after that replacement and delete the new owner's. A changed claim
524// answers removed=false, like any claim that is not the caller's.
525func claimRawDetachClaim(ctx context.Context, s release.Store, host, account, app string) (bool, error) {
526 cur, etag, err := getClaimETag(ctx, s, host)
527 if errors.Is(err, os.ErrNotExist) {
528 return false, nil
529 }
530 if err != nil {
531 return false, err
532 }
533 if cur.Account != account || cur.App != app {
534 return false, nil
535 }
536 if cur.Instances {
537 err := release.DeleteIfMatch(ctx, s, ClaimKey(host), etag)
538 if errors.Is(err, release.ErrCASConflict) {
539 return false, nil
540 }
541 if err != nil {
542 return false, err
543 }
544 return true, nil
545 }
546 if err := s.Delete(ctx, ClaimKey(host)); err != nil {
547 return false, err
548 }
549 return true, nil
550}
551
552// ListClaims returns every valid claim in the bucket. An unparseable or
553// newer-schema object is logged and skipped — one bad claim must never take
554// the whole listing down.
555func ListClaims(ctx context.Context, s release.Store) ([]Claim, error) {
556 keys, err := s.List(ctx, ClaimsPrefix)
557 if err != nil {
558 return nil, fmt.Errorf("domains: list claims: %w", err)
559 }
560 var claims []Claim
561 for _, key := range keys {
562 if c, ok, _ := readClaim(ctx, s, key); ok {
563 claims = append(claims, c)
564 }
565 }
566 return claims, nil
567}
568
569// readClaim reads and decodes one claim object. Every refusal is a skip and
570// never an error to the caller: one unparseable or too-new object under the
571// prefix must not take down the whole fold.
572//
573// The two failure kinds are told apart, and the distinction is load-bearing
574// for ClaimCache. A DECODE refusal (unparseable, or a schema this binary
575// does not understand) is deterministic — the same bytes will refuse the
576// same way next pass — so it is safe to remember. A READ failure is a
577// network blip and is not: remembering it would hide a perfectly good claim
578// until something unrelated moved its bucket stamp, which for a claim that
579// nobody edits is indistinguishable from forever. So a read failure reports
580// firm=false and the cache declines to keep it.
581//
582// Shared with ClaimCache so the two folds cannot drift into different ideas
583// of what counts as a claim — which would show up as a hostname that appears
584// on a cached read and vanishes on an uncached one.
585func readClaim(ctx context.Context, s release.Store, key string) (c Claim, ok, firm bool) {
586 raw, _, err := s.Get(ctx, key)
587 if err != nil {
588 // Not-found is quiet: a claim detached between the listing and this
589 // read, or, through the fleet store door, another account's claim,
590 // whose NAME the door lists but whose body it withholds (#215). The
591 // listing still counts it as claimed (listWithHosts).
592 if !errors.Is(err, os.ErrNotExist) {
593 log.Printf("domains: claim %s: %v", key, err)
594 }
595 return Claim{}, false, false
596 }
597 if err := json.Unmarshal(raw, &c); err != nil {
598 log.Printf("domains: claim %s: unparseable: %v", key, err)
599 return Claim{}, false, true
600 }
601 if c.Schema > 1 {
602 log.Printf("domains: claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema)
603 return Claim{}, false, true
604 }
605 return c, true, true
606}
607
608// hostLabelRe mirrors provision.labelRe (which itself mirrors
609// domains.accountNameRe — see that var's comment) — pinned HERE rather than
610// imported from internal/provision, because internal/provision/reconcile.go
611// already imports internal/domains for account/route materialization;
612// importing provision.ValidHostShape back would be a cycle. Same DNS label
613// rule: lowercase a-z, 0-9, hyphen, no leading/trailing hyphen, 1-63 chars.
614var hostLabelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
615
616// validHostShape is provision.ValidHostShape's check, duplicated for the
617// import-cycle reason hostLabelRe explains: every label of host must match
618// hostLabelRe, and the whole host must fit in the DNS 253-character ceiling.
619func validHostShape(host string) bool {
620 if host == "" || len(host) > 253 {
621 return false
622 }
623 for _, label := range strings.Split(host, ".") {
624 if !hostLabelRe.MatchString(label) {
625 return false
626 }
627 }
628 return true
629}
630
631// NormalizeAliasHost lowercases raw, strips one trailing dot, and validates
632// the result: validHostShape (the same label rules a route host is held to)
633// AND at least one interior dot — a bare label like "localhost" is never a
634// public hostname, however it parses.
635func NormalizeAliasHost(raw string) (string, bool) {
636 h := strings.ToLower(raw)
637 h = strings.TrimSuffix(h, ".")
638 if !validHostShape(h) || !strings.Contains(h, ".") {
639 return "", false
640 }
641 return h, true
642}
643
644// HostDenied reports whether host equals any entry in deny, or is a
645// subdomain of one (ends in "."+entry). Empty entries are skipped, so a
646// deny list padded with "" (e.g. from a blank config line) can never match
647// every host.
648func HostDenied(host string, deny []string) bool {
649 for _, entry := range deny {
650 if entry == "" {
651 continue
652 }
653 if host == entry || strings.HasSuffix(host, "."+entry) {
654 return true
655 }
656 }
657 return false
658}
659
660// ErrCatchallNeedsWildcard is returned by SetClaimCapabilities for a
661// catchall request without wildcard: a catch-all nobody can serve TLS for
662// is a config error, not a state — refused at the write path, never stored.
663var ErrCatchallNeedsWildcard = errors.New("catchall requires wildcard (no cert could serve the catch-all)")
664
665// SetClaimCapabilities is the ONLY writer of a claim's Wildcard/Catchall
666// fields. AttachClaim's converge deliberately repoints Route and nothing
667// else — it can preserve these fields but never set them (#151's ruling) —
668// so capability changes go through this CAS read-modify-write, which
669// preserves every other field. Owner-gated: a caller who does not hold the
670// claim gets the same neutral ErrHostUnavailable the attach door uses, and
671// a missing claim answers identically (no existence oracle).
672func claimRawSetClaimCapabilities(ctx context.Context, s release.Store, host, account, app string, wildcard, catchall bool) (changed bool, err error) {
673 if catchall && !wildcard {
674 return false, ErrCatchallNeedsWildcard
675 }
676 for attempt := 0; ; attempt++ {
677 cur, etag, err := getClaimETag(ctx, s, host)
678 if errors.Is(err, os.ErrNotExist) {
679 return false, ErrHostUnavailable
680 } else if err != nil {
681 return false, err
682 }
683 // An instance catch-all has no capabilities to set: it answers as a
684 // claim the caller does not hold. No door reaches this with a "*"
685 // host; the check keeps it true for any caller that ever does.
686 if cur.Account != account || cur.App != app || cur.Instances {
687 return false, ErrHostUnavailable
688 }
689 if cur.Wildcard == wildcard && cur.Catchall == catchall {
690 return false, nil
691 }
692 next := *cur
693 next.Wildcard = wildcard
694 next.Catchall = catchall
695 body, err := json.Marshal(next)
696 if err != nil {
697 return false, err
698 }
699 if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
700 return true, nil
701 } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
702 continue
703 } else {
704 return false, err
705 }
706 }
707}
708
709// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains
710// this defends no external budget — there is no ACME equivalent for a path
711// row — it bounds the claim object's size and the per-box fold that reads it
712// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB
713// object refolded every couple of seconds, which is comfortable; sharding a
714// host's map across objects is deliberately not built until a real host
715// approaches this number.
716const MaxPathsPerClaim = 10000
717
718// ErrTooManyPaths is returned by SetClaimPaths for a map above
719// MaxPathsPerClaim. Refused whole: a silently truncated map would route a
720// subset of accounts and look successful.
721var ErrTooManyPaths = errors.New("too many path prefixes")
722
723// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare
724// single path segment.
725var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment")
726
727// ErrBadPathTarget is returned by SetClaimPaths for a VALUE that is not a
728// normalised hostname. A sentinel rather than a bare fmt.Errorf so the API
729// door above can answer it as the caller's error (422, naming the entry) —
730// without it, the only way to tell a malformed target from a store failure
731// would be to match on the message text.
732var ErrBadPathTarget = errors.New("path target is not a normalised hostname")
733
734// validPathPrefix reports whether p is a bare first path segment: non-empty,
735// no slashes, and nothing that would not survive a URL path intact. The edge
736// matches the request's first segment by EQUALITY, so anything that cannot be
737// a first segment could never match and must not be stored.
738func validPathPrefix(p string) bool {
739 if p == "" || len(p) > 255 {
740 return false
741 }
742 for i := 0; i < len(p); i++ {
743 c := p[i]
744 switch {
745 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
746 case c == '-' || c == '_' || c == '.' || c == '~':
747 default:
748 return false
749 }
750 }
751 // "." and ".." would be normalised away by any path cleaner between the
752 // browser and here, so a row keyed on either is unreachable by
753 // construction.
754 return p != "." && p != ".."
755}
756
757// SetClaimPaths is the ONLY writer of a claim's Paths field, the same
758// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall
759// and for the same reason: AttachClaim's converge repoints Route and nothing
760// else, so it can preserve this map but must never invent or strip it.
761//
762// The write is WHOLESALE — paths replaces the stored map rather than merging
763// into it. That is the contract the caller's platform relies on: it
764// regenerates the map from its own database and publishes the result, so an
765// entry it no longer lists must disappear. Merging would make rollback
766// impossible to express.
767//
768// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold
769// the claim gets the neutral ErrHostUnavailable, and a missing claim answers
770// identically, so this door is not an existence oracle.
771func claimRawSetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) {
772 if len(paths) > MaxPathsPerClaim {
773 return false, ErrTooManyPaths
774 }
775 for prefix, target := range paths {
776 if !validPathPrefix(prefix) {
777 return false, ErrBadPathPrefix
778 }
779 // The target must be a plausible hostname. Whether it is a route this
780 // account owns is decided by the reconcile pass, which — unlike this
781 // door — can see the registry.
782 if n, ok := NormalizeAliasHost(target); !ok || n != target {
783 return false, fmt.Errorf("domains: path prefix %q: target %q: %w", prefix, target, ErrBadPathTarget)
784 }
785 }
786 for attempt := 0; ; attempt++ {
787 cur, etag, err := getClaimETag(ctx, s, host)
788 if errors.Is(err, os.ErrNotExist) {
789 return false, ErrHostUnavailable
790 } else if err != nil {
791 return false, err
792 }
793 // Nor prefix maps: the same answer, for the same reason.
794 if cur.Account != account || cur.App != app || cur.Instances {
795 return false, ErrHostUnavailable
796 }
797 if samePaths(cur.Paths, paths) {
798 return false, nil
799 }
800 next := *cur
801 if len(paths) == 0 {
802 next.Paths = nil // omitempty: an empty map and no map are one state
803 } else {
804 next.Paths = make(map[string]string, len(paths))
805 for k, v := range paths {
806 next.Paths[k] = v
807 }
808 }
809 body, err := json.Marshal(next)
810 if err != nil {
811 return false, err
812 }
813 if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil {
814 return true, nil
815 } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 {
816 continue
817 } else {
818 return false, err
819 }
820 }
821}
822
823// samePaths reports whether two prefix maps are equal, treating nil and empty
824// as the same state. Republishing an unchanged map is the COMMON case — the
825// caller republishes wholesale as a repair — so this is what keeps a repair
826// from costing a bucket write and a fleet-wide edge reload.
827func samePaths(a, b map[string]string) bool {
828 if len(a) != len(b) {
829 return false
830 }
831 for k, v := range a {
832 if b[k] != v {
833 return false
834 }
835 }
836 return true
837}
838func AttachInstanceCatchall(ctx context.Context, s release.Store, parent, account, app, createdAt, createdBy string) (claimResult0 bool, claimResult1 error) {
839 claimResult1 = release.WithAppMutation(ctx, s, account, app, "domains.AttachInstanceCatchall", func(claimContext context.Context) error {
840 claimResult0, claimResult1 = claimRawAttachInstanceCatchall(claimContext, s, parent, account, app, createdAt, createdBy)
841 return claimResult1
842 })
843 return
844}
845func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (claimResult0 bool, claimResult1 error) {
846 claimResult1 = release.WithAppMutation(ctx, s, account, app, "domains.DetachClaim", func(claimContext context.Context) error {
847 claimResult0, claimResult1 = claimRawDetachClaim(claimContext, s, host, account, app)
848 return claimResult1
849 })
850 return
851}
852func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, app string, wildcard, catchall bool) (claimResult0 bool, claimResult1 error) {
853 claimResult1 = release.WithAppMutation(ctx, s, account, app, "domains.SetClaimCapabilities", func(claimContext context.Context) error {
854 claimResult0, claimResult1 = claimRawSetClaimCapabilities(claimContext, s, host, account, app, wildcard, catchall)
855 return claimResult1
856 })
857 return
858}
859func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (claimResult0 bool, claimResult1 error) {
860 claimResult1 = release.WithAppMutation(ctx, s, account, app, "domains.SetClaimPaths", func(claimContext context.Context) error {
861 claimResult0, claimResult1 = claimRawSetClaimPaths(claimContext, s, host, account, app, paths)
862 return claimResult1
863 })
864 return
865}
866