package console import ( "context" "strings" "time" "github.com/carlosframework/platform/internal/certwarm" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/status" ) // One state per custom domain (#299 §2a), DERIVED on read and never stored. // // The issue asked the platform to stop making every app rebuild lifecycle // states out of raw readings. These are those states: one word per domain, // with a stable machine reason and a human sentence beside it, computed in // ONE place so `carlos domains list`, the console Settings page and // `carlos deploy`'s exit can never tell a member three different stories // about the same hostname. const ( // StatePendingDNS — the resolver answered and there is no record yet. // The customer has something to do; guidance is right. StatePendingDNS = "pending-dns" // StateMisdirected — a real answer, pointing somewhere that is not us. // // NOT the issue's proposed "verifying": that word named titogo's // ownership-proof phase, which is out of scope here, so the phase it // names does not exist in this design. Reusing it for "your record // resolves elsewhere" would label a customer-side condition as platform // activity — the opposite of what the state is for. StateMisdirected = "misdirected" // StateIssuingCert — points at us, no usable certificate yet. This is // platform activity, and naming it is only honest because stage 3's warm // actually performs it. StateIssuingCert = "issuing-cert" // StateLive — points at us AND a current certificate covers it. StateLive = "live" // StateChecking — attached, and no edge has reported on it yet. A // transient state that resolves within one sweep (~5 minutes). // // Split out from StateUnknown deliberately. Both mean "we have no // verdict", but they are opposite news: this one is the ordinary first // few minutes after an attach, and the other is a fault. Collapsing them // would make every successful attach open with the word "unknown". StateChecking = "checking" // StateFailed — the platform tried to obtain a certificate for this // hostname and it is not working. // // Reachable only because the issuer now writes durable warm-outcome // records (#299 §3e, internal/certwarm). Before those existed the // attempt count lived in the issuer's process memory and reset on every // roll and every failover, so a domain broken for a week and one // attached ninety seconds ago looked identical and this word could not // be said honestly. It is the state's whole justification: the thing it // reports is "we have been trying and stopping for hours", which is a // claim about history, and history is what the records carry. StateFailed = "failed" // StateUnknown — we have no verdict AND that is not routine: our own // lookup failed, or a box reported something this build cannot read. // Deliberately distinct from pending-dns: "we could not check" must // never be shown to a customer as "you have not done your part". StateUnknown = "unknown" // StateUnverified — the claim is stored, but whoever attached it has not // yet proved they control the hostname (#215). Nothing serves it and no // certificate is ordered for it until the TXT record in its records is // published and the attach is repeated. It outranks every DNS-derived // state: a box never reports on a pending claim, so any reading for the // host belongs to someone else's route, not to this claim. StateUnverified = "unverified" ) // domainStates is the vocabulary this build can actually produce, in // lifecycle order. Published in the capability block so a client can tell // which states a deployment might return without guessing from a doc. // // "failed" was absent until the warm-outcome records (§3e) made it // reachable, on the rule this list exists to enforce: a deployment // advertises only the states it can actually return, because a state nobody // will ever see is the same dishonesty that got "verifying" renamed. It is // listed now because the issuer writes those records and domainState reads // them. var domainStates = []string{ StateUnverified, StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateFailed, StateLive, StateUnknown, } // domainFailedAttempts is how many consecutive failed warm attempts it takes // before a hostname is called "failed" rather than "issuing-cert". // // Three, because the warm retries hourly: three is about three hours of // trying, which is long past any transient the member could have caused and // long past a CA blip. One would be wrong in the other direction — a single // failed order is ordinary, and telling somebody their domain has failed // because the first attempt lost a race would make the word useless the // first week it shipped. const domainFailedAttempts = 3 // domainState derives one hostname's state, a stable machine reason, and a // human sentence. // // seen reports whether any non-stale box reported on this host at all. The // distinction between "no box mentioned it" and "a box says there is no DNS // record" is the difference between our problem and the customer's, so it is // never collapsed. // warm is this hostname's durable warm-outcome record, or nil when there is // none — and nil is the ordinary case, because a record exists only while a // host is failing. func domainState(reading status.AliasReading, seen bool, warm *certwarm.Record, now time.Time) (state, reason, detail string) { if !seen { return StateChecking, "no-reading", "No edge has reported on this hostname yet. Checks run every few minutes, so this should settle shortly after attaching." } switch reading.DNSStatus { case "unresolved": // OUR lookup failed. Never phrased as a customer action. return StateUnknown, "lookup-failed", "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." case "": return StatePendingDNS, "no-record", "No DNS record found for this hostname yet. Create the records below, then checks pick it up within a few minutes." case "elsewhere": return StateMisdirected, "points-elsewhere", "This hostname resolves, but not to this deployment. Update the records below to point it here." case "pointing": if reading.CertExpiresAt > now.Unix() { return StateLive, "serving", "This hostname points here and is served over HTTPS with a current certificate." } // DNS is right and there is no usable certificate. Ordinarily that // is the platform working; the warm record is the only thing that // can distinguish it from the platform failing, and only while it is // FRESH — a frozen record from a wedged issuer must not be read as // "still trying" OR as "gave up". if warm != nil && warm.Fresh(now) { switch { case warm.Class == certwarm.ClassRateLimited: // Reported on the first occurrence, not at the threshold. // The retry is twelve hours away, and a member watching a // "usually a minute or two" message for half a day is worse // served than one told plainly what is happening. return StateFailed, "cert-rate-limited", "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." case warm.Attempts >= domainFailedAttempts: return StateFailed, "cert-failed", "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." } } return StateIssuingCert, "awaiting-certificate", "DNS is correct. A certificate is being obtained for this hostname — usually a minute or two, and no action is needed." } // A status this build does not know: a newer box stamped it. Report // unknown rather than guessing at a verdict we cannot interpret. return StateUnknown, "unrecognised-status", "This hostname reported a status this console does not recognise. It may be running a newer build." } // claimState is domainState for a claim: an unproven claim reads // "unverified" whatever the readings say (see StateUnverified), and every // other claim reads what its readings derive. func claimState(c domains.Claim, reading status.AliasReading, seen bool, warm *certwarm.Record, now time.Time) (state, reason, detail string) { if c.Pending { return StateUnverified, "ownership-unproven", "Prove you control this hostname by adding the TXT record below. Nothing is served for it until then." } return domainState(reading, seen, warm, now) } // dnsRecord is one record a customer must create. Structured so an app does // not have to parse the English sentence beside it. type dnsRecord struct { Type string `json:"type"` Name string `json:"name"` Value string `json:"value"` TTL int `json:"ttl"` // Set groups records that must be created TOGETHER, and distinguishes // alternatives from requirements. With steering armed the platform offers // a CNAME *or* a set of A records; a flat array cannot say "one of these // two", and a client that created every row would publish a CNAME // alongside A records for the same name — illegal, and exactly the // contradiction the English guidance avoids by saying "works too". // // Records sharing a Set are one alternative. A client picks ONE set per // name and creates every record in it. Set string `json:"set"` // Required marks a record that must exist whichever alternative is // chosen (the ACME challenge CNAME for a wildcard-enabled claim). Required bool `json:"required,omitempty"` } // domainRecordTTL is the TTL the platform asks for, and the reason is not // cosmetic: steering withdraws a dead edge from DNS in about 90 seconds, and // a customer A record cached for an hour nullifies that entirely — the // failover the platform performs would be invisible to their visitors for // the rest of the TTL. const domainRecordTTL = 300 // domainRecords is the structured form of domainGuidance — the SAME advice, // as data. The two are built side by side deliberately: if they ever // disagreed, an app would create records that contradict the sentence shown // to the person operating it. // // The alias/CNAME alternative is emitted ONLY when steering is armed. // edge. exists only where the steering converger maintains its // records; on a non-steering deployment naming it would hand out a hostname // with no records behind it. func domainRecords(host string, t DNSTargets, wildcard bool, delegationZone string) []dnsRecord { var out []dnsRecord steering := t.SteeringHost != "" && !isApexDomain(host) if steering { out = append(out, dnsRecord{ Type: "CNAME", Name: host, Value: t.SteeringHost, TTL: domainRecordTTL, Set: "cname", }) } for _, ip := range t.EdgeIPs { out = append(out, dnsRecord{ Type: "A", Name: host, Value: ip, TTL: domainRecordTTL, Set: "a", }) } // A wildcard-enabled claim also needs its delegation CNAME, and it is // required alongside EITHER alternative above — the platform already // instructs this at the attach door and stamps delegation_status from it, // so omitting it here would make "what you must create" incomplete // against our own surface. if wildcard && delegationZone != "" { out = append(out, dnsRecord{ Type: "CNAME", Name: "_acme-challenge." + host, Value: "_acme-challenge." + host + "." + delegationZone, TTL: domainRecordTTL, Set: "acme", Required: true, }) } return out } // deploymentCapability is the self-describing block folded into the domains // list answer (#299 §2c). // // Folded into an EXISTING answer rather than given its own endpoint. Every // caller is already app-bound and the list answers fine with zero claims, so // a separate "capabilities" door would need a new role decision and a new // rate-limit budget to tell a client something this answer can carry for // free. Present-or-absent then comes for free too: an older console omits // the key, and a console with no domains door at all 404s — the signal // `carlos deploy` already uses. type deploymentCapability struct { // ManagedDomains is the flag an app checks before deciding it must // implement DNS verification itself. This is what retires titogo's // TITO_CUSTOM_DOMAINS switch. ManagedDomains bool `json:"managed_domains"` AppsDomain string `json:"apps_domain,omitempty"` SteeringHost string `json:"steering_host,omitempty"` EdgeIPs []string `json:"edge_ips,omitempty"` DelegationZone string `json:"delegation_zone,omitempty"` RecordTTL int `json:"record_ttl"` // States is the vocabulary THIS deployment can return (see domainStates). States []string `json:"states"` // Features is the app's own switches (#347, apifeatures.go) — the one // per-APP entry in an otherwise per-deployment block, placed here // because this block is the fetch the app already makes. Three // readings, deliberately distinct on the wire: {} is "none set", null // is "the console could not read the record", and a missing key is an // older console. FeaturesSetAt is when the map last changed, the // checked_at-style freshness stamp an app's own poll ages against. Features map[string]string `json:"features"` FeaturesSetAt string `json:"features_set_at,omitempty"` } func (s *Server) domainCapability(ctx context.Context, acct, app string) deploymentCapability { t := s.dnsTargets() features, setAt := s.appFeatures(ctx, acct, app) return deploymentCapability{ ManagedDomains: true, AppsDomain: strings.TrimSpace(s.cfg.AppsDomain), SteeringHost: t.SteeringHost, EdgeIPs: t.EdgeIPs, DelegationZone: s.delegationZone, RecordTTL: domainRecordTTL, States: domainStates, Features: features, FeaturesSetAt: setAt, } }