caddy icon indicating copy to clipboard operation
caddy copied to clipboard

Combination of ech and a site block for the ech name cannot use tls resolver config

Open Gunni opened this issue 1 month ago • 4 comments

Issue Details

{
        acme_dns cloudflare foo
        ech example.com
}

example.com {
        tls {
                resolvers 1.1.1.1
        }
}

Result:

Error: hostname appears in more than one automation policy, making certificate management ambiguous: example.com

I didn't even have to redact the file, any hostname triggers this, if set in both ech and a site block.

xCaddy Build script
#!/bin/bash

IFS=$'\n\t'
set -euo pipefail

# Debug logs
#set -x

# Done by dependency
#/usr/libexec/xcaddy/install_golang.sh

export PATH=/opt/golang/latest/go/bin:$PATH

go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

export PATH=$PATH:${GOPATH}/bin

xcaddy build --output xcaddy master \
        --with github.com/caddy-dns/cloudflare \
        --with github.com/aksdb/caddy-cgi/v2

cd /opt/xcaddy

tmpdir=$(mktemp -d --tmpdir=/opt/xcaddy .xcaddy-XXXX)
chmod go+rx ${tmpdir}

tmpdir_nodot="${tmpdir//./}"

# Copy built files to tmpdir
cp -r \
        /tmp/xcaddy \
        ${tmpdir}/

restorecon -RvF ${tmpdir}
mv ${tmpdir} ${tmpdir_nodot}

sync /opt/xcaddy

ln --verbose --relative --force --no-dereference --symbolic $(basename ${tmpdir_nodot}) live

# Rename old directories for cleanup by systemd-tmpfiles
find . -maxdepth 1 -type d -name 'xcaddy-*' ! -name $(basename $(realpath /opt/xcaddy/live)) -exec bash -c 'mv -v $(basename {}) .$(basename {})'  \;

Assistance Disclosure

AI not used

If AI was used, describe the extent to which it was used.

No response

Gunni avatar Nov 19 '25 16:11 Gunni

Just discovered that by adding any OTHER site block, this error disappears???

The other block doesn't even need content:

{
        acme_dns cloudflare foo
        ech example.com
}

example.com {
        tls {
                resolvers 1.1.1.1
        }
}

example.org

Boom?

I discovered this because I knew i had this active another server already and i had copied the config over, and removed the extra stuff, confusingly triggering the above issue.

Gunni avatar Nov 19 '25 17:11 Gunni

Here's what I found in caddyconfig/httpcaddyfile/tlsapp.go:

  1. ECH creates automation policy in buildTLSApp()
  2. Default issuers are added to ECH policy in buildTLSApp()
  3. Site block with tls { resolvers ... } creates policy with explicit ACME issuers that have custom DNS resolvers
  4. Consolidation fails in consolidateAutomationPolicies() - policies only merge if reflect.DeepEqual on issuers returns true

Same issue occurs with tls internal - site block gets InternalIssuer while ECH gets default ACME issuers.

Siomachkin avatar Nov 25 '25 19:11 Siomachkin

@Siomachkin does that explain why it works if I add any other site block? What changes with a second site block?

Gunni avatar Nov 26 '25 09:11 Gunni

@Gunni I added detailed logging to consolidateAutomationPolicies() and tested both scenarios with a mock DNS provider:

Case 1: Does not work

  {
      acme_dns mock foo
      ech example.com
  }
  example.com {
      tls {
          resolvers 1.1.1.1
      }
  }
=== DEBUG: consolidateAutomationPolicies START ===
Total policies: 3
Policy 0: subjects=[], issuers=1, managers=0
  Issuer 0: {"challenges":{"dns":{"provider":{"argument":"foo","name":"mock"}}},"module":"acme"}
Policy 1: subjects=[example.com], issuers=1, managers=0
  Issuer 0: {"challenges":{"dns":{"provider":{"argument":"foo","name":"mock"},"resolvers":["1.1.1.1"]}},"module":"acme"}
Policy 2: subjects=[example.com], issuers=1, managers=0
  Issuer 0: {"challenges":{"dns":{"provider":{"argument":"foo","name":"mock"}}},"module":"acme"}

Comparing policies 0 (subjects=[example.com]) and 1 (subjects=[example.com]): issuersEqual=false
Comparing policies 0 (subjects=[example.com]) and 2 (subjects=[]): issuersEqual=true
Policies 0 and 2 can be consolidated!
Policy 0 shadows check: shadowIdx=1, j=2, can delete=false
Cannot delete policy 0 - shadowed by policy at 1
Comparing policies 1 (subjects=[example.com]) and 2 (subjects=[]): issuersEqual=false

=== DEBUG: consolidateAutomationPolicies END ===
Final policies count: 3
Final Policy 0: subjects=[example.com], issuers=1  (with resolvers)
Final Policy 1: subjects=[example.com], issuers=1  (ECH, without resolvers)
Final Policy 2: subjects=[], issuers=1  (catch-all)

Case 2: Does work (your workaround with example.org)

  {
      acme_dns mock foo
      ech example.com
  }
  example.com {
      tls {
          resolvers 1.1.1.1
      }
  }
  example.org
=== DEBUG: consolidateAutomationPolicies START ===
Total policies: 4
Policy 0: subjects=[], issuers=1, managers=0
  Issuer 0: {"challenges":{"dns":{"provider":{"argument":"foo","name":"mock"}}},"module":"acme"}
Policy 1: subjects=[example.com], issuers=1, managers=0
  Issuer 0: {"challenges":{"dns":{"provider":{"argument":"foo","name":"mock"},"resolvers":["1.1.1.1"]}},"module":"acme"}
Policy 2: subjects=[example.org], issuers=1, managers=0
  Issuer 0: {"challenges":{"dns":{"provider":{"argument":"foo","name":"mock"}}},"module":"acme"}
Policy 3: subjects=[example.com], issuers=1, managers=0
  Issuer 0: {"challenges":{"dns":{"provider":{"argument":"foo","name":"mock"}}},"module":"acme"}

Comparing policies 0 (subjects=[example.com]) and 1 (subjects=[example.org]): issuersEqual=false
Comparing policies 0 (subjects=[example.com]) and 2 (subjects=[example.com]): issuersEqual=false
Comparing policies 0 (subjects=[example.com]) and 3 (subjects=[]): issuersEqual=false
Comparing policies 1 (subjects=[example.org]) and 2 (subjects=[example.com]): issuersEqual=true
Policies 1 and 2 can be consolidated!
Comparing policies 1 (subjects=[example.org example.com]) and 2 (subjects=[]): issuersEqual=true
Policies 1 and 2 can be consolidated!
Policy 1 shadows check: shadowIdx=2, j=2, can delete=true
Deleting policy 1 in favor of catch-all at 2

=== DEBUG: consolidateAutomationPolicies END ===
Final policies count: 2
Final Policy 0: subjects=[example.com], issuers=1  (with resolvers)
Final Policy 1: subjects=[], issuers=1  (catch-all)

The consolidation algorithm does this:

  1. Policy example.org and Policy ECH example.com have identical issuers - they consolidate into [example.org, example.com]
  2. This consolidated policy then consolidates with the catch-all Policy (also identical issuers)
  3. Since the catch-all has empty subjects, the more specific policy gets deleted
  4. Result: example.com appears only once. The extra site blocks triggered the consolidation chain that absorbed the ECH policy.

Siomachkin avatar Nov 26 '25 21:11 Siomachkin