Compare commits

...
Author SHA1 Message Date
Simon Zeyer b2701161cb add AXFR request handling to support slaves
E2E Tests / e2e-tests (pull_request) Failing after 7s
golangci-lint / lint (pull_request) Failing after 4s
Go Coverage / Build and Test (pull_request) Failing after 13m36s
2026-02-24 19:50:54 +00:00
Simon Zeyer 87db76cf39 implement SOA serial bump and DNS slave server notify 2026-02-24 19:50:03 +00:00
Joona HoikkalaandGitHub 4e5a69e5fb Fix errors in auto-released docker images (#399) 2026-02-05 18:09:31 +02:00
Joona HoikkalaandGitHub 055f80bf9b Build and push a new docker image to Docker Hub when a release is triggered (#397) 2026-02-05 17:32:32 +02:00
Joona HoikkalaandGitHub 917ff10563 Update readme and changelog (#396) 2026-02-05 17:13:05 +02:00
16 changed files with 390 additions and 70 deletions
+6
View File
@@ -26,8 +26,14 @@ jobs:
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
+14 -1
View File
@@ -1,7 +1,7 @@
builds:
- binary: acme-dns
env:
- CGO_ENABLED=1
- CGO_ENABLED=0
goos:
- linux
goarch:
@@ -20,3 +20,16 @@ archives:
signs:
- artifacts: checksum
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
dockers:
- image_templates:
- "joohoi/acme-dns:{{ .Tag }}"
- "joohoi/acme-dns:latest"
dockerfile: Dockerfile.release
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.name={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
+59
View File
@@ -0,0 +1,59 @@
# Changelog
## v2.0
- Update goreleaser configuration and add a GitHub action to build a release on new version tags (#395)
- Huge refactoring and modernization (#325)
## v1.1
- Add timeout to golangci job (#369)
- Update deps to support go 1.23 (#368)
- Bump dependencies (#334)
## v1.0
- New
- Refactoring of the codebase to something more robust
- Changed
- Updated dependencies
- v0.8
- NOTE: configuration option: "api_domain" deprecated!
- New
- Automatic HTTP API certificate provisioning using DNS challenges making acme-dns able to acquire certificates even with HTTP api not being accessible from public internet.
- Configuration value for "tls": "letsencryptstaging". Setting it will help you to debug possible issues with HTTP API certificate acquiring process. This is the new default value.
- Changed
- Fixed: EDNS0 support
- Migrated from autocert to [certmagic](https://github.com/mholt/certmagic) for HTTP API certificate handling
- v0.7.2
- Changed
- Fixed: Regression error of not being able to answer to incoming random-case requests.
- Fixed: SOA record added to a correct header field in NXDOMAIN responses.
- v0.7.1
- Changed
- Fixed: SOA record correctly added to the TCP DNS server when using both, UDP and TCP servers.
- v0.7
- New
- Added an endpoint to perform health checks
- Changed
- A new protocol selection for DNS server "both", that binds both - UDP and TCP ports.
- Refactored DNS server internals.
- Handle some aspects of DNS spec better.
- v0.6
- New
- Command line flag `-c` to specify location of config file.
- Proper refusal of dynamic update requests.
- Release signing
- Changed
- Better error messages for goroutines
- v0.5
- New
- Configurable certificate cache directory
- Changed
- Process wide umask to ensure created files are only readable by the user running acme-dns
- Replaced package that handles UUIDs because of a flaw in the original package
- Updated dependencies
- Better error messages
- v0.4 Clear error messages for bad TXT record content, proper handling of static CNAME records, fixed IP address parsing from the request, added option to disable registration endpoint in the configuration.
- v0.3.2 Dockerfile was fixed for users using autocert feature
- v0.3.1 Added goreleaser for distributing binary builds of the releases
- v0.3 Changed autocert to use HTTP-01 challenges, as TLS-SNI is disabled by Let's Encrypt
- v0.2 Now powered by httprouter, support wildcard certificates, Docker images
- v0.1 Initial release
+2 -2
View File
@@ -1,12 +1,12 @@
FROM golang:alpine AS builder
LABEL maintainer="joona@kuori.org"
RUN apk add --update gcc musl-dev git
RUN apk add --update git
ENV GOPATH /tmp/buildcache
RUN git clone https://github.com/joohoi/acme-dns /tmp/acme-dns
WORKDIR /tmp/acme-dns
RUN CGO_ENABLED=1 go build
RUN CGO_ENABLED=0 go build
FROM alpine:latest
+12
View File
@@ -0,0 +1,12 @@
FROM alpine:latest
RUN apk --no-cache add ca-certificates && update-ca-certificates
RUN mkdir -p /etc/acme-dns
RUN mkdir -p /var/lib/acme-dns
COPY acme-dns /usr/local/bin/acme-dns
VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"]
ENTRYPOINT ["acme-dns"]
EXPOSE 53 80 443
EXPOSE 53/udp
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2016 Joona Hoikkala
Copyright (c) 2016-2026 Joona Hoikkala
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -50
View File
@@ -338,55 +338,6 @@ use for the renewal.
- Generic client library in Go: [https://github.com/cpu/goacmedns](https://github.com/cpu/goacmedns)
## Changelog
- v1.0
- New
- Refactoring of the codebase to something more robust
- Changed
- Updated dependencies
- v0.8
- NOTE: configuration option: "api_domain" deprecated!
- New
- Automatic HTTP API certificate provisioning using DNS challenges making acme-dns able to acquire certificates even with HTTP api not being accessible from public internet.
- Configuration value for "tls": "letsencryptstaging". Setting it will help you to debug possible issues with HTTP API certificate acquiring process. This is the new default value.
- Changed
- Fixed: EDNS0 support
- Migrated from autocert to [certmagic](https://github.com/mholt/certmagic) for HTTP API certificate handling
- v0.7.2
- Changed
- Fixed: Regression error of not being able to answer to incoming random-case requests.
- Fixed: SOA record added to a correct header field in NXDOMAIN responses.
- v0.7.1
- Changed
- Fixed: SOA record correctly added to the TCP DNS server when using both, UDP and TCP servers.
- v0.7
- New
- Added an endpoint to perform health checks
- Changed
- A new protocol selection for DNS server "both", that binds both - UDP and TCP ports.
- Refactored DNS server internals.
- Handle some aspects of DNS spec better.
- v0.6
- New
- Command line flag `-c` to specify location of config file.
- Proper refusal of dynamic update requests.
- Release signing
- Changed
- Better error messages for goroutines
- v0.5
- New
- Configurable certificate cache directory
- Changed
- Process wide umask to ensure created files are only readable by the user running acme-dns
- Replaced package that handles UUIDs because of a flaw in the original package
- Updated dependencies
- Better error messages
- v0.4 Clear error messages for bad TXT record content, proper handling of static CNAME records, fixed IP address parsing from the request, added option to disable registration endpoint in the configuration.
- v0.3.2 Dockerfile was fixed for users using autocert feature
- v0.3.1 Added goreleaser for distributing binary builds of the releases
- v0.3 Changed autocert to use HTTP-01 challenges, as TLS-SNI is disabled by Let's Encrypt
- v0.2 Now powered by httprouter, support wildcard certificates, Docker images
- v0.1 Initial release
## TODO
@@ -401,4 +352,4 @@ If you have an idea for improvement, please open an new issue or feel free to wr
## License
acme-dns is released under the [MIT License](http://www.opensource.org/licenses/MIT).
acme-dns is released under the [MIT License](https://www.opensource.org/licenses/MIT).
+6
View File
@@ -18,6 +18,12 @@ records = [
# specify that auth.example.org will resolve any *.auth.example.org records
"auth.example.org. NS auth.example.org.",
]
# path to cache SOA serial
serialpath = "soa-serial.save"
# slaves to notify on update and allowed to request AXFR
slaves = [
# "10.5.1.1"
]
# debug messages from CORS etc
debug = false
+7
View File
@@ -10,6 +10,7 @@ type AcmednsDB interface {
Register(cidrslice Cidrslice) (ACMETxt, error)
GetByUsername(uuid.UUID) (ACMETxt, error)
GetTXTForDomain(string) ([]string, error)
GetTXTForAllDomains() ([]TXTRecord, error)
Update(ACMETxtPost) error
GetBackend() *sql.DB
SetBackend(*sql.DB)
@@ -21,4 +22,10 @@ type AcmednsNS interface {
SetOwnAuthKey(key string)
SetNotifyStartedFunc(func())
ParseRecords()
BumpSerial() error
}
type TXTRecord struct {
Subdomain string
Value string
}
+2
View File
@@ -25,6 +25,8 @@ type general struct {
Nsadmin string
Debug bool
StaticRecords []string `toml:"records"`
Serialpath string
SlaveHosts []string `toml:"slaves"`
}
type dbsettings struct {
+7 -4
View File
@@ -15,10 +15,11 @@ import (
)
type AcmednsAPI struct {
Config *acmedns.AcmeDnsConfig
DB acmedns.AcmednsDB
Logger *zap.SugaredLogger
errChan chan error
Config *acmedns.AcmeDnsConfig
DB acmedns.AcmednsDB
Logger *zap.SugaredLogger
errChan chan error
dnsServers []acmedns.AcmednsNS
}
func Init(config *acmedns.AcmeDnsConfig, db acmedns.AcmednsDB, logger *zap.SugaredLogger, errChan chan error) AcmednsAPI {
@@ -27,6 +28,8 @@ func Init(config *acmedns.AcmeDnsConfig, db acmedns.AcmednsDB, logger *zap.Sugar
}
func (a *AcmednsAPI) Start(dnsservers []acmedns.AcmednsNS) {
//we need the dnsservers later to bump serial
a.dnsServers = dnsservers
var err error
//TODO: do we want to debug log the HTTP server?
stderrorlog, err := zap.NewStdLogAt(a.Logger.Desugar(), zap.ErrorLevel)
+4
View File
@@ -49,6 +49,10 @@ func (a *AcmednsAPI) webUpdatePost(w http.ResponseWriter, r *http.Request, _ htt
upd = []byte("{\"txt\": \"" + atxt.Value + "\"}")
}
}
for _, s := range a.dnsServers {
//bump SOA serial on update (and notify slaves if configured)
s.BumpSerial()
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(updStatus)
_, _ = w.Write(upd)
+44
View File
@@ -263,6 +263,50 @@ func (d *acmednsdb) GetByUsername(u uuid.UUID) (acmedns.ACMETxt, error) {
return acmedns.ACMETxt{}, fmt.Errorf("user not found: %s", u.String())
}
func (d *acmednsdb) GetTXTForAllDomains() ([]acmedns.TXTRecord, error) {
d.Mutex.Lock()
defer d.Mutex.Unlock()
var txts []acmedns.TXTRecord
getSQL := `
SELECT Subdomain, Value FROM txt
`
if d.Config.Database.Engine == "sqlite" {
getSQL = getSQLiteStmt(getSQL)
}
sm, err := d.DB.Prepare(getSQL)
if err != nil {
return txts, err
}
defer sm.Close()
rows, err := sm.Query()
if err != nil {
return txts, err
}
defer rows.Close()
for rows.Next() {
var subdomain string
var value string
err = rows.Scan(&subdomain, &value)
if err != nil {
return txts, err
}
d.Logger.Debugw("GetTXTForAllDomains() TXT Record:", subdomain, value)
txts = append(txts, acmedns.TXTRecord{
Subdomain: subdomain,
Value: value,
})
}
return txts, nil
}
func (d *acmednsdb) GetTXTForDomain(domain string) ([]string, error) {
d.Mutex.Lock()
defer d.Mutex.Unlock()
+113
View File
@@ -2,12 +2,48 @@ package nameserver
import (
"fmt"
"net"
"strings"
"github.com/miekg/dns"
)
func (n *Nameserver) handleRequest(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 1 {
q := r.Question[0]
if q.Qtype == dns.TypeAXFR || q.Qtype == dns.TypeIXFR { // Get remote IP
remoteIP, _, err := net.SplitHostPort(w.RemoteAddr().String())
if err != nil {
n.Logger.Errorw("Failed to parse remote address", "err", err)
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeRefused
_ = w.WriteMsg(m)
return
}
// Check if remote IP is in slave list
allowed := false
for _, slave := range n.Config.General.SlaveHosts {
if remoteIP == slave {
allowed = true
break
}
}
if !allowed {
n.Logger.Warnw("AXFR/IXFR request denied", "remote", remoteIP)
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeRefused
_ = w.WriteMsg(m)
return
}
n.handleAXFR(w, r)
return
}
}
m := new(dns.Msg)
m.SetReply(r)
// handle edns0
@@ -71,6 +107,9 @@ func (n *Nameserver) answer(q dns.Question) ([]dns.RR, int, bool, error) {
r = append(r, txtRRs...)
}
}
if q.Qtype == dns.TypeSOA {
r = append(r, n.SOA)
}
if len(r) > 0 {
// Make sure that we return NOERROR if there were dynamic records for the domain
rcode = dns.RcodeSuccess
@@ -158,3 +197,77 @@ func (n *Nameserver) getRecord(name string, qtype uint16) ([]dns.RR, error) {
}
return rr, nil
}
func (n *Nameserver) handleAXFR(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
zone := dns.Fqdn(r.Question[0].Name)
records, ok := n.Domains[zone]
if !ok {
m := new(dns.Msg)
m.SetRcode(r, dns.RcodeNameError)
_ = w.WriteMsg(m)
return
}
// AXFR muss über Transfer laufen
tr := new(dns.Transfer)
c := make(chan *dns.Envelope)
go func() {
defer close(c)
var rr []dns.RR
// Start SOA
rr = append(rr, n.SOA)
// NS
rr = append(rr, records.NS...)
// Andere Records
// rr = append(rr, filterSOA(records.Records)...)
rr = append(rr, records.Records...)
// TXT Records nur für diese Zone!
txtRecords, err := n.DB.GetTXTForAllDomains()
if err == nil {
for _, rec := range txtRecords {
if rec.Value == "" {
continue
}
fqdn := dns.Fqdn(rec.Subdomain + "." + zone)
txtRR := &dns.TXT{
Hdr: dns.RR_Header{
Name: fqdn,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: 1,
},
Txt: []string{rec.Value},
}
rr = append(rr, txtRR)
n.Logger.Debugw("handleAXFR TXT Record", "subdomain", rec.Subdomain, "value", rec.Value, "fqdn", fqdn)
rr = append(rr, txtRR)
}
} else {
n.Logger.Errorw("Failed to get TXT records for AXFR", "error", err)
}
// End SOA
rr = append(rr, n.SOA)
c <- &dns.Envelope{RR: rr}
}()
_ = tr.Out(w, r, c)
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
// Records is a slice of ResourceRecords
type Records struct {
Records []dns.RR
NS []dns.RR
}
type Nameserver struct {
@@ -23,7 +24,7 @@ type Nameserver struct {
Server *dns.Server
OwnDomain string
NotifyStartedFunc func()
SOA dns.RR
SOA *dns.SOA
mu sync.RWMutex
personalAuthKey string
Domains map[string]Records
+110 -11
View File
@@ -2,12 +2,64 @@ package nameserver
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
)
func loadSerial(path string) (uint32, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return 0, nil // first start
}
return 0, err
}
s := strings.TrimSpace(string(data))
val, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return 0, err
}
return uint32(val), nil
}
func saveSerial(path string, serial uint32) error {
tmp := path + ".tmp"
data := []byte(fmt.Sprintf("%d\n", serial))
// write temp file
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
// atomic replace
return os.Rename(tmp, path)
}
func nextSerial(old uint32) uint32 {
today := time.Now().Format("20060102")
oldStr := fmt.Sprintf("%d", old)
if strings.HasPrefix(oldStr, today) {
return old + 1
}
newSerial, _ := strconv.Atoi(today + "00")
if uint32(newSerial) <= old {
return old + 1
}
return uint32(newSerial)
}
// ParseRecords parses a slice of DNS record string
func (n *Nameserver) ParseRecords() {
for _, v := range n.Config.General.StaticRecords {
@@ -22,25 +74,72 @@ func (n *Nameserver) ParseRecords() {
n.appendRR(rr)
}
// Create serial
serial := time.Now().Format("2006010215")
// Add SOA
SOAstring := fmt.Sprintf("%s. SOA %s. %s. %s 28800 7200 604800 86400", strings.ToLower(n.Config.General.Domain), strings.ToLower(n.Config.General.Nsname), strings.ToLower(n.Config.General.Nsadmin), serial)
soarr, err := dns.NewRR(SOAstring)
serial, err := loadSerial(n.Config.General.Serialpath)
if err != nil {
n.Logger.Errorw("Error while adding SOA record",
"error", err.Error(),
"soa", SOAstring)
} else {
n.appendRR(soarr)
n.SOA = soarr
n.Logger.Errorw("Could not load temp serial",
"error", err.Error())
}
if serial == 0 {
serial = uint32(time.Now().Unix())
}
// Add SOA
//Refresh = 30s → Slaves fragen alle 30s nach Änderungen
//Retry = 10s → Wenn Master nicht erreichbar, probiert der Slave alle 10s erneut
//Expire = 604800s (1w) → Wie lange der Slave die Zone noch behält, falls Master ausfällt
//Minimum TTL = 20s → Resolver cachen die TXT-Einträge nur kurz
//SOAstring := fmt.Sprintf("%s. SOA %s. %s. %s 5 10 604800 20", strings.ToLower(n.Config.General.Domain), strings.ToLower(n.Config.General.Nsname), strings.ToLower(n.Config.General.Nsadmin), serial)
n.SOA = &dns.SOA{
Hdr: dns.RR_Header{
Name: dns.Fqdn(n.Config.General.Domain),
Rrtype: dns.TypeSOA,
Class: dns.ClassINET,
Ttl: 5,
},
Ns: dns.Fqdn(n.Config.General.Nsname),
Mbox: dns.Fqdn(n.Config.General.Nsadmin),
Serial: serial,
Refresh: 5,
Retry: 10,
Expire: 604800,
Minttl: 1,
}
}
func sendNotify(zone string, slaveAddr string) error {
m := new(dns.Msg)
m.SetNotify(dns.Fqdn(zone)) // Set opcode to NOTIFY
m.Authoritative = true // Must be authoritative
c := new(dns.Client)
_, _, err := c.Exchange(m, slaveAddr)
return err
}
func (n *Nameserver) BumpSerial() error {
n.mu.Lock()
defer n.mu.Unlock()
n.SOA.Serial = nextSerial(n.SOA.Serial)
for _, slave := range n.Config.General.SlaveHosts {
slave := slave + ":53"
if err := sendNotify(n.SOA.Hdr.Name, slave); err != nil {
n.Logger.Errorw("Failed to notify slave", "slave", slave, "err", err)
} else {
n.Logger.Debugw("Notify send to slave", "slave", slave)
}
}
return saveSerial(n.Config.General.Serialpath, n.SOA.Serial)
}
func (n *Nameserver) appendRR(rr dns.RR) {
addDomain := rr.Header().Name
_, ok := n.Domains[addDomain]
if !ok {
n.Domains[addDomain] = Records{[]dns.RR{rr}}
n.Domains[addDomain] = Records{
Records: []dns.RR{rr}, // initialisiere Records
NS: []dns.RR{}, // leeres NS-Slice, sonst Fehler
}
} else {
drecs := n.Domains[addDomain]
drecs.Records = append(drecs.Records, rr)