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
3import (
4 "context"
5 "strings"
6 "time"
7
8 "github.com/carlosframework/platform/internal/certwarm"
9 "github.com/carlosframework/platform/internal/domains"
10 "github.com/carlosframework/platform/internal/status"
11)
12
13// One state per custom domain (#299 §2a), DERIVED on read and never stored.
14//
15// The issue asked the platform to stop making every app rebuild lifecycle
16// states out of raw readings. These are those states: one word per domain,
17// with a stable machine reason and a human sentence beside it, computed in
18// ONE place so `carlos domains list`, the console Settings page and
19// `carlos deploy`'s exit can never tell a member three different stories
20// about the same hostname.
21const (
22 // StatePendingDNS — the resolver answered and there is no record yet.
23 // The customer has something to do; guidance is right.
24 StatePendingDNS = "pending-dns"
25 // StateMisdirected — a real answer, pointing somewhere that is not us.
26 //
27 // NOT the issue's proposed "verifying": that word named titogo's
28 // ownership-proof phase, which is out of scope here, so the phase it
29 // names does not exist in this design. Reusing it for "your record
30 // resolves elsewhere" would label a customer-side condition as platform
31 // activity — the opposite of what the state is for.
32 StateMisdirected = "misdirected"
33 // StateIssuingCert — points at us, no usable certificate yet. This is
34 // platform activity, and naming it is only honest because stage 3's warm
35 // actually performs it.
36 StateIssuingCert = "issuing-cert"
37 // StateLive — points at us AND a current certificate covers it.
38 StateLive = "live"
39 // StateChecking — attached, and no edge has reported on it yet. A
40 // transient state that resolves within one sweep (~5 minutes).
41 //
42 // Split out from StateUnknown deliberately. Both mean "we have no
43 // verdict", but they are opposite news: this one is the ordinary first
44 // few minutes after an attach, and the other is a fault. Collapsing them
45 // would make every successful attach open with the word "unknown".
46 StateChecking = "checking"
47 // StateFailed — the platform tried to obtain a certificate for this
48 // hostname and it is not working.
49 //
50 // Reachable only because the issuer now writes durable warm-outcome
51 // records (#299 §3e, internal/certwarm). Before those existed the
52 // attempt count lived in the issuer's process memory and reset on every
53 // roll and every failover, so a domain broken for a week and one
54 // attached ninety seconds ago looked identical and this word could not
55 // be said honestly. It is the state's whole justification: the thing it
56 // reports is "we have been trying and stopping for hours", which is a
57 // claim about history, and history is what the records carry.
58 StateFailed = "failed"
59 // StateUnknown — we have no verdict AND that is not routine: our own
60 // lookup failed, or a box reported something this build cannot read.
61 // Deliberately distinct from pending-dns: "we could not check" must
62 // never be shown to a customer as "you have not done your part".
63 StateUnknown = "unknown"
64 // StateUnverified — the claim is stored, but whoever attached it has not
65 // yet proved they control the hostname (#215). Nothing serves it and no
66 // certificate is ordered for it until the TXT record in its records is
67 // published and the attach is repeated. It outranks every DNS-derived
68 // state: a box never reports on a pending claim, so any reading for the
69 // host belongs to someone else's route, not to this claim.
70 StateUnverified = "unverified"
71)
72
73// domainStates is the vocabulary this build can actually produce, in
74// lifecycle order. Published in the capability block so a client can tell
75// which states a deployment might return without guessing from a doc.
76//
77// "failed" was absent until the warm-outcome records (§3e) made it
78// reachable, on the rule this list exists to enforce: a deployment
79// advertises only the states it can actually return, because a state nobody
80// will ever see is the same dishonesty that got "verifying" renamed. It is
81// listed now because the issuer writes those records and domainState reads
82// them.
83var domainStates = []string{
84 StateUnverified, StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateFailed, StateLive, StateUnknown,
85}
86
87// domainFailedAttempts is how many consecutive failed warm attempts it takes
88// before a hostname is called "failed" rather than "issuing-cert".
89//
90// Three, because the warm retries hourly: three is about three hours of
91// trying, which is long past any transient the member could have caused and
92// long past a CA blip. One would be wrong in the other direction — a single
93// failed order is ordinary, and telling somebody their domain has failed
94// because the first attempt lost a race would make the word useless the
95// first week it shipped.
96const domainFailedAttempts = 3
97
98// domainState derives one hostname's state, a stable machine reason, and a
99// human sentence.
100//
101// seen reports whether any non-stale box reported on this host at all. The
102// distinction between "no box mentioned it" and "a box says there is no DNS
103// record" is the difference between our problem and the customer's, so it is
104// never collapsed.
105// warm is this hostname's durable warm-outcome record, or nil when there is
106// none — and nil is the ordinary case, because a record exists only while a
107// host is failing.
108func domainState(reading status.AliasReading, seen bool, warm *certwarm.Record, now time.Time) (state, reason, detail string) {
109 if !seen {
110 return StateChecking, "no-reading",
111 "No edge has reported on this hostname yet. Checks run every few minutes, so this should settle shortly after attaching."
112 }
113 switch reading.DNSStatus {
114 case "unresolved":
115 // OUR lookup failed. Never phrased as a customer action.
116 return StateUnknown, "lookup-failed",
117 "We could not look this hostname up just now. This is a problem on our side, not with your DNS — checks retry every few minutes."
118 case "":
119 return StatePendingDNS, "no-record",
120 "No DNS record found for this hostname yet. Create the records below, then checks pick it up within a few minutes."
121 case "elsewhere":
122 return StateMisdirected, "points-elsewhere",
123 "This hostname resolves, but not to this deployment. Update the records below to point it here."
124 case "pointing":
125 if reading.CertExpiresAt > now.Unix() {
126 return StateLive, "serving",
127 "This hostname points here and is served over HTTPS with a current certificate."
128 }
129 // DNS is right and there is no usable certificate. Ordinarily that
130 // is the platform working; the warm record is the only thing that
131 // can distinguish it from the platform failing, and only while it is
132 // FRESH — a frozen record from a wedged issuer must not be read as
133 // "still trying" OR as "gave up".
134 if warm != nil && warm.Fresh(now) {
135 switch {
136 case warm.Class == certwarm.ClassRateLimited:
137 // Reported on the first occurrence, not at the threshold.
138 // The retry is twelve hours away, and a member watching a
139 // "usually a minute or two" message for half a day is worse
140 // served than one told plainly what is happening.
141 return StateFailed, "cert-rate-limited",
142 "Our certificate authority is rate-limiting this deployment, so the certificate for this hostname is waiting. It retries automatically — there is nothing to change on your side."
143 case warm.Attempts >= domainFailedAttempts:
144 return StateFailed, "cert-failed",
145 "DNS is correct, but we have not been able to obtain a certificate for this hostname after several attempts. We keep retrying, and we are looking at it — you do not need to change anything."
146 }
147 }
148 return StateIssuingCert, "awaiting-certificate",
149 "DNS is correct. A certificate is being obtained for this hostname — usually a minute or two, and no action is needed."
150 }
151 // A status this build does not know: a newer box stamped it. Report
152 // unknown rather than guessing at a verdict we cannot interpret.
153 return StateUnknown, "unrecognised-status",
154 "This hostname reported a status this console does not recognise. It may be running a newer build."
155}
156
157// claimState is domainState for a claim: an unproven claim reads
158// "unverified" whatever the readings say (see StateUnverified), and every
159// other claim reads what its readings derive.
160func claimState(c domains.Claim, reading status.AliasReading, seen bool, warm *certwarm.Record, now time.Time) (state, reason, detail string) {
161 if c.Pending {
162 return StateUnverified, "ownership-unproven",
163 "Prove you control this hostname by adding the TXT record below. Nothing is served for it until then."
164 }
165 return domainState(reading, seen, warm, now)
166}
167
168// dnsRecord is one record a customer must create. Structured so an app does
169// not have to parse the English sentence beside it.
170type dnsRecord struct {
171 Type string `json:"type"`
172 Name string `json:"name"`
173 Value string `json:"value"`
174 TTL int `json:"ttl"`
175 // Set groups records that must be created TOGETHER, and distinguishes
176 // alternatives from requirements. With steering armed the platform offers
177 // a CNAME *or* a set of A records; a flat array cannot say "one of these
178 // two", and a client that created every row would publish a CNAME
179 // alongside A records for the same name — illegal, and exactly the
180 // contradiction the English guidance avoids by saying "works too".
181 //
182 // Records sharing a Set are one alternative. A client picks ONE set per
183 // name and creates every record in it.
184 Set string `json:"set"`
185 // Required marks a record that must exist whichever alternative is
186 // chosen (the ACME challenge CNAME for a wildcard-enabled claim).
187 Required bool `json:"required,omitempty"`
188}
189
190// domainRecordTTL is the TTL the platform asks for, and the reason is not
191// cosmetic: steering withdraws a dead edge from DNS in about 90 seconds, and
192// a customer A record cached for an hour nullifies that entirely — the
193// failover the platform performs would be invisible to their visitors for
194// the rest of the TTL.
195const domainRecordTTL = 300
196
197// domainRecords is the structured form of domainGuidance — the SAME advice,
198// as data. The two are built side by side deliberately: if they ever
199// disagreed, an app would create records that contradict the sentence shown
200// to the person operating it.
201//
202// The alias/CNAME alternative is emitted ONLY when steering is armed.
203// edge.<apps-domain> exists only where the steering converger maintains its
204// records; on a non-steering deployment naming it would hand out a hostname
205// with no records behind it.
206func domainRecords(host string, t DNSTargets, wildcard bool, delegationZone string) []dnsRecord {
207 var out []dnsRecord
208 steering := t.SteeringHost != "" && !isApexDomain(host)
209 if steering {
210 out = append(out, dnsRecord{
211 Type: "CNAME", Name: host, Value: t.SteeringHost,
212 TTL: domainRecordTTL, Set: "cname",
213 })
214 }
215 for _, ip := range t.EdgeIPs {
216 out = append(out, dnsRecord{
217 Type: "A", Name: host, Value: ip,
218 TTL: domainRecordTTL, Set: "a",
219 })
220 }
221 // A wildcard-enabled claim also needs its delegation CNAME, and it is
222 // required alongside EITHER alternative above — the platform already
223 // instructs this at the attach door and stamps delegation_status from it,
224 // so omitting it here would make "what you must create" incomplete
225 // against our own surface.
226 if wildcard && delegationZone != "" {
227 out = append(out, dnsRecord{
228 Type: "CNAME",
229 Name: "_acme-challenge." + host,
230 Value: "_acme-challenge." + host + "." + delegationZone,
231 TTL: domainRecordTTL, Set: "acme", Required: true,
232 })
233 }
234 return out
235}
236
237// deploymentCapability is the self-describing block folded into the domains
238// list answer (#299 §2c).
239//
240// Folded into an EXISTING answer rather than given its own endpoint. Every
241// caller is already app-bound and the list answers fine with zero claims, so
242// a separate "capabilities" door would need a new role decision and a new
243// rate-limit budget to tell a client something this answer can carry for
244// free. Present-or-absent then comes for free too: an older console omits
245// the key, and a console with no domains door at all 404s — the signal
246// `carlos deploy` already uses.
247type deploymentCapability struct {
248 // ManagedDomains is the flag an app checks before deciding it must
249 // implement DNS verification itself. This is what retires titogo's
250 // TITO_CUSTOM_DOMAINS switch.
251 ManagedDomains bool `json:"managed_domains"`
252 AppsDomain string `json:"apps_domain,omitempty"`
253 SteeringHost string `json:"steering_host,omitempty"`
254 EdgeIPs []string `json:"edge_ips,omitempty"`
255 DelegationZone string `json:"delegation_zone,omitempty"`
256 RecordTTL int `json:"record_ttl"`
257 // States is the vocabulary THIS deployment can return (see domainStates).
258 States []string `json:"states"`
259 // Features is the app's own switches (#347, apifeatures.go) — the one
260 // per-APP entry in an otherwise per-deployment block, placed here
261 // because this block is the fetch the app already makes. Three
262 // readings, deliberately distinct on the wire: {} is "none set", null
263 // is "the console could not read the record", and a missing key is an
264 // older console. FeaturesSetAt is when the map last changed, the
265 // checked_at-style freshness stamp an app's own poll ages against.
266 Features map[string]string `json:"features"`
267 FeaturesSetAt string `json:"features_set_at,omitempty"`
268}
269
270func (s *Server) domainCapability(ctx context.Context, acct, app string) deploymentCapability {
271 t := s.dnsTargets()
272 features, setAt := s.appFeatures(ctx, acct, app)
273 return deploymentCapability{
274 ManagedDomains: true,
275 AppsDomain: strings.TrimSpace(s.cfg.AppsDomain),
276 SteeringHost: t.SteeringHost,
277 EdgeIPs: t.EdgeIPs,
278 DelegationZone: s.delegationZone,
279 RecordTTL: domainRecordTTL,
280 States: domainStates,
281 Features: features,
282 FeaturesSetAt: setAt,
283 }
284}
285