Add catalog-zone-gen tool
Generate RFC 9432 DNS catalog zone files from a declarative input file. Parses zone-to-catalog assignments with optional group and coo properties, produces deterministic BIND-format output with automatic SOA serial management and change detection.
This commit is contained in:
166
README.md
Normal file
166
README.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# catalog-zone-gen
|
||||||
|
|
||||||
|
Generate RFC 9432 DNS catalog zone files from a declarative input file.
|
||||||
|
|
||||||
|
Given a list of zones with their catalog assignments and optional properties,
|
||||||
|
this tool produces one BIND-format zone file per catalog. Output is
|
||||||
|
deterministic: re-running with unchanged input produces unchanged output (no
|
||||||
|
serial bump, no file write).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
go install catalog-zone-gen@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build from source:
|
||||||
|
|
||||||
|
```
|
||||||
|
go build -o catalog-zone-gen .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
catalog-zone-gen [--config path] [--output-dir path] <input-file>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
|
||||||
|
- `--config` — path to YAML config file (default: `catz.yaml` next to the input file)
|
||||||
|
- `--output-dir` — directory for output zone files (default: same directory as the input file)
|
||||||
|
|
||||||
|
## Configuration File
|
||||||
|
|
||||||
|
The config file (default `catz.yaml`) defines catalog zone names and SOA parameters.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
catalogs:
|
||||||
|
catalog1:
|
||||||
|
zone: catalog1.example.com.
|
||||||
|
catalog2:
|
||||||
|
zone: catalog2.example.com.
|
||||||
|
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `catalogs` | yes | Map of catalog names to their zone FQDNs. Names are used as references in the input file. |
|
||||||
|
| `catalogs.<name>.zone` | yes | The FQDN of the catalog zone (trailing dot optional, will be normalized). |
|
||||||
|
| `soa.mname` | yes | Primary nameserver for the SOA record. |
|
||||||
|
| `soa.rname` | yes | Responsible person email (in DNS format: `hostmaster.example.com.`). |
|
||||||
|
|
||||||
|
SOA timing values are hardcoded: refresh=900, retry=600, expire=2147483646, minimum=0.
|
||||||
|
The NS record is hardcoded to `invalid.` per RFC 9432.
|
||||||
|
|
||||||
|
## Input File Format
|
||||||
|
|
||||||
|
Whitespace and comma delimited. Lines starting with `#` are comments.
|
||||||
|
Blank lines are ignored.
|
||||||
|
|
||||||
|
```
|
||||||
|
<zone-name> <catalog>[, <catalog>...] [, group=<value>] [, coo=<fqdn>]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Position | Format | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| First token | FQDN | Zone name (trailing dot optional, normalized internally). |
|
||||||
|
| Bare names | identifier | Catalog assignments — must match a key in the config `catalogs` map. At least one required. |
|
||||||
|
| `group=<value>` | key=value | RFC 9432 group property. Tells consumers to apply shared configuration to grouped zones. |
|
||||||
|
| `coo=<fqdn>` | key=value | RFC 9432 change-of-ownership property. Points to the old catalog zone during migration. |
|
||||||
|
|
||||||
|
A zone can appear in multiple catalogs (for distributing to different server groups).
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
# Production zones
|
||||||
|
zone.example.org catalog1, catalog2
|
||||||
|
zone.example.com catalog2, coo=old-catalog.example.com.
|
||||||
|
test.example.net catalog1, group=internal
|
||||||
|
app.example.org catalog1, group=external, coo=migrated.example.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
Whitespace and comma placement is flexible. These are all equivalent:
|
||||||
|
|
||||||
|
```
|
||||||
|
zone.example.org catalog1,catalog2
|
||||||
|
zone.example.org catalog1 , catalog2
|
||||||
|
zone.example.org catalog1, catalog2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
One BIND-format zone file per catalog, written to the output directory.
|
||||||
|
|
||||||
|
**Filename:** `<catalog-zone-fqdn>.zone` (e.g., `catalog1.example.com.zone`)
|
||||||
|
|
||||||
|
**Example output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
catalog1.example.com. 0 IN SOA ns1.example.com. hostmaster.example.com. 2026030201 900 600 2147483646 0
|
||||||
|
catalog1.example.com. 0 IN NS invalid.
|
||||||
|
version.catalog1.example.com. 0 IN TXT "2"
|
||||||
|
grfen8g.zones.catalog1.example.com. 0 IN PTR app.example.org.
|
||||||
|
group.grfen8g.zones.catalog1.example.com. 0 IN TXT "external"
|
||||||
|
coo.grfen8g.zones.catalog1.example.com. 0 IN PTR migrated.example.com.
|
||||||
|
2qvgcfg.zones.catalog1.example.com. 0 IN PTR test.example.net.
|
||||||
|
group.2qvgcfg.zones.catalog1.example.com. 0 IN TXT "internal"
|
||||||
|
1860l9o.zones.catalog1.example.com. 0 IN PTR zone.example.org.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Record order
|
||||||
|
|
||||||
|
1. SOA record
|
||||||
|
2. NS record (`invalid.`)
|
||||||
|
3. Version TXT record (`"2"`)
|
||||||
|
4. Member zones sorted alphabetically by zone name, each with:
|
||||||
|
- PTR record (member zone)
|
||||||
|
- Group TXT record (if `group=` set)
|
||||||
|
- COO PTR record (if `coo=` set)
|
||||||
|
|
||||||
|
All records use TTL 0 and class IN. Fully-qualified owner names; no `$ORIGIN` or `$TTL` directives.
|
||||||
|
|
||||||
|
### Member zone hashing
|
||||||
|
|
||||||
|
Member zone labels are generated by FNV-1a 32-bit hashing the normalized zone
|
||||||
|
name, then encoding as lowercase base32hex without padding. This produces
|
||||||
|
compact labels like `grfen8g`.
|
||||||
|
|
||||||
|
Changing tools will likely produce different hash labels, which is intentional
|
||||||
|
per RFC 9432 Section 5.4 — it triggers a reconfig event on consumers.
|
||||||
|
|
||||||
|
### SOA serial
|
||||||
|
|
||||||
|
Format: `YYYYMMDDNN` where NN is a sequence number (01-99).
|
||||||
|
|
||||||
|
- New zone files start at `YYYYMMDD01`.
|
||||||
|
- On subsequent runs, the tool generates output with the existing serial and
|
||||||
|
compares bytes. If unchanged, no write occurs.
|
||||||
|
- If content differs, the serial is incremented (same date bumps NN; new date
|
||||||
|
resets to `YYYYMMDD01`).
|
||||||
|
- If NN reaches 99 on the same date: the tool errors.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The tool validates input and reports errors with file location:
|
||||||
|
|
||||||
|
```
|
||||||
|
error: zones.txt:3: unknown catalog "bogus"
|
||||||
|
error: zones.txt:5: zone example.com. already assigned to catalog "catalog1" at line 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checked conditions:**
|
||||||
|
|
||||||
|
- Unknown catalog name (not in config) — error
|
||||||
|
- Same zone assigned to the same catalog more than once — error
|
||||||
|
- Hash collision (two zone names produce the same hash within a catalog) — error
|
||||||
|
- Missing required config fields — error
|
||||||
|
- Unknown properties (anything other than `group` and `coo`) — error
|
||||||
249
catalog.go
Normal file
249
catalog.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// base32hex encoding, lowercase, no padding
|
||||||
|
var b32 = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding)
|
||||||
|
|
||||||
|
// Hardcoded SOA timing values
|
||||||
|
const (
|
||||||
|
soaRefresh = 900
|
||||||
|
soaRetry = 600
|
||||||
|
soaExpire = 2147483646
|
||||||
|
soaMinimum = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// hashZoneName computes the FNV-1a 32-bit hash of a normalized zone name
|
||||||
|
// and returns it as a lowercase base32hex string (no padding).
|
||||||
|
func hashZoneName(zone string) string {
|
||||||
|
h := fnv.New32a()
|
||||||
|
h.Write([]byte(zone))
|
||||||
|
sum := h.Sum32()
|
||||||
|
b := make([]byte, 4)
|
||||||
|
b[0] = byte(sum >> 24)
|
||||||
|
b[1] = byte(sum >> 16)
|
||||||
|
b[2] = byte(sum >> 8)
|
||||||
|
b[3] = byte(sum)
|
||||||
|
return b32.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCatalogZone builds the zone file content for a single catalog.
|
||||||
|
func generateCatalogZone(catName string, cfg *Config, members []ZoneEntry, serial uint32) (string, error) {
|
||||||
|
catCfg := cfg.Catalogs[catName]
|
||||||
|
origin := catCfg.Zone
|
||||||
|
|
||||||
|
var records []string
|
||||||
|
|
||||||
|
// SOA
|
||||||
|
soaStr := fmt.Sprintf("%s 0 IN SOA %s %s %d %d %d %d %d",
|
||||||
|
origin, cfg.SOA.Mname, cfg.SOA.Rname, serial, soaRefresh, soaRetry, soaExpire, soaMinimum)
|
||||||
|
soaRR, err := dns.New(soaStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("building SOA: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, soaRR.String())
|
||||||
|
|
||||||
|
// NS
|
||||||
|
nsStr := fmt.Sprintf("%s 0 IN NS invalid.", origin)
|
||||||
|
nsRR, err := dns.New(nsStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("building NS: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, nsRR.String())
|
||||||
|
|
||||||
|
// Version TXT
|
||||||
|
verStr := fmt.Sprintf("version.%s 0 IN TXT \"2\"", origin)
|
||||||
|
verRR, err := dns.New(verStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("building version TXT: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, verRR.String())
|
||||||
|
|
||||||
|
// Sort members alphabetically by normalized zone name
|
||||||
|
sorted := make([]ZoneEntry, len(members))
|
||||||
|
copy(sorted, members)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].Zone < sorted[j].Zone
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check for hash collisions
|
||||||
|
hashToZone := make(map[string]string)
|
||||||
|
for _, entry := range sorted {
|
||||||
|
h := hashZoneName(entry.Zone)
|
||||||
|
if existing, ok := hashToZone[h]; ok && existing != entry.Zone {
|
||||||
|
return "", fmt.Errorf("error: %s:%d: hash collision between %s and %s in catalog %q",
|
||||||
|
entry.File, entry.Line, existing, entry.Zone, catName)
|
||||||
|
}
|
||||||
|
hashToZone[h] = entry.Zone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member records
|
||||||
|
for _, entry := range sorted {
|
||||||
|
h := hashZoneName(entry.Zone)
|
||||||
|
|
||||||
|
// PTR record
|
||||||
|
ptrOwner := fmt.Sprintf("%s.zones.%s", h, origin)
|
||||||
|
ptrStr := fmt.Sprintf("%s 0 IN PTR %s", ptrOwner, entry.Zone)
|
||||||
|
ptrRR, err := dns.New(ptrStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("building PTR for %s: %w", entry.Zone, err)
|
||||||
|
}
|
||||||
|
records = append(records, ptrRR.String())
|
||||||
|
|
||||||
|
// Group TXT (optional)
|
||||||
|
if entry.Group != "" {
|
||||||
|
groupOwner := fmt.Sprintf("group.%s.zones.%s", h, origin)
|
||||||
|
groupStr := fmt.Sprintf("%s 0 IN TXT \"%s\"", groupOwner, entry.Group)
|
||||||
|
groupRR, err := dns.New(groupStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("building group TXT for %s: %w", entry.Zone, err)
|
||||||
|
}
|
||||||
|
records = append(records, groupRR.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// COO PTR (optional)
|
||||||
|
if entry.COO != "" {
|
||||||
|
cooOwner := fmt.Sprintf("coo.%s.zones.%s", h, origin)
|
||||||
|
cooStr := fmt.Sprintf("%s 0 IN PTR %s", cooOwner, entry.COO)
|
||||||
|
cooRR, err := dns.New(cooStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("building coo PTR for %s: %w", entry.Zone, err)
|
||||||
|
}
|
||||||
|
records = append(records, cooRR.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(records, "\n") + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readExistingSerial reads an existing zone file and extracts the SOA serial.
|
||||||
|
// Returns 0, nil if the file doesn't exist.
|
||||||
|
func readExistingSerial(path string) (uint32, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("reading existing zone %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
zp := dns.NewZoneParser(f, "", path)
|
||||||
|
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
|
||||||
|
if soa, ok := rr.(*dns.SOA); ok {
|
||||||
|
return soa.Serial, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := zp.Err(); err != nil {
|
||||||
|
return 0, fmt.Errorf("parsing existing zone %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readExistingContent reads the full content of an existing zone file.
|
||||||
|
// Returns empty string if file doesn't exist.
|
||||||
|
func readExistingContent(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSerial returns a serial for today with sequence 01: YYYYMMDD01.
|
||||||
|
func defaultSerial(now time.Time) uint32 {
|
||||||
|
return uint32(now.Year())*1000000 +
|
||||||
|
uint32(now.Month())*10000 +
|
||||||
|
uint32(now.Day())*100 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpSerial increments a serial. If same date, bumps the sequence number.
|
||||||
|
// If different date, starts at YYYYMMDD01.
|
||||||
|
// Returns error if sequence reaches 99 and needs another bump.
|
||||||
|
func bumpSerial(old uint32, now time.Time) (uint32, error) {
|
||||||
|
todayBase := uint32(now.Year())*1000000 +
|
||||||
|
uint32(now.Month())*10000 +
|
||||||
|
uint32(now.Day())*100
|
||||||
|
|
||||||
|
if old >= todayBase && old < todayBase+100 {
|
||||||
|
// Same date, bump sequence
|
||||||
|
seq := old - todayBase
|
||||||
|
if seq >= 99 {
|
||||||
|
return 0, fmt.Errorf("serial overflow: already at %d, cannot bump further on the same date", old)
|
||||||
|
}
|
||||||
|
return old + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different date or old serial doesn't follow our format
|
||||||
|
return todayBase + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processCatalog handles generating and writing a single catalog zone file.
|
||||||
|
// Returns true if the file was written (changed), false if unchanged.
|
||||||
|
func processCatalog(catName string, cfg *Config, members []ZoneEntry, outputDir string, now time.Time) (bool, error) {
|
||||||
|
catCfg := cfg.Catalogs[catName]
|
||||||
|
outputPath := outputDir + "/" + catCfg.Zone + "zone"
|
||||||
|
|
||||||
|
// Read existing serial
|
||||||
|
oldSerial, err := readExistingSerial(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use old serial or default
|
||||||
|
serial := oldSerial
|
||||||
|
if serial == 0 {
|
||||||
|
serial = defaultSerial(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate with current serial
|
||||||
|
content, err := generateCatalogZone(catName, cfg, members, serial)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with existing file
|
||||||
|
existing, err := readExistingContent(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == existing {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content changed — bump serial only if we had an existing file.
|
||||||
|
// For new files, the default serial is already correct.
|
||||||
|
if oldSerial != 0 {
|
||||||
|
newSerial, err := bumpSerial(serial, now)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if newSerial != serial {
|
||||||
|
content, err = generateCatalogZone(catName, cfg, members, newSerial)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(outputPath, []byte(content), 0o644); err != nil {
|
||||||
|
return false, fmt.Errorf("writing zone file %s: %w", outputPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
332
catalog_test.go
Normal file
332
catalog_test.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashZoneName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
zone string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"example.com.", "lq2vr60"},
|
||||||
|
{"example.org.", "an5oaj8"},
|
||||||
|
// Same input should always produce same output
|
||||||
|
{"example.com.", "lq2vr60"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := hashZoneName(tt.zone)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("hashZoneName(%q) = %q, want %q", tt.zone, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashZoneNameUniqueness(t *testing.T) {
|
||||||
|
zones := []string{
|
||||||
|
"example.com.",
|
||||||
|
"example.org.",
|
||||||
|
"example.net.",
|
||||||
|
"test.example.com.",
|
||||||
|
"app.example.org.",
|
||||||
|
"zone.example.org.",
|
||||||
|
"zone.example.com.",
|
||||||
|
"test.example.net.",
|
||||||
|
}
|
||||||
|
|
||||||
|
hashes := make(map[string]string)
|
||||||
|
for _, zone := range zones {
|
||||||
|
h := hashZoneName(zone)
|
||||||
|
if existing, ok := hashes[h]; ok {
|
||||||
|
t.Errorf("hash collision: %q and %q both hash to %q", existing, zone, h)
|
||||||
|
}
|
||||||
|
hashes[h] = zone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBumpSerial(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
old uint32
|
||||||
|
year int
|
||||||
|
month int
|
||||||
|
day int
|
||||||
|
want uint32
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "new serial from zero",
|
||||||
|
old: 0,
|
||||||
|
year: 2026, month: 3, day: 2,
|
||||||
|
want: 2026030201,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bump same date",
|
||||||
|
old: 2026030201,
|
||||||
|
year: 2026, month: 3, day: 2,
|
||||||
|
want: 2026030202,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new date",
|
||||||
|
old: 2026030205,
|
||||||
|
year: 2026, month: 3, day: 3,
|
||||||
|
want: 2026030301,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "old serial different format",
|
||||||
|
old: 12345,
|
||||||
|
year: 2026, month: 3, day: 2,
|
||||||
|
want: 2026030201,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overflow at 99",
|
||||||
|
old: 2026030299,
|
||||||
|
year: 2026, month: 3, day: 2,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
now := fixedTime(tt.year, tt.month, tt.day)
|
||||||
|
got, err := bumpSerial(tt.old, now)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("bumpSerial(%d) = %d, want %d", tt.old, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultSerial(t *testing.T) {
|
||||||
|
now := fixedTime(2026, 3, 2)
|
||||||
|
got := defaultSerial(now)
|
||||||
|
want := uint32(2026030201)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("defaultSerial() = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCatalogZone(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{
|
||||||
|
"cat1": {Zone: "catalog.example.com."},
|
||||||
|
},
|
||||||
|
SOA: SOAConfig{
|
||||||
|
Mname: "ns1.example.com.",
|
||||||
|
Rname: "hostmaster.example.com.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
members := []ZoneEntry{
|
||||||
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", File: "test", Line: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SOA is first line
|
||||||
|
lines := splitLines(content)
|
||||||
|
if len(lines) < 4 {
|
||||||
|
t.Fatalf("expected at least 4 lines, got %d", len(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOA should contain the serial
|
||||||
|
assertContains(t, lines[0], "2026030201")
|
||||||
|
assertContains(t, lines[0], "SOA")
|
||||||
|
|
||||||
|
// NS should be second
|
||||||
|
assertContains(t, lines[1], "NS")
|
||||||
|
assertContains(t, lines[1], "invalid.")
|
||||||
|
|
||||||
|
// Version TXT should be third
|
||||||
|
assertContains(t, lines[2], "TXT")
|
||||||
|
assertContains(t, lines[2], "\"2\"")
|
||||||
|
|
||||||
|
// a.example.com should come before b.example.com (sorted)
|
||||||
|
aIdx := -1
|
||||||
|
bIdx := -1
|
||||||
|
for i, line := range lines {
|
||||||
|
if containsStr(line, "a.example.com.") {
|
||||||
|
if aIdx == -1 {
|
||||||
|
aIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containsStr(line, "b.example.com.") {
|
||||||
|
if bIdx == -1 {
|
||||||
|
bIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if aIdx == -1 || bIdx == -1 {
|
||||||
|
t.Fatal("expected both a.example.com and b.example.com in output")
|
||||||
|
}
|
||||||
|
if aIdx >= bIdx {
|
||||||
|
t.Errorf("a.example.com (line %d) should come before b.example.com (line %d)", aIdx, bIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a.example.com should have a group TXT
|
||||||
|
foundGroup := false
|
||||||
|
for _, line := range lines {
|
||||||
|
if containsStr(line, "group.") && containsStr(line, "\"mygroup\"") {
|
||||||
|
foundGroup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundGroup {
|
||||||
|
t.Error("expected group TXT record for a.example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCatalogZoneCOO(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{
|
||||||
|
"cat1": {Zone: "catalog.example.com."},
|
||||||
|
},
|
||||||
|
SOA: SOAConfig{
|
||||||
|
Mname: "ns1.example.com.",
|
||||||
|
Rname: "hostmaster.example.com.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
members := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", File: "test", Line: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundCOO := false
|
||||||
|
for _, line := range splitLines(content) {
|
||||||
|
if containsStr(line, "coo.") && containsStr(line, "old.example.com.") {
|
||||||
|
foundCOO = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundCOO {
|
||||||
|
t.Error("expected coo PTR record")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadExistingSerial(t *testing.T) {
|
||||||
|
t.Run("file does not exist", func(t *testing.T) {
|
||||||
|
serial, err := readExistingSerial("/nonexistent/path.zone")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if serial != 0 {
|
||||||
|
t.Errorf("serial = %d, want 0", serial)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid zone file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/test.zone"
|
||||||
|
content := "example.com. 0 IN SOA ns1.example.com. hostmaster.example.com. 2026030205 900 600 2147483646 0\nexample.com. 0 IN NS invalid.\n"
|
||||||
|
writeTestFile(t, dir, "test.zone", content)
|
||||||
|
|
||||||
|
serial, err := readExistingSerial(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if serial != 2026030205 {
|
||||||
|
t.Errorf("serial = %d, want 2026030205", serial)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zone file with no SOA", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/test.zone"
|
||||||
|
content := "example.com. 0 IN NS invalid.\n"
|
||||||
|
writeTestFile(t, dir, "test.zone", content)
|
||||||
|
|
||||||
|
serial, err := readExistingSerial(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if serial != 0 {
|
||||||
|
t.Errorf("serial = %d, want 0 (no SOA found)", serial)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadExistingContent(t *testing.T) {
|
||||||
|
t.Run("file does not exist", func(t *testing.T) {
|
||||||
|
content, err := readExistingContent("/nonexistent/path.zone")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
t.Errorf("content = %q, want empty", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file exists", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestFile(t, dir, "test.zone", "hello\n")
|
||||||
|
|
||||||
|
content, err := readExistingContent(dir + "/test.zone")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if content != "hello\n" {
|
||||||
|
t.Errorf("content = %q, want %q", content, "hello\n")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCatalogZoneEmpty(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{
|
||||||
|
"cat1": {Zone: "catalog.example.com."},
|
||||||
|
},
|
||||||
|
SOA: SOAConfig{
|
||||||
|
Mname: "ns1.example.com.",
|
||||||
|
Rname: "hostmaster.example.com.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := generateCatalogZone("cat1", cfg, nil, 2026030201)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := splitLines(content)
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Fatalf("expected 3 lines (SOA, NS, version TXT), got %d", len(lines))
|
||||||
|
}
|
||||||
|
assertContains(t, lines[0], "SOA")
|
||||||
|
assertContains(t, lines[1], "NS")
|
||||||
|
assertContains(t, lines[2], "TXT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeFQDN(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
{"example.com.", "example.com."},
|
||||||
|
{"example.com", "example.com."},
|
||||||
|
{"Example.COM.", "example.com."},
|
||||||
|
{" example.com ", "example.com."},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := normalizeFQDN(tt.in)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("normalizeFQDN(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
config.go
Normal file
78
config.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CatalogConfig holds the configuration for a single catalog zone.
|
||||||
|
type CatalogConfig struct {
|
||||||
|
Zone string `yaml:"zone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOAConfig holds the configurable SOA fields.
|
||||||
|
type SOAConfig struct {
|
||||||
|
Mname string `yaml:"mname"`
|
||||||
|
Rname string `yaml:"rname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the parsed catz.yaml configuration.
|
||||||
|
type Config struct {
|
||||||
|
Catalogs map[string]CatalogConfig `yaml:"catalogs"`
|
||||||
|
SOA SOAConfig `yaml:"soa"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateConfig(&cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize zone FQDNs
|
||||||
|
for name, cat := range cfg.Catalogs {
|
||||||
|
cat.Zone = normalizeFQDN(cat.Zone)
|
||||||
|
cfg.Catalogs[name] = cat
|
||||||
|
}
|
||||||
|
cfg.SOA.Mname = normalizeFQDN(cfg.SOA.Mname)
|
||||||
|
cfg.SOA.Rname = normalizeFQDN(cfg.SOA.Rname)
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfig(cfg *Config) error {
|
||||||
|
if len(cfg.Catalogs) == 0 {
|
||||||
|
return fmt.Errorf("config: no catalogs defined")
|
||||||
|
}
|
||||||
|
for name, cat := range cfg.Catalogs {
|
||||||
|
if cat.Zone == "" {
|
||||||
|
return fmt.Errorf("config: catalog %q has no zone defined", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.SOA.Mname == "" {
|
||||||
|
return fmt.Errorf("config: soa.mname is required")
|
||||||
|
}
|
||||||
|
if cfg.SOA.Rname == "" {
|
||||||
|
return fmt.Errorf("config: soa.rname is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFQDN(name string) string {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if name != "" && !strings.HasSuffix(name, ".") {
|
||||||
|
name += "."
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
141
config_test.go
Normal file
141
config_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfig(t *testing.T) {
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestFile(t, dir, "catz.yaml", `catalogs:
|
||||||
|
cat1:
|
||||||
|
zone: catalog1.example.com.
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
`)
|
||||||
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Catalogs["cat1"].Zone != "catalog1.example.com." {
|
||||||
|
t.Errorf("zone = %q, want %q", cfg.Catalogs["cat1"].Zone, "catalog1.example.com.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("normalizes FQDNs", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestFile(t, dir, "catz.yaml", `catalogs:
|
||||||
|
cat1:
|
||||||
|
zone: Catalog1.Example.COM
|
||||||
|
soa:
|
||||||
|
mname: NS1.Example.COM
|
||||||
|
rname: Hostmaster.Example.COM
|
||||||
|
`)
|
||||||
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Catalogs["cat1"].Zone != "catalog1.example.com." {
|
||||||
|
t.Errorf("zone = %q, want normalized FQDN", cfg.Catalogs["cat1"].Zone)
|
||||||
|
}
|
||||||
|
if cfg.SOA.Mname != "ns1.example.com." {
|
||||||
|
t.Errorf("mname = %q, want normalized FQDN", cfg.SOA.Mname)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
_, err := loadConfig("/nonexistent/catz.yaml")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid YAML", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestFile(t, dir, "catz.yaml", "{{invalid yaml")
|
||||||
|
_, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid YAML")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig(t *testing.T) {
|
||||||
|
t.Run("no catalogs", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{},
|
||||||
|
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
|
||||||
|
}
|
||||||
|
if err := validateConfig(cfg); err == nil {
|
||||||
|
t.Fatal("expected error for no catalogs")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil catalogs", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
|
||||||
|
}
|
||||||
|
if err := validateConfig(cfg); err == nil {
|
||||||
|
t.Fatal("expected error for nil catalogs")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty zone", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{"cat1": {Zone: ""}},
|
||||||
|
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
|
||||||
|
}
|
||||||
|
if err := validateConfig(cfg); err == nil {
|
||||||
|
t.Fatal("expected error for empty zone")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing mname", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}},
|
||||||
|
SOA: SOAConfig{Rname: "hostmaster.example.com."},
|
||||||
|
}
|
||||||
|
if err := validateConfig(cfg); err == nil {
|
||||||
|
t.Fatal("expected error for missing mname")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing rname", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}},
|
||||||
|
SOA: SOAConfig{Mname: "ns1.example.com."},
|
||||||
|
}
|
||||||
|
if err := validateConfig(cfg); err == nil {
|
||||||
|
t.Fatal("expected error for missing rname")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}},
|
||||||
|
SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."},
|
||||||
|
}
|
||||||
|
if err := validateConfig(cfg); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigWritePermission(t *testing.T) {
|
||||||
|
// Test that loadConfig returns error for unreadable file
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "catz.yaml")
|
||||||
|
writeTestFile(t, dir, "catz.yaml", "catalogs: {}")
|
||||||
|
if err := os.Chmod(path, 0o000); err != nil {
|
||||||
|
t.Skip("cannot change file permissions")
|
||||||
|
}
|
||||||
|
defer os.Chmod(path, 0o644)
|
||||||
|
|
||||||
|
_, err := loadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unreadable file")
|
||||||
|
}
|
||||||
|
}
|
||||||
14
go.mod
Normal file
14
go.mod
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module catalog-zone-gen
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
codeberg.org/miekg/dns v0.6.61
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
)
|
||||||
12
go.sum
Normal file
12
go.sum
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
codeberg.org/miekg/dns v0.6.61 h1:EZZG2MWW3IeUQvX+L4Qg/2LrU96yH045p/M8PB1P6AE=
|
||||||
|
codeberg.org/miekg/dns v0.6.61/go.mod h1:fIxAzBMDPnXWSw0fp8+pfZMRiAqYY4+HHYLzUo/S6Dg=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
30
helpers_test.go
Normal file
30
helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixedTime(year, month, day int) time.Time {
|
||||||
|
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
s = strings.TrimRight(s, "\n")
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(s, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertContains(t *testing.T, s, substr string) {
|
||||||
|
t.Helper()
|
||||||
|
if !strings.Contains(s, substr) {
|
||||||
|
t.Errorf("expected %q to contain %q", s, substr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(s, substr string) bool {
|
||||||
|
return strings.Contains(s, substr)
|
||||||
|
}
|
||||||
130
input.go
Normal file
130
input.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ZoneEntry represents a parsed line from the input file.
|
||||||
|
type ZoneEntry struct {
|
||||||
|
Zone string // Normalized FQDN
|
||||||
|
Catalogs []string // Catalog names (bare names from input)
|
||||||
|
Group string // Optional RFC 9432 group property
|
||||||
|
COO string // Optional RFC 9432 change-of-ownership FQDN
|
||||||
|
File string // Source file
|
||||||
|
Line int // Source line number
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogMembers groups zone entries by catalog name.
|
||||||
|
type CatalogMembers map[string][]ZoneEntry
|
||||||
|
|
||||||
|
func parseInput(path string, cfg *Config) (CatalogMembers, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening input: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var entries []ZoneEntry
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
lineNum := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNum++
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := parseLine(line, path, lineNum)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCatalogMembers(entries, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
|
||||||
|
// Split on whitespace first, then handle comma separation within tokens
|
||||||
|
tokens := tokenize(line)
|
||||||
|
if len(tokens) < 2 {
|
||||||
|
return ZoneEntry{}, fmt.Errorf("error: %s:%d: expected zone name followed by at least one catalog name", file, lineNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := ZoneEntry{
|
||||||
|
Zone: normalizeFQDN(tokens[0]),
|
||||||
|
File: file,
|
||||||
|
Line: lineNum,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tok := range tokens[1:] {
|
||||||
|
if strings.Contains(tok, "=") {
|
||||||
|
key, value, _ := strings.Cut(tok, "=")
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "group":
|
||||||
|
if value == "" {
|
||||||
|
return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty group value", file, lineNum)
|
||||||
|
}
|
||||||
|
entry.Group = value
|
||||||
|
case "coo":
|
||||||
|
if value == "" {
|
||||||
|
return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty coo value", file, lineNum)
|
||||||
|
}
|
||||||
|
entry.COO = normalizeFQDN(value)
|
||||||
|
default:
|
||||||
|
return ZoneEntry{}, fmt.Errorf("error: %s:%d: unknown property %q", file, lineNum, key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bare name = catalog assignment
|
||||||
|
entry.Catalogs = append(entry.Catalogs, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entry.Catalogs) == 0 {
|
||||||
|
return ZoneEntry{}, fmt.Errorf("error: %s:%d: no catalog assignment for zone %s", file, lineNum, entry.Zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize splits a line on whitespace and commas, stripping empty tokens.
|
||||||
|
func tokenize(line string) []string {
|
||||||
|
// Replace commas with spaces, then split on whitespace
|
||||||
|
line = strings.ReplaceAll(line, ",", " ")
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, error) {
|
||||||
|
members := make(CatalogMembers)
|
||||||
|
|
||||||
|
// Track duplicates: catalog -> zone -> line
|
||||||
|
seen := make(map[string]map[string]int)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
for _, catName := range entry.Catalogs {
|
||||||
|
if _, ok := cfg.Catalogs[catName]; !ok {
|
||||||
|
return nil, fmt.Errorf("error: %s:%d: unknown catalog %q", entry.File, entry.Line, catName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen[catName] == nil {
|
||||||
|
seen[catName] = make(map[string]int)
|
||||||
|
}
|
||||||
|
if prevLine, dup := seen[catName][entry.Zone]; dup {
|
||||||
|
return nil, fmt.Errorf("error: %s:%d: zone %s already assigned to catalog %q at line %d",
|
||||||
|
entry.File, entry.Line, entry.Zone, catName, prevLine)
|
||||||
|
}
|
||||||
|
seen[catName][entry.Zone] = entry.Line
|
||||||
|
|
||||||
|
members[catName] = append(members[catName], entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
277
input_test.go
Normal file
277
input_test.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLine(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
wantZone string
|
||||||
|
wantCats []string
|
||||||
|
wantGrp string
|
||||||
|
wantCOO string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple single catalog",
|
||||||
|
line: "zone.example.org catalog1",
|
||||||
|
wantZone: "zone.example.org.",
|
||||||
|
wantCats: []string{"catalog1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple catalogs comma separated",
|
||||||
|
line: "zone.example.org catalog1, catalog2",
|
||||||
|
wantZone: "zone.example.org.",
|
||||||
|
wantCats: []string{"catalog1", "catalog2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with group",
|
||||||
|
line: "test.example.net catalog1, group=internal",
|
||||||
|
wantZone: "test.example.net.",
|
||||||
|
wantCats: []string{"catalog1"},
|
||||||
|
wantGrp: "internal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with coo",
|
||||||
|
line: "zone.example.com catalog2, coo=old-catalog.example.com.",
|
||||||
|
wantZone: "zone.example.com.",
|
||||||
|
wantCats: []string{"catalog2"},
|
||||||
|
wantCOO: "old-catalog.example.com.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with group and coo",
|
||||||
|
line: "app.example.org catalog1, group=external, coo=migrated.example.com.",
|
||||||
|
wantZone: "app.example.org.",
|
||||||
|
wantCats: []string{"catalog1"},
|
||||||
|
wantGrp: "external",
|
||||||
|
wantCOO: "migrated.example.com.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing dot on zone",
|
||||||
|
line: "zone.example.org. catalog1",
|
||||||
|
wantZone: "zone.example.org.",
|
||||||
|
wantCats: []string{"catalog1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no catalog",
|
||||||
|
line: "zone.example.org",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only properties no catalog",
|
||||||
|
line: "zone.example.org group=foo",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown property",
|
||||||
|
line: "zone.example.org catalog1, foo=bar",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty group value",
|
||||||
|
line: "zone.example.org catalog1, group=",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty coo value",
|
||||||
|
line: "zone.example.org catalog1, coo=",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
entry, err := parseLine(tt.line, "test.txt", 1)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if entry.Zone != tt.wantZone {
|
||||||
|
t.Errorf("zone = %q, want %q", entry.Zone, tt.wantZone)
|
||||||
|
}
|
||||||
|
if len(entry.Catalogs) != len(tt.wantCats) {
|
||||||
|
t.Fatalf("catalogs = %v, want %v", entry.Catalogs, tt.wantCats)
|
||||||
|
}
|
||||||
|
for i, cat := range entry.Catalogs {
|
||||||
|
if cat != tt.wantCats[i] {
|
||||||
|
t.Errorf("catalog[%d] = %q, want %q", i, cat, tt.wantCats[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Group != tt.wantGrp {
|
||||||
|
t.Errorf("group = %q, want %q", entry.Group, tt.wantGrp)
|
||||||
|
}
|
||||||
|
if entry.COO != tt.wantCOO {
|
||||||
|
t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
line string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"zone.example.org catalog1, catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
|
||||||
|
{"zone.example.org catalog1,catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
|
||||||
|
{"zone.example.org catalog1 , catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
|
||||||
|
{"zone.example.org\tcatalog1", []string{"zone.example.org", "catalog1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := tokenize(tt.line)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Errorf("tokenize(%q) = %v, want %v", tt.line, got, tt.want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Errorf("tokenize(%q)[%d] = %q, want %q", tt.line, i, got[i], tt.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCatalogMembers(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{
|
||||||
|
"catalog1": {Zone: "catalog1.example.com."},
|
||||||
|
"catalog2": {Zone: "catalog2.example.com."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("valid input", func(t *testing.T) {
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
|
||||||
|
{Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := buildCatalogMembers(entries, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(members["catalog1"]) != 2 {
|
||||||
|
t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"]))
|
||||||
|
}
|
||||||
|
if len(members["catalog2"]) != 1 {
|
||||||
|
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown catalog", func(t *testing.T) {
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"unknown"}, File: "test", Line: 1},
|
||||||
|
}
|
||||||
|
_, err := buildCatalogMembers(entries, cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown catalog")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate zone in same catalog", func(t *testing.T) {
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 2},
|
||||||
|
}
|
||||||
|
_, err := buildCatalogMembers(entries, cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for duplicate zone")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInput(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{
|
||||||
|
"catalog1": {Zone: "catalog1.example.com."},
|
||||||
|
"catalog2": {Zone: "catalog2.example.com."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
inputPath := filepath.Join(dir, "zones.txt")
|
||||||
|
|
||||||
|
content := `# Comment
|
||||||
|
zone.example.org catalog1, catalog2
|
||||||
|
|
||||||
|
test.example.net catalog1, group=internal
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(inputPath, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := parseInput(inputPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(members["catalog1"]) != 2 {
|
||||||
|
t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"]))
|
||||||
|
}
|
||||||
|
if len(members["catalog2"]) != 1 {
|
||||||
|
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputErrors(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{
|
||||||
|
"catalog1": {Zone: "catalog1.example.com."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
_, err := parseInput("/nonexistent/zones.txt", cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid line in input", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "zones.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("zone-with-no-catalog\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := parseInput(path, cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid line")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCatalogMembersSameZoneDifferentCatalogs(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Catalogs: map[string]CatalogConfig{
|
||||||
|
"catalog1": {Zone: "catalog1.example.com."},
|
||||||
|
"catalog2": {Zone: "catalog2.example.com."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same zone in different catalogs is OK
|
||||||
|
entries := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := buildCatalogMembers(entries, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(members["catalog1"]) != 1 {
|
||||||
|
t.Errorf("catalog1 members = %d, want 1", len(members["catalog1"]))
|
||||||
|
}
|
||||||
|
if len(members["catalog2"]) != 1 {
|
||||||
|
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
76
main.go
Normal file
76
main.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "", "path to YAML config (default: catz.yaml next to input file)")
|
||||||
|
outputDir := flag.String("output-dir", "", "directory for output zone files (default: same as input file)")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] <input-file>\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if flag.NArg() != 1 {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputFile := flag.Arg(0)
|
||||||
|
inputDir := filepath.Dir(inputFile)
|
||||||
|
|
||||||
|
if *configPath == "" {
|
||||||
|
*configPath = filepath.Join(inputDir, "catz.yaml")
|
||||||
|
}
|
||||||
|
if *outputDir == "" {
|
||||||
|
*outputDir = inputDir
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := parseInput(inputFile, cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Process catalogs in sorted order for deterministic output
|
||||||
|
catNames := make([]string, 0, len(members))
|
||||||
|
for name := range members {
|
||||||
|
catNames = append(catNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(catNames)
|
||||||
|
|
||||||
|
hasErrors := false
|
||||||
|
for _, catName := range catNames {
|
||||||
|
changed, err := processCatalog(catName, cfg, members[catName], *outputDir, now)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
hasErrors = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catZone := cfg.Catalogs[catName].Zone
|
||||||
|
if changed {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s%s: updated\n", catZone, "zone")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s%s: unchanged\n", catZone, "zone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasErrors {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
425
main_test.go
Normal file
425
main_test.go
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationEndToEnd(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Write config
|
||||||
|
configContent := `catalogs:
|
||||||
|
catalog1:
|
||||||
|
zone: catalog1.example.com.
|
||||||
|
catalog2:
|
||||||
|
zone: catalog2.example.com.
|
||||||
|
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
`
|
||||||
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
||||||
|
|
||||||
|
// Write input
|
||||||
|
inputContent := `# Test zones
|
||||||
|
zone.example.org catalog1, catalog2
|
||||||
|
zone.example.com catalog2, coo=old-catalog.example.com.
|
||||||
|
test.example.net catalog1, group=internal
|
||||||
|
app.example.org catalog1, group=external, coo=migrated.example.com.
|
||||||
|
`
|
||||||
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
||||||
|
|
||||||
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseInput: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := fixedTime(2026, 1, 15)
|
||||||
|
|
||||||
|
// First run: should create files
|
||||||
|
for catName, catMembers := range members {
|
||||||
|
changed, err := processCatalog(catName, cfg, catMembers, dir, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processCatalog(%s): %v", catName, err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Errorf("expected %s to be changed on first run", catName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify files exist
|
||||||
|
cat1Path := filepath.Join(dir, "catalog1.example.com.zone")
|
||||||
|
cat2Path := filepath.Join(dir, "catalog2.example.com.zone")
|
||||||
|
|
||||||
|
cat1Content := readTestFile(t, cat1Path)
|
||||||
|
cat2Content := readTestFile(t, cat2Path)
|
||||||
|
|
||||||
|
// Verify catalog1 content
|
||||||
|
assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tSOA")
|
||||||
|
assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tNS\tinvalid.")
|
||||||
|
assertContains(t, cat1Content, "version.catalog1.example.com.\t0\tIN\tTXT\t\"2\"")
|
||||||
|
assertContains(t, cat1Content, "IN\tPTR\tapp.example.org.")
|
||||||
|
assertContains(t, cat1Content, "IN\tPTR\ttest.example.net.")
|
||||||
|
assertContains(t, cat1Content, "IN\tPTR\tzone.example.org.")
|
||||||
|
assertContains(t, cat1Content, "IN\tTXT\t\"internal\"")
|
||||||
|
assertContains(t, cat1Content, "IN\tTXT\t\"external\"")
|
||||||
|
assertContains(t, cat1Content, "IN\tPTR\tmigrated.example.com.")
|
||||||
|
|
||||||
|
// Verify catalog2 content
|
||||||
|
assertContains(t, cat2Content, "catalog2.example.com.\t0\tIN\tSOA")
|
||||||
|
assertContains(t, cat2Content, "IN\tPTR\tzone.example.com.")
|
||||||
|
assertContains(t, cat2Content, "IN\tPTR\tzone.example.org.")
|
||||||
|
assertContains(t, cat2Content, "IN\tPTR\told-catalog.example.com.")
|
||||||
|
|
||||||
|
// Verify serial format
|
||||||
|
assertContains(t, cat1Content, "2026011501")
|
||||||
|
|
||||||
|
// Second run (same time): should be unchanged
|
||||||
|
for catName, catMembers := range members {
|
||||||
|
changed, err := processCatalog(catName, cfg, catMembers, dir, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processCatalog(%s) second run: %v", catName, err)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
t.Errorf("expected %s to be unchanged on second run", catName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content hasn't changed
|
||||||
|
cat1After := readTestFile(t, cat1Path)
|
||||||
|
if cat1After != cat1Content {
|
||||||
|
t.Error("catalog1 content changed on idempotent run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationSerialBump(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
configContent := `catalogs:
|
||||||
|
cat1:
|
||||||
|
zone: cat1.example.com.
|
||||||
|
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
`
|
||||||
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
||||||
|
|
||||||
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
members1 := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := fixedTime(2026, 1, 15)
|
||||||
|
|
||||||
|
// First run
|
||||||
|
changed, err := processCatalog("cat1", cfg, members1, dir, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Error("expected changed on first run")
|
||||||
|
}
|
||||||
|
|
||||||
|
content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
||||||
|
assertContains(t, content1, "2026011501")
|
||||||
|
|
||||||
|
// Second run with different input (same day)
|
||||||
|
members2 := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
||||||
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
changed, err = processCatalog("cat1", cfg, members2, dir, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Error("expected changed when input differs")
|
||||||
|
}
|
||||||
|
|
||||||
|
content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
||||||
|
assertContains(t, content2, "2026011502")
|
||||||
|
|
||||||
|
// Third run with same input: unchanged
|
||||||
|
changed, err = processCatalog("cat1", cfg, members2, dir, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
t.Error("expected unchanged on third run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fourth run next day with same input: new serial
|
||||||
|
nextDay := fixedTime(2026, 1, 16)
|
||||||
|
changed, err = processCatalog("cat1", cfg, members2, dir, nextDay)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Content hasn't changed, so this should be unchanged even though date differs
|
||||||
|
if changed {
|
||||||
|
t.Error("expected unchanged when content hasn't changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationSortOrder(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
configContent := `catalogs:
|
||||||
|
cat1:
|
||||||
|
zone: cat1.example.com.
|
||||||
|
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
`
|
||||||
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
||||||
|
|
||||||
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input in reverse order
|
||||||
|
members := []ZoneEntry{
|
||||||
|
{Zone: "z.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
||||||
|
{Zone: "m.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := fixedTime(2026, 1, 15)
|
||||||
|
_, err = processCatalog("cat1", cfg, members, dir, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
||||||
|
lines := splitLines(content)
|
||||||
|
|
||||||
|
// Find PTR lines and verify order
|
||||||
|
var ptrZones []string
|
||||||
|
for _, line := range lines {
|
||||||
|
if containsStr(line, "\tPTR\t") && !containsStr(line, "coo.") {
|
||||||
|
// Extract the PTR target
|
||||||
|
parts := strings.Split(line, "\t")
|
||||||
|
ptrZones = append(ptrZones, parts[len(parts)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ptrZones) != 3 {
|
||||||
|
t.Fatalf("expected 3 PTR records, got %d", len(ptrZones))
|
||||||
|
}
|
||||||
|
if ptrZones[0] != "a.example.com." || ptrZones[1] != "m.example.com." || ptrZones[2] != "z.example.com." {
|
||||||
|
t.Errorf("PTR records not sorted: %v", ptrZones)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCLI(t *testing.T) {
|
||||||
|
// Build the binary
|
||||||
|
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
|
||||||
|
cmd := exec.Command("go", "build", "-o", binary, ".")
|
||||||
|
cmd.Dir = projectDir(t)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("go build failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
configContent := `catalogs:
|
||||||
|
catalog1:
|
||||||
|
zone: catalog1.example.com.
|
||||||
|
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
`
|
||||||
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
||||||
|
|
||||||
|
inputContent := "a.example.com. catalog1\n"
|
||||||
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
||||||
|
|
||||||
|
// Run the binary
|
||||||
|
cmd = exec.Command(binary, "--config", filepath.Join(dir, "catz.yaml"), "--output-dir", dir, filepath.Join(dir, "zones.txt"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify output file exists
|
||||||
|
zonePath := filepath.Join(dir, "catalog1.example.com.zone")
|
||||||
|
if _, err := os.Stat(zonePath); err != nil {
|
||||||
|
t.Fatalf("output file not created: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := readTestFile(t, zonePath)
|
||||||
|
assertContains(t, content, "catalog1.example.com.")
|
||||||
|
assertContains(t, content, "a.example.com.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCLIErrors(t *testing.T) {
|
||||||
|
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
|
||||||
|
cmd := exec.Command("go", "build", "-o", binary, ".")
|
||||||
|
cmd.Dir = projectDir(t)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("go build failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no arguments", func(t *testing.T) {
|
||||||
|
cmd := exec.Command(binary)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error with no arguments")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing input file", func(t *testing.T) {
|
||||||
|
cmd := exec.Command(binary, "/nonexistent/zones.txt")
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error with missing input file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown catalog in input", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestFile(t, dir, "catz.yaml", `catalogs:
|
||||||
|
cat1:
|
||||||
|
zone: cat1.example.com.
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
`)
|
||||||
|
writeTestFile(t, dir, "zones.txt", "a.example.com. unknown_catalog\n")
|
||||||
|
|
||||||
|
cmd := exec.Command(binary,
|
||||||
|
"--config", filepath.Join(dir, "catz.yaml"),
|
||||||
|
"--output-dir", dir,
|
||||||
|
filepath.Join(dir, "zones.txt"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unknown catalog")
|
||||||
|
}
|
||||||
|
if !containsStr(string(out), "unknown catalog") {
|
||||||
|
t.Errorf("expected 'unknown catalog' in error output, got: %s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFile(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTestFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
// Find the project root by looking for go.mod
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
t.Fatal("could not find project root (go.mod)")
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the serial stays the same when the same time is used but content changes on a new day.
|
||||||
|
func TestIntegrationSerialNewDay(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
configContent := `catalogs:
|
||||||
|
cat1:
|
||||||
|
zone: cat1.example.com.
|
||||||
|
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
|
`
|
||||||
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
||||||
|
|
||||||
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
members := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
day1 := fixedTime(2026, 1, 15)
|
||||||
|
_, err = processCatalog("cat1", cfg, members, dir, day1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
||||||
|
assertContains(t, content1, "2026011501")
|
||||||
|
|
||||||
|
// Add a zone on day 2
|
||||||
|
members2 := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
||||||
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
day2 := fixedTime(2026, 1, 16)
|
||||||
|
changed, err := processCatalog("cat1", cfg, members2, dir, day2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Error("expected changed with new zone")
|
||||||
|
}
|
||||||
|
|
||||||
|
content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
||||||
|
assertContains(t, content2, "2026011601")
|
||||||
|
|
||||||
|
// Simulate not using the tool for a while, running on a much later date
|
||||||
|
day3 := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
members3 := []ZoneEntry{
|
||||||
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
||||||
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2},
|
||||||
|
{Zone: "c.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
changed, err = processCatalog("cat1", cfg, members3, dir, day3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Error("expected changed with new zone")
|
||||||
|
}
|
||||||
|
|
||||||
|
content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
||||||
|
assertContains(t, content3, "2026061501")
|
||||||
|
}
|
||||||
9
testdata/catz.yaml
vendored
Normal file
9
testdata/catz.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
catalogs:
|
||||||
|
catalog1:
|
||||||
|
zone: catalog1.example.com.
|
||||||
|
catalog2:
|
||||||
|
zone: catalog2.example.com.
|
||||||
|
|
||||||
|
soa:
|
||||||
|
mname: ns1.example.com.
|
||||||
|
rname: hostmaster.example.com.
|
||||||
5
testdata/zones.txt
vendored
Normal file
5
testdata/zones.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Production zones
|
||||||
|
zone.example.org catalog1, catalog2
|
||||||
|
zone.example.com catalog2, coo=old-catalog.example.com.
|
||||||
|
test.example.net catalog1, group=internal
|
||||||
|
app.example.org catalog1, group=external, coo=migrated.example.com.
|
||||||
Reference in New Issue
Block a user