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

+80 -22
View File
@@ -93,9 +93,9 @@ type Logger struct {
// live is a fixed-size ring of the most recent entries. Each slot is a
// pointer, so a writer on the query path never races a reader walking the
// ring: publishing is one atomic word, and a reader sees either the whole
// entry or the one it replaced.
// entry or the one it replaced. The slot an entry lands in comes from its
// id, so concurrent writers cannot leave a hole or reorder the stream.
live []atomic.Pointer[liveEntry]
livePos atomic.Uint32
liveMu sync.RWMutex
liveCond *sync.Cond
liveFull atomic.Bool
@@ -104,6 +104,10 @@ type Logger struct {
pendingMu sync.Mutex
pending []Entry
// closed is set by Close; queries can still be logged after shutdown, so
// Log has to notice rather than write to a closed bbolt handle.
closed atomic.Bool
// counts are rolling per-domain counters.
counts *domainCounts
@@ -208,8 +212,20 @@ func New(opt Options) (*Logger, error) {
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketMeta)
return err
meta, err := tx.CreateBucketIfNotExists(bucketMeta)
if err != nil {
return err
}
// Resume the id sequence where the previous process stopped. The
// bbolt keys are that same sequence, so ids stay unique across a
// restart: a repeated id would make the SSE stream and the UI show
// a live entry as an older one carrying the same id.
if v := meta.Get(keyNextID); len(v) == 8 {
// next_id is the id the next entry will take, while seq
// counts ids already handed out.
l.seq.Store(beUint64(v) - 1)
}
return nil
}); err != nil {
_ = db.Close()
return nil, err
@@ -251,6 +267,9 @@ func (l *Logger) ShouldLog(a Action) bool {
// Log records a query. It never blocks on bbolt: the entry goes to the live
// ring and a batch, and a background goroutine does the disk write.
func (l *Logger) Log(e Entry) {
if l == nil || l.closed.Load() {
return
}
if e.TS.IsZero() {
e.TS = l.opts.Now()
}
@@ -264,8 +283,9 @@ func (l *Logger) Log(e Entry) {
// Live ring. The slot is published with a single atomic store; a reader
// walking the ring concurrently gets either the old or the new entry,
// never a half-written one.
pos := l.livePos.Add(1) - 1
l.live[pos%uint32(len(l.live))].Store(&liveEntry{entry: e})
// The index is the entry id rather than a second counter, so a burst from
// several resolver goroutines still lands gap free and in id order.
l.live[(e.ID-1)%uint64(len(l.live))].Store(&liveEntry{entry: e})
// Fan out to SSE subscribers (non-blocking: a slow consumer drops entries
// rather than stalling the query path).
@@ -308,7 +328,7 @@ func (l *Logger) flushLoop() {
// Flush writes any pending entries to bbolt and trims the ring.
func (l *Logger) Flush() {
if l.db == nil {
if l == nil || l.db == nil {
return
}
l.pendingMu.Lock()
@@ -359,17 +379,34 @@ func (l *Logger) trim(b *bolt.Bucket, nextID uint64) error {
if l.opts.MaxEntries <= 0 {
return nil
}
stats := b.Stats()
if stats.KeyN <= l.opts.MaxEntries {
// The keys are a dense id range, so the count is the span from the oldest
// surviving key up to nextID. Bucket.Stats cannot answer that here: it
// walks the pages as they sit on disk, and a write transaction has not
// spilled this batch yet, so it always under-counts and the cap is never
// enforced.
cur := b.Cursor()
first, _ := cur.First()
if first == nil {
return nil
}
excess := stats.KeyN - l.opts.MaxEntries
cur := b.Cursor()
for k, _ := cur.First(); k != nil && excess > 0; k, _ = cur.Next() {
oldest := beUint64(first)
span := nextID - oldest
if span <= uint64(l.opts.MaxEntries) {
return nil
}
excess := span - uint64(l.opts.MaxEntries)
// Collect the doomed keys before deleting any: bbolt rewrites a leaf
// page in place once it drops under a quarter full, which invalidates the
// cursor's page stack, so deleting while iterating stops early and the
// cap is only half enforced.
keys := make([][]byte, 0, excess)
for k, _ := cur.First(); k != nil && len(keys) < int(excess); k, _ = cur.Next() {
keys = append(keys, append([]byte(nil), k...))
}
for _, k := range keys {
if err := b.Delete(k); err != nil {
return err
}
excess--
}
return nil
}
@@ -426,6 +463,9 @@ type Page struct {
// It powers the SSE initial burst so a client connecting mid-stream sees
// recent history immediately.
func (l *Logger) Recent(limit int) []Entry {
if l == nil {
return nil
}
if limit <= 0 {
limit = 200
}
@@ -433,14 +473,14 @@ func (l *Logger) Recent(limit int) []Entry {
limit = len(l.live)
}
out := make([]Entry, 0, limit)
pos := l.livePos.Load()
size := uint64(len(l.live))
pos := l.seq.Load() // the newest id doubles as the newest ring position
total := pos
if total > uint32(len(l.live)) {
total = uint32(len(l.live))
if total > size {
total = size
}
for i := uint32(0); i < total; i++ {
idx := (pos - 1 - i) % uint32(len(l.live))
le := l.live[idx].Load()
for i := uint64(0); i < total; i++ {
le := l.live[(pos-1-i)%size].Load()
if le == nil {
// Never written (or already aged out of the window).
continue
@@ -455,6 +495,9 @@ func (l *Logger) Recent(limit int) []Entry {
// Search queries the persisted log.
func (l *Logger) Search(q Query) (Page, error) {
if l == nil {
return Page{Offset: q.Offset, Limit: q.Limit}, nil
}
l.Flush()
page := Page{Offset: q.Offset, Limit: q.Limit}
if q.Limit <= 0 {
@@ -623,14 +666,22 @@ func (l *Logger) SubscriberCount() int {
// Clear removes every persisted entry.
func (l *Logger) Clear() error {
if l.db == nil {
if l == nil || l.db == nil {
return nil
}
err := l.db.Update(func(tx *bolt.Tx) error {
if tx.Bucket(bucketLog) == nil {
return nil
_, err := tx.CreateBucket(bucketLog)
return err
}
return tx.DeleteBucket(bucketLog)
if err := tx.DeleteBucket(bucketLog); err != nil {
return err
}
// Recreate it in the same transaction: the flush path assumes the
// bucket is there, and a logger that silently stopped persisting after
// a clear would look like a healthy, empty log.
_, err := tx.CreateBucket(bucketLog)
return err
})
if err != nil {
l.logErrors.Add(1)
@@ -640,6 +691,9 @@ func (l *Logger) Clear() error {
// Count is the number of persisted entries.
func (l *Logger) Count() int {
if l == nil {
return 0
}
if l.db == nil {
return int(l.seq.Load())
}
@@ -708,6 +762,10 @@ func (l *Logger) Stats() Stats {
// Close flushes and releases resources.
func (l *Logger) Close() error {
if l == nil {
return nil
}
l.closed.Store(true)
select {
case <-l.stop:
default: