joohoi / acme-dns

Limited DNS server with RESTful HTTP API to handle ACME DNS challenges easily and securely.
MIT License
2.11k stars 231 forks source link

Unable to obtain the corresponding TXT record through _acme-challenge.example.tld #357

Open jinrenjie opened 1 month ago

jinrenjie commented 1 month ago

Architecture

These services all run in containers and can communicate with each other.

acme-dns.cfg is configured as follows:

[general]
# DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
# In this case acme-dns will error out and you will need to define the listening interface
# for example: listen = "127.0.0.1:53"
listen = "0.0.0.0:53"
# protocol, "both", "both4", "both6", "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
protocol = "both"
# domain name to serve the requests off of
domain = "auth.acme.org"
# zone name server
nsname = "ns1.auth.acme.org"
# admin email address, where @ is substituted with .
nsadmin = "admin.acme.org"
# predefined records served in addition to the TXT
records = [
    "ingress.test. A 10.8.10.254",
    "_acme-challenge.ingress.test. CNAME 1821f647-e876-455c-9cd1-d7c54b5aacd0.auth.acme.org.",
    "ingress.test. NS ns1.auth.acme.org.",
    # domain pointing to the public IP of your acme-dns server 
    "ns1.auth.acme.org. A 10.8.10.252",
    # specify that auth.example.org will resolve any *.auth.example.org records
    "auth.acme.org. NS ns1.auth.acme.org."
]
# debug messages from CORS etc
debug = true

[database]
# Database engine to use, sqlite3 or postgres
engine = "postgres"
# Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres
# Please note that the default Docker image uses path /var/lib/acme-dns/acme-dns.db for sqlite3
# connection = "/var/lib/acme-dns/acme-dns.db"
connection = "postgres://xxxxxx:xxxxxxx@xxxxxxx:5432/acme-dns?sslmode=disable"

[api]
# listen ip eg. 127.0.0.1
ip = "0.0.0.0"
# possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
tls = "none"
# listen port, eg. 443 for default HTTPS
port = "8080"
# disable registration endpoint
disable_registration = false
# only used if tls = "cert"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem"
# only used if tls = "letsencrypt"
acme_cache_dir = "api-certs"
# optional e-mail address to which Let's Encrypt will send expiration notices for the API's cert
notification_email = ""
# CORS AllowOrigins, wildcards can be used
corsorigins = [
    "*"
]
# use HTTP header to get the client ip
use_header = false
# header name to pull the ip address / list of ip addresses from
header_name = "X-Forwarded-For"

[logconfig]
# logging level: "error", "warning", "info" or "debug"
loglevel = "debug"
# possible values: stdout, TODO file & integrations
logtype = "stdout"
# file path for logfile TODO
# logfile = "./acme-dns.log"
# format, either "json" or "text"
logformat = "text"

Get acme-dns account fulldomain TXT records:

dig @10.8.10.252 1821f647-e876-455c-9cd1-d7c54b5aacd0.auth.acme.org. txt

; <<>> DiG 9.10.6 <<>> @10.8.10.252 1821f647-e876-455c-9cd1-d7c54b5aacd0.auth.acme.org. txt
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 59470
;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;1821f647-e876-455c-9cd1-d7c54b5aacd0.auth.acme.org. IN TXT

;; ANSWER SECTION:
1821f647-e876-455c-9cd1-d7c54b5aacd0.auth.acme.org. 1 IN TXT "___validation_token_received_from_the_ca___"
1821f647-e876-455c-9cd1-d7c54b5aacd0.auth.acme.org. 1 IN TXT "___validation_token_received_from_the_ca___"

;; Query time: 4 msec
;; SERVER: 10.8.10.252#53(10.8.10.252)
;; WHEN: Mon Jul 15 23:18:04 CST 2024
;; MSG SIZE  rcvd: 291

This seems to be all working fine!

Get the NS record of ingress.test:

dig @10.8.10.252 ingress.test ns

; <<>> DiG 9.10.6 <<>> @10.8.10.252 ingress.test ns
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 25235
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;ingress.test.          IN  NS

;; ANSWER SECTION:
ingress.test.       3600    IN  NS  ns1.auth.acme.org.

;; Query time: 1 msec
;; SERVER: 10.8.10.252#53(10.8.10.252)
;; WHEN: Mon Jul 15 23:19:42 CST 2024
;; MSG SIZE  rcvd: 84

Get the TXT record of _acme-challenge.ingress.test:

dig @10.8.10.252 _acme-challenge.ingress.test txt

; <<>> DiG 9.10.6 <<>> @10.8.10.252 _acme-challenge.ingress.test txt
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 58130
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;_acme-challenge.ingress.test.  IN  TXT

;; ANSWER SECTION:
_acme-challenge.ingress.test. 3600 IN   CNAME   1821f647-e876-455c-9cd1-d7c54b5aacd0.auth.acme.org.

;; Query time: 3 msec
;; SERVER: 10.8.10.252#53(10.8.10.252)
;; WHEN: Mon Jul 15 23:22:26 CST 2024
;; MSG SIZE  rcvd: 149

There seems to be a problem here. In theory, both the CNAME and TXT records should be queried at the same time, but no TXT record appears. As a result, the Smallstep CA I use cannot verify the DNS challenge and cannot issue a certificate!

This problem has troubled me for a long time and I have not found a solution. I look forward to your answer, which will be of great help to me. Thank you!

jinrenjie commented 1 month ago

I think the problem might be here:

func (d *DNSServer) answer(q dns.Question) ([]dns.RR, int, bool, error) {
    var rcode int
    var err error
    var txtRRs []dns.RR
    var authoritative = d.isAuthoritative(q)
    if !d.isOwnChallenge(q.Name) && !d.answeringForDomain(q.Name) {
        rcode = dns.RcodeNameError
    }
    r, _ := d.getRecord(q)

+   for _, rr := range r {
+       if rr.Header().Rrtype == dns.TypeCNAME && len(r) == 1 {
+           q = dns.Question{
+               Name:   rr.(*dns.CNAME).Target,
+               Qtype:  q.Qtype,
+               Qclass: q.Qclass,
+           }
+       }
+   }

    if q.Qtype == dns.TypeTXT {
        if d.isOwnChallenge(q.Name) {
            txtRRs, err = d.answerOwnChallenge(q)
        } else {
            txtRRs, err = d.answerTXT(q)
        }
        if err == nil {
            r = append(r, txtRRs...)
        }
    }
    if len(r) > 0 {
        // Make sure that we return NOERROR if there were dynamic records for the domain
        rcode = dns.RcodeSuccess
    }
    log.WithFields(log.Fields{"qtype": dns.TypeToString[q.Qtype], "domain": q.Name, "rcode": dns.RcodeToString[rcode]}).Debug("Answering question for domain")
    return r, rcode, authoritative, nil
}

When there is only one CNAME record obtained from DNS, use the value of the CNAME record as the parameter of d.answerTXT() to obtain the TXT record in the database.

Once I did this, Smallstep CA Server was able to verify and issue certificates just fine!

I don't know if this is a common practice, But I can traverse and query the TXT records on the CNAME in the cloud service provider's DNS like this:

dig TXT _acme-challenge.betterde.com @223.5.5.5

; <<>> DiG 9.10.6 <<>> TXT _acme-challenge.betterde.com @223.5.5.5
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 59316
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1408
;; QUESTION SECTION:
;_acme-challenge.betterde.com.  IN  TXT

;; ANSWER SECTION:
_acme-challenge.betterde.com. 600 IN    CNAME   1ef56a0d-5f76-4aae-93ca-d3209823a217.betterde.com.
1ef56a0d-5f76-4aae-93ca-d3209823a217.betterde.com. 600 IN TXT "L6gnTrq24MA66xjFQ0jvAFhtia83cxu2zJBtPdMB6UH"

;; Query time: 200 msec
;; SERVER: 223.5.5.5#53(223.5.5.5)
;; WHEN: Tue Jul 16 03:13:24 CST 2024
;; MSG SIZE  rcvd: 164
TRPB commented 3 weeks ago

@jinrenjie are you able to provide some more specific instructions on that fix?

I have the exact same issue and assumed it was something I'd configured incorrectly. Is there a workaround in the DNS config?

Are you saying we can't have any other CNAMEs at all on the DNS for it to work?

jinrenjie commented 2 weeks ago

@TRPB I think the problem is that when we query the DNS for the TXT record, it doesn't process the CNAME record that exists on the DNS and then query the corresponding TXT record according to the CNAME record!

Later, I did not use this project as the DNS Challenge service provider, but wrote my own project github.com/betterde/cdns, but my project is limited to intranet development or test environment, not for production environment!