Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions src/pkg/cli/client/byoc/azure/byoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return nil
}
62 changes: 48 additions & 14 deletions src/pkg/clouds/azure/aca/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,17 @@ const (
// for `serviceName` in `resourceGroup`. Steps:
//
// 1. Find the ContainerApp by tag (defang-service: <serviceName>).
// 2. Wait for DNS records (CNAME -> app FQDN, TXT asuid.<host> -> verificationId).
// 2. Wait for DNS records: a routing record (subdomain → CNAME to app FQDN;
// apex → A record to the env IP) plus TXT asuid.<host> → 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://<hostname>/.
//
// 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
Expand Down Expand Up @@ -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 -> <Container Apps environment IP> (apex)\n", hostname)
Comment on lines +145 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t accept any resolvable IP as apex readiness.

After the exact CNAME check fails, LookupIPAddr(hostname) makes routeOK true for any existing A/AAAA record or wrong CNAME target. That can proceed while the hostname still points away from Container Apps. Compare the hostname IPs with the resolved expectedCname IPs, or pass the expected environment IPs into this owning layer and print/validate those. As per coding guidelines, “Prefer a narrow option passed into the owning package over duplicating path discovery or state reconstruction in a caller” and “file-existence and working-directory behavior should be deterministic and tested at the owning layer.”

Possible localized direction
-			if ips, err := resolverAt("").LookupIPAddr(ctx, hostname); err == nil && len(ips) > 0 {
+			hostIPs, hostErr := resolverAt("").LookupIPAddr(ctx, hostname)
+			targetIPs, targetErr := resolverAt("").LookupIPAddr(ctx, expectedCname)
+			if hostErr == nil && targetErr == nil && hasCommonIP(hostIPs, targetIPs) {
 				routeOK = true
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pkg/clouds/azure/aca/cert.go` around lines 145 - 176, The readiness check
in waitForBYODdns is too broad because it marks routeOK true for any resolvable
hostname IP after the CNAME check fails. Tighten the apex-path validation by
comparing the hostname’s resolved IPs against the IPs for expectedCname, or
better, thread the expected Container Apps environment IPs into waitForBYODdns
and validate against those in this layer. Keep the existing asuid TXT check and
prompt logic, but remove the “any IP is enough” fallback so the function only
proceeds when the hostname actually points to the intended target.

Source: Coding guidelines

term.Printf(" TXT asuid.%s -> %s\n", hostname, expectedTxt)
term.Infof("Waiting for DNS propagation (timeout %v)...", dnsWaitTimeout)
promptShown = true
Expand Down Expand Up @@ -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.<hostname> 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 {
Expand All @@ -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)
}

Expand Down
49 changes: 49 additions & 0 deletions src/pkg/clouds/azure/dns/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading