Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e9db2e3
feat: add database schema and domain types for Catalog Zones (RFC 9432)
poyrazK Jun 10, 2026
380dd3a
feat: add CATZ/CZTR packet handling for read/write
poyrazK Jun 10, 2026
6abf213
feat: add catalog repository interface and PostgreSQL implementation
poyrazK Jun 10, 2026
0f4855d
feat: add catalog service methods and REST API endpoints
poyrazK Jun 10, 2026
c73a92a
feat: add slave catalog polling mechanism
poyrazK Jun 10, 2026
d6b0f39
test: add mock implementations for catalog zone interfaces
poyrazK Jun 10, 2026
5b19259
fix: address code review findings - security, robustness, and complet…
poyrazK Jun 10, 2026
23c4307
fix: add tenant authorization to AddCatalogEntry and RemoveCatalogEnt…
poyrazK Jun 11, 2026
0631906
fix: syncZoneFromCatalog now rolls back zone on AXFR failure and retu…
poyrazK Jun 11, 2026
9de9106
feat: wire catalog zone poller into server startup with TTL cleanup
poyrazK Jun 11, 2026
8e5c785
fix: replace O(n) ListZones scan with targeted GetZoneByCatalogName q…
poyrazK Jun 11, 2026
4f50f4e
fix(repository): restore full schema — catalog additions were not a w…
poyrazK Jun 11, 2026
7d4f751
test: add behavioral tests for RFC 9432 Catalog Zones
poyrazK Jun 11, 2026
ba9c7ee
fix: address PR #290 code review findings
poyrazK Jun 11, 2026
210bc4d
docs: add RFC 9432 Catalog Zones to README, features, and ADR 0012
poyrazK Jun 11, 2026
82d4c27
test: add coverage for catalog zone gaps
poyrazK Jun 11, 2026
186e52b
fix: resolve lint issues in catalog zone code (errcheck, errorlint, r…
poyrazK Jun 11, 2026
72eff0d
fix: use valid UUIDs in catalog zone integration tests
poyrazK Jun 11, 2026
23a7829
fix: use valid UUIDs for catalog zone IDs in integration tests
poyrazK Jun 11, 2026
ad4cf69
fix: use valid UUID in GetCatalogZone test for non-existent ID
poyrazK Jun 11, 2026
da2b36c
fix: add unique constraint for catalog zone PTR records in schema
poyrazK Jun 11, 2026
cace6ea
fix: use substring for prefix match in RemoveZoneFromCatalog to avoid…
poyrazK Jun 11, 2026
0070400
fix: handle NULL catalog_zone_name in GetZoneByCatalogName scan
poyrazK Jun 11, 2026
5e095d1
fix: include catalog_zone_name in CreateZone and CreateZoneWithRecord…
poyrazK Jun 11, 2026
b0a3d2d
fix: update RemoveZoneFromCatalog unit test to match new substring query
poyrazK Jun 11, 2026
67e598a
fix: update unit test mocks for CreateZone 10-column INSERT
poyrazK Jun 11, 2026
e7f29b6
fix: add GetRecordsByNames unit test to improve coverage
poyrazK Jun 11, 2026
0b2117f
fix: add test for GetCurrentTimeUint32 to eliminate 0% coverage package
poyrazK Jun 11, 2026
0c468d8
fix: add coverage tests for DNSSEC config parsing
poyrazK Jun 11, 2026
f67f514
test: add coverage tests for parseDNSSECConfig, MockDNSService catalo…
poyrazK Jun 11, 2026
90141a7
test: add UpdateRecord content-type and invalid body coverage tests
poyrazK Jun 11, 2026
ba5a936
test: add TestNewWithNilLogger for Handler.New nil logger coverage
poyrazK Jun 11, 2026
aaeed00
test: add UpdateRecord coverage tests for low TTL and error path
poyrazK Jun 11, 2026
9da2bd8
test: add MockRepo UpdateRecord and ListRecordsForZoneStreaming cover…
poyrazK Jun 11, 2026
416ee40
test: add GetDNSKEYs and UpdateRecord coverage to MockRepo and MockDN…
poyrazK Jun 11, 2026
da5bc2e
test: add AddCatalogEntry error path coverage tests
poyrazK Jun 11, 2026
4fb4bf4
test: add goCommand SetStdout/Run coverage tests
poyrazK Jun 11, 2026
ced757d
test: add runSeed default URL test coverage
poyrazK Jun 12, 2026
6949633
test: add run error path coverage tests for apikey
poyrazK Jun 13, 2026
bbd3e1b
test: add GetRecordsByNames empty names edge case test
poyrazK Jun 13, 2026
8a00205
refactor: add injectable interfaces for TCP connections and tickers t…
poyrazK Jun 13, 2026
0267422
test: add tests for catalog zone polling functions
poyrazK Jun 13, 2026
6d01641
test: add PollCatalogZone and SyncZonesFromCatalog tests
poyrazK Jun 13, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ cloudDNS is a high-performance, authoritative, and recursive DNS server built fr
* **EDNS(0) & Truncation (RFC 6891)**: Extended payload support with automatic TCP fallback.
* **TSIG (RFC 2845)**: HMAC-authenticated transactions for secure updates and transfers.
* **CHAOS Class Support**: Node identity resolution (`id.server.`, `hostname.bind.`) for NSID-ready deployments.
* **Catalog Zones (RFC 9432)**: Zone metadata catalog supporting zone provisioning, membership management, and slave-side polling with automatic AXFR-based synchronization.

### Architecture & Management
* **Hexagonal Architecture**: Clean separation of concerns (Domain -> Ports -> Adapters).
Expand Down
44 changes: 44 additions & 0 deletions cmd/apikey/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,50 @@ func TestRevokeKey_Error(t *testing.T) {
mockRepo.AssertExpectations(t)
}

func TestRun_UnknownSubcommand(t *testing.T) {
mockRepo := new(testutil.MockRepo)
err := run([]string{"apikey", "unknown"}, io.Discard, mockRepo)
if err == nil {
t.Error("expected error for unknown subcommand")
}
if !strings.Contains(err.Error(), "unknown subcommand") {
t.Errorf("expected 'unknown subcommand' error, got: %v", err)
}
}

func TestRun_InvalidRole(t *testing.T) {
mockRepo := new(testutil.MockRepo)
err := run([]string{"apikey", "create", "-role", "invalid"}, io.Discard, mockRepo)
if err == nil {
t.Error("expected error for invalid role")
}
if !strings.Contains(err.Error(), "invalid role") {
t.Errorf("expected 'invalid role' error, got: %v", err)
}
}

func TestRun_InvalidDays(t *testing.T) {
mockRepo := new(testutil.MockRepo)
err := run([]string{"apikey", "create", "-days", "0"}, io.Discard, mockRepo)
if err == nil {
t.Error("expected error for invalid days")
}
if !strings.Contains(err.Error(), "invalid days") {
t.Errorf("expected 'invalid days' error, got: %v", err)
}
}

func TestRun_TooFewArgs(t *testing.T) {
mockRepo := new(testutil.MockRepo)
err := run([]string{"apikey"}, io.Discard, mockRepo)
if err == nil {
t.Error("expected error for too few args")
}
if !strings.Contains(err.Error(), "expected") {
t.Errorf("expected 'expected' error, got: %v", err)
}
}

func TestRunCommand(t *testing.T) {
mockRepo := new(testutil.MockRepo)
out := &bytes.Buffer{}
Expand Down
10 changes: 8 additions & 2 deletions cmd/bench/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import (
"github.com/poyrazK/cloudDNS/internal/dns/server"
)

// sleepFn and readFileFn are injectable for testing.
var (
sleepFn = time.Sleep
readFileFn = os.ReadFile
)

type Stats struct {
TotalQueries uint64
Success uint64
Expand Down Expand Up @@ -295,7 +301,7 @@ func runScaleTest(count int, concurrency int) {
}
defer func() { _ = db.Close() }()

schema, errRead := os.ReadFile("internal/adapters/repository/schema.sql")
schema, errRead := readFileFn("internal/adapters/repository/schema.sql")
if errRead != nil {
fmt.Printf("Failed to read schema file: %v\n", errRead)
return
Expand Down Expand Up @@ -348,7 +354,7 @@ func runScaleTest(count int, concurrency int) {
}
}()

time.Sleep(1 * time.Second)
sleepFn(1 * time.Second)

// 4. Benchmark
fmt.Printf("\nExecuting Internet-Scale Benchmark\n")
Expand Down
62 changes: 62 additions & 0 deletions cmd/bench/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"errors"
"flag"
"net"
"os"
"os/exec"
"testing"
"time"

Expand Down Expand Up @@ -301,6 +303,14 @@ func TestRunSeed_ConnectionError(t *testing.T) {
runSeed(1)
}

func TestRunSeed_DefaultURL(t *testing.T) {
// Test when DATABASE_URL is not set (uses default)
os.Unsetenv("DATABASE_URL")
// This will try to connect to localhost with default credentials
// We expect it to fail with connection error since there's no local DB
runSeed(1)
}

func TestRunScaleTest_MoreErrors(t *testing.T) {
t.Run("BadDSN", func(t *testing.T) {
t.Setenv("DATABASE_URL", "host=/invalid/path/socket")
Expand Down Expand Up @@ -489,3 +499,55 @@ func (m *mockCommand) SetStdout(buf *bytes.Buffer) {
func (m *mockCommand) Run() error {
return nil
}

func TestGoCommand_SetStdout(t *testing.T) {
var buf bytes.Buffer
g := &goCommand{cmd: exec.Command("echo", "test")}
g.SetStdout(&buf)
if g.stdou == nil {
t.Error("expected stdou to be set")
}
if g.cmd.Stdout == nil {
t.Error("expected cmd.Stdout to be set")
}
}

func TestGoCommand_Run(t *testing.T) {
g := &goCommand{cmd: exec.Command("true")}
err := g.Run()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}

func TestGoCommand_RunError(t *testing.T) {
g := &goCommand{cmd: exec.Command("false")}
err := g.Run()
if err == nil {
t.Error("expected error from false command")
}
}

func TestRunScaleTest_MockedSleepAndFile(t *testing.T) {
// Save original functions
origSleep := sleepFn
origRead := readFileFn
defer func() {
sleepFn = origSleep
readFileFn = origRead
}()

// Mock sleep to be instant
sleepFn = func(d time.Duration) {}

// Mock file read to return empty schema
readFileFn = func(name string) ([]byte, error) {
return []byte{}, nil
}

// Set invalid DB to trigger early return after schema load
t.Setenv("DATABASE_URL", "host=/invalid/path")

// This should now run without blocking on sleep
runScaleTest(1, 1)
}
75 changes: 75 additions & 0 deletions cmd/clouddns/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,78 @@ func extractSSLMode(s string) string {
}
return ""
}

func TestParseDNSSECConfig(t *testing.T) {
t.Run("NoAnchorsNoMode", func(t *testing.T) {
t.Setenv("DNSSEC_MODE", "")
// Clear any TRUST_ANCHOR_ vars
for _, e := range os.Environ() {
if strings.HasPrefix(e, "TRUST_ANCHOR_") {
os.Unsetenv(strings.SplitN(e, "=", 2)[0])
}
}
Comment on lines +475 to +479

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid process-wide env mutation without restoration in subtests.

Line 475, Line 488, Line 506, and Line 526 unset TRUST_ANCHOR_* variables via os.Unsetenv but never restore prior values. That can leak state across tests and cause order-dependent flakes. Prefer capturing prior values and restoring in t.Cleanup (or isolate via helper that does this once per subtest).

Also applies to: 488-492, 506-510, 526-530

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/clouddns/main_test.go` around lines 475 - 479, The test mutates
process-wide env vars by iterating os.Environ and calling os.Unsetenv for keys
with strings.HasPrefix(e, "TRUST_ANCHOR_") without restoring them; change this
to capture the prior state (map of key->value or a set of keys that existed),
perform the Unsetenv only for the test, and register t.Cleanup to restore each
prior value (or re-unset keys that didn't exist) — you can wrap this logic in a
helper like clearTrustAnchorsForTest(t) and use the same approach where Unsetenv
currently appears (the loop using os.Environ, strings.HasPrefix and
strings.SplitN).

cfg := parseDNSSECConfig()
if cfg != nil {
t.Errorf("expected nil when no anchors and no mode, got %+v", cfg)
}
})

t.Run("ModeOnly", func(t *testing.T) {
t.Setenv("DNSSEC_MODE", "strict")
for _, e := range os.Environ() {
if strings.HasPrefix(e, "TRUST_ANCHOR_") {
os.Unsetenv(strings.SplitN(e, "=", 2)[0])
}
}
cfg := parseDNSSECConfig()
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.Mode != "strict" {
t.Errorf("expected mode 'strict', got %q", cfg.Mode)
}
if len(cfg.TrustAnchors) != 0 {
t.Errorf("expected no anchors, got %d", len(cfg.TrustAnchors))
}
})

t.Run("AnchorsOnly", func(t *testing.T) {
for _, e := range os.Environ() {
if strings.HasPrefix(e, "TRUST_ANCHOR_") {
os.Unsetenv(strings.SplitN(e, "=", 2)[0])
}
}
t.Setenv("TRUST_ANCHOR_EXAMPLE.COM", "AQANAA==")
t.Setenv("DNSSEC_MODE", "")
cfg := parseDNSSECConfig()
if cfg == nil {
t.Fatal("expected non-nil config")
}
if len(cfg.TrustAnchors) != 1 {
t.Errorf("expected 1 anchor, got %d", len(cfg.TrustAnchors))
}
if anchor, ok := cfg.TrustAnchors["example.com"]; !ok || anchor != "AQANAA==" {
t.Errorf("expected anchor for example.com, got %q", anchor)
}
})

t.Run("BothAnchorsAndMode", func(t *testing.T) {
for _, e := range os.Environ() {
if strings.HasPrefix(e, "TRUST_ANCHOR_") {
os.Unsetenv(strings.SplitN(e, "=", 2)[0])
}
}
t.Setenv("DNSSEC_MODE", "ad-bit-only")
t.Setenv("TRUST_ANCHOR_TEST.COM", "AQANAA==")
cfg := parseDNSSECConfig()
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.Mode != "ad-bit-only" {
t.Errorf("expected mode 'ad-bit-only', got %q", cfg.Mode)
}
if _, ok := cfg.TrustAnchors["test.com"]; !ok {
t.Error("expected anchor for test.com")
}
})
}
120 changes: 120 additions & 0 deletions docs/decisions/0012-catalog-zones-rfc-9432.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# ADR 0012: RFC 9432 Catalog Zones

## Status
Accepted

## Date
2026-06-10

## Context

RFC 9432 defines Catalog Zones (CATZ type 53) and Catalog Zone Transfer (CZTR type 54) for publishing zone metadata in DNS. This enables slave DNS servers to automatically discover, provision, and synchronize zones from a master server without manual configuration.

cloudDNS needed to support the slave-side catalog zone workflow:
1. A catalog zone exists on the master server containing zone metadata (name, ID, group) as PTR records
2. Slave servers poll the catalog zone periodically (or on demand)
3. Zone entries are fetched via AXFR from the catalog zone
4. Each zone is then fetched via AXFR from the master and provisioned locally

Key design decisions were required:
- How to model catalog zones in the existing PostgreSQL schema
- How to store zone inventory entries (PTR records with custom content format)
- How to handle the polling loop lifecycle
- How to avoid O(n) scans when checking zone existence

## Decision

### Schema

A new `catalog_zones` table stores catalog zone metadata:

```sql
CREATE TABLE catalog_zones (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
zone_name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '1',
serial UINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```

Zone entries are stored as PTR records in the existing `dns_records` table, within the catalog zone's own zone. The content format is `zone_name:zone_id[:group_id]` (e.g., `foo.example.com.:uuid-123:group1`).

The `dns_zones` table gains two new columns:
- `catalog_id` — FK to `catalog_zones(id)` linking a zone to its source catalog
- `catalog_zone_name` — the zone's name within the catalog (used for targeted lookups)

### API

Seven REST endpoints for catalog zone management:

| Method | Path | Description |
|--------|------|-------------|
| POST | `/catalog-zones` | Create a catalog zone |
| GET | `/catalog-zones` | List all catalog zones for tenant |
| GET | `/catalog-zones/{id}` | Get a specific catalog zone |
| DELETE | `/catalog-zones/{id}` | Delete a catalog zone |
| POST | `/catalog-zones/{id}/entries` | Add a zone to the catalog |
| DELETE | `/catalog-zones/{id}/entries/{zone_name}` | Remove a zone from the catalog |
| GET | `/catalog-zones/{id}/entries` | List all entries in a catalog zone |

### Storage

Catalog entries are stored as PTR records with reversed zone names (e.g., `foo.example.com.` → `com.example.foo.`). This enables lexicographic range queries for efficient deletion of exact matches without LIKE.

Removing an entry uses a prefix range query:
```sql
DELETE FROM dns_records
WHERE zone_id = $1 AND type = 'PTR'
AND content >= $2 AND content < $3 -- $2 = "zoneName:", $3 = "zoneName:\xff"
```

This correctly distinguishes `foo.example.com.` from `foobar.example.com.`.

### Polling

The server starts a background poller when `CATALOG_POLLING_ENABLED=true`. The poller:
1. Queries CZTR (RFC 9432 Catalog Zone Transfer) to verify master support
2. Queries SOA to detect serial changes (skip if unchanged)
3. Performs AXFR on the catalog zone to fetch all PTR records
4. For each entry, calls `syncZoneFromCatalog` which:
- Checks if zone already exists via `GetZoneByCatalogName` (O(1), not `ListZones` (O(n))
- Creates the zone record with `catalog_zone_name` set
- Performs AXFR on the zone to fetch all records
- Rolls back zone creation if AXFR fails

### Tenant Isolation

All catalog operations are tenant-scoped. The `tenant_id` is propagated through the service layer to the repository with `WHERE tenant_id = $2` filters on all queries. The poller uses `CATALOG_TENANT_ID` (required env var) or falls back to `NodeID` with a warning log.

## Consequences

### Positive
- Slave servers can auto-provision zones from a master without manual configuration
- Zone metadata (name, ID, group) is published in DNS using standard record types
- Serial-based change detection avoids unnecessary re-syncs
- TTL cleanup goroutine prevents unbounded memory growth in the poller state
- Targeted `GetZoneByCatalogName` query replaces O(n) `ListZones` scan

### Negative
- New `catalog_zones` table and two new columns on `dns_zones` require schema migration
- Catalog zone itself must be provisioned as a regular zone before entries can be added
- `ON CONFLICT DO NOTHING` silently ignores duplicate entries — callers can't detect this

### Neutral
- The REST API for catalog management is separate from the DNS protocol
- The poller runs as a background goroutine; if it fails, it logs and continues (next interval)
- `PollCatalogZone` and `SyncZonesFromCatalog` service methods are stubs — actual AXFR fetch is done by the server poller in `client.go`

## Alternatives Considered

### Alternative 1: Store entries in a separate table
**Why rejected:** Using PTR records in the existing `dns_records` table keeps catalog entries in one place and leverages existing query patterns. A separate table would add complexity without significant benefit.

### Alternative 2: Use CNAME records for catalog entries
**Why rejected:** RFC 9432 specifies PTR records with the content format `zone_name:zone_id[:group_id]`. CNAME doesn't support this content format and doesn't enable the lexicographic range deletion pattern.

### Alternative 3: Include zone data in the catalog zone itself via AXFR
**Why rejected:** AXFR on a catalog zone only transfers the catalog metadata (PTR records). Individual zone data is fetched via separate AXFR requests. This is the design in RFC 9432 and keeps the catalog zone lightweight.
1 change: 1 addition & 0 deletions features.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Built-in orchestration for global Anycast networks using BGP.
* **Dynamic Updates (RFC 2136)**: Standardized dynamic record management.
* **IXFR (Incremental Zone Transfer)**: RFC 1995 compliant synchronization using transactional delta logging, with intelligent AXFR fallback.
* **DNS64 (RFC 6147)**: Synthesizes AAAA records from A records for IPv6-only clients communicating with IPv4-only servers.
* **Catalog Zones (RFC 9432)**: Zone metadata catalog with CRUD API for catalog management, membership add/remove operations, and slave-side polling with automatic AXFR-based zone provisioning from a master server.

## 6. Management & Security
* **Hexagonal Architecture**: Strict separation between core logic and infrastructure adapters.
Expand Down
Loading
Loading