The DNSSEC notes in CHANGELOG.md belong under Unreleased; git had put them into the 0.1.0 section, which didn't exist when the branch was made. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
7.2 KiB
Go
208 lines
7.2 KiB
Go
package importer
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"jarl/internal/model"
|
|
"jarl/internal/validate"
|
|
)
|
|
|
|
// copyTree copies testdata/<name> to a temp dir, replacing @DIR@.
|
|
func copyTree(t *testing.T, name string) string {
|
|
t.Helper()
|
|
dst := t.TempDir()
|
|
src := filepath.Join("testdata", name)
|
|
entries, err := os.ReadDir(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
b, err := os.ReadFile(filepath.Join(src, e.Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b = []byte(strings.ReplaceAll(string(b), "@DIR@", dst))
|
|
if err := os.WriteFile(filepath.Join(dst, e.Name()), b, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func seqAlloc() func(context.Context, int) ([]int64, error) {
|
|
next := int64(0)
|
|
return func(_ context.Context, n int) ([]int64, error) {
|
|
out := make([]int64, n)
|
|
for i := range out {
|
|
next++
|
|
out[i] = next
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
func TestImportDebian(t *testing.T) {
|
|
dir := copyTree(t, "debian")
|
|
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc(), User: "import"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// rndc.key stays an include; its secret is not imported.
|
|
if len(rep.Kept) != 1 || !strings.HasSuffix(rep.Kept[0], "rndc.key") {
|
|
t.Errorf("kept = %v", rep.Kept)
|
|
}
|
|
if st.KeyByName("rndc-key") != nil {
|
|
t.Error("rndc key imported")
|
|
}
|
|
if k := st.KeyByName("xfer-key"); k == nil || k.Algorithm != "hmac-sha256" {
|
|
t.Errorf("xfer-key = %+v", k)
|
|
}
|
|
if a := st.ACLByName("homelab"); a == nil || strings.Join(a.Elements, ",") != "192.168.1.0/24,10.10.0.0/16,localhost" {
|
|
t.Errorf("acl = %+v", a)
|
|
}
|
|
if o := st.FindOption(model.ScopeOptions, 0, "allow-recursion"); o == nil || o.Value != "{ homelab; }" {
|
|
t.Errorf("allow-recursion = %+v", o)
|
|
}
|
|
if st.FindOption(model.ScopeTopLevel, 0, "controls") == nil || st.FindOption(model.ScopeTopLevel, 0, "logging") == nil {
|
|
t.Error("controls/logging not kept")
|
|
}
|
|
z := st.FindZone("adbraun.lol", 0)
|
|
if z == nil || z.Type != model.ZonePrimary || z.SOA.Serial != 2024051201 || z.SOA.RName != "admin.adbraun.lol." || z.DefaultTTL != 3600 {
|
|
t.Fatalf("zone = %+v", z)
|
|
}
|
|
if st.FindOption(model.ScopeZone, z.ID, "allow-transfer") == nil {
|
|
t.Error("zone option lost")
|
|
}
|
|
recs := map[string]*model.Record{}
|
|
for _, r := range st.ZoneRecords(z.ID) {
|
|
recs[r.Name+" "+r.Type+" "+r.Data] = r
|
|
}
|
|
if r := recs["nas.adbraun.lol. A 192.168.1.20"]; r == nil || r.Comment != "synology" || r.TTL != nil {
|
|
t.Errorf("nas = %+v", r)
|
|
}
|
|
if r := recs["www.adbraun.lol. A 192.168.1.11"]; r == nil || r.TTL == nil || *r.TTL != 300 {
|
|
t.Errorf("www = %+v", r)
|
|
}
|
|
if recs["adbraun.lol. MX 10 mail.adbraun.lol."] == nil || recs["media.adbraun.lol. CNAME nas.adbraun.lol."] == nil {
|
|
t.Errorf("records = %v", recs)
|
|
}
|
|
rz := st.FindZone("1.168.192.in-addr.arpa", 0)
|
|
if rz == nil {
|
|
t.Fatal("reverse zone missing")
|
|
}
|
|
if n := len(st.ZoneRecords(rz.ID)); n != 6 { // NS, 2 PTR, 3 generated PTR
|
|
t.Errorf("reverse zone has %d records", n)
|
|
}
|
|
if fz := st.FindZone("consul", 0); fz == nil || fz.Type != model.ZoneForward {
|
|
t.Error("forward zone missing")
|
|
}
|
|
// The imported configuration is valid (the open allow-query is fine
|
|
// because allow-recursion is restricted).
|
|
ps := validate.State(st, validate.Options{ExternalKeys: []string{"rndc-key"}})
|
|
if validate.HasErrors(ps) {
|
|
t.Errorf("imported state has errors: %v", validate.Errors(ps))
|
|
}
|
|
for _, p := range ps {
|
|
if p.Code == "security_open_resolver" {
|
|
t.Error("false open resolver warning")
|
|
}
|
|
}
|
|
if rep.Counts["zones"] != 3 || len(rep.Zones) != 3 {
|
|
t.Errorf("report = %+v", rep)
|
|
}
|
|
}
|
|
|
|
func TestImportErrors(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`include "`+dir+`/missing.conf";`), 0o644)
|
|
if _, _, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}); err == nil {
|
|
t.Error("missing include accepted")
|
|
}
|
|
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; };`), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\nbad_name IN A 192.0.2.2\nother.test. IN A 192.0.2.3\n"), 0o644)
|
|
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rep.Skipped) != 2 || len(st.Records) != 2 {
|
|
t.Errorf("skipped = %v, records = %d", rep.Skipped, len(st.Records))
|
|
}
|
|
}
|
|
|
|
func TestParseTTL(t *testing.T) {
|
|
cases := map[string]uint32{"3600": 3600, "1h": 3600, "1h30m": 5400, "1W": 604800, "2d3s": 172803}
|
|
for in, want := range cases {
|
|
if got, ok := ParseTTL(in); !ok || got != want {
|
|
t.Errorf("ParseTTL(%q) = %d %v", in, got, ok)
|
|
}
|
|
}
|
|
for _, bad := range []string{"", "h", "1x", "-5"} {
|
|
if _, ok := ParseTTL(bad); ok {
|
|
t.Errorf("ParseTTL(%q) accepted", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImportAllowUpdateWarning(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"), 0o644)
|
|
for conf, want := range map[string]bool{
|
|
`allow-update { none; };`: false,
|
|
`allow-update { 127.0.0.1; };`: true,
|
|
`update-policy local;`: true,
|
|
} {
|
|
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; `+conf+` };`), 0o644)
|
|
_, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := len(rep.Warnings) > 0; got != want {
|
|
t.Errorf("%s: warnings = %v", conf, rep.Warnings)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImportSignedZones(t *testing.T) {
|
|
dir := t.TempDir()
|
|
zone := "$TTL 300\n@ IN SOA ns1 h 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"
|
|
for _, n := range []string{"a", "b", "c"} {
|
|
os.WriteFile(filepath.Join(dir, n+".db"), []byte(strings.ReplaceAll(zone, "ns1", "ns1."+n+".test.")), 0o644)
|
|
}
|
|
conf := `options { directory "` + dir + `"; dnssec-policy default; key-directory "/var/cache/bind/keys"; };
|
|
view "external" {
|
|
zone "a.test" { type primary; file "a.db"; key-directory "/elsewhere"; };
|
|
zone "b.test" { type primary; file "b.db"; dnssec-policy none; };
|
|
};
|
|
view "internal" {
|
|
dnssec-policy "none";
|
|
zone "c.test" { type primary; file "c.db"; };
|
|
};`
|
|
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(conf), 0o644)
|
|
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
policy := map[string]string{}
|
|
for _, z := range st.Zones {
|
|
policy[z.Name] = z.DNSSECPolicy
|
|
}
|
|
if policy["a.test."] != "default" || policy["b.test."] != "" || policy["c.test."] != "" {
|
|
t.Errorf("policies: %v", policy)
|
|
}
|
|
for _, o := range st.Options {
|
|
if o.Name == "dnssec-policy" || o.Scope == model.ScopeZone && o.Name == "key-directory" {
|
|
t.Errorf("option kept: %+v", o)
|
|
}
|
|
}
|
|
if !strings.Contains(strings.Join(rep.Warnings, "\n"), "zone a.test is signed (dnssec-policy default). Before 'jarld adopt', copy its key files (Ka.test.+*)") {
|
|
t.Errorf("warnings: %v", rep.Warnings)
|
|
}
|
|
if ps := validate.Errors(validate.State(st, validate.Options{})); len(ps) != 0 {
|
|
t.Errorf("imported state has errors: %v", ps)
|
|
}
|
|
}
|