diff --git a/src/pkg/cli/client/byoc/azure/byoc.go b/src/pkg/cli/client/byoc/azure/byoc.go index 609f9b215..1b112f324 100644 --- a/src/pkg/cli/client/byoc/azure/byoc.go +++ b/src/pkg/cli/client/byoc/azure/byoc.go @@ -1024,16 +1024,37 @@ func (b *ByocAzure) UpdateShardDomain(context.Context) error { return fmt.Errorf("UpdateShardDomain: %w", errors.ErrUnsupported) } -// UpdateServiceInfo implements byoc.ServiceInfoUpdater. When a service has a -// `domainname` set in compose, mark it for managed-cert issuance so -// `defang cert generate` picks it up via the CertIssuer path. Azure Container -// Apps managed certs are free, auto-renewing, and validated via CNAME — no -// hosted-zone presence required (unlike AWS, where ZoneId triggers a different -// path). -func (b *ByocAzure) UpdateServiceInfo(_ context.Context, si *defangv1.ServiceInfo, _, _ string, service composeTypes.ServiceConfig) error { +// UpdateServiceInfo implements byoc.ServiceInfoUpdater. When a service sets a +// `domainname` in compose, look for a public Azure DNS zone for that domain in +// the current subscription (mirroring AWS's findZone). If one exists, record its +// ARM resource ID in si.ZoneId so the CD/Pulumi program manages the records and +// issues an Azure-managed cert directly in that zone. Otherwise fall back to +// si.UseAcmeCert, where the ACA managed cert is validated via CNAME without +// Defang owning the hosted zone. +// +// Any failure to look up zones (e.g. AZURE_SUBSCRIPTION_ID not yet known, or the +// deploy identity lacking DNS read permission) falls back to the ACME path +// rather than failing the whole deploy. +func (b *ByocAzure) UpdateServiceInfo(ctx context.Context, si *defangv1.ServiceInfo, _, _ string, service composeTypes.ServiceConfig) error { if service.DomainName == "" { return nil } - si.UseAcmeCert = true + if err := b.setUpLocation(); err != nil || b.driver.SubscriptionID == "" { + term.Debugf("BYOD %q: cannot resolve subscription for DNS lookup (%v); using ACME cert", service.DomainName, err) + si.UseAcmeCert = true + return nil + } + zoneID, err := azuredns.New("", b.driver.Azure).FindZone(ctx, service.DomainName) + if err != nil { + term.Debugf("BYOD %q: FindZone failed (%v); using ACME cert", service.DomainName, err) + si.UseAcmeCert = true + return nil + } + if zoneID == "" { + si.UseAcmeCert = true + } else { + // Set the ZoneId so CD can manage records + issue a managed cert for us. + si.ZoneId = zoneID + } return nil } diff --git a/src/pkg/clouds/azure/aca/cert.go b/src/pkg/clouds/azure/aca/cert.go index 0c8981abf..ba6777068 100644 --- a/src/pkg/clouds/azure/aca/cert.go +++ b/src/pkg/clouds/azure/aca/cert.go @@ -43,12 +43,17 @@ const ( // for `serviceName` in `resourceGroup`. Steps: // // 1. Find the ContainerApp by tag (defang-service: ). -// 2. Wait for DNS records (CNAME -> app FQDN, TXT asuid. -> verificationId). +// 2. Wait for DNS records: a routing record (subdomain → CNAME to app FQDN; +// apex → A record to the env IP) plus TXT asuid. → verificationId. // 3. Register the custom hostname with bindingType: Disabled (validates asuid TXT). -// 4. Issue a managed certificate via CNAME validation. +// 4. Issue a managed certificate (subdomain → CNAME validation; apex → HTTP). // 5. Flip the customDomain to bindingType: SniEnabled, attaching the cert. // 6. Verify TLS is serving on https:///. // +// Apex domains (e.g. example.com) can't have a CNAME (RFC 1034), so they route +// via an A record and validate over HTTP. Apex vs subdomain is detected from DNS +// and from Azure's validation-method rejection — no caller hint is needed. +// // Each ARM step is idempotent: re-running after a partial failure picks up // where it left off. resolverAt is used by step 2 to chase the DNS chain — pass // dns.DirectResolverAt to query authoritative servers directly (CD task) or @@ -137,23 +142,38 @@ func findContainerAppByService(ctx context.Context, client *armappcontainers.Con return nil, fmt.Errorf("no Container App in %s tagged %s=%s", rg, ServiceTagKey, serviceName) } -// waitForBYODdns blocks until both the CNAME and asuid TXT records resolve -// correctly, prompting the user once with the values to add. +// waitForBYODdns blocks until a routing record and the asuid TXT record both +// resolve, prompting the user once with the values to add. +// +// The routing record is a CNAME → app FQDN for a subdomain, or an A record for +// an apex domain (no CNAME possible at the zone apex). We don't know which the +// hostname is, so we accept either: the precise CNAME→app-FQDN check, OR any +// resolvable address at the hostname (the apex A case — the env IP isn't known +// to this layer, and the cert step validates the apex binding over HTTP). The +// asuid TXT is always required. func waitForBYODdns(ctx context.Context, hostname, expectedCname, expectedTxt string, resolverAt func(string) dns.Resolver) error { asuid := "asuid." + hostname deadline := time.Now().Add(dnsWaitTimeout) promptShown := false for { - cnameOK := dns.CheckDomainDNSReady(ctx, hostname, []string{expectedCname}, resolverAt) + routeOK := dns.CheckDomainDNSReady(ctx, hostname, []string{expectedCname}, resolverAt) + if !routeOK { + // Apex domains route via an A record, not a CNAME to the app FQDN; + // accept any resolvable address at the hostname. + if ips, err := resolverAt("").LookupIPAddr(ctx, hostname); err == nil && len(ips) > 0 { + routeOK = true + } + } txtOK, _ := dns.LookupTXTContains(ctx, asuid, expectedTxt, resolverAt("")) - if cnameOK && txtOK { + if routeOK && txtOK { term.Infof("DNS records for %s verified", hostname) return nil } if !promptShown { term.Printf("Configure DNS records for %s:\n", hostname) - term.Printf(" CNAME %s -> %s\n", hostname, expectedCname) + term.Printf(" CNAME %s -> %s (subdomain)\n", hostname, expectedCname) + term.Printf(" A %s -> (apex)\n", hostname) term.Printf(" TXT asuid.%s -> %s\n", hostname, expectedTxt) term.Infof("Waiting for DNS propagation (timeout %v)...", dnsWaitTimeout) promptShown = true @@ -270,12 +290,15 @@ func hasCustomDomain(app *armappcontainers.ContainerApp, hostname string) bool { return false } -// issueManagedCertificate creates the managed cert. CNAME validation is the -// default and works for any hostname that has a CNAME (subdomains pointing at -// the Container Apps FQDN). Apex domains can't have a CNAME (RFC 1034) and -// Azure rejects CNAME validation with InvalidValidationMethod; in that case -// we fall back to TXT validation, which requires the user to add a -// _dnsauth. TXT record with the validationToken Azure returns. +// issueManagedCertificate creates the managed cert, choosing the validation +// method to match the hostname's routing record. CNAME validation is the +// default and works for subdomains pointing at the Container Apps FQDN. Apex +// domains can't have a CNAME (RFC 1034), so Azure rejects CNAME validation with +// InvalidValidationMethod — we detect that and retry with HTTP validation, which +// Container Apps completes automatically once the apex A record points at the +// env IP and the hostname is registered (no extra DNS record needed). If HTTP +// also fails we fall back to TXT validation (the interactive _dnsauth dance, +// usable from the CLI). func issueManagedCertificate(ctx context.Context, client *armappcontainers.ManagedCertificatesClient, rg, envName, certName, hostname, location string) (*armappcontainers.ManagedCertificate, error) { resp, err := submitManagedCert(ctx, client, rg, envName, certName, hostname, location, armappcontainers.ManagedCertificateDomainControlValidationCNAME) if err == nil { @@ -284,7 +307,18 @@ func issueManagedCertificate(ctx context.Context, client *armappcontainers.Manag if !isInvalidValidationMethod(err) { return nil, fmt.Errorf("issuing managed certificate %s: %w", certName, err) } - term.Infof("CNAME validation rejected for %s (apex domain); falling back to TXT validation", hostname) + + // Apex domain: CNAME validation is impossible. HTTP validation needs no extra + // DNS record (the asuid TXT + A record already in place suffice), so it works + // unattended in the CD task — unlike TXT, which requires an interactive + // _dnsauth record. + term.Infof("CNAME validation rejected for %s (apex domain); using HTTP validation", hostname) + resp, err = submitManagedCert(ctx, client, rg, envName, certName, hostname, location, armappcontainers.ManagedCertificateDomainControlValidationHTTP) + if err == nil { + return resp, nil + } + + term.Infof("HTTP validation failed for %s (%v); falling back to TXT validation", hostname, err) return submitManagedCertTXT(ctx, client, rg, envName, certName, hostname, location) } diff --git a/src/pkg/clouds/azure/dns/dns.go b/src/pkg/clouds/azure/dns/dns.go index 5d4981473..6de192567 100644 --- a/src/pkg/clouds/azure/dns/dns.go +++ b/src/pkg/clouds/azure/dns/dns.go @@ -10,6 +10,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" @@ -77,6 +78,54 @@ func (d *DNS) EnsureZoneExists(ctx context.Context, domain string) ([]string, er return nameServers(created.Zone), nil } +// FindZone returns the ARM resource ID of the public DNS zone in the current +// subscription whose name is the longest DNS suffix of domain, or "" if no zone +// matches. It mirrors AWS's findZone (byoc/aws/byoc.go): when a service brings +// its own domain, the caller sets ServiceInfo.ZoneId so the CD/Pulumi program +// manages records directly in that zone instead of the ACME fallback. +// +// The lookup is subscription-wide and applies no ownership/tag filter (per the +// BYOD design): whichever existing zone is the closest parent of domain wins. +// The resource group is irrelevant here, so a DNS value built with New("", az) +// is fine. Azure has no cross-subscription equivalent of Route53's AssumeRole, +// so only the current subscription is searched. +func (d *DNS) FindZone(ctx context.Context, domain string) (string, error) { + client, err := d.newZonesClient() + if err != nil { + return "", err + } + + domain = strings.ToLower(strings.TrimSuffix(domain, ".")) + var bestID, bestName string + pager := client.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return "", fmt.Errorf("listing DNS zones: %w", err) + } + for _, z := range page.Value { + if z == nil || z.Name == nil || z.ID == nil { + continue + } + name := strings.ToLower(*z.Name) + // Match the domain itself or any parent zone (e.g. domain + // "api.example.com" matches a zone named "example.com"). + if domain != name && !strings.HasSuffix(domain, "."+name) { + continue + } + if len(name) > len(bestName) { + bestName, bestID = name, *z.ID + } + } + } + if bestID == "" { + term.Debugf("no DNS zone in subscription matches %q", domain) + return "", nil + } + term.Debugf("DNS zone %q (%s) matches %q", bestName, bestID, domain) + return bestID, nil +} + func nameServers(zone armdns.Zone) []string { // Consistent zero value: callers can rely on a non-nil empty slice when // there are no name servers, regardless of whether Properties was unset