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:
48
README.md
48
README.md
@@ -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
64
bindconf.go
Normal 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
138
bindconf_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
input.go
35
input.go
@@ -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
|
||||
|
||||
|
||||
@@ -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
14
main.go
@@ -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)
|
||||
}
|
||||
|
||||
125
main_test.go
125
main_test.go
@@ -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=")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user