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 <noreply@anthropic.com>
This commit is contained in:
1 parent
250e734046
commit
ea26171875
6 files changed
+165
-5
No files matched your search
@@ -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
|
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,
|
cert show` say what named uses a certificate for (DNS over TLS or HTTPS,
|
||||||
on which addresses and ports).
|
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]`,
|
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
||||||
and a **Compare** button on each record.
|
and a **Compare** button on each record.
|
||||||
|
|
||||||
@@ -74,6 +76,10 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- **Compare views** and `jarl zone diff` are rebuilt around the view
|
||||||
you're on (docs/views.md#comparing-views): a summary per view, then
|
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,
|
what is answered differently (a view's own records in linked zones,
|
||||||
|
|||||||
+51
-5
@@ -586,6 +586,12 @@ func optionCmd() *cobra.Command {
|
|||||||
Short: "Set an option (replaces an existing option of the same name)",
|
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.
|
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 recursion no
|
||||||
jarl option set allow-recursion '{ homelab; localhost; }'
|
jarl option set allow-recursion '{ homelab; localhost; }'
|
||||||
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
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") {
|
if cmd.Flags().Changed("comment") {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
label := optionLabel(strings.ToLower(args[0]), value)
|
||||||
for _, o := range opts {
|
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 {
|
if o.Name == strings.ToLower(args[0]) && !multiple {
|
||||||
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -631,7 +641,7 @@ func optionCmd() *cobra.Command {
|
|||||||
set.Flags().StringVar(&comment, "comment", "", "comment")
|
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)")
|
set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)")
|
||||||
unset := &cobra.Command{
|
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)",
|
Short: "Remove an option (BIND's default applies again)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
@@ -645,8 +655,8 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
return report(resp)
|
return report(resp)
|
||||||
}
|
}
|
||||||
if len(args) != 1 {
|
if len(args) < 1 || len(args) > 2 {
|
||||||
return fmt.Errorf("give the option name or --id")
|
return fmt.Errorf("give the option name (and the name after it, for tls NAME and the like) or --id")
|
||||||
}
|
}
|
||||||
sc, id, err := scope()
|
sc, id, err := scope()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -658,15 +668,24 @@ func optionCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
var matches []api.Option
|
var matches []api.Option
|
||||||
for _, o := range opts {
|
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)
|
matches = append(matches, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch len(matches) {
|
switch len(matches) {
|
||||||
case 0:
|
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:
|
case 1:
|
||||||
default:
|
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))
|
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)
|
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)
|
cmd.AddCommand(show, set)
|
||||||
return cmd
|
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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -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 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 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 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 unset forwarders
|
||||||
jarl option describe # every option allowed at this scope
|
jarl option describe # every option allowed at this scope
|
||||||
jarl option describe allow-query-cache # grammar and ARM description
|
jarl option describe allow-query-cache # grammar and ARM description
|
||||||
jarl option generated # what Jarl adds to named.conf by itself, and why
|
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
|
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
|
||||||
be given without the braces: elements are separated by `;`, `,` or spaces,
|
be given without the braces: elements are separated by `;`, `,` or spaces,
|
||||||
and `key NAME` and `port N` stay with their element. Options are always
|
and `key NAME` and `port N` stay with their element. Options are always
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ host name's address records in your private views.
|
|||||||
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
|
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
|
||||||
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
|
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
|
## Setting it up
|
||||||
|
|
||||||
```sh
|
```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 |
|
| 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 |
|
| 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
|
## What Jarl checks
|
||||||
|
|
||||||
* A `tls` statement whose files don't exist is an error
|
* A `tls` statement whose files don't exist is an error
|
||||||
|
|||||||
@@ -497,6 +497,27 @@ run "acknowledged: info, with the reason" 0 $J exposure
|
|||||||
contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose"
|
contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose"
|
||||||
run "take it back" 0 $J exposure unack external security_open_resolver
|
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
|
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
|
run "fix: hide the version" 0 $J exposure fix server exposure_version
|
||||||
expect_dig "version.bind is hidden" "" -c CH version.bind TXT
|
expect_dig "version.bind is hidden" "" -c CH version.bind TXT
|
||||||
run "mark external private" 0 $J view update external --exposure private
|
run "mark external private" 0 $J view update external --exposure private
|
||||||
|
|||||||
Reference in new issue
Block a user