Add DNSSEC signing, key backups and Porkbun support
Zones get a DNSSEC setting: named signs them with BIND's default (or a custom) dnssec-policy and inline signing, with keys in a per-view key directory. Jarl shows the keys, the DS record and when it is safe to publish it, checks the parent's DS records (check-ds, which also tells named), and refuses steps that would break a signed domain: switching straight to unsigned, finishing a turn-off too early, deleting or moving a signed zone, TTLs above max-zone-ttl. Turning off goes through the insecure policy; wipe starts over once the parent has no DS. Backups store the DNSSEC keys and restore puts them back for named; the backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's serial above named's whenever it writes an older one (after a restore). The serials check compares the signed serial. With a Porkbun API key in jarl.yaml, the registrar's name servers, glue records and DS records can be shown and changed, each change checked first (dry run, force to override). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
293e6ddd33
commit
5630e7a848
72 files changed
+5760
-153
No files matched your search
@@ -61,6 +61,9 @@ type importer struct {
|
||||
dir string // the directory option, for relative zone file names
|
||||
seen map[string]bool
|
||||
ctrl map[string]bool // key names used by controls
|
||||
// policies are dnssec-policy statements of the options block (key
|
||||
// "options") and of views ("view:ID"), which Jarl sets per zone.
|
||||
policies map[string]string
|
||||
}
|
||||
|
||||
func (im *importer) warn(format string, args ...any) {
|
||||
@@ -90,7 +93,7 @@ func (im *importer) meta() model.Meta {
|
||||
|
||||
// Import reads the configuration starting at mainPath.
|
||||
func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *Report, error) {
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}}
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}, policies: map[string]string{}}
|
||||
stmts, err := im.readFile(mainPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -116,6 +119,7 @@ func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
im.dnssecZones()
|
||||
im.rep.Counts = map[string]int{"views": len(im.st.Views), "zones": len(im.st.Zones), "records": len(im.st.Records), "acls": len(im.st.ACLs), "tsig_keys": len(im.st.Keys), "options": len(im.st.Options)}
|
||||
sort.Slice(im.rep.Zones, func(i, j int) bool {
|
||||
a, b := im.rep.Zones[i], im.rep.Zones[j]
|
||||
@@ -246,6 +250,20 @@ func (im *importer) keepInclude(path string) {
|
||||
}
|
||||
|
||||
func (im *importer) addOption(scope string, scopeID int64, name, value, comment string) {
|
||||
switch {
|
||||
case name == "dnssec-policy" && scope == model.ScopeOptions:
|
||||
im.policies["options"] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeView:
|
||||
im.policies[fmt.Sprintf("view:%d", scopeID)] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeZone:
|
||||
im.policies[fmt.Sprintf("zone:%d", scopeID)] = value
|
||||
return
|
||||
case name == "key-directory" && scope == model.ScopeZone:
|
||||
// Jarl chooses signed zones' key directories (see dnssecKeys).
|
||||
return
|
||||
}
|
||||
id, err := im.id()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -259,6 +277,45 @@ func (im *importer) addOption(scope string, scopeID int64, name, value, comment
|
||||
im.st.Options[id] = &model.Option{ID: id, Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: pos, Comment: comment, Meta: im.meta()}
|
||||
}
|
||||
|
||||
// policyName turns a dnssec-policy value into a zone's DNSSECPolicy.
|
||||
func policyName(v string) string {
|
||||
v = strings.Trim(strings.TrimSpace(v), `"`)
|
||||
switch l := strings.ToLower(v); l {
|
||||
case "none":
|
||||
return ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
return l
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// dnssecZones gives primary zones the dnssec-policy they inherit from
|
||||
// their view or the options block (Jarl sets it per zone), and warns
|
||||
// about signed zones' keys: Jarl keeps them in its own key directory, and
|
||||
// unless the existing keys are moved there, named makes new ones that the
|
||||
// parent's DS record doesn't match.
|
||||
func (im *importer) dnssecZones() {
|
||||
for _, z := range im.st.SortedZones() {
|
||||
v, ok := im.policies[fmt.Sprintf("zone:%d", z.ID)]
|
||||
if !ok {
|
||||
v, ok = im.policies[fmt.Sprintf("view:%d", z.ViewID)]
|
||||
}
|
||||
if !ok {
|
||||
v = im.policies["options"]
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
if p := policyName(v); p != "" {
|
||||
im.warn("zone %s: named signs this %s zone (dnssec-policy %s); Jarl only signs primary zones, so the setting was dropped", dnsname.Display(z.Name), z.Type, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
z.DNSSECPolicy = policyName(v)
|
||||
if z.DNSSECPolicy != "" {
|
||||
im.warn("zone %s is signed (dnssec-policy %s). Before 'jarld adopt', copy its key files (K%s+*) into the key directory Jarl uses for it ('jarl zone dnssec status %s' shows it), owned by named's user; otherwise named makes new keys, and if the parent zone has a DS record for the old ones, the zone stops validating (docs/dnssec.md#adopting-a-signed-zone)", dnsname.Display(z.Name), z.DNSSECPolicy, z.Name, dnsname.Display(z.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restText(s *namedconf.Stmt) string {
|
||||
return namedconf.Compact(s.Items[1:])
|
||||
}
|
||||
|
||||
@@ -146,3 +146,43 @@ func TestParseTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user