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
@@ -0,0 +1,142 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
)
|
||||
|
||||
// defaultMaxZoneTTL is max-zone-ttl of BIND's built-in policies (and of
|
||||
// custom ones that don't set it): named refuses to load a signed zone
|
||||
// with a longer TTL.
|
||||
const defaultMaxZoneTTL = 86400
|
||||
|
||||
// Policies returns the custom dnssec-policy statements defined at the top
|
||||
// level, by name.
|
||||
func Policies(s *model.State) map[string]*namedconf.Stmt {
|
||||
out := map[string]*namedconf.Stmt{}
|
||||
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if strings.ToLower(o.Name) != "dnssec-policy" {
|
||||
continue
|
||||
}
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil || stmt.Arg(0) == "" {
|
||||
continue
|
||||
}
|
||||
out[stmt.Arg(0)] = stmt
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) checkDNSSEC() {
|
||||
policies := Policies(c.s)
|
||||
// A custom policy without keys crashes named 9.20 when it loads the
|
||||
// configuration (an assertion in kaspconf.c), although named-checkconf
|
||||
// accepts it. BIND's built-in "insecure" policy is the way to go
|
||||
// unsigned.
|
||||
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if strings.ToLower(o.Name) != "dnssec-policy" {
|
||||
continue
|
||||
}
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil {
|
||||
continue // reported by checkOptions
|
||||
}
|
||||
if b := stmt.FirstBlock(); b != nil {
|
||||
if keys := namedconf.FindOne(b.Stmts, "keys"); keys != nil {
|
||||
if kb := keys.FirstBlock(); kb != nil && len(kb.Stmts) == 0 {
|
||||
c.errf("dnssec_policy_no_keys", "option", o.ID, "dnssec-policy %s has an empty keys list, which makes named 9.20 crash when it loads the configuration; to remove signing from a zone, use BIND's built-in insecure policy (jarl zone dnssec disable)", stmt.Arg(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, z := range c.s.SortedZones() {
|
||||
if z.DNSSECPolicy == "" {
|
||||
continue
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
c.zoneProblem(z, Error, "dnssec_zone_type", "only primary zones can be signed by Jarl; a %s zone gets its signatures from its primary", z.Type)
|
||||
continue
|
||||
}
|
||||
maxTTL := uint32(defaultMaxZoneTTL)
|
||||
switch z.DNSSECPolicy {
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
default:
|
||||
stmt := policies[z.DNSSECPolicy]
|
||||
if stmt == nil {
|
||||
c.zoneProblem(z, Error, "dnssec_policy_unknown", "dnssec-policy %q doesn't exist: use default (BIND's built-in policy), or define it as a top-level dnssec-policy statement", z.DNSSECPolicy)
|
||||
continue
|
||||
}
|
||||
if b := stmt.FirstBlock(); b != nil {
|
||||
if m := namedconf.FindOne(b.Stmts, "max-zone-ttl"); m != nil {
|
||||
if v, ok := parseDuration(m.Arg(0)); ok && v > 0 {
|
||||
maxTTL = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if o := c.s.FindOption(model.ScopeZone, z.ID, "inline-signing"); o != nil && isNo(o.Value) {
|
||||
c.zoneProblem(z, Error, "dnssec_inline_signing", "a signed zone needs inline signing: named keeps its signatures in a separate copy of the zone, because Jarl rewrites the zone file (remove the inline-signing option)")
|
||||
}
|
||||
// named refuses to load a signed zone with a TTL above the
|
||||
// policy's max-zone-ttl, and keeps serving the old version.
|
||||
if z.DefaultTTL > maxTTL {
|
||||
c.zoneProblem(z, Error, "dnssec_ttl_too_long", "the default TTL (%d) is longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", z.DefaultTTL, maxTTL, maxTTL)
|
||||
}
|
||||
for _, r := range c.zoneRecords[z.ID] {
|
||||
if r.TTL != nil && *r.TTL > maxTTL {
|
||||
p := c.zoneProblem(z, Error, "dnssec_ttl_too_long", "%s %s has TTL %d, longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", strings.TrimSuffix(r.Name, "."), r.Type, *r.TTL, maxTTL, maxTTL)
|
||||
p.ObjectType, p.ObjectID, p.Name = "record", r.ID, r.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isNo(v string) bool {
|
||||
switch strings.ToLower(strings.Trim(strings.TrimSpace(v), `";`)) {
|
||||
case "no", "false", "0":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
isoDuration = regexp.MustCompile(`^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$`)
|
||||
ttlDuration = regexp.MustCompile(`^(?:\d+[wdhms])+$`)
|
||||
ttlPart = regexp.MustCompile(`(\d+)([wdhms])`)
|
||||
)
|
||||
|
||||
// parseDuration parses a BIND duration: ISO 8601 (P1D, PT12H), seconds, or
|
||||
// TTL style (1d, 12h30m).
|
||||
func parseDuration(s string) (uint32, bool) {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if n, err := strconv.ParseUint(s, 10, 32); err == nil {
|
||||
return uint32(n), true
|
||||
}
|
||||
if m := isoDuration.FindStringSubmatch(s); m != nil && s != "P" && s != "PT" {
|
||||
units := []uint64{31536000, 2592000, 604800, 86400, 3600, 60, 1}
|
||||
var total uint64
|
||||
for i, u := range units {
|
||||
if m[i+1] != "" {
|
||||
n, _ := strconv.ParseUint(m[i+1], 10, 32)
|
||||
total += n * u
|
||||
}
|
||||
}
|
||||
return uint32(min(total, 1<<32-1)), true
|
||||
}
|
||||
l := strings.ToLower(s)
|
||||
if ttlDuration.MatchString(l) {
|
||||
units := map[string]uint64{"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
|
||||
var total uint64
|
||||
for _, m := range ttlPart.FindAllStringSubmatch(l, -1) {
|
||||
n, _ := strconv.ParseUint(m[1], 10, 32)
|
||||
total += n * units[m[2]]
|
||||
}
|
||||
return uint32(min(total, 1<<32-1)), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func TestDNSSEC(t *testing.T) {
|
||||
b := newBuilder()
|
||||
ok := b.primary("signed.example", nil)
|
||||
ok.DNSSECPolicy = model.DNSSECDefault
|
||||
ps := State(b.s, Options{})
|
||||
if len(codes(ps, Error)) != 0 {
|
||||
t.Fatalf("a signed zone: %v", ps)
|
||||
}
|
||||
|
||||
sec := b.zone("copy.example", nil, model.ZoneSecondary)
|
||||
b.opt(model.ScopeZone, sec.ID, "primaries", "{ 192.0.2.1; }")
|
||||
sec.DNSSECPolicy = model.DNSSECDefault
|
||||
unknown := b.primary("custom.example", nil)
|
||||
unknown.DNSSECPolicy = "fast"
|
||||
long := b.primary("long.example", nil)
|
||||
long.DNSSECPolicy = model.DNSSECInsecure
|
||||
r := b.rec(long, "www", "A", "192.0.2.80")
|
||||
r.TTL = model.Ptr(uint32(172800))
|
||||
b.opt(model.ScopeZone, ok.ID, "inline-signing", "no")
|
||||
ps = State(b.s, Options{})
|
||||
for _, c := range []string{"dnssec_zone_type", "dnssec_policy_unknown", "dnssec_ttl_too_long", "dnssec_inline_signing"} {
|
||||
if !hasCode(ps, c) {
|
||||
t.Errorf("missing %s in %v", c, ps)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom policy: its max-zone-ttl applies; an empty keys list would
|
||||
// crash named.
|
||||
b2 := newBuilder()
|
||||
z := b2.primary("custom.example", nil)
|
||||
z.DNSSECPolicy = "fast"
|
||||
z.DefaultTTL = 600
|
||||
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `fast { keys { csk lifetime unlimited algorithm 13; }; max-zone-ttl PT5M; }`)
|
||||
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `"off" { keys { }; }`)
|
||||
ps = State(b2.s, Options{})
|
||||
if !hasCode(ps, "dnssec_ttl_too_long") || !hasCode(ps, "dnssec_policy_no_keys") || hasCode(ps, "dnssec_policy_unknown") {
|
||||
t.Errorf("custom policy: %v", ps)
|
||||
}
|
||||
|
||||
// dnssec-policy is set per zone in Jarl, not as an option.
|
||||
b3 := newBuilder()
|
||||
z = b3.primary("example.com", nil)
|
||||
b3.opt(model.ScopeZone, z.ID, "dnssec-policy", "default")
|
||||
b3.opt(model.ScopeOptions, 0, "dnssec-policy", "default")
|
||||
b3.opt(model.ScopeZone, z.ID, "key-directory", `"/tmp"`)
|
||||
if n := len(codes(State(b3.s, Options{}), Error)); n != 3 {
|
||||
t.Errorf("managed options: %v", State(b3.s, Options{}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
for in, want := range map[string]uint32{"86400": 86400, "P1D": 86400, "PT5M": 300, "P1W": 604800, "1d": 86400, "12h30m": 45000, "pt1h": 3600} {
|
||||
if got, ok := parseDuration(in); !ok || got != want {
|
||||
t.Errorf("parseDuration(%q) = %d %v, want %d", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "P", "unlimited", "1x"} {
|
||||
if _, ok := parseDuration(bad); ok {
|
||||
t.Errorf("parseDuration(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ func State(s *model.State, opts Options) []Problem {
|
||||
c.checkZones()
|
||||
c.checkOptions()
|
||||
c.checkSecurity()
|
||||
c.checkDNSSEC()
|
||||
sortProblems(c.problems)
|
||||
return c.problems
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user