carlos / platform Public

infra+console: steering IAM surface + custom-domain guidance

The tofu half: a steering_zone_ids list var (region-host, same bare-Z-id
validation as route53_zone_ids) and the inline policy it gates. The action
list is exactly what internal/steering's route53API interface calls —
ChangeResourceRecordSets (conditioned to A + UPSERT/DELETE, which is all
converge ever writes) and ListResourceRecordSets on the named zones,
ListHealthChecks/CreateHealthCheck globally, and
DeleteHealthCheck/ChangeTagsForResource/ListTagsForResources on the
healthcheck ARN space. Two review carries are in here: the action is
ListTagsForResources (PLURAL — the singular grants the batched call
nothing, and every pass would abort AccessDenied), and UpdateHealthCheck is
not granted because nothing calls it. Route53 supports no tag conditions on
health checks; the comment says so plainly rather than implying the
carlos-steering tag is an IAM bound when it is only a code one.

The console half: custom-domain guidance gains the CNAME-to-edge.<apps-domain>
option beside the A record, on a deployment that steers. Apexes keep
A-records with the spec's honesty rule stated in the sentence
(round-robin, no latency bias); apex-ness comes from the public suffix
list, so example.co.uk is an apex and app.example.com is not. Threaded as
a DNSTargets pair (edge IPs + steering hostname) so the app page, Settings
and the /domains API cannot drift.

Inert everywhere today: the hostname is derived from CARLOS_STEERING_ZONE_ID
— the same read, with the same validation, the converger performs — so no
deployment currently sets it, and every guidance string stays byte-for-byte
what it was. The module change is `tofu validate` + `tofu fmt` clean
locally; there is no test harness for tofu.
Paul Campbell pushed by paul@keymail.dev e83dc6ff26cc7e6289f108a2dd66dd379081c902 parent dc30c28
10 files changed, +311 −36
  • cmd/console/main.go +25 −0
  • infra/modules/README.md +2 −0
  • infra/modules/region-host/main.tf +73 −0
  • infra/modules/region-host/variables.tf +10 −0
  • internal/console/apidomains.go +1 −1
  • internal/console/appsettings.go +1 −1
  • internal/console/server.go +41 −2
  • internal/console/server_test.go +63 −4
  • internal/console/view.go +86 −19
  • internal/console/view_test.go +9 −9
diff --git a/cmd/console/main.go b/cmd/console/main.go
index e10202bca..6ffc45649 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -67,6 +67,7 @@ import (
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/steering"
)
func main() {
@@ -246,6 +247,7 @@ func serve() {
EdgeIPs: edgeIPs(),
SecretsPub: secretsPub(),
AppsDomain: appsDomain(),
+ SteeringHost: steeringHost(),
RedirectHosts: redirectHosts(),
DenyDomains: denyDomains(),
StoreProvisioner: storeProvisioner,
@@ -346,6 +348,29 @@ func secretsPub() string {
// DNS record would produce records the reconcile pass skips — and protects
// — forever. Unset is a legal deployment: the door answers 503 naming the
// store-mode operator act.
+// steeringHost derives this deployment's DNS-steering hostname —
+// edge.<apps-domain>, the name the steering converger keeps one latency A
+// record per armed edge on (multi-region ingress spec, Part 4) — for the
+// console's custom-domain guidance.
+//
+// The armed test is steering.SteeringZoneID(): the SAME read, with the same
+// validation, that the converger itself performs on the box
+// (internal/host/steering_loop.go). That is deliberate — it is what stops
+// this console advertising a CNAME target no pass maintains. Unset (every
+// deployment today) or malformed leaves this "" and every guidance string
+// stays byte-for-byte the A-record sentence it has always been.
+func steeringHost() string {
+ if steering.SteeringZoneID() == "" {
+ return ""
+ }
+ d := appsDomain()
+ if d == "" {
+ log.Print("console: CARLOS_STEERING_ZONE_ID is set but PLATFORM_APPS_DOMAIN is not — no steering hostname to advertise; custom-domain guidance keeps the A-record form")
+ return ""
+ }
+ return "edge." + d
+}
+
func appsDomain() string {
d := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
if d == "" {
diff --git a/infra/modules/README.md b/infra/modules/README.md
index 8d43b6a22..b8de86a3a 100644
--- a/infra/modules/README.md
+++ b/infra/modules/README.md
@@ -65,6 +65,8 @@ bucket, so a wrong-region wake fails loudly at the IAM layer.
| `deployment_bucket_arn` | ARN of the deployment bucket. |
| `data_bucket_arn` | ARN of THIS region's data bucket. |
| `ssm_parameter_arn` | SSM parameter ARN pattern the host may read, e.g. `arn:aws:ssm:<region>:<account>:parameter/carlos/*`. |
+| `route53_zone_ids` | Hosted zones the box may publish ACME DNS-01 challenge TXTs into (wildcard certs). Bare `Z...` ids; empty = no Route53 access. |
+| `steering_zone_ids` | Hosted zones the DNS-steering converger may write latency A records into, plus the health checks that withdraw a dead edge. Bare `Z...` ids; empty = no steering grant (the default — steering also needs `CARLOS_STEERING_ZONE_ID` in the box env). |
| `ami_name_pattern` | AMI name filter (default `debian-13-arm64-*`). |
| `ami_owner` | AMI owner account (default `136693071363`, Debian). |
| `enable_auto_recovery` | Arm the EC2 auto-recovery alarm (HA ladder rung 0) — a system-status-check failure recovers the instance onto healthy hardware, same instance id, EIP and volumes. Default `true`; set `false` only where an external supervisor owns recovery. |
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf
index 1f5187efe..3fa21d42a 100644
--- a/infra/modules/region-host/main.tf
+++ b/infra/modules/region-host/main.tf
@@ -143,6 +143,79 @@ resource "aws_iam_role_policy" "host_route53" {
})
}
+# DNS steering (internal/steering/route53.go, run as a housekeeper duty on
+# whichever box holds the lease): latency A records per armed edge, each bound
+# to a Route53 health check on that edge's EIP. Every action here is one the
+# converger's route53API interface actually calls — nothing is granted "for
+# later", and nothing it calls is missing (a missing action is not a partial
+# feature: the whole pass aborts AccessDenied and no record ever converges).
+resource "aws_iam_role_policy" "host_steering" {
+ count = length(var.steering_zone_ids) > 0 ? 1 : 0
+ name = "${var.iam_name}-steering"
+ role = aws_iam_role.host.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ # The write. Conditioned as tightly as the converger's own behaviour
+ # allows: it only ever UPSERTs or DELETEs A records (desiredRecord is a
+ # latency A set; converge builds no other type or action), so an apex
+ # NS/SOA/MX in a steering zone is out of this role's reach by IAM and
+ # not merely by code.
+ Effect = "Allow"
+ Action = ["route53:ChangeResourceRecordSets"]
+ Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
+ Condition = {
+ "ForAllValues:StringEquals" = {
+ "route53:ChangeResourceRecordSetsRecordTypes" = ["A"]
+ "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"]
+ }
+ }
+ },
+ {
+ # The read, split out because ListResourceRecordSets supports none of
+ # the condition keys above — folded into one statement they would be
+ # decoration on this action, which is worse than saying so here.
+ Effect = "Allow"
+ Action = ["route53:ListResourceRecordSets"]
+ Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
+ },
+ {
+ # Health checks are NOT zone-scoped and Route53 supports NO
+ # tag-based condition keys on them (the service authorization
+ # reference lists no condition keys for the healthcheck resource
+ # type, and CreateHealthCheck takes no tags at all — the converger
+ # tags in a second call, ChangeTagsForResource). Said plainly rather
+ # than dressed up: within this account, this role may create, tag,
+ # read the tags of, and delete ANY Route53 health check. The
+ # carlos-steering=<zone-id> tag is the converger's OWN ownership
+ # rule (ownedHealthChecks / gcHealthChecks refuse to delete anything
+ # else); it is a code bound, not an IAM one, and it cannot be made
+ # one here.
+ #
+ # ListHealthChecks and CreateHealthCheck have no resource ARN to name
+ # at all, hence "*" for the pair; the per-check operations are held to
+ # the healthcheck ARN space.
+ Effect = "Allow"
+ Action = ["route53:ListHealthChecks", "route53:CreateHealthCheck"]
+ Resource = "*"
+ },
+ {
+ Effect = "Allow"
+ Action = [
+ "route53:DeleteHealthCheck",
+ "route53:ChangeTagsForResource",
+ # PLURAL — the batched form (ListTagsForResources) is what the
+ # converger calls, ten checks at a time. The singular
+ # ListTagsForResource grants it nothing.
+ "route53:ListTagsForResources",
+ ]
+ Resource = "arn:aws:route53:::healthcheck/*"
+ },
+ ]
+ })
+}
+
# Cost Explorer read for the economics dashboard's daily bill pull
# (internal/console/cepull.go). Off by default: only the box that runs the
# console needs it, and ce:GetCostAndUsage has no resource-level scoping —
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf
index 23e1156e1..3bfd76587 100644
--- a/infra/modules/region-host/variables.tf
+++ b/infra/modules/region-host/variables.tf
@@ -77,6 +77,16 @@ variable "route53_zone_ids" {
}
}
+variable "steering_zone_ids" {
+ description = "Hosted-zone IDs the DNS-steering converger (internal/steering, a housekeeper duty) may write latency A records into, and whose health checks it may manage. Empty = no steering grant, which is every deployment that leaves CARLOS_STEERING_ZONE_ID unset. Normally ONE id: the zone holding the apps domain and any steered custom domains."
+ type = list(string)
+ default = []
+ validation {
+ condition = alltrue([for z in var.steering_zone_ids : can(regex("^Z[A-Z0-9]+$", z))])
+ error_message = "steering_zone_ids entries must be bare hosted-zone ids (Z...), not /hostedzone/-prefixed paths or wildcards."
+ }
+}
+
variable "enable_auto_recovery" {
description = "Arm an EC2 auto-recovery alarm on the host (HA ladder rung 0)."
type = bool
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 7d06eedcf..0b1ee0fe4 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -593,7 +593,7 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
item.CertExpiresAt = reading.CertExpiresAt
}
if item.DNSStatus != "pointing" {
- item.Guidance = domainGuidance(c.Host, s.cfg.EdgeIPs)
+ item.Guidance = domainGuidance(c.Host, s.dnsTargets())
}
// Each stamp is a live, sequential, uncancellable-per-call DNS round
// trip on the request path (lookupCNAME) — capped at
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 35d55cb6b..c4cb18b2d 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -256,7 +256,7 @@ func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string,
out[i].Claimed = true
continue
}
- out = append(out, claimDomainView(c.Host, s.cfg.EdgeIPs))
+ out = append(out, claimDomainView(c.Host, s.dnsTargets()))
}
return out
}
diff --git a/internal/console/server.go b/internal/console/server.go
index fd581946d..92d3a132c 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -520,6 +520,16 @@ type Config struct {
// guessing. Normalized (lowercase, trimmed) into Server.delegationZone at
// construction, same as RedirectHosts is normalized into `redirects`.
DelegationZone string
+ // SteeringHost is the deployment's DNS-steering hostname — the
+ // edge.<apps-domain> name the steering converger keeps one latency A
+ // record per armed edge on (multi-region ingress spec, Part 4). Set only
+ // where the deployment actually steers: cmd/console derives it from
+ // CARLOS_STEERING_ZONE_ID (the same variable the converger reads, so the
+ // console cannot advertise a hostname no pass maintains) plus
+ // PLATFORM_APPS_DOMAIN. "" — every deployment today — leaves custom-domain
+ // guidance byte-for-byte what it has always been. Normalized (lowercase,
+ // trimmed, no trailing dot) into Server.steeringHost at construction.
+ SteeringHost string
}
// Server is the console's http.Handler.
@@ -606,6 +616,34 @@ type Server struct {
// post-construction.
delegationZone string
lookupCNAME func(ctx context.Context, host string) (string, error)
+ // steeringHost is cfg.SteeringHost, normalized once here — see
+ // dnsTargets, the one reader.
+ steeringHost string
+}
+
+// dnsTargets is what every custom-domain guidance string in this console is
+// built from (view.go's domainGuidance): the deployment's edge addresses and
+// its steering hostname, read from ONE place so the app page, the Settings
+// page and the /domains API can never disagree about where a member should
+// point DNS.
+func (s *Server) dnsTargets() DNSTargets {
+ return DNSTargets{EdgeIPs: s.cfg.EdgeIPs, SteeringHost: s.steeringHost}
+}
+
+// normalizeSteeringHost lowercases, trims and shape-validates the steering
+// hostname (Config.SteeringHost). Same posture as normalizeDelegationZone
+// below and for the same reason: a value that is not a legal hostname
+// degrades to "" — guidance keeps the A-record form, which always works —
+// rather than being pasted verbatim into a sentence every member of this
+// deployment is shown. Loud, because a deployment that armed steering and
+// typo'd the variable would otherwise mislead silently.
+func normalizeSteeringHost(raw string) string {
+ h := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), ".")))
+ if h != "" && !provision.ValidHostShape(h) {
+ log.Printf("console: steering hostname %q is not a valid domain — custom-domain guidance keeps the A-record form", raw)
+ return ""
+ }
+ return h
}
// normalizeDelegationZone lowercases, trims, and shape-validates raw (from
@@ -679,6 +717,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// goroutines and buffers without bound.
shipSem: make(chan struct{}, 2),
delegationZone: normalizeDelegationZone(cfg.DelegationZone),
+ steeringHost: normalizeSteeringHost(cfg.SteeringHost),
lookupCNAME: net.DefaultResolver.LookupCNAME,
}
if s.km != nil {
@@ -1922,7 +1961,7 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
return
}
if len(apps) > 0 {
- views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, apps, s.cfg.EdgeIPs)
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, apps, s.dnsTargets())
if err != nil {
log.Printf("console: assemble: %v", err)
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
@@ -2057,7 +2096,7 @@ func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (pa
// bucket hiccup there is deliberately non-fatal, and this is where it gets
// another chance. Best-effort — a page view never fails on it.
s.ensureAccountManifest(r.Context(), acct)
- views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.cfg.EdgeIPs)
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.dnsTargets())
if err != nil || len(views) != 1 {
log.Printf("console: assemble %s: %v", app, err)
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 3d57f1c67..8c2376a43 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -1418,12 +1418,12 @@ func TestAppPageBackfillsAccountManifest(t *testing.T) {
// type from the platform flag, unknown DNS as "" (rendered em-dash),
// guidance ONLY for non-pointing custom domains, canary labeling preserved.
func TestDomainViewStates(t *testing.T) {
- edge := []string{"203.0.113.7", "198.51.100.9"}
+ edge := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}}
renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix()
cases := []struct {
name string
rt registry.Route
- ips []string
+ dns DNSTargets
want DomainView
}{
{"platform pointing ready with cert",
@@ -1457,19 +1457,78 @@ func TestDomainViewStates(t *testing.T) {
DomainView{Domain: "rc.kass.bab.oncarlos.com", Type: "platform · canary/rc", DNS: "pointing", TLS: "ready"}},
{"no known edge IPs: guidance still helps, without inventing an address",
registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"},
- nil,
+ DNSTargets{},
DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready",
Guidance: "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
- if got := domainView(c.rt, c.ips); got != c.want {
+ if got := domainView(c.rt, c.dns); got != c.want {
t.Errorf("domainView:\n got %+v\n want %+v", got, c.want)
}
})
}
}
+// TestDomainGuidanceSteering pins the custom-domain attach guidance across
+// the steering axis (multi-region ingress spec, Part 4):
+//
+// - no steering hostname (every deployment today): byte-for-byte the
+// sentence this console has always shown — the whole inertness claim;
+// - steering armed + a subdomain: the CNAME to edge.<apps-domain> is the
+// recommended form, with the A record still offered;
+// - steering armed + an APEX: no CNAME is offered at all (it is illegal
+// there) and the round-robin-without-latency-bias truth is stated —
+// the spec's honesty rule. Apex-ness comes from the public suffix list,
+// so a three-label apex (example.co.uk) is an apex and a three-label
+// subdomain (app.example.com) is not.
+func TestDomainGuidanceSteering(t *testing.T) {
+ ips := []string{"203.0.113.7", "198.51.100.9"}
+ steered := DNSTargets{EdgeIPs: ips, SteeringHost: "edge.oncarlos.com"}
+ cases := []struct {
+ name, host string
+ dns DNSTargets
+ want string
+ }{
+ {"unsteered deployment is untouched", "kass.training", DNSTargets{EdgeIPs: ips},
+ "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."},
+ {"unsteered, no edge IPs", "kass.training", DNSTargets{},
+ "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."},
+ {"steered subdomain is offered the CNAME", "www.kass.training", steered,
+ "Point a CNAME for www.kass.training at edge.oncarlos.com — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically. An A record at 203.0.113.7, 198.51.100.9 works too, but round-robins without latency bias. Checks run every few minutes."},
+ {"steered apex keeps A records, honestly", "kass.training", steered,
+ "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. kass.training is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias. Checks run every few minutes."},
+ {"multi-label public suffix is still an apex", "kass.co.uk", steered,
+ "Point an A record for kass.co.uk at 203.0.113.7, 198.51.100.9. kass.co.uk is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias. Checks run every few minutes."},
+ {"three labels under a plain TLD is a subdomain", "app.kass.co.uk", steered,
+ "Point a CNAME for app.kass.co.uk at edge.oncarlos.com — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically. An A record at 203.0.113.7, 198.51.100.9 works too, but round-robins without latency bias. Checks run every few minutes."},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := domainGuidance(c.host, c.dns); got != c.want {
+ t.Errorf("domainGuidance(%q):\n got %q\n want %q", c.host, got, c.want)
+ }
+ })
+ }
+}
+
+// TestSteeringHostNormalization: the console never pastes an unusable
+// steering hostname into member-facing guidance — a malformed value degrades
+// to the A-record form rather than into the sentence.
+func TestSteeringHostNormalization(t *testing.T) {
+ for raw, want := range map[string]string{
+ "": "",
+ " Edge.OnCarlos.com ": "edge.oncarlos.com",
+ "edge.oncarlos.com.": "edge.oncarlos.com",
+ "*.oncarlos.com": "",
+ "not a host": "",
+ } {
+ if got := normalizeSteeringHost(raw); got != want {
+ t.Errorf("normalizeSteeringHost(%q) = %q, want %q", raw, got, want)
+ }
+ }
+}
+
// TestAppPageRendersDomains: the app page's Domains table renders from
// registry state — platform + custom rows, em-dash unknowns, and the
// guidance row beneath a non-pointing custom domain.
diff --git a/internal/console/view.go b/internal/console/view.go
index 1bb2eedb2..2ed4c95d6 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -17,6 +17,8 @@ import (
"sync"
"time"
+ "golang.org/x/net/publicsuffix"
+
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
@@ -79,30 +81,94 @@ type DomainView struct {
Claimed bool
}
-// domainGuidance is the A-record instruction a custom host that is not
-// pointing at this deployment yet gets shown — shared by domainView (a
-// routed custom host) and claimDomainView (a claim with no route yet), so
+// DNSTargets is where this deployment tells a member to point a custom
+// domain. Two facts, threaded together because the guidance is one
+// sentence and needs both: the edge addresses an A record names, and —
+// only on a deployment that STEERS (multi-region ingress spec, Part 4) —
+// the steering hostname a non-apex host may CNAME to instead.
+type DNSTargets struct {
+ // EdgeIPs is PLATFORM_EDGE_IPS, read at console start. nil is legal:
+ // guidance then says "this deployment's edge IP" rather than inventing
+ // an address.
+ //
+ // Once a deployment steers, this list must name EVERY armed edge's EIP,
+ // not just the local box's: the DNS check that stamps "pointing"
+ // compares a resolved address against it, and a steered name resolves
+ // to whichever edge the resolver is nearest.
+ EdgeIPs []string
+ // SteeringHost is the deployment's steering hostname (edge.<apps-domain>,
+ // itself latency-routed with one record per armed edge). "" — every
+ // deployment that does not set CARLOS_STEERING_ZONE_ID, which is all of
+ // them today — keeps the guidance byte-for-byte what it has always been.
+ SteeringHost string
+}
+
+// domainGuidance is the point-your-DNS-here instruction a custom host that
+// is not pointing at this deployment yet gets shown — shared by domainView
+// (a routed custom host) and claimDomainView (a claim with no route yet), so
// the two rows never drift into different wording for the same instruction.
-func domainGuidance(host string, edgeIPs []string) string {
+//
+// With steering armed the CNAME is the RECOMMENDED form for a subdomain:
+// it inherits the steering hostname's latency records and its health
+// checks, so visitors reach the nearest healthy edge and a dead edge stops
+// being handed out within ~90s. An apex cannot carry a CNAME at all (the
+// spec's honesty rule, Part 4: "apexes cannot CNAME, so apex guidance
+// remains A-records-to-all-EIPs — round-robin without latency bias,
+// documented as such in attach guidance"), so an apex is told the truth
+// about what its A records do rather than being offered an impossible
+// option. Apex-ness is decided by the public suffix list, not by counting
+// labels: "example.co.uk" is an apex and "app.example.com" is not, and both
+// have three.
+func domainGuidance(host string, t DNSTargets) string {
target := "this deployment's edge IP"
- if len(edgeIPs) > 0 {
- target = strings.Join(edgeIPs, ", ")
+ if len(t.EdgeIPs) > 0 {
+ target = strings.Join(t.EdgeIPs, ", ")
+ }
+ aRecord := "Point an A record for " + host + " at " + target + "."
+ switch {
+ case t.SteeringHost == "":
+ // No steering: exactly the sentence this deployment has always shown.
+ case isApexDomain(host):
+ aRecord += " " + host + " is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias."
+ default:
+ aRecord = "Point a CNAME for " + host + " at " + t.SteeringHost +
+ " — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically." +
+ " An A record at " + target + " works too, but round-robins without latency bias."
+ }
+ return aRecord + " Checks run every few minutes."
+}
+
+// isApexDomain reports whether host is the registrable domain itself — the
+// one place a CNAME is illegal (RFC 1034: a CNAME cannot coexist with the
+// SOA and NS records an apex must carry). golang.org/x/net/publicsuffix is
+// already in this module's dependency set (the edge's IDNA path pulls
+// x/net), and the PSL is the only correct answer here: label counting calls
+// "example.co.uk" a subdomain and would hand an apex a CNAME it cannot use.
+//
+// An unknown suffix (a name whose TLD is not in the list — a test domain,
+// an internal name) is treated as an APEX: the conservative answer keeps
+// guidance to the A-record form, which always works, instead of proposing a
+// CNAME that might be refused by the member's registrar.
+func isApexDomain(host string) bool {
+ h := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
+ etld1, err := publicsuffix.EffectiveTLDPlusOne(h)
+ if err != nil {
+ return true
}
- return "Point an A record for " + host + " at " + target + ". Checks run every few minutes."
+ return etld1 == h
}
// claimDomainView folds a domain claim that has no colocated registry row
// yet into its own Domains placeholder row — an alias claim (which never
// gets a route row; see domainattach.go) or a first-route claim before the
// provisioning reconciler has materialized its route.
-func claimDomainView(host string, edgeIPs []string) DomainView {
- return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, edgeIPs)}
+func claimDomainView(host string, t DNSTargets) DomainView {
+ return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, t)}
}
-// domainView folds one registry route into its Domains row. edgeIPs is the
-// console's PLATFORM_EDGE_IPS (nil = unknown; guidance then names "this
-// deployment's edge IP" rather than inventing an address).
-func domainView(rt registry.Route, edgeIPs []string) DomainView {
+// domainView folds one registry route into its Domains row. dns carries the
+// deployment's edge addresses and steering hostname (see DNSTargets).
+func domainView(rt registry.Route, dns DNSTargets) DomainView {
typ := "custom"
if rt.Platform {
typ = "platform"
@@ -125,7 +191,7 @@ func domainView(rt registry.Route, edgeIPs []string) DomainView {
// Guidance only for custom + non-pointing: platform domains are ours to
// fix, and a pointing custom domain needs no pointing instructions.
if !rt.Platform && rt.DNSStatus != "pointing" {
- dv.Guidance = domainGuidance(rt.Host, edgeIPs)
+ dv.Guidance = domainGuidance(rt.Host, dns)
}
return dv
}
@@ -346,10 +412,11 @@ func openRegistry(path string) (*registry.Registry, error) {
// Assemble builds the view for the given (owned) apps: routes from the
// registry at regPath, channels from the release store s verified against
-// pub, Domains rows with guidance targeting edgeIPs (nil = unknown). Every
-// app in apps gets an AppView, even an empty one; per-channel failures
-// degrade to ChannelView.Err.
-func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct string, apps []string, edgeIPs []string) ([]AppView, error) {
+// pub, Domains rows with guidance targeting dns (the zero value = neither
+// edge addresses nor a steering hostname known). Every app in apps gets an
+// AppView, even an empty one; per-channel failures degrade to
+// ChannelView.Err.
+func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct string, apps []string, dns DNSTargets) ([]AppView, error) {
ctx := context.Background()
reg, err := openRegistry(regPath)
if err != nil {
@@ -432,7 +499,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
Caps: capsFor,
Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned,
})
- domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs))
+ domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, dns))
}
now := time.Now()
sorted := append([]string(nil), apps...)
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 81d5bfd9f..efad811c0 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -153,7 +153,7 @@ func TestAssemble(t *testing.T) {
if err := release.SetProduction(context.Background(), store, priv, release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}
@@ -209,7 +209,7 @@ func TestAssembleCanaries(t *testing.T) {
Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil {
t.Fatal(err)
}
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}
@@ -231,7 +231,7 @@ func TestAssembleCanaries(t *testing.T) {
func TestAssembleFiltersToGivenApps(t *testing.T) {
regPath, store, _, pub := buildFixtures(t)
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}
@@ -249,7 +249,7 @@ func TestAssembleFiltersToGivenApps(t *testing.T) {
func TestAssembleEmptyAppStillListed(t *testing.T) {
regPath, store, _, pub := buildFixtures(t)
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"ghost", "kass"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"ghost", "kass"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}
@@ -267,7 +267,7 @@ func TestAssembleDegradesBadChannel(t *testing.T) {
if _, err := store.Put(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "beta"), []byte("not a signed pointer"), ""); err != nil {
t.Fatal(err)
}
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
if err != nil {
t.Fatalf("Assemble must not fail on a bad channel: %v", err)
}
@@ -296,7 +296,7 @@ func TestAssembleCarriesProcessOwnership(t *testing.T) {
Unit: "titogo@demo.service", Backing: registry.BackingUnit, Provisioned: true}); err != nil {
t.Fatal(err)
}
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}
@@ -339,7 +339,7 @@ func TestMismatchListsRoutes(t *testing.T) {
t.Fatal(err)
}
}
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}
@@ -379,7 +379,7 @@ func TestMismatchAllRoutes(t *testing.T) {
t.Fatal(err)
}
}
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}
@@ -412,7 +412,7 @@ func TestMismatchIgnoresPlatformRoutes(t *testing.T) {
t.Fatal(err)
}
}
- views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, nil)
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, DNSTargets{})
if err != nil {
t.Fatal(err)
}