Fix $TTL in the zone parser; add make check-go
ci / Console typecheck and build (push) Successful in 12s
ci / Binary with the console embedded (push) Canceled after 0s
ci / Container image (push) Canceled after 0s
ci / Go build, vet and test (push) Canceled after 2m24s

The zone parser ignored an explicit $TTL and stamped every record with the SOA
minimum instead, so a 60s zone served 3600s and DefaultTTL was wrong too.
RFC 1035 §5.2: an explicit $TTL wins. qualifyRdata was also added to apply
RFC 1035 §5.1 relative-name expansion to RDATA, which is correct but has one
outstanding defect noted below.

make check-go is the tight inner loop: gofmt check, go vet, compile. It uses
go-build rather than build, because build depends on web and shells out to npm
to rebuild a console bundle that rarely changes. check now depends on it, so
the fast path and the CI path cannot drift apart.

Outstanding, not fixed here: a relative CNAME target is still emitted as
"host." instead of "host.example.com.". qualifyRdata is called with the right
origin, and relative() is correct in isolation, so the target is almost
certainly being dot-terminated in Zone.assemble before dns.NewRR ever sees it,
which makes relative() treat it as already absolute. Narrowed to that, not
confirmed — see the localzone test failures.
This commit is contained in:
developer committed 2026-09-27 13:59:31 +00:00
1 parent f02bb0aecf
commit 4c00bb4144
4 files changed
+1006 -36

No files matched your search

+78 -13
View File
@@ -48,6 +48,9 @@ type Zone struct {
SOA *dns.SOA
// DefaultTTL is the $TTL directive value, else the SOA minimum.
DefaultTTL uint32
// ttlDirective records that the file carried a $TTL, which outranks the
// SOA when the two disagree (RFC 1035 §5.2).
ttlDirective bool
}
// Options tunes parsing.
@@ -101,7 +104,7 @@ func Parse(r io.Reader, opt Options) (*Zone, error) {
lastOwner = z.Origin
continue
}
assembled, err := z.assemble(logical, lastOwner)
assembled, hasTTL, err := z.assemble(logical, lastOwner)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
@@ -109,6 +112,17 @@ func Parse(r io.Reader, opt Options) (*Zone, error) {
if err != nil {
return nil, fmt.Errorf("%s:%d: %q: %w", where(opt.File), startLine, assembled, err)
}
// RFC 1035 §5.2: a record with no TTL column takes the $TTL in force.
// The RR library stamps its own default on such a record, so the
// zone default has to be applied here or $TTL is silently ignored.
if !hasTTL && z.DefaultTTL > 0 {
rr.Header().Ttl = z.DefaultTTL
}
// RFC 1035 §5.1: a domain name in the RDATA section that is written
// without a trailing dot is relative to the zone origin, not to the
// root. Left to the library it becomes "host.", which points a CNAME
// at the root instead of at the zone.
qualifyRdata(rr, z.Origin)
rec := Record{
Name: strings.ToLower(ensureDot(rr.Header().Name)),
TTL: rr.Header().Ttl,
@@ -124,10 +138,14 @@ func Parse(r io.Reader, opt Options) (*Zone, error) {
}
if soa, ok := rr.(*dns.SOA); ok {
z.SOA = cloneSOA(soa)
if rec.TTL > 0 {
z.DefaultTTL = rec.TTL
} else if z.DefaultTTL == 0 {
z.DefaultTTL = soa.Minttl
// An explicit $TTL wins; the SOA only supplies the default for a
// file that never set one.
if !z.ttlDirective {
if rec.TTL > 0 {
z.DefaultTTL = rec.TTL
} else if z.DefaultTTL == 0 {
z.DefaultTTL = soa.Minttl
}
}
}
z.Records = append(z.Records, rec)
@@ -169,12 +187,15 @@ func deparen(s string) string {
return strings.TrimSpace(s)
}
// assemble fills in the omitted owner name, TTL and class so that the result
// is a complete RR line that dns.NewRR will accept.
func (z *Zone) assemble(line, lastOwner string) (string, error) {
// assemble fills in the omitted owner name and class so that the result is a
// complete RR line that dns.NewRR will accept. hasTTL reports whether the line
// carried a TTL of its own, so the caller can tell an omitted TTL from an
// explicit one and apply the $TTL default only to the former.
func (z *Zone) assemble(line, lastOwner string) (string, bool, error) {
var hasTTL bool
fields := splitFields(line)
if len(fields) == 0 {
return "", fmt.Errorf("empty record")
return "", false, fmt.Errorf("empty record")
}
var head string
var rest []string
@@ -187,7 +208,7 @@ func (z *Zone) assemble(line, lastOwner string) (string, error) {
rest = fields[1:]
}
if head == "" {
return "", fmt.Errorf("record has no owner name and no previous owner to inherit")
return "", false, fmt.Errorf("record has no owner name and no previous owner to inherit")
}
head = relative(head, z.Origin)
@@ -197,18 +218,19 @@ func (z *Zone) assemble(line, lastOwner string) (string, error) {
switch {
case isTTLToken(rest[0]):
out = append(out, rest[0])
hasTTL = true
case isClassToken(rest[0]):
out = append(out, strings.ToUpper(rest[0]))
default:
return "", fmt.Errorf("unexpected token %q before the record type", rest[0])
return "", false, fmt.Errorf("unexpected token %q before the record type", rest[0])
}
rest = rest[1:]
}
if len(rest) == 0 {
return "", fmt.Errorf("record %q has no type", head)
return "", false, fmt.Errorf("record %q has no type", head)
}
out = append(out, rest...)
return strings.Join(out, " "), nil
return strings.Join(out, " "), hasTTL, nil
}
// directive handles $TTL, $ORIGIN and $INCLUDE.
@@ -224,6 +246,7 @@ func (z *Zone) directive(line string, opt Options) error {
return err
}
z.DefaultTTL = ttl
z.ttlDirective = true
case "$ORIGIN":
if len(fields) < 2 {
return fmt.Errorf("$ORIGIN needs a name")
@@ -267,6 +290,48 @@ func cloneSOA(in *dns.SOA) *dns.SOA {
return &out
}
// qualifyRdata appends the origin to the domain names in a record's RDATA
// that were written without a trailing dot. RFC 1035 §5.1 makes those names
// relative to the zone origin, so "www CNAME host" in example.com. means
// host.example.com. and not host. A name that already ends in a dot is
// absolute and left alone, which keeps the RPZ targets "." and "rpz-drop."
// and the null MX/SRV targets intact.
func qualifyRdata(rr dns.RR, origin string) {
switch v := rr.(type) {
case *dns.CNAME:
v.Target = relative(v.Target, origin)
case *dns.DNAME:
v.Target = relative(v.Target, origin)
case *dns.NS:
v.Ns = relative(v.Ns, origin)
case *dns.PTR:
v.Ptr = relative(v.Ptr, origin)
case *dns.MX:
v.Mx = relative(v.Mx, origin)
case *dns.SRV:
v.Target = relative(v.Target, origin)
case *dns.SOA:
v.Ns = relative(v.Ns, origin)
v.Mbox = relative(v.Mbox, origin)
case *dns.NAPTR:
v.Replacement = relative(v.Replacement, origin)
case *dns.RP:
v.Mbox = relative(v.Mbox, origin)
v.Txt = relative(v.Txt, origin)
case *dns.MINFO:
v.Rmail = relative(v.Rmail, origin)
v.Email = relative(v.Email, origin)
case *dns.MB:
v.Mb = relative(v.Mb, origin)
case *dns.MG:
v.Mg = relative(v.Mg, origin)
case *dns.MR:
v.Mr = relative(v.Mr, origin)
case *dns.KX:
v.Exchanger = relative(v.Exchanger, origin)
}
}
func isRRTYPE(s string) bool { return rrTypes[strings.ToUpper(s)] }
func isTTLToken(s string) bool {