diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4a653..747e410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,41 @@ All notable changes to Jarl are documented here. The format follows ### Added +- DNSSEC signing per zone (docs/dnssec.md): `jarl zone dnssec + enable|status|check-ds|disable|wipe` and the zone page's DNSSEC tab, or + the zone's new `dnssec_policy` field in the API. named signs the zone + with BIND's `default` policy (one ECDSA P-256 key that is never replaced) + or a custom one, using inline signing, with keys in a per-view directory + under `/var/lib/bind/jarl/dnssec-keys`. Jarl shows the keys and the DS + record to give the registrar, says when it's safe to publish it, and + checks the parent's DS records (`check-ds`, which also tells named with + `rndc dnssec -checkds`). It refuses steps that would break a signed + domain: switching straight to unsigned (it goes through BIND's + `insecure` policy instead, and finishes only once named is done), + deleting a signed zone or moving it to another view, and TTLs above the + policy's `max-zone-ttl`. `wipe` starts over with a clean slate once the + parent has no DS. Each step in the web UI opens a dialog that says what it + needs first. `jarl zone delegation` now also compares the parent's DS + records with the zone's keys. +- Porkbun support (optional, docs/dnssec.md#porkbun): with an API key in + the new `porkbun:` section of jarl.yaml (and nowhere else), `jarl zone + registrar status|sync-glue|set-ns|publish-ds|remove-ds` and the zone + page's Registrar tab show and change the domain's name servers, glue + records (one per name server inside the domain, with all its IPv4 and + IPv6 addresses) and DS records at Porkbun. Every change first checks that + it won't take the domain offline (the name servers answer for the zone, + glue matches, a DS goes out only once the domain is delegated to servers + that serve the signed zone, DS records match the zone's keys before the + name servers change); `--dry-run` shows the checks and `--force` skips + them. Administrators only. +- Backups include the DNSSEC keys (private keys too), stored inside the + backup file, and `jarld restore` puts them back for named + (`bind.named_user`, default `bind`). `jarl-backup.service` has + `CAP_DAC_READ_SEARCH` to read them; `--without-keys` skips them. +- Jarl raises a zone's serial above the one named has loaded whenever it + writes a zone file whose serial isn't higher (after restoring an older + database, say), so named loads it (it refuses otherwise for a signed + zone) and secondaries pick it up. - `scripts/testenv.sh`: turns a fresh Debian/Ubuntu machine into a test bed. It installs BIND and the build tools (Go from go.dev), writes a hand-made style configuration with internal and external views, a split-horizon @@ -17,6 +52,16 @@ All notable changes to Jarl are documented here. The format follows ### Changed +- `jarl zone serials` compares the serial named serves, which for a signed + zone is the signed zone's (ahead of Jarl's). +- `jarld import` turns `dnssec-policy` in an existing configuration into + the zones' DNSSEC setting, and warns that their keys must be moved into + Jarl's key directory before adopting. +- `dnssec-policy` is no longer accepted as a zone, view or server option + (zones have a DNSSEC setting instead), nor `key-directory` as a zone + option. +- Deleting a zone also removes the journal and signed files named kept + next to it. - Release candidates (tags like `v0.2.0-rc.1`) publish their `.deb`s to a separate `testing` distribution of the package registry instead of `stable`. Servers that follow `stable` can't pick up an RC with diff --git a/README.md b/README.md index e7081fe..4542b21 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,20 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A) limited. * **Audit log** of every login, change and token, with user, token, IP and outcome. -* **Daily backups** of the database by a systemd timer, checked as they are - written, with retention that never deletes the last few; `jarld restore` - puts one back safely. +* **Daily backups** of the database and the DNSSEC keys by a systemd timer, + checked as they are written, with retention that never deletes the last + few; `jarld restore` puts one back safely, and raises zone serials so + named and the secondaries take the restored data. * **Name server checks**: whether the secondaries serve the current serial, and, for zones on the internet, whether the parent zone delegates them - to the right name servers with the right glue. + to the right name servers with the right glue and DS records. +* **DNSSEC signing** per zone, by BIND itself, with every step that involves + the parent zone guarded: Jarl says when the DS record can be published, + refuses to make a zone unsigned while its DS may still be out there, turns + signing off in safe steps, and can wipe the slate clean. +* **Porkbun support** (optional): the domain's name servers, glue records + (any number, IPv4 and IPv6) and DS records at the registrar, set from Jarl + after checks that the change won't take the domain offline. * **No token needed on the server**: root's `sudo jarl ...` talks to jarld over a local unix socket, so a lost token or password never locks you out. @@ -101,6 +109,7 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network. | [docs/api.md](docs/api.md) | REST API reference | | [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles | | [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none | +| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun | | [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik | | [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention | | [docs/validation.md](docs/validation.md) | Every check Jarl makes | @@ -123,8 +132,10 @@ these pages: fields adapt to the zone type), per-zone records with inline editing, type-specific hints and a preview of the exact record that will be added (reverse zones take addresses and suggest host names), settings (SOA, TTL, serial - policy, view), options, the generated zone file, and pasting a zone file - to merge or replace records. + policy, view), options, the generated zone file, pasting a zone file + to merge or replace records, name server checks, DNSSEC (keys, the DS + record to copy, and each step with what it needs first) and, with a + Porkbun key, the registrar. * **Views, ACLs, TSIG keys, Server options**: wherever an address match list is entered, the existing ACLs and keys are offered and unknown names are flagged. The options editor knows every diff --git a/cmd/jarl/dnssec.go b/cmd/jarl/dnssec.go new file mode 100644 index 0000000..2ec7385 --- /dev/null +++ b/cmd/jarl/dnssec.go @@ -0,0 +1,365 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "jarl/internal/api" + "jarl/internal/dnssec" +) + +func zoneDNSSECCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dnssec", + Short: "Sign a zone with DNSSEC, and turn signing off safely", + Long: `named signs the zone itself (BIND's dnssec-policy with inline signing): Jarl +keeps writing the unsigned zone, and named keeps a signed copy next to it +and its keys in Jarl's key directory. See docs/dnssec.md. + +Turning signing on is safe at any time; validation only starts once you add +the zone's DS record at the parent zone (your registrar), and 'status' says +when that is safe. Turning it off goes through BIND's insecure policy, which +keeps the zone signed until the DS is gone from the parent.`, + } + var view string + status := &cobra.Command{ + Use: "status ZONE", + Short: "Show the zone's keys, its DS record and the next step", + Example: " jarl zone dnssec status adbraun.lol --view external", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := dnssecStatus(args[0], view) + if err != nil { + return err + } + return output(resp, func(w io.Writer) { printDNSSEC(w, resp) }) + }, + } + status.Flags().StringVar(&view, "view", "", "view of the zone") + + var policy string + enable := &cobra.Command{ + Use: "enable ZONE", + Short: "Start signing the zone", + Long: `Starts signing the zone with BIND's default policy (one ECDSA P-256 key that +is never replaced, so the DS record at the parent never has to change) or a +custom dnssec-policy statement. + +Nothing changes for resolvers until the zone's DS record is added at the +parent zone. named first publishes the key for long enough that every cache +has it ('status' shows when: about a day with the default policy); add the +DS after that.`, + Example: " jarl zone dnssec enable adbraun.lol --view external", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"dnssec_policy": policy}) + if err != nil { + return err + } + if err := report(resp); err != nil || !resp.Applied { + return err + } + if !flagQuiet { + fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view)) + } + return nil + }, + } + enable.Flags().StringVar(&view, "view", "", "view of the zone") + enable.Flags().StringVar(&policy, "policy", "default", "dnssec-policy: default, or a custom policy defined as a top-level dnssec-policy statement") + + var yes bool + disable := &cobra.Command{ + Use: "disable ZONE", + Short: "Turn signing off, one safe step at a time", + Long: `Turns signing off in two steps, so the zone never goes unsigned while the +parent zone still has its DS record: + +1. The first run switches the zone to BIND's insecure policy. named keeps it + signed and publishes a CDS "delete" record. Remove the DS record at your + registrar, then run 'jarl zone dnssec check-ds' so named knows. named + waits until resolvers have forgotten the DS (the parent's DS TTL, often a + day) and then removes the keys and signatures. +2. Run it again when 'status' says turning off has finished: DNSSEC is then + switched off for the zone. + +A zone whose DS never reached the parent can be wiped at once instead +('jarl zone dnssec wipe').`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + st, err := dnssecStatus(args[0], view) + if err != nil { + return err + } + var next string + switch { + case st.Policy == "": + fmt.Println("DNSSEC is already off for this zone.") + if st.Phase.Code == dnssec.PhaseLeftover { + fmt.Println("Old keys are still in its key directory; 'jarl zone dnssec wipe' removes them.") + } + return nil + case st.Policy != "insecure": + if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record at your registrar.", displayName(st.Zone)), yes) { + return fmt.Errorf("canceled") + } + next = "insecure" + case !st.Phase.CanFinish: + fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Summary) + if st.Phase.Next != "" { + fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Next) + } + return &checkFailed{"turning DNSSEC off hasn't finished yet"} + } + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"dnssec_policy": next}) + if err != nil { + return err + } + if err := report(resp); err != nil || !resp.Applied || flagQuiet { + return err + } + if next == "insecure" { + fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view)) + } else { + fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').") + } + return nil + }, + } + disable.Flags().StringVar(&view, "view", "", "view of the zone") + disable.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") + + var resolver string + checkDS := &cobra.Command{ + Use: "check-ds ZONE", + Short: "Check the parent zone's DS records and tell named", + Long: `Asks every server of the parent zone for the zone's DS records, and tells +named (rndc dnssec -checkds) when its key's DS has appeared there (after you +added it at the registrar) or is gone (while turning DNSSEC off). named +moves to the next step only once it knows. + +The parent is found through jarld's resolver, or --resolver. Exits with 2 +when the parent couldn't be checked.`, + Example: " jarl zone dnssec check-ds adbraun.lol --view external", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + if resolver != "" { + qv.Set("resolver", resolver) + } + var resp api.CheckDSResponse + if err := c.Do(ctx(), "POST", path+"/dnssec/check-ds", qv, nil, &resp); err != nil { + return err + } + err = output(resp, func(w io.Writer) { + p := resp.Parent + if p.Error != "" { + fmt.Fprintf(w, "error: %s\n", p.Error) + } else { + fmt.Fprintf(w, "Parent zone:\t%s (%d of %d servers answered, found through %s)\n", displayName(p.Parent), p.Reached, len(p.Servers), p.Resolver) + if !p.Delegated { + fmt.Fprintf(w, "Delegation:\tnone (the zone isn't delegated from %s)\n", displayName(p.Parent)) + } + if len(p.DS) == 0 { + fmt.Fprintln(w, "DS records:\tnone") + } + for _, d := range p.DS { + fmt.Fprintf(w, "DS record:\tkey %d, algorithm %d, digest type %d\n", d.KeyTag, d.Algorithm, d.DigestType) + } + } + flush(w) + for _, m := range resp.Marked { + fmt.Printf("named told: %s\n", m) + } + for _, n := range resp.Notes { + fmt.Printf("note: %s\n", n) + } + if resp.Status != nil { + fmt.Printf("\n%s\n", resp.Status.Phase.Summary) + if resp.Status.Phase.Next != "" { + fmt.Println(resp.Status.Phase.Next) + } + } + }) + if err != nil { + return err + } + if resp.Parent.Error != "" { + return &checkFailed{"the parent zone couldn't be checked"} + } + return nil + }, + } + checkDS.Flags().StringVar(&view, "view", "", "view of the zone") + checkDS.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with, e.g. 9.9.9.9 (default: the server's)") + + var force bool + wipe := &cobra.Command{ + Use: "wipe ZONE", + Short: "Switch signing off at once and delete the zone's keys (a clean slate)", + Long: `Switches DNSSEC off for the zone at once (skipping the insecure policy) and +deletes its keys and named's signed copy, so that signing it again starts +from scratch. Also cleans up a zone that is off but still has old keys. + +This is only safe when the parent zone has no DS record for the zone: Jarl +checks the parent first and refuses while it has one. When the parent can't +be checked (a zone that isn't on the internet, or no internet access), +--force wipes anyway. Needs an administrator.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !confirm(fmt.Sprintf("Switch DNSSEC off for %s and delete its keys? Make sure the parent zone has no DS record for it.", strings.TrimSuffix(args[0], ".")), yes) { + return fmt.Errorf("canceled") + } + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + if resolver != "" { + qv.Set("resolver", resolver) + } + if flagDryRun { + qv.Set("dry_run", "1") + } + var resp api.DNSSECWipeResponse + if err := c.Do(ctx(), "POST", path+"/dnssec/wipe", qv, api.DNSSECWipe{Force: force, DryRun: flagDryRun}, &resp); err != nil { + return err + } + return output(resp, func(w io.Writer) { + verb := "removed" + if resp.DryRun { + verb = "would remove" + } + for _, f := range resp.Removed { + fmt.Printf("%s %s\n", verb, f) + } + if resp.DryRun { + fmt.Println("dry run: nothing was changed") + } else { + fmt.Println("DNSSEC is off and the zone's keys are gone.") + } + }) + }, + } + wipe.Flags().StringVar(&view, "view", "", "view of the zone") + wipe.Flags().BoolVar(&force, "force", false, "wipe although the parent zone couldn't be checked") + wipe.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with (default: the server's)") + wipe.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") + + cmd.AddCommand(status, enable, disable, checkDS, wipe) + return cmd +} + +func viewFlag(view string) string { + if view == "" { + return "" + } + return " --view " + view +} + +func dnssecStatus(zone, view string) (*api.DNSSECResponse, error) { + c, err := newClient() + if err != nil { + return nil, err + } + path, qv := zonePath(zone, view) + var resp api.DNSSECResponse + if err := c.Get(ctx(), path+"/dnssec", qv, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func printDNSSEC(w io.Writer, r *api.DNSSECResponse) { + head := displayName(r.Zone) + if r.View != "" { + head += " [view " + r.View + "]" + } + fmt.Fprintf(w, "Zone:\t%s\n", head) + policy := r.Policy + switch policy { + case "": + policy = "none (not signed)" + case "insecure": + policy = "insecure (turning DNSSEC off)" + } + fmt.Fprintf(w, "Policy:\t%s\n", policy) + fmt.Fprintf(w, "Status:\t%s\n", r.Phase.Summary) + if r.Phase.Next != "" { + fmt.Fprintf(w, "Next:\t%s\n", r.Phase.Next) + } + if r.Named != nil && r.Named.Secure { + fmt.Fprintf(w, "named:\tsigned serial %d (zone file %d)", r.Named.SignedSerial, r.Named.Serial) + if r.Named.NextKeyEvent != "" { + fmt.Fprintf(w, ", next key event %s", r.Named.NextKeyEvent) + } + fmt.Fprintln(w) + } + if r.NamedError != "" { + fmt.Fprintf(w, "named:\t%s\n", r.NamedError) + } + if r.Policy != "" || len(r.Keys) > 0 { + fmt.Fprintf(w, "Key directory:\t%s\n", r.KeyDir) + } + if r.KeyError != "" { + fmt.Fprintf(w, "Keys:\tcan't be read: %s\n", r.KeyError) + } + flush(w) + if len(r.Keys) > 0 { + fmt.Fprintln(w, "\nKEY\tROLE\tALGORITHM\tGOAL\tDNSKEY\tDS AT PARENT\tCREATED") + for _, k := range r.Keys { + created := "-" + if k.Created != nil { + created = k.Created.Local().Format("2006-01-02 15:04") + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", k.Tag, k.Role, k.AlgorithmName, dash(k.Goal), dash(k.DNSKEYState), dash(k.DSState), created) + } + flush(w) + } + var ds []*dnssec.Key + for _, k := range r.Keys { + if k.DS != nil && k.Goal == dnssec.Omnipresent { + ds = append(ds, k) + } + } + if len(ds) > 0 && r.Policy != "insecure" { + where := "at your registrar" + if r.ParentZone != "" { + where = "in the parent zone " + displayName(r.ParentZone) + " (in Jarl: add it as a DS record there)" + } + fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where) + if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil { + fmt.Fprintf(w, " (not yet: wait until %s)\n", r.Phase.DSReadyAt.Local().Format(time.RFC1123)) + } + for _, k := range ds { + fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " ")) + fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest) + } + } +} + +func dash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/cmd/jarl/registrar.go b/cmd/jarl/registrar.go new file mode 100644 index 0000000..c0c3495 --- /dev/null +++ b/cmd/jarl/registrar.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "jarl/internal/api" +) + +func zoneRegistrarCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "registrar", + Short: "The domain's name servers, glue and DS records at the registrar (Porkbun)", + Long: `Shows and changes a domain's delegation at the registrar Porkbun: its name +servers, the glue records of name servers inside the domain, and its DNSSEC +DS records. Needs a Porkbun API key in jarl.yaml (porkbun:, see +docs/dnssec.md#porkbun) and an administrator. + +Every change checks first that it won't break the domain (for example that +the new name servers serve the zone, or that the zone is signed with the +key a DS record points at) and is refused otherwise; --dry-run shows those +checks, --force skips them.`, + } + var view string + status := &cobra.Command{ + Use: "status ZONE", + Short: "Compare the registrar's name servers, glue and DS records with the zone", + Example: " jarl zone registrar status adbraun.lol --view external", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + var resp api.RegistrarResponse + if err := c.Get(ctx(), path+"/registrar", qv, &resp); err != nil { + return err + } + err = output(resp, func(w io.Writer) { + if resp.Error != "" { + fmt.Fprintf(w, "error: %s\n", resp.Error) + return + } + fmt.Fprintf(w, "Domain:\t%s at %s\n", resp.Domain, resp.Registrar) + fmt.Fprintf(w, "Name servers:\t%s\n", strings.Join(resp.NS, ", ")) + fmt.Fprintf(w, "Zone's NS records:\t%s\n", strings.Join(resp.JarlNS, ", ")) + flush(w) + if len(resp.Glue) > 0 { + fmt.Fprintln(w, "\nGLUE FOR\tAT REGISTRAR\tIN THE ZONE\tTO DO") + for _, g := range resp.Glue { + todo := g.Action + if todo == "" { + todo = "-" + if !g.Needed && !g.InUse { + todo = "unused" + } + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", g.Host, firstNonEmpty(strings.Join(g.Registry, ", "), "none"), firstNonEmpty(strings.Join(g.Jarl, ", "), "-"), todo) + } + flush(w) + } + if len(resp.DS) > 0 { + fmt.Fprintln(w, "\nDS KEY TAG\tALGORITHM\tDIGEST TYPE\tMATCHES THE ZONE") + for _, d := range resp.DS { + fmt.Fprintf(w, "%d\t%d\t%d\t%v\n", d.KeyTag, d.Algorithm, d.DigestType, d.Matches) + } + flush(w) + } + fmt.Println() + for _, f := range resp.Findings { + fmt.Printf("%-8s %s\n", f.Level+":", f.Message) + } + }) + if err != nil { + return err + } + if resp.Error != "" { + return &checkFailed{"the registrar couldn't be asked"} + } + for _, f := range resp.Findings { + if f.Level == "error" { + return &checkFailed{"the registrar's records don't match the zone"} + } + } + return nil + }, + } + status.Flags().StringVar(&view, "view", "", "view of the zone") + + var force bool + change := func(use, short, long, method, sub string, args cobra.PositionalArgs, body func(args []string) (string, any, error)) *cobra.Command { + c := &cobra.Command{ + Use: use, + Short: short, + Long: long, + Args: args, + RunE: func(cmd *cobra.Command, args []string) error { + cl, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + if flagDryRun { + qv.Set("dry_run", "1") + } + extra, b, err := body(args) + if err != nil { + return err + } + var resp api.RegistrarChangeResponse + if err := cl.Do(ctx(), method, path+"/registrar/"+sub+extra, qv, b, &resp); err != nil { + return err + } + return output(resp, func(w io.Writer) { + for _, f := range resp.Checks { + fmt.Printf("%-8s %s\n", f.Level+":", f.Message) + } + verb := "done:" + if resp.DryRun { + verb = "would:" + } + for _, d := range resp.Done { + fmt.Printf("%-8s %s\n", verb, d) + } + if len(resp.Done) == 0 { + fmt.Println("nothing to change") + } + for _, wn := range resp.Warnings { + fmt.Printf("note: %s\n", wn) + } + if resp.DryRun { + fmt.Println("dry run: nothing was changed") + } + }) + }, + } + c.Flags().StringVar(&view, "view", "", "view of the zone") + c.Flags().BoolVar(&force, "force", false, "skip the checks that the change won't break the domain") + return c + } + syncGlue := change("sync-glue ZONE", "Create or update glue records for the zone's name servers inside the domain", + `Gives every name server in the zone's NS records that is inside the domain +(ns1.adbraun.lol for adbraun.lol) a glue record at the registrar with the +zone's A and AAAA records for it: one glue record per name server, each with +all its addresses. Resolvers need glue to find such a server at all. + +Changing the glue of a name server the domain is delegated to sends +resolvers to the new addresses, so they must answer for the zone first.`, + "POST", "glue", cobra.ExactArgs(1), func([]string) (string, any, error) { + return "", api.RegistrarChange{Force: force, DryRun: flagDryRun}, nil + }) + var key uint16 + publishDS := change("publish-ds ZONE", "Publish the zone's DS record at the registrar (turns DNSSEC validation on)", + `Adds the DS record of the zone's key at the registrar, which publishes it in +the parent zone: from then on validating resolvers check the zone's +signatures. Refused until named says the zone is ready, and unless the +domain is delegated to name servers that serve the signed zone. + +Afterwards run 'jarl zone dnssec check-ds ZONE' once the parent zone has it.`, + "POST", "ds", cobra.ExactArgs(1), func([]string) (string, any, error) { + return "", api.RegistrarChange{KeyTag: key, Force: force, DryRun: flagDryRun}, nil + }) + publishDS.Flags().Uint16Var(&key, "key", 0, "key tag (default: every key that needs a DS)") + removeDS := change("remove-ds ZONE KEYTAG", "Remove a DS record at the registrar", + `Removes a DS record at the registrar. Without the zone's DS record, resolvers +stop validating it (it stays signed). This is the step 'jarl zone dnssec +disable' asks for; a DS record that matches none of the zone's keys breaks +the domain and should go at once.`, + "DELETE", "ds/", cobra.ExactArgs(2), func(args []string) (string, any, error) { + return args[1], nil, nil + }) + setNS := change("set-ns ZONE [NAMESERVER...]", "Delegate the domain to the zone's name servers (or the ones given)", + `Sets the domain's name servers at the registrar: by default to the zone's NS +records. Checked first: each name server answers for the zone with the +current serial, name servers inside the domain have glue records that match +the zone (run sync-glue before), and DS records at the registrar match the +zone's keys (otherwise the domain goes dark for validating resolvers).`, + "PUT", "ns", cobra.MinimumNArgs(1), func(args []string) (string, any, error) { + return "", api.RegistrarChange{NS: args[1:], Force: force, DryRun: flagDryRun}, nil + }) + cmd.AddCommand(status, syncGlue, publishDS, removeDS, setNS) + return cmd +} diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index 6738391..ee7b81b 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -367,7 +367,7 @@ equal to the file: records not in the file are deleted.`, importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file") importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file") - cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd()) + cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd()) return cmd } diff --git a/cmd/jarld/backup.go b/cmd/jarld/backup.go index 6189ff7..4c9d131 100644 --- a/cmd/jarld/backup.go +++ b/cmd/jarld/backup.go @@ -7,8 +7,11 @@ import ( "fmt" "io" "os" + "os/user" "path/filepath" + "strconv" "strings" + "syscall" "text/tabwriter" "time" @@ -18,11 +21,13 @@ import ( "jarl/internal/auth" "jarl/internal/backup" "jarl/internal/config" + "jarl/internal/render" "jarl/internal/store" ) func backupCmd() *cobra.Command { - return &cobra.Command{ + var withoutKeys bool + cmd := &cobra.Command{ Use: "backup [FILE]", Short: "Back up the database (safe while jarld runs)", Long: `Writes a consistent copy of Jarl's database and checks it. @@ -35,10 +40,18 @@ say. This is what the jarl-backup.timer systemd timer runs every day. With FILE it writes a single copy there, which is never deleted automatically. +Each backup also holds the DNSSEC keys of signed zones (from Jarl's key +directory, private keys included), so that 'jarld restore' can put them +back: a zone whose keys are lost stops validating while its parent still +has a DS record for them. named's private keys are readable only by named's +user, so run this as root; jarl-backup.service has the capability to read +them. --without-keys backs up only the database. + Every backup is recorded in the database, and the dashboard shows the newest. Restore one with 'jarld restore'.`, - Example: ` sudo -u jarl jarld backup - sudo -u jarl jarld backup /root/jarl-before-upgrade.db`, + Example: ` sudo jarld backup + sudo jarld backup /root/jarl-before-upgrade.db + sudo systemctl start jarl-backup.service`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load(configPath, true) @@ -59,7 +72,14 @@ Restore one with 'jarld restore'.`, defer newDirOwner(filepath.Dir(cfg.Database)).fix(filepath.Dir(cfg.Database)) ctx := context.Background() now := time.Now() + keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir() + if withoutKeys { + keysDir = "" + } fail := func(err error) error { + if strings.Contains(err.Error(), "DNSSEC keys") { + err = fmt.Errorf("%w; or back up only the database with --without-keys", err) + } if rerr := st.RecordBackup(ctx, store.BackupRecord{At: now, Error: err.Error()}); rerr != nil { fmt.Fprintln(os.Stderr, "warning: couldn't record the failure in the database:", rerr) } @@ -74,27 +94,33 @@ Restore one with 'jarld restore'.`, os.Remove(dst) return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err)) } + if keysDir != "" { + if _, err := backup.AddFiles(ctx, dst, keysDir); err != nil { + os.Remove(dst) + return fail(err) + } + } if err := backup.Verify(ctx, dst); err != nil { os.Remove(dst) return fail(fmt.Errorf("the backup failed its check: %w", err)) } fi, _ := os.Stat(dst) st.RecordBackup(ctx, store.BackupRecord{At: now, File: dst, Size: fi.Size()}) - fmt.Printf("Database backed up to %s (%s)\n", dst, size(fi.Size())) + fmt.Printf("Database backed up to %s (%s)%s\n", dst, size(fi.Size()), keysNote(ctx, dst)) return nil } dir := cfg.Backup.Dir defer newDirOwner(dir).fix(dir) - f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now) + f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now, keysDir) if err != nil { return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err)) } - fmt.Printf("Database backed up to %s (%s)\n", f.Path, size(f.Size)) + fmt.Printf("Database backed up to %s (%s)%s\n", f.Path, size(f.Size), keysNote(ctx, f.Path)) kinds := []string{backup.KindJarl} if cfg.Backup.Stats { if _, err := os.Stat(cfg.Stats.Database); err == nil { - sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now) + sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now, "") if err != nil { return fail(fmt.Errorf("backing up the statistics database %s: %w", cfg.Stats.Database, err)) } @@ -117,10 +143,20 @@ Restore one with 'jarld restore'.`, return nil }, } + cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "back up only the database, not the DNSSEC keys") + return cmd +} + +// keysNote says how many DNSSEC key files a backup holds. +func keysNote(ctx context.Context, path string) string { + if in, err := backup.Inspect(ctx, path); err == nil && in.KeyFiles > 0 { + return ", with " + plural(in.KeyFiles, "DNSSEC key file") + } + return "" } func restoreCmd() *cobra.Command { - var yes bool + var yes, withoutKeys bool cmd := &cobra.Command{ Use: "restore [FILE | latest]", Short: "Replace the database with a backup (jarld must be stopped)", @@ -132,13 +168,20 @@ confirm replaces the database with it. jarld must be stopped first. The current database is kept as DATABASE.before-restore-, and the restore is recorded in the restored database's audit log. +The DNSSEC keys stored in the backup go back into Jarl's key directory, +owned by named's user (bind.named_user), which needs root. Key files that +are there but not in the backup are left alone. --without-keys restores +only the database. + Then start jarld and bring named's files in line with the restored database: systemctl start jarl jarl resync --dry-run # what differs jarl resync --force # write everything and reload named -See "Restoring" in docs/operations.md, also for zone serials and secondaries.`, +Jarl raises the serials of zones whose data goes back to an older state +above what named and the secondaries have, so they pick it up. See +"Restoring" in docs/operations.md.`, Example: ` sudo jarld restore sudo systemctl stop jarl sudo jarld restore latest @@ -180,6 +223,24 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`, if info.Schema > store.SchemaVersion() { return fmt.Errorf("%s was made by a newer Jarl (schema %d, this one knows %d); install that version to restore it", src, info.Schema, store.SchemaVersion()) } + keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir() + var keyFiles []backup.StoredFile + if !withoutKeys { + var root string + root, keyFiles, err = backup.Files(ctx, src) + if err != nil { + return fmt.Errorf("reading the DNSSEC keys in %s: %w", src, err) + } + if len(keyFiles) > 0 && os.Geteuid() != 0 && !runningAs(cfg.BIND.NamedUser) { + return fmt.Errorf("the backup holds %s, and putting them back for named needs root: run this as root (or add --without-keys to restore only the database)", plural(len(keyFiles), "DNSSEC key file")) + } + if root != "" && root != keysDir && len(keyFiles) > 0 { + fmt.Printf("The keys were backed up from %s; they go to %s now.\n", root, keysDir) + } + } + if info.SignedZones > 0 && len(keyFiles) == 0 && !withoutKeys { + fmt.Fprintf(os.Stderr, "warning: the backup has %s but no DNSSEC keys (it was made with --without-keys, or before Jarl backed them up). Unless the keys are still in %s, named makes new ones, and a DS record for the old ones at the parent stops matching.\n", plural(info.SignedZones, "signed zone"), keysDir) + } unlock, err := lockDatabase(cfg.Database) if errors.Is(err, errInUse) { return fmt.Errorf("jarld is running; stop it first (systemctl stop jarl), then run this again") @@ -192,6 +253,9 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`, w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "Backup:\t%s\n", src) describe(w, info, backupTime(src)) + if len(keyFiles) > 0 { + fmt.Fprintf(w, " DNSSEC keys\t%s (go back to %s)\n", plural(len(keyFiles), "file"), keysDir) + } if cur, err := backup.Inspect(ctx, cfg.Database); err == nil { fmt.Fprintf(w, "\nCurrent database:\t%s\n", cfg.Database) describe(w, cur, time.Time{}) @@ -224,13 +288,27 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`, moved = true } } - if err := copyFile(src, db, 0o640); err != nil { + restoreErr := copyFile(src, db, 0o640) + if restoreErr == nil { + // The keys go back to their own files, not into the + // live database. + restoreErr = backup.DropFiles(ctx, db) + } + if restoreErr != nil { + os.Remove(db) if moved { os.Rename(aside, db) os.Rename(aside+"-wal", db+"-wal") os.Rename(aside+"-shm", db+"-shm") } - return fmt.Errorf("restoring: %w (the current database was left in place)", err) + return fmt.Errorf("restoring: %w (the current database was left in place)", restoreErr) + } + if len(keyFiles) > 0 { + n, err := restoreKeys(cfg, keysDir, keyFiles) + if err != nil { + return fmt.Errorf("the database was restored, but putting back the DNSSEC keys failed after %d files: %w", n, err) + } + fmt.Printf("Put back %s in %s.\n", plural(n, "DNSSEC key file"), keysDir) } // Open it as jarld will: an older backup gets the current // schema now, and the restore goes into its audit log. @@ -255,17 +333,89 @@ Next: jarl resync --dry-run # what differs between the restored database and named's files jarl resync --force # write them and reload named -If you have secondary servers, zone serials older than what they last saw -are ignored by them: raise them with 'jarl zone update ZONE --serial N' -(see "Restoring" in docs/operations.md). +Zones whose data goes back to an older state get a serial above the one +named has, so named and the secondaries load them (see "Restoring" in +docs/operations.md). `) return nil }, } cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") + cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "restore only the database, not the DNSSEC keys") return cmd } +// runningAs reports whether this process runs as the named user. +func runningAs(name string) bool { + u, err := user.Lookup(name) + return err == nil && u.Uid == strconv.Itoa(os.Geteuid()) +} + +// restoreKeys writes DNSSEC key files from a backup into keysDir, owned +// by named's user and Jarl's file group with their backed-up modes, so +// named can use them. Directories get the owner of the zone directory +// (jarld's user), like the ones jarld makes. Files already there are +// replaced; others are left alone. +func restoreKeys(cfg *config.Config, keysDir string, files []backup.StoredFile) (int, error) { + uid, gid := -1, -1 + if u, err := user.Lookup(cfg.BIND.NamedUser); err == nil { + uid, _ = strconv.Atoi(u.Uid) + } else { + return 0, fmt.Errorf("named's user %q (bind.named_user): %w", cfg.BIND.NamedUser, err) + } + if g, err := user.LookupGroup(cfg.BIND.FileGroup); err == nil { + gid, _ = strconv.Atoi(g.Gid) + } + dirUID, dirGID := -1, gid + if fi, err := os.Stat(cfg.BIND.ZoneDir); err == nil { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + dirUID = int(st.Uid) + } + } + var mkdir func(dir string) error + mkdir = func(dir string) error { + if _, err := os.Stat(dir); err == nil { + return nil + } + if err := mkdir(filepath.Dir(dir)); err != nil { + return err + } + if err := os.Mkdir(dir, 0o770); err != nil && !os.IsExist(err) { + return err + } + os.Chown(dir, dirUID, dirGID) + return os.Chmod(dir, 0o770|os.ModeSetgid) + } + n := 0 + for _, f := range files { + if filepath.IsAbs(f.Path) || strings.HasPrefix(filepath.Clean(f.Path), "..") { + return n, fmt.Errorf("refusing the file name %q in the backup", f.Path) + } + dst := filepath.Join(keysDir, f.Path) + if err := mkdir(filepath.Dir(dst)); err != nil { + return n, err + } + tmp := dst + ".restore-tmp" + if err := os.WriteFile(tmp, f.Data, 0o600); err != nil { + return n, err + } + if err := os.Chown(tmp, uid, gid); err != nil { + os.Remove(tmp) + return n, err + } + if err := os.Chmod(tmp, f.Mode); err != nil { + os.Remove(tmp) + return n, err + } + if err := os.Rename(tmp, dst); err != nil { + os.Remove(tmp) + return n, err + } + n++ + } + return n, nil +} + func listBackups(dir string) error { files, err := backup.List(dir) if err != nil { @@ -290,6 +440,9 @@ func describe(w io.Writer, in *backup.Info, made time.Time) { fmt.Fprintf(w, " made\t%s (%s ago)\n", made.Local().Format("2006-01-02 15:04:05"), fmtDuration(time.Since(made))) } fmt.Fprintf(w, " contents\t%s, %s, %s (schema %d)\n", plural(in.Zones, "zone"), plural(in.Records, "record"), plural(in.Users, "user"), in.Schema) + if in.SignedZones > 0 { + fmt.Fprintf(w, " signed zones\t%d\n", in.SignedZones) + } if in.LastChange != nil { fmt.Fprintf(w, " last change\t%s\n", in.LastChange.Local().Format("2006-01-02 15:04:05")) } diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index dbba407..cba4a91 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -27,6 +27,7 @@ import ( "jarl/internal/config" "jarl/internal/engine" "jarl/internal/model" + "jarl/internal/porkbun" "jarl/internal/render" "jarl/internal/server" "jarl/internal/stats" @@ -291,12 +292,20 @@ func serveCmd() *cobra.Command { // database closes, however serving ends. defer func() { cancel(); <-collectorDone }() } + var pb *porkbun.Client + if a.cfg.Porkbun.Enabled() { + pb = &porkbun.Client{BaseURL: a.cfg.Porkbun.APIURL, APIKey: a.cfg.Porkbun.APIKey, Secret: a.cfg.Porkbun.SecretAPIKey} + if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 { + a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath) + } + } srv := server.New(server.Config{ Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, Version: version.Version, Stats: collector, Socket: a.cfg.Socket, BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter), + Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows, HTTPReq: server.HTTPReq{ Enabled: a.cfg.ACME.HTTPReq.Enabled, AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS, diff --git a/deploy/systemd/jarl-backup.service b/deploy/systemd/jarl-backup.service index 7285ebe..6b51051 100644 --- a/deploy/systemd/jarl-backup.service +++ b/deploy/systemd/jarl-backup.service @@ -1,4 +1,5 @@ -# Jarl: one database backup, run daily by jarl-backup.timer. +# Jarl: one database backup (with the DNSSEC keys), run daily by +# jarl-backup.timer. # Installed as /etc/systemd/system/jarl-backup.service (see # docs/operations.md#backups). What to keep, and where, is in the backup: # section of /etc/jarl/jarl.yaml; when it runs is in jarl-backup.timer. @@ -28,8 +29,12 @@ PrivateTmp=yes PrivateDevices=yes PrivateNetwork=yes NoNewPrivileges=yes -CapabilityBoundingSet= -AmbientCapabilities= +# Read access to every file, and nothing else: named's DNSSEC private keys +# (under /var/lib/bind/jarl/dnssec-keys) are readable only by named's user, +# and the backup includes them. Everything outside the paths above stays +# read-only (ProtectSystem=strict). +CapabilityBoundingSet=CAP_DAC_READ_SEARCH +AmbientCapabilities=CAP_DAC_READ_SEARCH ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes diff --git a/docs/api.md b/docs/api.md index 3903343..ef571bb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -144,6 +144,14 @@ may be the name. | GET | `/zones/{zone}/file` | the generated zone file (text/plain) | | GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) | | GET | `/zones/{zone}/delegation` | checks the parent zone's delegation of a primary zone (below); `?resolver=IP` | +| GET | `/zones/{zone}/dnssec` | the zone's DNSSEC keys, DS record and phase ([DNSSEC](#dnssec)) | +| POST | `/zones/{zone}/dnssec/check-ds` | asks the parent for the zone's DS records and tells named; `?resolver=IP` | +| POST | `/zones/{zone}/dnssec/wipe` | `{"force": false, "dry_run": false}`: signing off at once, keys deleted (administrators) | +| GET | `/zones/{zone}/registrar` | name servers, glue and DS records at the registrar ([Registrar](#registrar-porkbun)) | +| POST | `/zones/{zone}/registrar/glue` | create or update glue records | +| PUT | `/zones/{zone}/registrar/ns` | set the domain's name servers | +| POST | `/zones/{zone}/registrar/ds` | publish the zone's DS record | +| DELETE | `/zones/{zone}/registrar/ds/{keytag}` | remove a DS record | | POST | `/zones/{zone}/import` | `{"text": "", "mode": "merge"\|"replace", "use_soa": false, "dry_run": false}` | ZoneInput: @@ -159,6 +167,7 @@ ZoneInput: "refresh": 3600, "retry": 900, "expire": 1209600, "minimum": 300}, "default_ttl": 3600, "serial_policy": "date", // date, increment, unixtime + "dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off) "comment": "", // create only: "nameservers": ["ns1.adbraun.lol."], // default: settings @@ -197,6 +206,96 @@ when called ([operations.md](operations.md#checking-name-servers-and-the-delegat (`name`, `parent`, `jarl`, `match`), `servers` (as above, for the delegated name servers) and `findings`: `{"level": "ok"|"info"|"warning"|"error", "message": "..."}`. A zone that isn't delegated gets one `info` finding. +`parent_ds` lists the zone's DS records at the parent, and the findings +compare them with the zone's keys (a DS that matches no key, or a DS for a +zone that isn't signed, is an `error`). + +Zones in responses also carry `registrar` (`"porkbun"`) when Jarl can +manage the domain at a registrar. + +#### DNSSEC + +Signing is the zone's `dnssec_policy` field, changed with `PATCH +/zones/{zone}` like any other field, so it goes through validation, +changesets and history. Jarl refuses unsafe steps there: `""` straight from +a signed policy (use `"insecure"` first), `""` before named has finished +removing the signatures, deleting a signed zone, or moving it to another +view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)). + +`GET /zones/{zone}/dnssec`: + +```json +{ + "zone": "adbraun.lol.", "view": "external", "type": "primary", + "policy": "default", + "key_dir": "/var/lib/bind/jarl/dnssec-keys/view-3", + "keys": [{ + "tag": 51126, "algorithm": 13, "algorithm_name": "ECDSAP256SHA256", "role": "CSK", + "dnskey": "adbraun.lol.\t3600\tIN\tDNSKEY\t257 3 13 ...", + "ds": {"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "record": "adbraun.lol.\t3600\tIN\tDS\t51126 13 2 FFBE..."}, + "goal": "omnipresent", "dnskey_state": "rumoured", "ds_state": "hidden", + "zone_rrsig_state": "rumoured", "key_rrsig_state": "rumoured", + "created": "...", "published": "...", "active": "...", "publish_cds": "...", + "files": ["Kadbraun.lol.+013+51126.key", "Kadbraun.lol.+013+51126.private", "Kadbraun.lol.+013+51126.state"] + }], + "phase": { + "code": "signing", "summary": "named is signing the zone. ...", "next": "Wait until ...", + "ds_ready_at": "...", "can_publish_ds": false, "can_finish": false + }, + "named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."}, + "policies": ["default"], + "parent_zone": "", // set when the parent zone is a primary zone in Jarl + "registrar": "porkbun" // when the DS can be published through Jarl +} +``` + +`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`, +`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`, +`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)). + +`POST /dnssec/check-ds` (zones write) returns `parent` (the parent's +servers with their `ds`, the union `ds`, `delegated`, `reached`, +`consistent`, or `error`), `marked` (what named was told, `rndc dnssec +-checkds`), `notes` and the updated `status`. + +`POST /dnssec/wipe` (administrators) asks the parent first and answers +`409` while it has a DS record for the zone, or while named still counts a +removed one as cached; when the parent can't be asked, it needs `"force": +true`. It returns `removed` (the files deleted) and `changeset_id`. + +#### Registrar (Porkbun) + +Administrators only, and only with a Porkbun key in jarl.yaml +([dnssec.md](dnssec.md#porkbun)). `GET /zones/{zone}/registrar`: + +```json +{ + "registrar": "porkbun", "domain": "adbraun.lol", + "ns": ["curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"], "jarl_ns": ["ns1.adbraun.lol", "ns2.adbraun.lol"], + "ns_match": false, + "glue": [{"host": "ns1.adbraun.lol", "registry": [], "jarl": ["203.0.113.1", "2001:db8::1"], + "needed": true, "in_use": false, "action": "create"}], + "ds": [{"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "matches": true}], + "findings": [{"level": "warning", "message": "..."}], + "error": "" // e.g. "Porkbun: ... (API_ACCESS_DISABLED)"; the rest is then empty +} +``` + +The changes take `{"force": false, "dry_run": false}` (`PUT /ns` also +`"ns": [...]`, default the zone's NS records; `POST /ds` also `"key_tag"`) +and answer: + +```json +{"done": ["create glue for ns1.adbraun.lol: 203.0.113.1, 2001:db8::1"], "dry_run": false, + "checks": [{"level": "ok"|"info"|"warning"|"error", "message": "..."}], "warnings": []} +``` + +Each change runs its checks first: the name servers answer for the zone, +glue matches, the DS points at a key the zone has, and the domain is +delegated to servers that serve the signed zone before a DS is published. An +`error` check refuses the change with `409` unless `force` is set; a dry run +returns the checks without changing anything. Errors from Porkbun are `502 +registrar_error`. ### Records diff --git a/docs/cli.md b/docs/cli.md index cf06023..2116267 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -150,7 +150,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents. |------|---------| | 0 | Success | | 1 | Other error (bad arguments, connection failure, ...) | -| 2 | A check found problems (`zone serials`, `zone delegation`); its report was printed | +| 2 | A check found problems (`zone serials`, `zone delegation`, `zone dnssec check-ds`, `zone registrar status`), or a step can't be taken yet (`zone dnssec disable` before turning off has finished); the report was printed | | 3 | Not authenticated, or not allowed (token lacks the permission) | | 4 | Not found | | 5 | Conflict: concurrent change, object in use, drift | @@ -211,6 +211,37 @@ zone's delegation and glue against the zone, and calls a zone that isn't delegated (an internal-only zone) a note, not a problem. Both only run when you run them; see [operations.md](operations.md#checking-name-servers-and-the-delegation). +### DNSSEC and the registrar + +```sh +jarl zone dnssec status adbraun.lol --view external # keys, DS record, what's next +jarl zone dnssec enable adbraun.lol --view external [--policy NAME] +jarl zone dnssec check-ds adbraun.lol --view external # has the parent got the DS? tells named +jarl zone dnssec disable adbraun.lol --view external # step 1: insecure; run again to finish +jarl zone dnssec wipe adbraun.lol --view external [--force] # off at once, keys deleted + +jarl zone registrar status adbraun.lol --view external # Porkbun: NS, glue, DS vs the zone +jarl zone registrar sync-glue adbraun.lol --view external [--dry-run] +jarl zone registrar set-ns adbraun.lol --view external [NS...] [--dry-run] +jarl zone registrar publish-ds adbraun.lol --view external [--key TAG] +jarl zone registrar remove-ds adbraun.lol 51126 --view external +``` + +[dnssec.md](dnssec.md) explains the steps. `enable` is safe at any time: +validation only starts once the zone's DS record is at the parent, and +`status` says when to publish it. `disable` never makes a zone unsigned +while the parent may still have its DS: its first run switches to BIND's +insecure policy, and it finishes (exit 0) only when named is done (exit 2 +before). `wipe` switches off at once and deletes the keys, and refuses +while the parent zone has a DS record for the zone; `--force` wipes when the +parent can't be checked. + +The `registrar` commands need a Porkbun API key in jarl.yaml and an +administrator. Each change checks first that it won't break the domain and +is refused otherwise: `--dry-run` shows the checks, `--force` skips them. +(Here `--force` means that; elsewhere it overwrites files changed outside +Jarl.) + ## Records ```sh diff --git a/docs/concepts.md b/docs/concepts.md index 5d6e6cc..2fac872 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -40,8 +40,8 @@ renders the complete configuration from the database: `jarld adopt` turns `/etc/bind/named.conf` into a single `include "/etc/bind/jarl/named.conf";`, so named loads only what Jarl generates. Zone files go under `/var/lib/bind` because Debian's AppArmor -profile lets named write there. That keeps journals and future DNSSEC -signing working. +profile lets named write there: its journals, the signed copies of zones +it signs, and their keys (under `dnssec-keys/`, see [dnssec.md](dnssec.md)). Because files are always regenerated, anything you edit by hand is overwritten. Jarl records a checksum of every file it writes and reports @@ -290,8 +290,6 @@ journal through jarld's log. * **Dynamic updates** (`allow-update`, `update-policy`): named would write to zone files that Jarl regenerates, so the validator rejects them on Jarl zones. Use the API (for example `jarl acme`) instead of nsupdate. -* **DNSSEC signing**: not managed yet. The zone directory is - named-writable, so `dnssec-policy` can be added later. * **Several servers from one jarld**: each jarld manages the named on its own host. A second server runs its own jarld with secondary zones, which copy the primary's zones by normal AXFR/IXFR (see "A secondary server" in diff --git a/docs/configuration.md b/docs/configuration.md index 4aef99a..b19739f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,8 +39,8 @@ Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`). ### Backups `jarld backup` without a file name, which the `jarl-backup.timer` systemd -timer runs daily, writes `jarl-.db` into `backup.dir` and then -deletes old backups there. It reads these settings from this file each +timer runs daily, writes `jarl-.db` into `backup.dir` (with the +DNSSEC keys of signed zones inside it) and then deletes old backups there. It reads these settings from this file each time it runs; when it runs is the timer's (`systemctl edit jarl-backup.timer`). See [operations.md](operations.md#backups), also for restoring. @@ -91,9 +91,10 @@ jarld restarts. |-----|---------|---------| | `bind.main_config` | `/etc/bind/named.conf` | The file named loads. `jarld import` reads it, and `jarld adopt` replaces it with an include of Jarl's configuration. | | `bind.config_dir` | `/etc/bind/jarl` | Where Jarl writes `named.conf` and `keys.conf`. Must be readable by named. | -| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`/.zone`). named may write journals here. | +| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`/.zone`). named may write journals, signed copies of zones and, under `dnssec-keys/`, DNSSEC keys here. | | `bind.cache_dir` | `/var/cache/bind/jarl` | Where named stores secondary, stub and mirror zones. | | `bind.file_group` | `bind` | Group given to generated files and directories so named can read them. Empty leaves the group alone. | +| `bind.named_user` | `bind` | The user named runs as. `jarld restore` gives it the DNSSEC keys it puts back. | | `bind.named_checkconf` | `/usr/bin/named-checkconf` | Used to check every candidate configuration (`-z` also loads the zones). | | `bind.named_checkzone` | `/usr/bin/named-checkzone` | Used to check zone files. | | `bind.rndc` | `/usr/sbin/rndc` | Used to reconfigure and reload named and to read zone serials. | @@ -111,6 +112,26 @@ there (`ReadWritePaths`). If you change them, update both ReadWritePaths=/srv/bind/jarl /srv/bind/zones ``` +### Porkbun (registrar) + +With an API key, Jarl can show and set a domain's name servers, glue +records and DNSSEC DS records at Porkbun, after checking that the change +won't break the domain ([dnssec.md](dnssec.md#porkbun)). Without a key +(the default) nothing about Porkbun shows up. + +| Key | Default | Meaning | +|-----|---------|---------| +| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. | +| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). | +| `porkbun.domains` | `[]` | Only these domains (default: every primary zone the key can reach). | + +The key can move your domain anywhere, so it is only ever read from this +file: it isn't stored in the database or backups, and there is no API to +set it. Keep the file readable only by root and jarld (`chown root:jarl +jarl.yaml; chmod 640 jarl.yaml`, as the Debian package installs it); jarld +warns at start when everyone can read it. At porkbun.com, switch on "API +Access" for the domain, and limit the key to the domain if you can. + ## Example: management network with TLS ```yaml diff --git a/docs/development.md b/docs/development.md index 2f1021f..2e855b0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,8 +24,10 @@ internal/ web/static/ the web UI: plain ES modules, no build step, embedded with go:embed api/, client/ JSON types and the Go client shared by server and CLI acme/ ACME DNS-01 challenge records, shared by jarl acme and the httpreq endpoint - backup/ database backups: write (VACUUM INTO) and check, list, prune by the time in the name - nscheck/ DNS queries to other name servers: zone serials and the parent's delegation + backup/ database backups: write (VACUUM INTO, plus the DNSSEC key files) and check, list, prune by the time in the name + nscheck/ DNS queries to other name servers: zone serials, the parent's delegation and DS records + dnssec/ reads named's DNSSEC key and state files; DS records; the signing phases (docs/dnssec.md) + porkbun/ Porkbun API client (name servers, glue, DS records) and an in-memory fake for tests config/ jarld's YAML configuration deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files deploy/debian/ the Debian package's control template and postinst/prerm/postrm @@ -110,7 +112,10 @@ checker tests work with the installed `bind9-utils` too. `scripts/e2e.sh` builds a legacy BIND setup, starts named, runs `jarld init`, `import`, `adopt` and `serve`, then drives every CLI command (records, batches, changesets, views, ACLs, TSIG, options, users, tokens, -ACME hooks, audit) and checks the answers with `dig`. `KEEP=1` leaves named +ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks +the answers with `dig`. The Porkbun code is tested against +`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real +one. `KEEP=1` leaves named and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG` to that directory's `cli.yaml` to keep using the CLI against it. diff --git a/docs/dnssec.md b/docs/dnssec.md new file mode 100644 index 0000000..b17cab2 --- /dev/null +++ b/docs/dnssec.md @@ -0,0 +1,334 @@ +# DNSSEC signing + +DNSSEC lets resolvers that validate (Cloudflare 1.1.1.1, Google 8.8.8.8, +Quad9, many ISPs) detect forged answers for your domain. Jarl signs zones +with BIND's own key and signing policy (`dnssec-policy`), and guides you +through the steps that involve the parent zone, where getting the order +wrong takes a domain offline. + +- [Should you sign?](#should-you-sign) +- [How it works](#how-it-works) +- [Turning signing on](#turning-signing-on) +- [Putting your own name servers on the internet](#putting-your-own-name-servers-on-the-internet) +- [Porkbun](#porkbun) +- [Turning signing off](#turning-signing-off) +- [Wipe: a clean slate](#wipe-a-clean-slate) +- [Keys, backups and moving servers](#keys-backups-and-moving-servers) +- [What Jarl refuses, and why](#what-jarl-refuses-and-why) +- [Phases](#phases) +- [Custom policies](#custom-policies) +- [Adopting a signed zone](#adopting-a-signed-zone) + +## Should you sign? + +Sign a zone that the internet sees through a delegation from its parent: +your registered domain (`adbraun.lol`), as served to the outside world. +Validation only happens once the parent zone (`lol`) publishes a DS +record for the zone's key, which you add at your registrar. + +Signing an internal-only zone (one no registrar delegates, or a split-DNS +view only your own network reaches) adds nothing. There is no parent to +publish a DS, and BIND doesn't validate the zones it serves itself. + +**Split DNS.** In Jarl a zone belongs to a view, so you sign the external +view's `adbraun.lol` and leave the internal view's copy unsigned. That is +the usual setup, with one catch. Once the DS is at the parent, the world +expects `adbraun.lol` to be signed. A device on your network that validates +DNSSEC *itself* and gets the internal, unsigned copy rejects it. Examples +are a Pi-hole or Unbound with DNSSEC on, systemd-resolved with +`DNSSEC=yes`, and some routers. Devices that just ask this server are fine. +If you run such a validator, sign the internal copy too and publish both +DS records. + +**What it costs.** It is one more way for the domain to break: if the DS at +the parent stops matching the zone's keys (keys lost, wiped, or a server +moved without them), every validating resolver rejects the whole domain. +Most of Jarl's DNSSEC features exist to stop that from happening. + +## How it works + +named signs the zone itself, with *inline signing*: + +- Jarl writes the zone file as before (`/var/lib/bind/jarl//.zone`). + named keeps a signed copy next to it (`.signed`, `.signed.jnl`), signs + every change Jarl makes, and re-signs before signatures expire. +- The keys live in Jarl's key directory, + `/var/lib/bind/jarl/dnssec-keys//`. The subdirectory is named after + the view's ID, so renaming a view doesn't move the keys. Each key is + three files, `K.+013+.key`, `.private` (readable only by + named's user) and `.state`. Zones of the same name in two views have + separate directories, because named requires that. +- With BIND's `default` policy there is one key, a CSK (combined signing + key) using ECDSA P-256 (algorithm 13). It is never replaced, so the DS + record at the registrar never has to change. +- The signed zone has its own serial, usually a little ahead of Jarl's. + Jarl's reload check compares the serial of the file it wrote. The serials + check (`jarl zone serials`) compares the signed serial, which is what + secondaries serve. +- Secondaries transfer the signed zone and need nothing else. +- The server's clock must be right (NTP): signatures are valid for a fixed + time window. + +## Turning signing on + +```sh +jarl zone dnssec enable adbraun.lol --view external +jarl zone dnssec status adbraun.lol --view external +``` + +or, in the web UI, the zone's **DNSSEC** tab → *Turn signing on*. This is +safe at any time. Resolvers keep treating the zone as unsigned until its +DS record is at the parent. + +`status` shows the key, the DS record, and when it is safe to publish: + +``` +Status: named is signing the zone. Resolvers still treat it as unsigned, ... +Next: Wait until Fri 9 Oct 21:13 UTC before adding the DS at the parent: ... + +KEY ROLE ALGORITHM GOAL DNSKEY DS AT PARENT CREATED +51126 CSK ECDSAP256SHA256 omnipresent rumoured hidden 2026-10-08 21:08 + +DS record for the parent zone (at your registrar): + (not yet: wait until Fri, 09 Oct 2026 21:13:51 UTC) + adbraun.lol. 3600 IN DS 51126 13 2 FFBECE3D... + key tag 51126, algorithm 13 (ECDSAP256SHA256), digest type 2 (SHA-256), digest FFBECE3D... +``` + +named first publishes the key for long enough that every resolver's cache +has caught up: about a day with the default policy. Adding the DS earlier +can make validating resolvers reject cached answers. Then: + +1. **Make sure the domain is delegated to servers that serve the signed + zone.** Your own servers, or secondaries that transfer from them. + Publishing a DS while the registrar still points at other name servers + (Porkbun's own, say) breaks the domain. `jarl zone delegation ZONE` + shows where the parent points. +2. **Publish the DS record at the registrar**, by hand (the DNSSEC tab has + copy buttons for the whole record and for its four fields), or with + Jarl's [Porkbun](#porkbun) support, which checks step 1 first. +3. **Tell named.** Once the parent zone has the DS (minutes to hours after + the registrar accepts it), run the DS check: + + ```sh + jarl zone dnssec check-ds adbraun.lol --view external + ``` + + It asks every server of the parent zone for the zone's DS records, and + tells named (`rndc dnssec -checkds`) when all of them have it. named + waits for the parent's DS TTL before it counts the DS as in place. +4. **Check.** `jarl zone delegation ZONE` now also compares the parent's + DS with the zone's keys. A DS that matches no key is an error. + [DNSViz](https://dnsviz.net/) gives an independent picture. + +If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol` +inside `adbraun.lol`), the DNSSEC tab says so: add the DS as a record in +that zone. + +## Putting your own name servers on the internet + +To serve your domain from your own server, the parent zone must delegate +it to your server's *name* (NS records can't hold addresses). If that name +is inside the domain itself (`ns1.adbraun.lol` for `adbraun.lol`), +resolvers couldn't look it up without already reaching your server. **Glue +records** solve this: the registry publishes `ns1.adbraun.lol`'s address +together with the delegation. Each name server inside the domain needs its +own glue record, with all of its addresses (IPv4 and IPv6). + +The order matters: + +1. In the zone (external view): NS records for your name servers, and A/AAAA + records for the ones inside the domain. +2. At the registrar: a glue record for each of those (Porkbun calls them + "glue records", others "host records" or "child name servers"). +3. Check that every name server answers for the zone from the internet. +4. At the registrar: set the domain's name servers. +5. `jarl zone delegation ZONE` until it is all green. Resolvers may use the + old name servers for a day or two after the change, so keep those + serving the zone until then. + +Jarl's [Porkbun](#porkbun) support does steps 2 to 4 with checks. + +Before you go public: + +- **Two name servers.** Registries and RFC 1034 expect at least two, on + different networks: with one server at home, a power or internet outage + takes the domain off the internet. A free secondary service (Hurricane + Electric's dns.he.net, for example) or a small VPS transfers the zone + from Jarl, signed zone included. A *hidden primary* goes one step + further: your server feeds the secondaries but isn't in the NS records, + so your home address isn't advertised. +- **A dynamic home address** means the glue must follow it. +- **Inbound port 53** (UDP and TCP) must reach the server: some ISPs block + it, and CGNAT makes it impossible. + +## Porkbun + +With a Porkbun API key, Jarl manages a domain's name servers, glue records +and DS records at Porkbun, and checks each change before making it. + +**Setting it up** + +1. At porkbun.com, *Account → API Access*: create a key. If you can, + limit it to the domain and the permissions it needs (domains, read), + and to your server's address. +2. On the domain's page, switch **API Access** on (Porkbun refuses API + calls for a domain until you do). +3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld: + + ```yaml + porkbun: + api_key: pk1_... + secret_api_key: sk1_... + domains: [adbraun.lol] # optional: only these + ``` + +The key can move your domain anywhere, so it lives only in that file: +never in Jarl's database or its backups, and there is no API or CLI command +to upload it. Keep the file readable by root and jarld only (`chown +root:jarl`, `chmod 640`, as the package installs it); jarld warns at start +when everyone can read it. Only administrators can use the registrar +features. + +**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`: + +| | | +|---|---| +| `status ZONE` | Name servers, glue and DS records at Porkbun next to the zone's, with what doesn't match. | +| `sync-glue ZONE` | Gives every name server inside the domain a glue record with the zone's A and AAAA records for it. Changing the glue of a name server the domain already uses sends resolvers to the new addresses, so Jarl checks first that they answer for the zone. | +| `set-ns ZONE [NS...]` | Sets the domain's name servers (default: the zone's NS records). Checked first: each answers for the zone with the current serial, the ones inside the domain have matching glue, and DS records at Porkbun match the zone's keys (otherwise the domain goes dark for validating resolvers). | +| `publish-ds ZONE` | Publishes the zone's DS record. Refused until named says the zone is ready, and unless the domain is delegated to the zone's name servers and they serve the signed zone. | +| `remove-ds ZONE KEYTAG` | Removes a DS record (the step when turning signing off; also the fix for a DS that matches no key). | + +Every change can be tried first with `--dry-run`, which shows the checks. +Errors refuse the change; `--force` (in the web UI, a checkbox) overrides +them, for example when this server can't reach its own public address to +check it. + +## Turning signing off + +Never switch a signed zone straight to unsigned while its DS might still +be at the parent: validating resolvers would reject the whole domain until +the DS is gone *and* its TTL has passed (often a day). Jarl refuses that +step and goes through BIND's `insecure` policy instead: + +```sh +jarl zone dnssec disable adbraun.lol --view external +``` + +1. The zone switches to the `insecure` policy. named keeps it signed and + publishes a CDS "delete" record. Nothing breaks. +2. Remove the DS record at your registrar (`jarl zone registrar remove-ds` + with Porkbun). Once the parent zone no longer has it, run `jarl zone + dnssec check-ds` so named knows. +3. named waits until resolvers have forgotten the DS (the parent's DS TTL + plus a margin, about a day), then removes the keys and signatures. + `status` shows the progress. +4. Run `jarl zone dnssec disable` again (or *Finish turning off* in the web + UI) when `status` says so: DNSSEC is then off for the zone. + +If the DS never reached the parent, named skips the wait and you can +finish at once. The old keys stay in the key directory (named keeps them +retired; signing again makes a new key) until you wipe them. + +## Wipe: a clean slate + +```sh +jarl zone dnssec wipe adbraun.lol --view external +``` + +or *Wipe (clean slate)* on the DNSSEC tab. It switches signing off at once, +without the insecure policy, and deletes the zone's keys and named's signed +copy, so signing again starts from scratch. It also cleans up a zone that +is off but still has old keys. + +That is only safe when the parent zone has no DS record for the zone, so +Jarl asks the parent's servers first and **refuses while they have one**. +It also refuses while named still counts a recently removed DS as cached. +When the parent can't be asked (no internet access, or a zone that isn't on +the internet), `--force` wipes anyway. Wiping needs an administrator, and +the web UI asks you to type the zone's name. + +Deleting a zone that is signed is refused: turn signing off first. + +## Keys, backups and moving servers + +The zone's keys exist only in the key directory: Jarl's database doesn't +hold them. Losing them while the parent has a DS for them breaks the +domain. + +- **Backups.** Every backup (`jarld backup`, and the daily + `jarl-backup.timer`) stores the key directory's files inside the backup + file, private keys included. Keep `/var/backups/jarl` private (it is + `0700`). named's private keys are readable only by named's user, so + `jarl-backup.service` runs with the one capability it needs to read them + (`CAP_DAC_READ_SEARCH`); a manual backup must run as root. `--without-keys` + backs up only the database. +- **Restoring.** `jarld restore` puts the keys back, owned by named's user + (`bind.named_user`), which needs root. Key files that exist but aren't in + the backup are left alone. +- **Serials after a restore.** A restored database may have older zone + serials than named has. named won't load a signed zone's file unless its + serial went up, and secondaries ignore a serial that went down. Jarl + checks this whenever it writes a zone file, and raises the serial above + named's when needed (it shows in the result, and in the audit log). +- **Moving to a new server.** Restore the backup *before* named loads + Jarl's configuration there. Otherwise named finds no keys and makes new + ones, which the parent's DS doesn't match. + +## What Jarl refuses, and why + +| Change | Why | +|---|---| +| Switching a signed zone straight to unsigned | The parent may still have the DS: use `disable` (insecure first), or `wipe` when the parent has none. | +| Finishing `disable` before named is done | Resolvers may still have the DS cached. | +| Deleting a signed zone | Turn signing off first. | +| Moving a signed zone to another view | It would get new keys (another key directory) that the DS doesn't match. | +| A TTL above the policy's `max-zone-ttl` (a day by default) | named refuses to load such a signed zone and keeps serving the old one. | +| `dnssec-policy` as a zone, view or server option | Jarl sets it per zone, so it can guard the steps. Top-level `dnssec-policy NAME { ... }` statements (custom policies) are fine. | +| A custom policy with an empty `keys { }` list | It crashes named 9.20 when the configuration loads (named-checkconf accepts it). Use the built-in `insecure` policy. | +| `inline-signing no` on a signed zone | Jarl rewrites zone files; named must keep its signatures in a separate copy. | +| Signing a secondary zone | A secondary gets its signatures from its primary. | + +## Phases + +`status`, the DNSSEC tab and the API report a phase: + +| Phase | Meaning | Next | +|---|---|---| +| `off` | Not signed. | | +| `leftover_keys` | Not signed, but old keys are in the key directory. | Wipe them, or sign again. | +| `starting` | Signing is on; named hasn't made the key yet. | Wait a moment. | +| `signing` | Signed, but caches may not have the key yet. | Wait until the time shown, then publish the DS. | +| `ready` | Safe to add the DS at the parent. | Publish it, then run the DS check. | +| `ds_pending` | named knows the DS is at the parent and waits for its TTL. | | +| `secure` | Signed, and the parent's DS points at the key. | | +| `removing_ds` | Turning off; the DS must go from the parent first. | Remove it, then run the DS check. | +| `ds_expiring` | The DS is gone; caches may still have it. | Wait. | +| `removing_keys` | named removes the keys and signatures. | Finish when you like (it's safe now). | +| `unsigned` | Turning off has finished. | Switch DNSSEC off for the zone. | + +The key states come from named (`rndc dnssec -status ZONE` shows the same): +*rumoured* means published, but caches may not have it yet; *omnipresent* +means everyone has it; *unretentive* means removed, but caches may still +have it; *hidden* means gone. + +## Custom policies + +`default` suits most domains. For anything else (scheduled key rollovers, +NSEC3, other algorithms), define a policy as a top-level statement +(`jarl option set dnssec-policy 'NAME { ... }' --toplevel`, or *Server +options* in the web UI) and enable it with `jarl zone dnssec enable ZONE +--policy NAME`. With a policy that rolls the key-signing key, +the DS at the parent must change at every rollover: `status` and the DS +check tell you when, and named waits for the DS check before it retires +the old key. + +## Adopting a signed zone + +When `jarld import` finds a zone that named already signs, it imports its +policy and warns. Before `jarld adopt`, copy the zone's key files +(`K.+*`) from its old key directory into the one Jarl uses (`jarl +zone dnssec status ZONE` shows it), owned by named's user. Otherwise named +makes new keys, and if the parent has a DS for the old ones, the zone stops +validating. diff --git a/docs/install.md b/docs/install.md index 464fdbf..70c2996 100644 --- a/docs/install.md +++ b/docs/install.md @@ -167,7 +167,7 @@ This installs: | `/usr/local/bin/jarld`, `/usr/local/bin/jarl` | daemon and CLI | | `/usr/local/share/bash-completion/completions/jarl`, `.../jarld` | bash completion (loaded automatically by the `bash-completion` package) | | `/etc/systemd/system/jarl.service` | systemd unit (`Type=notify`, sandboxed) | -| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup ([operations.md](operations.md#backups)) | +| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup, DNSSEC keys included ([operations.md](operations.md#backups)) | | `/usr/lib/sysusers.d/jarl.conf` | creates the `jarl` system user and adds it to group `bind` | | `/usr/lib/tmpfiles.d/jarl.conf` | creates `/etc/jarl`, `/var/lib/jarl`, `/etc/bind/jarl`, `/var/lib/bind/jarl`, `/var/cache/bind/jarl`, `/var/backups/jarl` | | `/etc/jarl/jarl.yaml` | example configuration (only if none exists) | @@ -390,7 +390,7 @@ a backup (it is safe while jarld runs); `jarld restore` puts it back if needed: ```sh -sudo -u jarl jarld backup +sudo jarld backup ``` ## Uninstalling diff --git a/docs/operations.md b/docs/operations.md index 4ff0785..9010519 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,7 +6,8 @@ |------|-----| | Is everything healthy? | `jarl status`, or the web UI dashboard: named reachable, adoption, drift, open changesets, configuration problems, the newest backup, tokens about to expire | | Are the secondaries up to date? | `jarl zone serials ZONE`, or the zone's Name servers tab ([below](#checking-name-servers-and-the-delegation)) | -| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo -u jarl jarld backup`; restore with `jarld restore` ([below](#backups)) | +| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo jarld backup`; restore with `jarld restore` ([below](#backups)) | +| DNSSEC | `jarl zone dnssec status ZONE`, or the zone's DNSSEC tab ([dnssec.md](dnssec.md)) | | What changed and who did it? | `jarl history`, `jarl changeset show ID` (with diff), `jarl audit` | | Undo a change | `jarl changeset revert ID`, review it, then `jarl changeset apply NEW_ID` (or History → Revert in the UI) | | Check the configuration | `jarl check`: full validation plus `named-checkconf -z` | @@ -23,8 +24,10 @@ loaded. jarld is only needed to make changes. Everything Jarl knows is in its database (`/var/lib/jarl/jarl.db`): zones, records, views, ACLs, TSIG keys, options, users, tokens, history and the -audit log. named's files are generated from it, so the database is the one -thing to back up. +audit log. named's files are generated from it. The one exception is the +DNSSEC keys of signed zones, which named keeps in Jarl's key directory +(`/var/lib/bind/jarl/dnssec-keys`): every backup stores those too, inside the +backup file ([dnssec.md](dnssec.md#keys-backups-and-moving-servers)). ### The daily backup @@ -41,17 +44,19 @@ Each run: 1. writes a consistent copy of the database with SQLite's `VACUUM INTO` (safe while jarld runs and changes things) to a temporary file in the backup directory; -2. checks the copy with SQLite's `integrity_check`, and only then gives it +2. stores the DNSSEC keys in it (every file of the key directory, private + keys included); +3. checks the copy with SQLite's `integrity_check`, and only then gives it its name, `jarl-.db`, e.g. `jarl-20261008T031512Z.db`. A file with that name is always a complete, intact backup; -3. deletes backups older than `retention`, but always keeps the newest +4. deletes backups older than `retention`, but always keeps the newest `keep_at_least`, however old they are. If backups stop for a while (the machine was off, the disk was full), the old ones aren't all deleted by the next run. Ages come from the time in the name, not the file's modification time, which copying the directory would change. Only files named `jarl-