| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | const ( |
| 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. |
| 83 | var 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. |
| 96 | const 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. |
| 108 | func 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. |
| 160 | func 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. |
| 170 | type 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. |
| 195 | const 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. |
| 206 | func 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. |
| 247 | type 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 | |
| 270 | func (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 | |