diff --git a/go.mod b/go.mod index 483ba1c..de528c1 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index f404198..a0565ce 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/zone/controller.go b/internal/zone/controller.go index a1cea18..56ec183 100644 --- a/internal/zone/controller.go +++ b/internal/zone/controller.go @@ -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)), @@ -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))) @@ -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( @@ -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 @@ -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 } @@ -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 { diff --git a/internal/zone/controller_test.go b/internal/zone/controller_test.go index b587851..ce2a33d 100644 --- a/internal/zone/controller_test.go +++ b/internal/zone/controller_test.go @@ -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() diff --git a/internal/zone/pdns.go b/internal/zone/pdns.go index 64c5ba3..132d216 100644 --- a/internal/zone/pdns.go +++ b/internal/zone/pdns.go @@ -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) { @@ -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 } diff --git a/internal/zone/testdata/acme-apex-at.zone b/internal/zone/testdata/acme-apex-at.zone new file mode 100644 index 0000000..d8f23b1 --- /dev/null +++ b/internal/zone/testdata/acme-apex-at.zone @@ -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" \ No newline at end of file diff --git a/internal/zone/testdata/expected-acme-wildcard-clean.zone b/internal/zone/testdata/expected-acme-wildcard-clean.zone new file mode 100644 index 0000000..530c3e2 --- /dev/null +++ b/internal/zone/testdata/expected-acme-wildcard-clean.zone @@ -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" diff --git a/internal/zone/testdata/expected-acme-wildcard-present.zone b/internal/zone/testdata/expected-acme-wildcard-present.zone new file mode 100644 index 0000000..df30d06 --- /dev/null +++ b/internal/zone/testdata/expected-acme-wildcard-present.zone @@ -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"