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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+167
-14
@@ -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-<UTC time>, 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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user