From 5587fdf37b0aa18e410eb64ed53e117264616a50 Mon Sep 17 00:00:00 2001 From: Edward J Date: Mon, 29 Jun 2026 10:29:16 -0700 Subject: [PATCH 1/2] feat(azure): look up BYOD DNS zone in current subscription When a service sets a custom `domainname`, search the current subscription for a public Azure DNS zone that is a parent of that domain (longest-suffix match, no ownership filter). If found, record its ARM resource ID in ServiceInfo.ZoneId so the CD/Pulumi program manages records + issues a managed cert directly in that zone, mirroring AWS's findZone. Otherwise fall back to the ACME managed-cert path. Lookup failures (no subscription resolved, or missing DNS read permission) fall back to ACME rather than failing the deploy. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pkg/cli/client/byoc/azure/byoc.go | 37 +++++++++++++++----- src/pkg/clouds/azure/dns/dns.go | 49 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) 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/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 From 49f0210afbdaceb36ca5a37e117c47099dd5a72f Mon Sep 17 00:00:00 2001 From: Edward J Date: Mon, 29 Jun 2026 11:38:26 -0700 Subject: [PATCH 2/2] feat(azure): issue managed cert for apex BYOD domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aca.IssueCert now handles apex custom domains (e.g. example.com) in addition to subdomains, detecting apex from DNS/Azure rather than a caller hint: - waitForBYODdns accepts an A record (apex routing) as well as a CNAME→app FQDN (subdomain), still requiring the asuid TXT in both cases. - issueManagedCertificate falls back from CNAME validation to HTTP validation when Azure rejects CNAME with InvalidValidationMethod (the apex signal). HTTP validation needs no extra DNS record, so it completes unattended in the CD task — unlike the TXT _dnsauth dance, which remains a last-resort fallback. The provider (pulumi-defang #305) creates the matching A + asuid records for apex BYOD domains. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pkg/clouds/azure/aca/cert.go | 62 ++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 14 deletions(-) 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) }