From ea261718754febdfa9a9e20f7427ab303f46a608 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 13 Oct 2026 18:39:28 +0100 Subject: [PATCH] option set: statements with a name replace only that one; forwarding over TLS documented jarl option set tls NAME (and key, remote-servers, primaries, http, dnssec-policy, key-store, server, acl, view, zone, parental-agents) matches the name after the keyword too: setting tls cloudflare no longer replaces tls quad9. jarl option unset tls NAME removes one by its name. docs/encrypted-dns.md has forwarding to Quad9 or Cloudflare over TLS: one changeset, the options explained, several providers, checking it works, and why not over HTTPS. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++++ cmd/jarl/config_cmds.go | 56 ++++++++++++++++++++++++++++++++++---- cmd/jarl/main_test.go | 17 ++++++++++++ docs/cli.md | 10 +++++++ docs/encrypted-dns.md | 60 +++++++++++++++++++++++++++++++++++++++++ scripts/e2e.sh | 21 +++++++++++++++ 6 files changed, 165 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 294e040..a11ae40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ All notable changes to Jarl are documented here. The format follows starts from where the records are now. The Certificates page and `jarl cert show` say what named uses a certificate for (DNS over TLS or HTTPS, on which addresses and ports). +- docs/encrypted-dns.md: forwarding to Quad9 or Cloudflare over TLS, how to + check it works, and why not over HTTPS. - Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`, and a **Compare** button on each record. @@ -74,6 +76,10 @@ All notable changes to Jarl are documented here. The format follows ### Changed +- `jarl option set tls NAME …` (and `key`, `remote-servers`, `http`, + `dnssec-policy`, `server` …) replaces only the statement with that name: + setting `tls cloudflare` used to replace `tls quad9`. `jarl option unset + tls cloudflare` removes one by its name. - **Compare views** and `jarl zone diff` are rebuilt around the view you're on (docs/views.md#comparing-views): a summary per view, then what is answered differently (a view's own records in linked zones, diff --git a/cmd/jarl/config_cmds.go b/cmd/jarl/config_cmds.go index 2959048..6688dc0 100644 --- a/cmd/jarl/config_cmds.go +++ b/cmd/jarl/config_cmds.go @@ -586,6 +586,12 @@ func optionCmd() *cobra.Command { Short: "Set an option (replaces an existing option of the same name)", Long: `Set an option; VALUE is named.conf syntax without the trailing semicolon. +It replaces the option of that name. Statements told apart by the name +after the keyword (tls NAME, key NAME, remote-servers NAME, http NAME, +dnssec-policy NAME, server ADDRESS, ...) replace only the one with that +name: setting tls cloudflare leaves tls quad9 alone. --add adds another +instance of any other option that may occur several times. + jarl option set recursion no jarl option set allow-recursion '{ homelab; localhost; }' jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }' @@ -610,7 +616,11 @@ func optionCmd() *cobra.Command { if cmd.Flags().Changed("comment") { body["comment"] = comment } + label := optionLabel(strings.ToLower(args[0]), value) for _, o := range opts { + if label != "" && optionLabel(o.Name, o.Value) != label { + continue // another tls (key, server, ...) statement + } if o.Name == strings.ToLower(args[0]) && !multiple { resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body) if err != nil { @@ -631,7 +641,7 @@ func optionCmd() *cobra.Command { set.Flags().StringVar(&comment, "comment", "", "comment") set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)") unset := &cobra.Command{ - Use: "unset NAME | unset --id ID", + Use: "unset NAME [LABEL] | unset --id ID", Short: "Remove an option (BIND's default applies again)", RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() @@ -645,8 +655,8 @@ func optionCmd() *cobra.Command { } return report(resp) } - if len(args) != 1 { - return fmt.Errorf("give the option name or --id") + if len(args) < 1 || len(args) > 2 { + return fmt.Errorf("give the option name (and the name after it, for tls NAME and the like) or --id") } sc, id, err := scope() if err != nil { @@ -658,15 +668,24 @@ func optionCmd() *cobra.Command { } var matches []api.Option for _, o := range opts { - if o.Name == strings.ToLower(args[0]) { + if o.Name == strings.ToLower(args[0]) && (len(args) == 1 || strings.EqualFold(optionLabel(o.Name, o.Value), strings.Trim(args[1], `"`))) { matches = append(matches, o) } } switch len(matches) { case 0: - return fmt.Errorf("option %s is not set here", args[0]) + return fmt.Errorf("option %s is not set here", strings.Join(args, " ")) case 1: default: + var labels []string + for _, o := range matches { + if l := optionLabel(o.Name, o.Value); l != "" { + labels = append(labels, l) + } + } + if len(labels) == len(matches) { + return fmt.Errorf("option %s is set %d times (%s); say which: jarl option unset %s %s", args[0], len(matches), strings.Join(labels, ", "), args[0], labels[0]) + } return fmt.Errorf("option %s is set %d times; remove one with --id", args[0], len(matches)) } resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil) @@ -847,3 +866,30 @@ func settingsCmd() *cobra.Command { cmd.AddCommand(show, set) return cmd } + +// labelled are the statements told apart by the name after the keyword: +// tls quad9 { ... }, key xfer { ... }, server 192.0.2.1 { ... }. +var labelled = map[string]bool{"tls": true, "http": true, "key": true, "remote-servers": true, "primaries": true, "masters": true, + "parental-agents": true, "dnssec-policy": true, "key-store": true, "server": true, "view": true, "acl": true, "zone": true} + +// optionLabel returns the name a labelled statement's value starts with +// ("quad9" for tls quad9 { ... }), lowercase; "" for other statements. +func optionLabel(name, value string) string { + if !labelled[name] { + return "" + } + v := strings.TrimSpace(value) + if v == "" || strings.HasPrefix(v, "{") { + return "" + } + if strings.HasPrefix(v, `"`) { + if end := strings.Index(v[1:], `"`); end >= 0 { + return strings.ToLower(v[1 : end+1]) + } + } + end := strings.IndexAny(v, " \t\n{;") + if end < 0 { + end = len(v) + } + return strings.ToLower(v[:end]) +} diff --git a/cmd/jarl/main_test.go b/cmd/jarl/main_test.go index e6d6d8b..4d5cd71 100644 --- a/cmd/jarl/main_test.go +++ b/cmd/jarl/main_test.go @@ -232,3 +232,20 @@ func TestOutputFields(t *testing.T) { } } } + +func TestOptionLabel(t *testing.T) { + for _, c := range []struct{ name, value, want string }{ + {"tls", `quad9 { remote-hostname "dns.quad9.net"; }`, "quad9"}, + {"tls", `Cloudflare{ remote-hostname "cloudflare-dns.com"; }`, "cloudflare"}, + {"key", `"xfer key" { algorithm hmac-sha256; secret "x"; }`, "xfer key"}, + {"server", "192.0.2.1 { keys { xfer; }; }", "192.0.2.1"}, + {"remote-servers", "secondaries { 192.0.2.53; }", "secondaries"}, + {"forwarders", "{ 9.9.9.9; }", ""}, + {"logging", "{ channel x { stderr; }; }", ""}, + {"tls", "", ""}, + } { + if got := optionLabel(c.name, c.value); got != c.want { + t.Errorf("optionLabel(%q, %q) = %q, want %q", c.name, c.value, got, c.want) + } + } +} diff --git a/docs/cli.md b/docs/cli.md index 17d71f7..9e1e264 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -482,12 +482,22 @@ jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal jarl option set response-policy '{ zone "rpz.local"; }' --view internal jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel +jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel +jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; }' --toplevel # tls quad9 stays +jarl option unset tls cloudflare --toplevel # statements with a name: say which jarl option unset forwarders jarl option describe # every option allowed at this scope jarl option describe allow-query-cache # grammar and ARM description jarl option generated # what Jarl adds to named.conf by itself, and why ``` +`option set` replaces the option of that name. Statements told apart by +the name after the keyword (`tls NAME`, `key NAME`, `remote-servers NAME`, +`http NAME`, `dnssec-policy NAME`, `server ADDRESS`, ...) replace only the +one with that name; `--add` adds another instance of any other option that +may occur several times. Forwarding to Quad9 or Cloudflare over TLS: +[encrypted-dns.md](encrypted-dns.md#forwarding-over-tls). + Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may be given without the braces: elements are separated by `;`, `,` or spaces, and `key NAME` and `port N` stay with their element. Options are always diff --git a/docs/encrypted-dns.md b/docs/encrypted-dns.md index e9dd822..553e323 100644 --- a/docs/encrypted-dns.md +++ b/docs/encrypted-dns.md @@ -14,6 +14,10 @@ host name's address records in your private views. In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl encrypted-dns`. In the API: `/api/v1/encrypted-dns`. +That is encryption between your clients and this server. For encryption +between this server and the resolvers it forwards to (Quad9, Cloudflare), +see [Forwarding over TLS](#forwarding-over-tls) below. + ## Setting it up ```sh @@ -151,6 +155,62 @@ What it can say: | not from a CA this server trusts (warning) | a staging or test CA: clients reject it unless they trust it | | REFUSED (warning) | it works, but this server's own address gets a view that doesn't answer for the host name | +## Forwarding over TLS + +When a view forwards its recursive queries (to Quad9, Cloudflare, your +ISP's resolver), named can send them over TLS, so your ISP doesn't see the +names you look up: only connections to the forwarder on port 853. It takes +two options, a `tls` statement for each provider (the name its certificate +must have) and the view's `forwarders` using it. Stage them in one +changeset, so one revert undoes them: + +```sh +CS=$(jarl changeset create "internal: forward to Quad9 over TLS" -q) +jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel -C $CS +jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 2620:fe::fe port 853 tls quad9; 2620:fe::9 port 853 tls quad9; }' --view internal -C $CS +jarl option set forward only --view internal -C $CS +jarl changeset diff $CS # what named.conf gets +jarl changeset apply $CS +# undo: jarl changeset apply $(jarl changeset revert $CS -q) +``` + +* **`remote-hostname` and `ca-file`** make named check the forwarder's + certificate against that name (Debian's CA bundle at that path). Without + them the connection is encrypted but anyone in between could answer. +* **`forward only`** stops named falling back to plain DNS (to the root + servers) when the forwarders don't answer: lookups then fail instead + (SERVFAIL), which you notice, rather than quietly going unencrypted. +* **Several providers**: a `tls` statement each, and their addresses in one + `forwarders` list. Setting `tls cloudflare` doesn't touch `tls quad9`: + `jarl option set` replaces only the statement of that name. + + ```sh + jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel + jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 1.1.1.1 port 853 tls cloudflare; 1.0.0.1 port 853 tls cloudflare; 2606:4700:4700::1111 port 853 tls cloudflare; 2606:4700:4700::1001 port 853 tls cloudflare; }' --view internal + ``` + + named sends each query to whichever forwarder has answered fastest, not + in order, and the providers filter differently (Quad9 blocks known + malicious domains, Cloudflare's 1.1.1.1 doesn't; 1.1.1.2 does, with + `remote-hostname "security.cloudflare-dns.com"`). +* **DNS over HTTPS** can't be used for forwarding: BIND serves DoH to + clients but forwards only over plain DNS or TLS. For a DoH upstream, run + a local proxy (dnscrypt-proxy, cloudflared) and forward to it on + `127.0.0.1`. +* Providers write their DoT address as `tls://dns.quad9.net`: the name is + `remote-hostname`, and the addresses it resolves to go in `forwarders` + (named needs addresses there, not names). + +**Checking it works**: Quad9 says how a query reached it. + +```sh +dig +short TXT proto.on.quad9.net @YOUR-SERVER # "dot": over TLS +``` + +`do53-udp` means plain DNS. `ss -tnp | grep :853` shows named's connections +only briefly: it closes them when idle. `tcpdump -ni any port 853` shows +the traffic as it happens. + ## What Jarl checks * A `tls` statement whose files don't exist is an error diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 3b8f405..c32c5fc 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -497,6 +497,27 @@ run "acknowledged: info, with the reason" 0 $J exposure contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose" run "take it back" 0 $J exposure unack external security_open_resolver run "close the resolver again" 0 $J option unset allow-recursion --view external +# Statements told apart by their name (tls NAME, key NAME, ...): setting +# one replaces only the one of that name. +run "a tls statement for forwarding" 0 $J option set tls 'e2e-quad9 { remote-hostname "dns.quad9.net"; }' --toplevel +run "a second one, without --add" 0 $J option set tls 'e2e-cf { remote-hostname "cloudflare-dns.com"; }' --toplevel +run "both are there" 0 $J option list --toplevel +contains "e2e-quad9" +contains "e2e-cf" +run "changing one leaves the other" 0 $J option set tls 'e2e-quad9 { remote-hostname "dns11.quad9.net"; }' --toplevel +run "list" 0 $J option list --toplevel +contains "dns11.quad9.net" +contains "e2e-cf" +not_contains '"dns.quad9.net"' +run "unset needs to know which (exit 1)" 1 $J option unset tls --toplevel +contains "say which: jarl option unset tls" +run "forwarders over TLS" 0 $J option set forwarders '{ 9.9.9.9 port 853 tls e2e-quad9; 1.1.1.1 port 853 tls e2e-cf; }' --view internal +run "named.conf has them" 0 grep -q 'tls e2e-quad9' $E/etc-jarl/named.conf +run "take the forwarders out" 0 $J option unset forwarders --view internal +run "unset one by its name" 0 $J option unset tls e2e-cf --toplevel +run "and the other" 0 $J option unset tls e2e-quad9 --toplevel +run "none left" 0 $J option list --toplevel +not_contains "e2e-quad9" run "fix: hide the version" 0 $J exposure fix server exposure_version expect_dig "version.bind is hidden" "" -c CH version.bind TXT run "mark external private" 0 $J view update external --exposure private