Add --bind-conf flag for BIND domains.conf generation

Generate a BIND-format domains.conf file alongside catalog zones.
New input properties: file= (zone data path) and dnssec (bare flag).
When --bind-conf is set, every zone must have file= or it errors.

Renames ZoneEntry.File to ZonesFile (input path for error messages)
and adds ZoneFile (BIND file path) and DNSSEC (bool) fields.
This commit is contained in:
2026-03-28 11:15:06 -07:00
parent 44d7867a0c
commit 0eddb9fcfe
9 changed files with 451 additions and 51 deletions

View File

@@ -22,13 +22,14 @@ go build -o catalog-zone-gen .
## Usage
```
catalog-zone-gen [--config path] [--output-dir path] <input-file>
catalog-zone-gen [--config path] [--output-dir path] [--bind-conf 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)
- `--bind-conf` — path to write a BIND `domains.conf` file (optional; see [BIND Config Output](#bind-config-output))
## Configuration File
@@ -64,7 +65,7 @@ Whitespace and comma delimited. Lines starting with `#` are comments.
Blank lines are ignored.
```
<zone-name> <catalog>[, <catalog>...] [, group=<value>] [, coo=<fqdn>]
<zone-name> <catalog>[, <catalog>...] [, group=<value>] [, coo=<fqdn>] [, file=<path>] [, dnssec]
```
### Fields
@@ -75,6 +76,8 @@ Blank lines are ignored.
| 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. |
| `file=<path>` | key=value | Zone data file path for BIND config output. Required when `--bind-conf` is used. |
| `dnssec` | bare flag | Adds `dnssec-policy standard; inline-signing yes;` to the BIND config for this zone. |
A zone can appear in multiple catalogs (for distributing to different server groups).
@@ -82,10 +85,10 @@ A zone can appear in multiple catalogs (for distributing to different server gro
```
# 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.
zone.example.org catalog1, catalog2, file=data/zones/example.org
zone.example.com catalog2, coo=old-catalog.example.com., file=data/zones/example.com
test.example.net catalog1, group=internal, file=data/zones/example.net, dnssec
app.example.org catalog1, group=external, coo=migrated.example.com., file=data/zones/app.example.org
```
Whitespace and comma placement is flexible. These are all equivalent:
@@ -163,4 +166,35 @@ error: zones.txt:5: zone example.com. already assigned to catalog "catalog1" at
- 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
- Unknown properties (anything other than `group`, `coo`, `file`) — error
- Empty `file=` value — error
- `dnssec=<anything>` (dnssec is a bare flag, not a key=value) — error
- When `--bind-conf` is used: any zone missing `file=` — error
## BIND Config Output
When `--bind-conf <path>` is specified, a BIND `domains.conf` file is written
in addition to the catalog zone files. This file defines all zones as `type
master` with their `file` paths from the `file=` input property.
**Example output:**
```
# THIS FILE IS GENERATED BY catalog-zone-gen
#=============================================
#
zone "askask.com" {
type master;
file "data/ask/askask.com";
};
zone "bitcard.org" {
type master;
file "data/misc/bitcard.org"; dnssec-policy standard; inline-signing yes;
};
```
- Zones are sorted alphabetically by name
- 8-space indentation
- DNSSEC zones (marked with `dnssec` in the input) get `dnssec-policy standard;
inline-signing yes;` on the same line as `file`
- Every zone must have a `file=` property when `--bind-conf` is used

64
bindconf.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
)
// generateBindConf produces a BIND domains.conf from all zone entries.
// Zones are sorted alphabetically. Each zone block uses 8-space indentation.
// DNSSEC zones get dnssec-policy and inline-signing directives on the file line.
func generateBindConf(entries []ZoneEntry) string {
sorted := make([]ZoneEntry, len(entries))
copy(sorted, entries)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Zone < sorted[j].Zone
})
var b strings.Builder
b.WriteString("# THIS FILE IS GENERATED BY catalog-zone-gen\n")
b.WriteString("#=============================================\n")
b.WriteString("#\n")
for _, entry := range sorted {
// Strip trailing dot for BIND zone name
zoneName := strings.TrimSuffix(entry.Zone, ".")
fmt.Fprintf(&b, "zone \"%s\" {\n", zoneName)
b.WriteString(" type master;\n")
fileLine := fmt.Sprintf(" file \"%s\";", entry.ZoneFile)
if entry.DNSSEC {
fileLine += " dnssec-policy standard; inline-signing yes;"
}
b.WriteString(fileLine + "\n")
b.WriteString("};\n")
}
return b.String()
}
// validateBindConf checks that every zone entry has a non-empty ZoneFile.
func validateBindConf(entries []ZoneEntry) error {
for _, entry := range entries {
if entry.ZoneFile == "" {
return fmt.Errorf("%s:%d: zone %s missing file= property (required for --bind-conf)",
entry.ZonesFile, entry.Line, entry.Zone)
}
}
return nil
}
// writeBindConf validates entries and writes the BIND config to path.
func writeBindConf(path string, entries []ZoneEntry) error {
if err := validateBindConf(entries); err != nil {
return err
}
content := generateBindConf(entries)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("writing bind config %s: %w", path, err)
}
return nil
}

138
bindconf_test.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateBindConf(t *testing.T) {
entries := []ZoneEntry{
{Zone: "bitcard.org.", ZoneFile: "data/misc/bitcard.org", DNSSEC: true, ZonesFile: "test", Line: 1},
{Zone: "askask.com.", ZoneFile: "data/ask/askask.com", ZonesFile: "test", Line: 2},
}
got := generateBindConf(entries)
// Header
assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen")
assertContains(t, got, "#=============================================")
// Zones should be sorted alphabetically (askask.com before bitcard.org)
askIdx := strings.Index(got, "askask.com")
bitIdx := strings.Index(got, "bitcard.org")
if askIdx == -1 || bitIdx == -1 {
t.Fatal("expected both zones in output")
}
if askIdx >= bitIdx {
t.Error("expected askask.com before bitcard.org (alphabetical sort)")
}
// Non-DNSSEC zone
assertContains(t, got, `zone "askask.com" {`)
assertContains(t, got, ` type master;`)
assertContains(t, got, ` file "data/ask/askask.com";`)
// DNSSEC zone has directives on file line
assertContains(t, got, ` file "data/misc/bitcard.org"; dnssec-policy standard; inline-signing yes;`)
// No trailing dot in zone name
if strings.Contains(got, `zone "askask.com."`) {
t.Error("zone name should not have trailing dot in BIND config")
}
}
func TestGenerateBindConfEmpty(t *testing.T) {
got := generateBindConf(nil)
// Should just have the header
lines := splitLines(got)
if len(lines) != 3 {
t.Errorf("expected 3 header lines for empty input, got %d", len(lines))
}
}
func TestGenerateBindConfNoDNSSEC(t *testing.T) {
entries := []ZoneEntry{
{Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1},
}
got := generateBindConf(entries)
if strings.Contains(got, "dnssec-policy") {
t.Error("non-DNSSEC zone should not have dnssec-policy")
}
if strings.Contains(got, "inline-signing") {
t.Error("non-DNSSEC zone should not have inline-signing")
}
}
func TestValidateBindConf(t *testing.T) {
t.Run("all zones have file", func(t *testing.T) {
entries := []ZoneEntry{
{Zone: "a.example.com.", ZoneFile: "data/a", ZonesFile: "zones.txt", Line: 1},
{Zone: "b.example.com.", ZoneFile: "data/b", ZonesFile: "zones.txt", Line: 2},
}
if err := validateBindConf(entries); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("missing file", func(t *testing.T) {
entries := []ZoneEntry{
{Zone: "a.example.com.", ZoneFile: "data/a", ZonesFile: "zones.txt", Line: 1},
{Zone: "b.example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 3},
}
err := validateBindConf(entries)
if err == nil {
t.Fatal("expected error for missing file")
}
assertContains(t, err.Error(), "zones.txt:3:")
assertContains(t, err.Error(), "b.example.com.")
assertContains(t, err.Error(), "missing file=")
})
}
func TestWriteBindConf(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "domains.conf")
entries := []ZoneEntry{
{Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1},
{Zone: "example.org.", ZoneFile: "data/example.org", DNSSEC: true, ZonesFile: "test", Line: 2},
}
if err := writeBindConf(path, entries); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(data)
assertContains(t, got, `zone "example.com"`)
assertContains(t, got, `zone "example.org"`)
assertContains(t, got, "dnssec-policy standard")
}
func TestWriteBindConfValidationError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "domains.conf")
entries := []ZoneEntry{
{Zone: "example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 5},
}
err := writeBindConf(path, entries)
if err == nil {
t.Fatal("expected validation error")
}
assertContains(t, err.Error(), "zones.txt:5:")
// File should not have been written
if _, statErr := os.Stat(path); statErr == nil {
t.Error("file should not be written when validation fails")
}
}

View File

@@ -85,7 +85,7 @@ func generateCatalogZone(catName string, cfg *Config, members []ZoneEntry, seria
h := hashZoneName(entry.Zone)
if existing, ok := hashToZone[h]; ok && existing != entry.Zone {
return "", fmt.Errorf("%s:%d: hash collision between %s and %s in catalog %q",
entry.File, entry.Line, existing, entry.Zone, catName)
entry.ZonesFile, entry.Line, existing, entry.Zone, catName)
}
hashToZone[h] = entry.Zone
zoneHash[entry.Zone] = h

View File

@@ -130,8 +130,8 @@ func TestGenerateCatalogZone(t *testing.T) {
}
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},
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", ZonesFile: "test", Line: 2},
}
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
@@ -203,7 +203,7 @@ func TestGenerateCatalogZoneCOO(t *testing.T) {
}
members := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", File: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", ZonesFile: "test", Line: 1},
}
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)

View File

@@ -13,17 +13,19 @@ type ZoneEntry struct {
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
ZoneFile string // file= property: zone data path for BIND config
DNSSEC bool // dnssec flag: adds dnssec-policy to BIND config
ZonesFile string // Input file path (for error messages)
Line int // Input line number (for error messages)
}
// CatalogMembers groups zone entries by catalog name.
type CatalogMembers map[string][]ZoneEntry
func parseInput(path string, cfg *Config) (CatalogMembers, error) {
func parseInput(path string, cfg *Config) ([]ZoneEntry, CatalogMembers, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening input: %w", err)
return nil, nil, fmt.Errorf("opening input: %w", err)
}
defer f.Close()
@@ -39,15 +41,19 @@ func parseInput(path string, cfg *Config) (CatalogMembers, error) {
entry, err := parseLine(line, path, lineNum)
if err != nil {
return nil, err
return nil, nil, err
}
entries = append(entries, entry)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
return nil, nil, fmt.Errorf("reading %s: %w", path, err)
}
return buildCatalogMembers(entries, cfg)
members, err := buildCatalogMembers(entries, cfg)
if err != nil {
return nil, nil, err
}
return entries, members, nil
}
func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
@@ -59,7 +65,7 @@ func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
entry := ZoneEntry{
Zone: normalizeFQDN(tokens[0]),
File: file,
ZonesFile: file,
Line: lineNum,
}
@@ -77,9 +83,18 @@ func parseLine(line, file string, lineNum int) (ZoneEntry, error) {
return ZoneEntry{}, fmt.Errorf("%s:%d: empty coo value", file, lineNum)
}
entry.COO = normalizeFQDN(value)
case "file":
if value == "" {
return ZoneEntry{}, fmt.Errorf("%s:%d: empty file value", file, lineNum)
}
entry.ZoneFile = value
case "dnssec":
return ZoneEntry{}, fmt.Errorf("%s:%d: dnssec is a flag, use without =", file, lineNum)
default:
return ZoneEntry{}, fmt.Errorf("%s:%d: unknown property %q", file, lineNum, key)
}
} else if tok == "dnssec" {
entry.DNSSEC = true
} else {
// Bare name = catalog assignment
entry.Catalogs = append(entry.Catalogs, tok)
@@ -107,7 +122,7 @@ func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, erro
for _, entry := range entries {
for _, catName := range entry.Catalogs {
if _, ok := cfg.Catalogs[catName]; !ok {
return nil, fmt.Errorf("%s:%d: unknown catalog %q", entry.File, entry.Line, catName)
return nil, fmt.Errorf("%s:%d: unknown catalog %q", entry.ZonesFile, entry.Line, catName)
}
if seen[catName] == nil {
@@ -115,7 +130,7 @@ func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, erro
}
if prevLine, dup := seen[catName][entry.Zone]; dup {
return nil, fmt.Errorf("%s:%d: zone %s already assigned to catalog %q at line %d",
entry.File, entry.Line, entry.Zone, catName, prevLine)
entry.ZonesFile, entry.Line, entry.Zone, catName, prevLine)
}
seen[catName][entry.Zone] = entry.Line

View File

@@ -13,6 +13,8 @@ func TestParseLine(t *testing.T) {
wantCats []string
wantGrp string
wantCOO string
wantFile string
wantDNS bool
wantErr bool
}{
{
@@ -55,6 +57,28 @@ func TestParseLine(t *testing.T) {
wantZone: "zone.example.org.",
wantCats: []string{"catalog1"},
},
{
name: "with file property",
line: "zone.example.org catalog1, file=data/zones/example.org",
wantZone: "zone.example.org.",
wantCats: []string{"catalog1"},
wantFile: "data/zones/example.org",
},
{
name: "with dnssec flag",
line: "zone.example.org catalog1, dnssec",
wantZone: "zone.example.org.",
wantCats: []string{"catalog1"},
wantDNS: true,
},
{
name: "with file and dnssec",
line: "zone.example.org catalog1, file=data/zones/example.org, dnssec",
wantZone: "zone.example.org.",
wantCats: []string{"catalog1"},
wantFile: "data/zones/example.org",
wantDNS: true,
},
{
name: "no catalog",
line: "zone.example.org",
@@ -80,6 +104,16 @@ func TestParseLine(t *testing.T) {
line: "zone.example.org catalog1, coo=",
wantErr: true,
},
{
name: "empty file value",
line: "zone.example.org catalog1, file=",
wantErr: true,
},
{
name: "dnssec with equals",
line: "zone.example.org catalog1, dnssec=yes",
wantErr: true,
},
}
for _, tt := range tests {
@@ -111,6 +145,12 @@ func TestParseLine(t *testing.T) {
if entry.COO != tt.wantCOO {
t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO)
}
if entry.ZoneFile != tt.wantFile {
t.Errorf("zonefile = %q, want %q", entry.ZoneFile, tt.wantFile)
}
if entry.DNSSEC != tt.wantDNS {
t.Errorf("dnssec = %v, want %v", entry.DNSSEC, tt.wantDNS)
}
})
}
}
@@ -150,8 +190,8 @@ func TestBuildCatalogMembers(t *testing.T) {
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},
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, ZonesFile: "test", Line: 2},
}
members, err := buildCatalogMembers(entries, cfg)
@@ -169,7 +209,7 @@ func TestBuildCatalogMembers(t *testing.T) {
t.Run("unknown catalog", func(t *testing.T) {
entries := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"unknown"}, File: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"unknown"}, ZonesFile: "test", Line: 1},
}
_, err := buildCatalogMembers(entries, cfg)
if err == nil {
@@ -179,8 +219,8 @@ func TestBuildCatalogMembers(t *testing.T) {
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},
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 2},
}
_, err := buildCatalogMembers(entries, cfg)
if err == nil {
@@ -207,7 +247,7 @@ test.example.net catalog1, group=internal
`
writeTestFile(t, dir, "zones.txt", content)
members, err := parseInput(inputPath, cfg)
_, members, err := parseInput(inputPath, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -228,7 +268,7 @@ func TestParseInputErrors(t *testing.T) {
}
t.Run("missing file", func(t *testing.T) {
_, err := parseInput("/nonexistent/zones.txt", cfg)
_, _, err := parseInput("/nonexistent/zones.txt", cfg)
if err == nil {
t.Fatal("expected error for missing file")
}
@@ -238,7 +278,7 @@ func TestParseInputErrors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "zones.txt")
writeTestFile(t, dir, "zones.txt", "zone-with-no-catalog\n")
_, err := parseInput(path, cfg)
_, _, err := parseInput(path, cfg)
if err == nil {
t.Fatal("expected error for invalid line")
}
@@ -255,7 +295,7 @@ func TestBuildCatalogMembersSameZoneDifferentCatalogs(t *testing.T) {
// Same zone in different catalogs is OK
entries := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, ZonesFile: "test", Line: 1},
}
members, err := buildCatalogMembers(entries, cfg)

14
main.go
View File

@@ -12,8 +12,9 @@ import (
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)")
bindConf := flag.String("bind-conf", "", "path to write BIND domains.conf")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] <input-file>\n")
fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] [--bind-conf path] <input-file>\n")
flag.PrintDefaults()
}
flag.Parse()
@@ -39,7 +40,7 @@ func main() {
os.Exit(1)
}
members, err := parseInput(inputFile, cfg)
entries, members, err := parseInput(inputFile, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
@@ -70,6 +71,15 @@ func main() {
}
}
if *bindConf != "" {
if err := writeBindConf(*bindConf, entries); err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
hasErrors = true
} else {
fmt.Fprintf(os.Stderr, "%s: written\n", *bindConf)
}
}
if hasErrors {
os.Exit(1)
}

View File

@@ -38,7 +38,7 @@ app.example.org catalog1, group=external, coo=migrated.example.com.
t.Fatalf("loadConfig: %v", err)
}
members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg)
_, members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg)
if err != nil {
t.Fatalf("parseInput: %v", err)
}
@@ -120,7 +120,7 @@ soa:
}
members1 := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
}
now := fixedTime(2026, 1, 15)
@@ -139,8 +139,8 @@ soa:
// 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},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
}
changed, err = processCatalog("cat1", cfg, members2, dir, now)
@@ -195,9 +195,9 @@ soa:
// 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},
{Zone: "z.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
{Zone: "m.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3},
}
now := fixedTime(2026, 1, 15)
@@ -371,7 +371,7 @@ soa:
}
members := []ZoneEntry{
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
}
day1 := fixedTime(2026, 1, 15)
@@ -385,8 +385,8 @@ soa:
// 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},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
}
day2 := fixedTime(2026, 1, 16)
@@ -404,9 +404,9 @@ soa:
// Simulate not using the tool for a while, running on a much later date
day3 := fixedTime(2026, 6, 15)
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},
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
{Zone: "c.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3},
}
changed, err = processCatalog("cat1", cfg, members3, dir, day3)
@@ -420,3 +420,102 @@ soa:
content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
assertContains(t, content3, "2026061501")
}
func TestIntegrationCLIBindConf(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)
}
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 := `zone.example.org catalog1, file=data/zones/example.org, dnssec
zone.example.com catalog1, file=data/zones/example.com
`
writeTestFile(t, dir, "zones.txt", inputContent)
bindConfPath := filepath.Join(dir, "domains.conf")
cmd = exec.Command(binary,
"--config", filepath.Join(dir, "catz.yaml"),
"--output-dir", dir,
"--bind-conf", bindConfPath,
filepath.Join(dir, "zones.txt"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out)
}
// Verify bind conf was written
content := readTestFile(t, bindConfPath)
assertContains(t, content, "# THIS FILE IS GENERATED BY catalog-zone-gen")
// zone.example.com should come before zone.example.org (sorted)
comIdx := strings.Index(content, "zone.example.com")
orgIdx := strings.Index(content, "zone.example.org")
if comIdx >= orgIdx {
t.Error("expected zone.example.com before zone.example.org (alphabetical)")
}
// DNSSEC zone
assertContains(t, content, "dnssec-policy standard; inline-signing yes;")
// Non-DNSSEC zone should not have it
lines := splitLines(content)
for _, line := range lines {
if strings.Contains(line, "example.com") && strings.Contains(line, "file") {
if strings.Contains(line, "dnssec-policy") {
t.Error("example.com should not have dnssec-policy")
}
}
}
}
func TestIntegrationCLIBindConfMissingFile(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)
}
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)
// Zone without file= property
inputContent := "zone.example.org catalog1\n"
writeTestFile(t, dir, "zones.txt", inputContent)
bindConfPath := filepath.Join(dir, "domains.conf")
cmd = exec.Command(binary,
"--config", filepath.Join(dir, "catz.yaml"),
"--output-dir", dir,
"--bind-conf", bindConfPath,
filepath.Join(dir, "zones.txt"))
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected error when file= is missing with --bind-conf")
}
assertContains(t, string(out), "missing file=")
}