Skip to content
Merged
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
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ go 1.26.5

require (
github.com/alecthomas/kong v1.16.1
github.com/getkin/kin-openapi v0.147.0
github.com/getkin/kin-openapi v0.149.0
github.com/go-fuego/fuego v0.20.0
github.com/miekg/dns v1.1.73
github.com/otiai10/copy v1.14.1
github.com/pires/go-proxyproto v0.15.0
github.com/stretchr/testify v1.12.1
github.com/vooon/zoneomatic/pkg/dnsfmt v0.0.0-20260812063423-006c750f57fb
github.com/vooon/zoneomatic/pkg/dnsfmt v0.0.0-20260826133051-10c0dab35e8a
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
go.opentelemetry.io/otel v1.46.0
Expand Down Expand Up @@ -59,8 +59,8 @@ require (
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/getkin/kin-openapi v0.147.0 h1:s+Xsm9gUMPJbgCnABZ2to3zSQQ5A9dyj/zo62VVsldY=
github.com/getkin/kin-openapi v0.147.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
github.com/go-fuego/fuego v0.20.0 h1:xYqO9IiJ4SBO3qP5gCvlfHQX7zCtvMimG+w/gpQ56ro=
github.com/go-fuego/fuego v0.20.0/go.mod h1:de2N+B18pGLATUjzjDsxRm2wnrVSpT6JMRkloc0zgII=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
Expand Down Expand Up @@ -125,10 +125,10 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU=
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
Expand Down
53 changes: 46 additions & 7 deletions internal/zone/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ func (s *File) load() (zf *zonefile.Zonefile, soa *zonefile.Entry, err error) {
return
}

func (s *File) updateRecords(ctx context.Context, lg1 *slog.Logger, matchers Matchers, values []zonefile.Entry, allowNew bool) (changed bool, err error) {
func (s *File) updateRecords(ctx context.Context, lg1 *slog.Logger, matchers Matchers, values []zonefile.Entry, anchor []byte, allowNew bool) (changed bool, err error) {
ctx, span := zoneTracer.Start(ctx, "zone.file.update_records")
span.SetAttributes(
attribute.String("zone.file", path.Base(s.path)),
Expand Down Expand Up @@ -402,15 +402,26 @@ func (s *File) updateRecords(ctx context.Context, lg1 *slog.Logger, matchers Mat
attribute.Int("zone.matched_entry_count", matchedCount),
)

// 2. If old record not found - add new values to the end, if allowed
// 2. If old record not found - add new values, if allowed
if !found {
if !allowNew {
lg.ErrorContext(ctx, "No matching record not found, but insert is not allowed.")
return false, ErrRecordNotFound
}

lg.DebugContext(ctx, "No matching record not found, but inserting to the end")
newEntries = append(newEntries, values...)
if anchor != nil {
if idx := anchorInsertIndex(newEntries, anchor); idx >= 0 {
lg.DebugContext(ctx, "No matching record not found, inserting after anchor", "index", idx)
newEntries = slices.Insert(newEntries, idx+1, values...)
span.SetAttributes(attribute.Int("zone.anchor_index", idx))
} else {
lg.DebugContext(ctx, "No matching record not found, no anchor found, inserting to the end")
newEntries = append(newEntries, values...)
}
} else {
lg.DebugContext(ctx, "No matching record not found, but inserting to the end")
newEntries = append(newEntries, values...)
}
}
span.SetAttributes(attribute.Int("zone.result_entry_count", len(newEntries)))

Expand Down Expand Up @@ -446,6 +457,34 @@ func (s *File) updateRecords(ctx context.Context, lg1 *slog.Logger, matchers Mat
return
}

// anchorInsertIndex returns the index of the last entry before which the
// new values should be inserted: the last entry with the same domain as the
// anchor, or, if none, the last _acme-challenge.* entry. Returns -1 if there
// is no suitable place, in which case new values are appended to the end.
func anchorInsertIndex(entries []zonefile.Entry, anchor []byte) int {
same, acme := -1, -1
for i, e := range entries {
if e.IsComment || e.IsControl {
continue
}
d := e.Domain()
if d == nil {
continue
}
if dnsNamesEqual(d, anchor) {
same = i
continue
}
if strings.HasPrefix(strings.ToLower(normalizeZoneName(string(d))), "_acme-challenge.") {
acme = i
}
}
if same >= 0 {
return same
}
return acme
}

func (s *File) UpdateDDNSAddress(ctx context.Context, domain string, addrs []netip.Addr) error {
ctx, span := zoneTracer.Start(ctx, "zone.file.update_ddns_address")
span.SetAttributes(
Expand Down Expand Up @@ -502,7 +541,7 @@ func (s *File) UpdateDDNSAddress(ctx context.Context, domain string, addrs []net
matchers = append(matchers, Matcher{Domain: shortDomain, RRType: dns.TypeAAAA})
}

_, err = s.updateRecords(ctx, lg, matchers, values, true)
_, err = s.updateRecords(ctx, lg, matchers, values, nil, true)
if err != nil {
recordSpanError(span, err)
return err
Expand Down Expand Up @@ -562,7 +601,7 @@ func (s *File) UpdateACMEChallenge(ctx context.Context, domain string, newToken,
},
}

_, err = s.updateRecords(ctx, lg, matchers, values, true)
_, err = s.updateRecords(ctx, lg, matchers, values, shortDomain, true)
if err != nil {
return err
}
Expand Down Expand Up @@ -618,7 +657,7 @@ func (s *File) ZMUpdateRecord(ctx context.Context, domain string, typ string, tt
},
}

return s.updateRecords(ctx, lg, matchers, values, false)
return s.updateRecords(ctx, lg, matchers, values, nil, false)
}

func StripOrigin(name, origin string) string {
Expand Down
30 changes: 30 additions & 0 deletions internal/zone/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,36 @@ func TestFile_UpdateACMEChallenge_WithTTL(t *testing.T) {
}
}

func TestFile_UpdateACMEChallenge_WildcardPlacement(t *testing.T) {
token1 := "fake/token1-helloworld"
token2 := "fake/token2-helloworld"

// NOTE: use synctest to have predictable time.New()
synctest.Test(t, func(t *testing.T) {
assert := assert.New(t)
ctx := context.TODO()
f := newZoneTemp(t, "./testdata/acme-apex-at.zone")

// Present first token - should replace the pre-seeded placeholder
err := f.UpdateACMEChallenge(ctx, "_acme-challenge", token1, EmptyPlaceholder)
assert.NoError(err)

// Present second token - no placeholder left, must be inserted right after the first
err = f.UpdateACMEChallenge(ctx, "_acme-challenge", token2, EmptyPlaceholder)
assert.NoError(err)

assertFiles(t, "./testdata/expected-acme-wildcard-present.zone", f.path)

// Cleanup - replace tokens back to placeholders in place
err = f.UpdateACMEChallenge(ctx, "_acme-challenge", "", token2)
assert.NoError(err)
err = f.UpdateACMEChallenge(ctx, "_acme-challenge", "", token1)
assert.NoError(err)

assertFiles(t, "./testdata/expected-acme-wildcard-clean.zone", f.path)
})
}

func TestFile_ZMUpdateRecord_TypeCaseInsensitive(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx := context.TODO()
Expand Down
4 changes: 2 additions & 2 deletions internal/zone/pdns.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ func (s *File) ReplaceRRSet(ctx context.Context, name, typ string, ttl int, valu
RRType: rrType,
}}

return s.updateRecords(ctx, lg, matchers, entries, true)
return s.updateRecords(ctx, lg, matchers, entries, nil, true)
}

func (s *File) DeleteRRSet(ctx context.Context, name, typ string) (changed bool, err error) {
Expand Down Expand Up @@ -313,7 +313,7 @@ func (s *File) DeleteRRSet(ctx context.Context, name, typ string) (changed bool,
RRType: rrType,
}}

changed, err = s.updateRecords(ctx, lg, matchers, nil, false)
changed, err = s.updateRecords(ctx, lg, matchers, nil, nil, false)
if errors.Is(err, ErrRecordNotFound) {
return false, nil
}
Expand Down
22 changes: 22 additions & 0 deletions internal/zone/testdata/acme-apex-at.zone
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
$ORIGIN at.example.com.
$TTL 60
; SOA Record
@ IN SOA ns1.example.com. hostmaster.example.com. (
1763822925 ; serial Sat, 22 Nov 2025 14:48:45 UTC
1H ; refresh
600 ; retry
1W ; expire
1D ; minimum
)

; NS Records
@ IN NS ns1.example.com.
IN NS ns2.example.com.

; Existing record
loop IN A 127.0.0.1
IN AAAA ::1

; sub-domain challenge
_acme-challenge.zot IN TXT "8NwtedqEdkceTHTZILXsMU2UWEeEon24tXw0dSSDkrs"
_acme-challenge IN TXT "placeholder"
23 changes: 23 additions & 0 deletions internal/zone/testdata/expected-acme-wildcard-clean.zone
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
$ORIGIN at.example.com.
$TTL 60
; SOA Record
@ IN SOA ns1.example.com. hostmaster.example.com. (
1763822929 ; serial Sun, 29 Oct 1769 06:46:00 UTC
1H ; refresh
600 ; retry
1W ; expire
1D ; minimum
)

; NS Records
@ IN NS ns1.example.com.
IN NS ns2.example.com.

; Existing record
loop IN A 127.0.0.1
IN AAAA ::1

; sub-domain challenge
_acme-challenge.zot IN TXT "8NwtedqEdkceTHTZILXsMU2UWEeEon24tXw0dSSDkrs"
_acme-challenge IN TXT "placeholder"
IN TXT "placeholder"
23 changes: 23 additions & 0 deletions internal/zone/testdata/expected-acme-wildcard-present.zone
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
$ORIGIN at.example.com.
$TTL 60
; SOA Record
@ IN SOA ns1.example.com. hostmaster.example.com. (
1763822927 ; serial Sun, 29 Oct 1769 06:18:00 UTC
1H ; refresh
600 ; retry
1W ; expire
1D ; minimum
)

; NS Records
@ IN NS ns1.example.com.
IN NS ns2.example.com.

; Existing record
loop IN A 127.0.0.1
IN AAAA ::1

; sub-domain challenge
_acme-challenge.zot IN TXT "8NwtedqEdkceTHTZILXsMU2UWEeEon24tXw0dSSDkrs"
_acme-challenge IN TXT "fake/token1-helloworld"
IN TXT "fake/token2-helloworld"