Encrypted DNS: several host names, views per listen address, what named uses a certificate for

The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 17:49:49 +01:00
1 parent bfab83da6e
commit fef916b7ab
19 files changed
+587 -122

No files matched your search

+9
View File
@@ -48,6 +48,15 @@ All notable changes to Jarl are documented here. The format follows
manageable: the public addresses fold away, show 25 at a time, and manageable: the public addresses fold away, show 25 at a time, and
**Dismiss these N** dismisses everything a search found in one change **Dismiss these N** dismisses everything a search found in one change
(`--dismiss` takes several networks). (`--dismiss` takes several networks).
- Encrypted DNS: **several host names** (one certificate for all; the
health check says when the served certificate misses one), and **each
listen address gets its own views** for the host names' records
(`--address 10.0.10.5=internal`, a view checkbox per address in the
form), local to the view, with a warning when a public view would get
a private address. A view no longer ticked loses the address. The form
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).
- 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.
+4
View File
@@ -45,6 +45,7 @@ type certInfo struct {
NextAttempt *time.Time `json:"next_attempt,omitempty"` NextAttempt *time.Time `json:"next_attempt,omitempty"`
IssuingSince *time.Time `json:"issuing_since,omitempty"` IssuingSince *time.Time `json:"issuing_since,omitempty"`
Serving bool `json:"serving,omitempty"` Serving bool `json:"serving,omitempty"`
NamedUse []string `json:"named_use,omitempty"`
Files *struct { Files *struct {
Chain string `json:"chain"` Chain string `json:"chain"`
Key string `json:"key"` Key string `json:"key"`
@@ -173,6 +174,9 @@ func printCert(w io.Writer, ci certInfo) {
} }
} }
fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", "))) fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", ")))
for _, u := range ci.NamedUse {
fmt.Fprintf(w, "named:\t%s\n", u)
}
if ci.Comment != "" { if ci.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment) fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
} }
+88 -32
View File
@@ -17,15 +17,17 @@ type encEndpoint struct {
} }
type encStatus struct { type encStatus struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Certificate string `json:"certificate"` Certificate string `json:"certificate"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
DoTHost string `json:"dot_host"` Hostnames []string `json:"hostnames"`
DoHURL string `json:"doh_url"` AddrViews map[string][]string `json:"address_views"`
DoT []encEndpoint `json:"dot"` DoTHost string `json:"dot_host"`
DoH []encEndpoint `json:"doh"` DoHURL string `json:"doh_url"`
Other bool `json:"other"` DoT []encEndpoint `json:"dot"`
Health *encHealth `json:"health"` DoH []encEndpoint `json:"doh"`
Other bool `json:"other"`
Health *encHealth `json:"health"`
Addresses []struct { Addresses []struct {
Address string `json:"address"` Address string `json:"address"`
Private bool `json:"private"` Private bool `json:"private"`
@@ -87,7 +89,14 @@ views.`,
if !s.Enabled { if !s.Enabled {
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)") fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
} else { } else {
fmt.Fprintf(w, "Host names:\t%s\n", strings.Join(s.Hostnames, ", "))
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate) fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
for _, e := range append(append([]encEndpoint{}, s.DoT...), s.DoH...) {
if vs, ok := s.AddrViews[e.Address]; ok {
fmt.Fprintf(w, "Records:\t%s in %s\n", e.Address, strings.Join(vs, ", "))
delete(s.AddrViews, e.Address)
}
}
if len(s.DoT) > 0 { if len(s.DoT) > 0 {
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT)) fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
} }
@@ -119,12 +128,13 @@ views.`,
}) })
}, },
} }
var host, certName string var hostList []string
var certName string
var addrs, views []string var addrs, views []string
var noDoT, noDoH bool var noDoT, noDoH bool
var dohPort, dotPort int var dohPort, dotPort int
setup := &cobra.Command{ setup := &cobra.Command{
Use: "setup --hostname NAME --address ADDR...", Use: "setup --hostname NAME... --address ADDR[=VIEW,...]...",
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)", Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses
given, which should be this server's addresses on your own networks: given, which should be this server's addresses on your own networks:
@@ -135,25 +145,57 @@ given, which should be this server's addresses on your own networks:
- named's tls statement and listen-on statements (and an explicit - named's tls statement and listen-on statements (and an explicit
listen-on { any; } when there was none, as the first listen-on would listen-on { any; } when there was none, as the first listen-on would
otherwise stop named listening on port 53 everywhere); otherwise stop named listening on port 53 everywhere);
- the host name's A and AAAA records, in the private views that have its - the host names' A and AAAA records, local to each view: per address,
zone (or --view), local to the view when the zone is linked. the views after = (--address 10.0.10.5=internal,lab), or the views of
--view for every address (default: the private views that have the
zone). A view no longer given loses the address.
It is one change, staged or applied like any other. Running it again Several --hostname: the certificate must be for all of them. It is one
replaces the earlier setup.`, change, staged or applied like any other. Running it again replaces the
earlier setup, ports included.`,
Example: ` jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address fdab:abab:abab:10::5=internal
jarl encrypted-dns setup --hostname dns.adbraun.lol --hostname doh.adbraun.lol --address 10.0.10.5 --view internal --dot-port 8853`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
if err != nil { if err != nil {
return err return err
} }
host = strings.ToLower(strings.TrimSuffix(host, ".")) var hosts []string
if host == "" { for _, h := range hostList {
for _, x := range strings.Split(h, ",") {
if x = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(x), ".")); x != "" {
hosts = append(hosts, x)
}
}
}
if len(hosts) == 0 {
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)") return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
} }
host := hosts[0]
var s encStatus var s encStatus
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil { if err := c.Get(ctx(), "/encrypted-dns", q("hostname", strings.Join(hosts, ",")), &s); err != nil {
return err return err
} }
// ADDR=VIEW,VIEW gives an address its own views.
addrViews := map[string][]string{}
perAddress := false
for i, a := range addrs {
addr, vs, ok := strings.Cut(a, "=")
addrs[i] = strings.TrimSpace(addr)
if ok {
perAddress = true
addrViews[addrs[i]] = nil
for _, v := range strings.Split(vs, ",") {
if v = strings.TrimSpace(v); v != "" {
addrViews[addrs[i]] = append(addrViews[addrs[i]], v)
}
}
}
}
if perAddress && len(views) > 0 {
return errors.New("give the views per address (--address ADDR=VIEW,...) or for all of them (--view), not both")
}
if len(addrs) == 0 { if len(addrs) == 0 {
var list []string var list []string
for _, a := range s.Addresses { for _, a := range s.Addresses {
@@ -163,20 +205,24 @@ replaces the earlier setup.`,
} }
// The certificate. // The certificate.
if certName == "" { if certName == "" {
certs:
for _, ci := range s.Certificates { for _, ci := range s.Certificates {
if coversName(append(ci.Names, ci.Domains...), host) { for _, h := range hosts {
certName = ci.Name if !coversName(append(ci.Names, ci.Domains...), h) {
break continue certs
}
} }
certName = ci.Name
break
} }
} }
if certName == "" { if certName == "" {
certName = strings.SplitN(host, ".", 2)[0] certName = strings.SplitN(host, ".", 2)[0]
fmt.Printf("Creating certificate %s for %s\n", certName, host) fmt.Printf("Creating certificate %s for %s\n", certName, strings.Join(hosts, ", "))
var r struct { var r struct {
Certificate certInfo `json:"certificate"` Certificate certInfo `json:"certificate"`
} }
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "uses": []string{"dns"}}, &r); err != nil { if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": hosts, "uses": []string{"dns"}}, &r); err != nil {
return err return err
} }
if err := waitIssue(c, certName); err != nil { if err := waitIssue(c, certName); err != nil {
@@ -204,7 +250,7 @@ replaces the earlier setup.`,
} }
} }
} }
body := map[string]any{"hostname": host, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH} body := map[string]any{"hostnames": hosts, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
if cmd.Flags().Changed("doh-port") { if cmd.Flags().Changed("doh-port") {
body["doh_port"] = dohPort body["doh_port"] = dohPort
} }
@@ -234,14 +280,24 @@ replaces the earlier setup.`,
if chk.Error != "" && !flagQuiet { if chk.Error != "" && !flagQuiet {
fmt.Println("Note:", chk.Error) fmt.Println("Note:", chk.Error)
} }
if len(views) == 0 { switch {
for _, v := range s.Views { case perAddress:
if !v.Public && v.HasZone { for _, a := range addrs {
views = append(views, v.Name) if _, ok := addrViews[a]; !ok {
addrViews[a] = []string{} // no records for it
} }
} }
body["address_views"] = addrViews
default:
if len(views) == 0 {
for _, v := range s.Views {
if !v.Public && v.HasZone {
views = append(views, v.Name)
}
}
}
body["views"] = views
} }
body["views"] = views
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body) resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
if err != nil { if err != nil {
return err return err
@@ -250,14 +306,14 @@ replaces the earlier setup.`,
}, },
} }
f := setup.Flags() f := setup.Flags()
f.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)") f.StringArrayVar(&hostList, "hostname", nil, "a name clients connect to (repeat, or comma separated, for several; the certificate must be for all)")
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)") f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on (repeat for several)") f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on, with the views that get it: 10.0.10.5=internal,lab (repeat for several)")
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS") f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS") f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS") f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)") f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
f.StringArrayVar(&views, "view", nil, "views to add the host name's records to (default: the private views that have its zone)") f.StringArrayVar(&views, "view", nil, "views where every address gets the host names' records (default: the private views that have the zone); per address: --address ADDR=VIEW")
var cAddrs []string var cAddrs []string
var cDoH int var cDoH int
+4
View File
@@ -192,6 +192,10 @@ jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
``` ```
The Certificates page (and `jarl cert show`) says what named uses each
for: `DNS over TLS on 10.0.10.5, fd00::5 port 853`, `DNS over HTTPS on
… port 443`, or a hand-written `tls` statement no listener uses.
A certificate named uses can't be deleted or lose its `dns` use until A certificate named uses can't be deleted or lose its `dns` use until
named no longer names its files. If you change `certificates.dir`, jarld named no longer names its files. If you change `certificates.dir`, jarld
writes the certificates to the new place when it starts and points the writes the certificates to the new place when it starts and points the
+26 -13
View File
@@ -18,6 +18,7 @@ encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
```sh ```sh
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5 jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address 203.0.113.5=external
``` ```
This: This:
@@ -47,25 +48,37 @@ This:
`listen-on` statement at all. The first one, for DoT, would end that, so `listen-on` statement at all. The first one, for DoT, would end that, so
when you have none the setup adds `listen-on { any; };` (and the same when you have none the setup adds `listen-on { any; };` (and the same
for IPv6) to keep plain DNS where it was. for IPv6) to keep plain DNS where it was.
4. **adds the host name's A and AAAA records** with the chosen addresses, 4. **adds the host names' A and AAAA records**, each address in the views
in the private views that have a zone for it (`--view` to choose). In a you give it: `--address 10.0.10.5=internal,lab`, or every address in
linked zone they are local to the view, so the name doesn't reach your the views of `--view` (default: the private views that have a zone for
external view. the name). A view's clients should only be given an address they can
reach, so a private address in a public view gets a warning. The
records are local to their view (in a linked zone the other views keep
theirs), and a view you no longer give an address loses it; other
records of the names stay.
Running it again replaces the earlier setup (other addresses, ports, DoT **Several host names** (`--hostname dns.adbraun.lol --hostname
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable` doh.adbraun.lol`, or comma separated): each gets the records, the
takes the listeners and the `tls` statement out; the certificate and the certificate must be for all of them (a new one is obtained with all of
records stay. them), and the health check says when the certificate named serves misses
one. Clients may use any of them; the client hints use the first.
Running it again replaces the earlier setup: other addresses, views, host
names, **ports** (clients set up with another port need theirs changed
too), DoT or DoH only with `--no-doh` / `--no-dot`. The web UI's form
starts from the current setup, with each address's views as its records
are now. `jarl encrypted-dns disable` takes the listeners and the `tls`
statement out; the certificate and the records stay.
| Option | Default | | | Option | Default | |
|--|--|--| |--|--|--|
| `--hostname` | | The name clients connect to; the certificate is for it | | `--hostname` | | A name clients connect to (repeat for several); the certificate is for all of them |
| `--address` | | This server's addresses to listen on (repeat). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there | | `--address` | | This server's addresses to listen on (repeat), with the views that get its records after `=` (`10.0.10.5=internal`). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages | | `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
| `--no-dot`, `--no-doh` | both on | | | `--no-dot`, `--no-doh` | both on | |
| `--dot-port` | 853 | Clients expect 853 | | `--dot-port` | 853 | Clients expect 853 |
| `--doh-port` | 443 | Any free port; clients take it in the URL | | `--doh-port` | 443 | Any free port; clients take it in the URL |
| `--view` | private views with the zone | Where the host name's records go | | `--view` | private views with the zone | Where every address's records go, when `--address` gives no views |
In the web UI the same form checks the ports as you choose, offers the In the web UI the same form checks the ports as you choose, offers the
free port, and obtains the certificate before setting up. free port, and obtains the certificate before setting up.
@@ -150,8 +163,8 @@ What it can say:
| Method | Path | Body | | Method | Path | Body |
|--------|------|------| |--------|------|------|
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostname`, `dot` and `doh` (address and port), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone` for `?hostname=`), `certificates` | | GET | `/encrypted-dns` | `enabled`, `certificate`, `hostnames` (and `hostname`, the first), `dot` and `doh` (address and port), `address_views` (per address, the views whose records give a host name it), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone`), `certificates`. `?hostname=a,b` asks about other names |
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` | | POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
| POST | `/encrypted-dns` | `{"hostname", "certificate", "addresses", "dot", "doh", "dot_port", "doh_port", "views"}`: staged or applied like any change; 409 while the certificate hasn't been obtained | | POST | `/encrypted-dns` | `{"hostnames", "certificate", "addresses", "address_views": {"10.0.10.5": ["internal"]}, "dot", "doh", "dot_port", "doh_port"}` (`"hostname"` and `"views"`, every address in each, work too): staged or applied like any change; 409 while the certificate hasn't been obtained |
| DELETE | `/encrypted-dns` | Turns it off | | DELETE | `/encrypted-dns` | Turns it off |
| POST | `/encrypted-dns/health` | Checks the listeners now: `checked`, `probes` (kind, address, port, `ok`, `problem`, `warning`, certificate names, expiry and serial, `trusted`, `rcode`, `ms`). `GET /encrypted-dns` has the last check as `health` | | POST | `/encrypted-dns/health` | Checks the listeners now: `checked`, `probes` (kind, address, port, `ok`, `problem`, `warning`, certificate names, expiry and serial, `trusted`, `rcode`, `ms`). `GET /encrypted-dns` has the last check as `health` |
+183 -25
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -208,8 +209,10 @@ func GetStatus(st *model.State, certDir string) Status {
// Setup is what to set up. // Setup is what to set up.
type Setup struct { type Setup struct {
// Hostname is the name clients connect to (the certificate's). // Hostnames are the names clients connect to; the certificate must be
Hostname string `json:"hostname"` // for all of them. Hostname (one) works too.
Hostnames []string `json:"hostnames,omitempty"`
Hostname string `json:"hostname,omitempty"`
// Certificate is the Jarl certificate (with the dns use) to serve. // Certificate is the Jarl certificate (with the dns use) to serve.
Certificate string `json:"certificate"` Certificate string `json:"certificate"`
// Addresses are the addresses of this server to listen on. // Addresses are the addresses of this server to listen on.
@@ -218,9 +221,27 @@ type Setup struct {
DoH bool `json:"doh"` DoH bool `json:"doh"`
DoHPort int `json:"doh_port"` // default 443 DoHPort int `json:"doh_port"` // default 443
DoTPort int `json:"dot_port"` // default 853 (clients expect it) DoTPort int `json:"dot_port"` // default 853 (clients expect it)
// Views get the hostname's A/AAAA records (default: none). In a // AddressViews says, per listen address, the views where the host
// linked zone they are local to the view. // names get it (A or AAAA): a private address shouldn't reach a public
Views []string `json:"views"` // view. Views (every address in each) works too. The records are local
// to their view in a linked zone.
AddressViews map[string][]string `json:"address_views,omitempty"`
Views []string `json:"views,omitempty"`
// PublicViews are the views the internet reaches (filled in by jarld,
// for a warning).
PublicViews map[string]bool `json:"-"`
}
// Hosts returns the setup's host names, normalized, the first first.
func (s Setup) Hosts() []string {
var out []string
for _, h := range append(append([]string{}, s.Hostnames...), s.Hostname) {
h = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(h), "."))
if h != "" && !slices.Contains(out, h) {
out = append(out, h)
}
}
return out
} }
// Plan returns the requests that set up encrypted DNS as s says, // Plan returns the requests that set up encrypted DNS as s says,
@@ -248,10 +269,15 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
if s.Certificate == "" { if s.Certificate == "" {
return nil, nil, fmt.Errorf("choose the certificate to serve") return nil, nil, fmt.Errorf("choose the certificate to serve")
} }
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), ".")) hosts := s.Hosts()
if host == "" || !strings.Contains(host, ".") { if len(hosts) == 0 {
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com") return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
} }
for _, host := range hosts {
if !strings.Contains(host, ".") || strings.HasPrefix(host, "*") {
return nil, nil, fmt.Errorf("%q isn't a host name clients can connect to, e.g. dns.example.com", host)
}
}
var v4, v6 []string var v4, v6 []string
for _, a := range s.Addresses { for _, a := range s.Addresses {
ip, err := netip.ParseAddr(strings.TrimSpace(a)) ip, err := netip.ParseAddr(strings.TrimSpace(a))
@@ -325,32 +351,98 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
add("listen-on", v4) add("listen-on", v4)
add("listen-on-v6", v6) add("listen-on-v6", v6)
// The host name's addresses, in the views asked for. // The host names' addresses, in the views asked for, per address.
fqdn := host + "." want := map[string][]string{} // view -> addresses
for _, vn := range s.Views { all := append(append([]string{}, v4...), v6...)
v := st.ViewByName(vn) if s.AddressViews != nil {
if v == nil { for a, vs := range s.AddressViews {
ip, err := netip.ParseAddr(strings.TrimSpace(a))
if err != nil || !slices.Contains(all, ip.Unmap().String()) {
return nil, nil, fmt.Errorf("address_views: %s isn't one of the addresses to listen on", a)
}
for _, vn := range vs {
want[vn] = append(want[vn], ip.Unmap().String())
}
}
} else {
for _, vn := range s.Views {
want[vn] = all
}
}
for vn, addrs := range want {
if st.ViewByName(vn) == nil {
return nil, nil, fmt.Errorf("view %q does not exist", vn) return nil, nil, fmt.Errorf("view %q does not exist", vn)
} }
z := zoneFor(st, fqdn, v.ID) if s.PublicViews[vn] {
if z == nil { for _, a := range addrs {
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host)) if ip, err := netip.ParseAddr(a); err == nil && Private(ip) {
continue notes = append(notes, fmt.Sprintf("view %s is reachable from the internet, but gets the private address %s for %s: its clients can't reach that", vn, a, strings.Join(hosts, ", ")))
}
}
} }
var local *bool }
if z.LinkGroup != 0 { // Ours are the addresses this setup (or the one before) gives the host
local = model.Ptr(true) // names: a view that no longer gets one loses it; other records of the
// names stay.
ours := map[string]bool{}
for _, a := range all {
ours[a] = true
}
for _, l := range current {
if l.Managed() {
for _, a := range l.Addrs {
ours[a] = true
}
} }
for _, set := range []struct { }
typ string names := slices.Clone(hosts)
addrs []string for _, h := range st.Settings.EncryptedDNSHosts {
}{{"A", v4}, {"AAAA", v6}} { if !slices.Contains(names, h) {
if len(set.addrs) == 0 { names = append(names, h)
}
}
for _, v := range st.OrderedViews() {
for _, host := range names {
fqdn := host + "."
z := zoneFor(st, fqdn, v.ID)
wanted := want[v.Name]
if !slices.Contains(hosts, host) {
wanted = nil // a host name no longer used
}
if z == nil {
if len(wanted) > 0 {
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
}
continue continue
} }
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local})) for _, typ := range []string{"A", "AAAA"} {
var have, next []string
for _, r := range st.RRset(z.ID, fqdn, typ) {
have = append(have, r.Data)
if !ours[r.Data] {
next = append(next, r.Data)
}
}
for _, a := range wanted {
if strings.Contains(a, ":") == (typ == "AAAA") && !slices.Contains(next, a) {
next = append(next, a)
}
}
slices.Sort(have)
slices.Sort(next)
if slices.Equal(have, next) {
continue
}
if next == nil {
next = []string{}
}
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: typ, Data: next, Local: model.Ptr(true)}))
}
} }
} }
if !slices.Equal(st.Settings.EncryptedDNSHosts, hosts) {
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &hosts}))
}
return reqs, notes, nil return reqs, notes, nil
} }
@@ -369,6 +461,69 @@ func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
return best return best
} }
// CurrentAddressViews returns, per address, the views whose records give
// one of the host names that address (what the setup form starts from).
func CurrentAddressViews(st *model.State, hosts []string) map[string][]string {
out := map[string][]string{}
for _, v := range st.OrderedViews() {
for _, host := range hosts {
fqdn := strings.TrimSuffix(host, ".") + "."
z := zoneFor(st, fqdn, v.ID)
if z == nil {
continue
}
for _, typ := range []string{"A", "AAAA"} {
for _, r := range st.RRset(z.ID, fqdn, typ) {
if !r.Disabled && !slices.Contains(out[r.Data], v.Name) {
out[r.Data] = append(out[r.Data], v.Name)
}
}
}
}
}
return out
}
// CertUses says what of named's configuration uses the certificate whose
// files are in dir: "DNS over TLS on 10.0.10.5, fd00::5 port 853", or the
// tls statement alone when no listener uses it.
func CertUses(st *model.State, dir string) []string {
var out []string
for _, b := range TLSBlocks(st) {
if filepath.Dir(b.CertFile) != filepath.Clean(dir) {
continue
}
used := false
byKind := map[string]map[int][]string{}
for _, l := range Listeners(st) {
if l.TLS != b.Name {
continue
}
used = true
k := l.Kind()
if byKind[k] == nil {
byKind[k] = map[int][]string{}
}
byKind[k][l.EffectivePort()] = append(byKind[k][l.EffectivePort()], l.Addrs...)
}
for _, k := range []string{"dot", "doh"} {
label := map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[k]
ports := make([]int, 0, len(byKind[k]))
for p := range byKind[k] {
ports = append(ports, p)
}
slices.Sort(ports)
for _, p := range ports {
out = append(out, fmt.Sprintf("%s on %s port %d", label, strings.Join(byKind[k][p], ", "), p))
}
}
if !used {
out = append(out, "tls statement "+b.Name+" (no listener uses it)")
}
}
return out
}
// Disable returns the requests that take the setup out: its listeners and // Disable returns the requests that take the setup out: its listeners and
// tls statement. Plain listeners and the host name's records stay. // tls statement. Plain listeners and the host name's records stay.
func Disable(st *model.State) []engine.Request { func Disable(st *model.State) []engine.Request {
@@ -383,6 +538,9 @@ func Disable(st *model.State) []engine.Request {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil)) reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
} }
} }
if len(reqs) > 0 && len(st.Settings.EncryptedDNSHosts) > 0 {
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &[]string{}}))
}
return reqs return reqs
} }
+67
View File
@@ -186,3 +186,70 @@ func TestPorts(t *testing.T) {
t.Errorf("free DoH port = %d, want 4443", p) t.Errorf("free DoH port = %d, want 4443", p)
} }
} }
// Each address gets its own views; a view no longer given loses it, and
// records of the host names that aren't the setup's stay.
func TestSetupAddressViews(t *testing.T) {
e := newEnv(t)
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
e.submit(engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{Zone: "example.com", View: "external", Name: "doh.example.com.", Type: "A", Data: []string{"198.51.100.9"}, Local: model.Ptr(true)}))
hosts := func(st *model.State, view string) []string {
z := st.FindZone("example.com.", st.ViewByName(view).ID)
var out []string
for _, r := range st.ZoneRecords(z.ID) {
if strings.HasPrefix(r.Name, "dns.") || strings.HasPrefix(r.Name, "doh.") {
out = append(out, strings.SplitN(r.Name, ".", 2)[0]+" "+r.Type+" "+r.Data)
}
}
return out
}
setup := Setup{Hostnames: []string{"dns.example.com", "doh.example.com"}, Certificate: "dns", DoT: true, DoH: true,
Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}, "10.0.10.6": {"external"}},
PublicViews: map[string]bool{"external": true}}
reqs, notes, err := Plan(e.state(), setup, e.certDir)
if err != nil {
t.Fatal(err)
}
if !slices.ContainsFunc(notes, func(n string) bool {
return strings.Contains(n, "view external is reachable from the internet, but gets the private address 10.0.10.6")
}) {
t.Errorf("notes %q", notes)
}
e.submit(reqs...)
st := e.state()
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5", "doh A 10.0.10.5"}; !slices.Equal(got, want) {
t.Errorf("internal %q, want %q", got, want)
}
// The external doh record that isn't the setup's stays.
if got, want := hosts(st, "external"), []string{"dns A 10.0.10.6", "dns A 203.0.113.5", "doh A 10.0.10.6", "doh A 198.51.100.9", "doh A 203.0.113.5"}; !slices.Equal(got, want) {
t.Errorf("external %q, want %q", got, want)
}
if !slices.Equal(st.Settings.EncryptedDNSHosts, []string{"dns.example.com", "doh.example.com"}) {
t.Errorf("hosts %q", st.Settings.EncryptedDNSHosts)
}
av := CurrentAddressViews(st, st.Settings.EncryptedDNSHosts)
if !slices.Equal(av["10.0.10.5"], []string{"internal"}) || !slices.Equal(av["10.0.10.6"], []string{"external"}) {
t.Errorf("address views %v", av)
}
uses := CertUses(st, filepath.Join(e.certDir, "dns"))
if len(uses) != 2 || !strings.HasPrefix(uses[0], "DNS over TLS on 10.0.10.5, 203.0.113.5, 10.0.10.6 port 853") || !strings.HasPrefix(uses[1], "DNS over HTTPS on") {
t.Errorf("cert uses %q", uses)
}
// 10.0.10.6 no longer in external, doh.example.com no longer a host name.
setup = Setup{Hostname: "dns.example.com", Certificate: "dns", DoT: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}}}
reqs, _, err = Plan(st, setup, e.certDir)
if err != nil {
t.Fatal(err)
}
e.submit(reqs...)
st = e.state()
if got, want := hosts(st, "external"), []string{"dns A 203.0.113.5", "doh A 198.51.100.9"}; !slices.Equal(got, want) {
t.Errorf("external after %q, want %q", got, want)
}
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5"}; !slices.Equal(got, want) {
t.Errorf("internal after %q, want %q", got, want)
}
}
+37
View File
@@ -206,3 +206,40 @@ func describe(err error, e Endpoint) string {
} }
return err.Error() return err.Error()
} }
// RequireNames marks the probes whose certificate doesn't cover every host
// name clients may use (the first is what the probe asked for).
func (h *Health) RequireNames(hosts []string) {
for i := range h.Probes {
p := &h.Probes[i]
if p.Problem != "" || len(p.CertNames) == 0 {
continue
}
var missing []string
for _, host := range hosts {
if !nameCovered(p.CertNames, host) {
missing = append(missing, host)
}
}
if len(missing) > 0 {
p.OK = false
p.Problem = fmt.Sprintf("named serves a certificate for %s, which doesn't cover %s: clients using that name reject it", strings.Join(p.CertNames, ", "), strings.Join(missing, ", "))
}
}
}
func nameCovered(names []string, host string) bool {
host = strings.ToLower(strings.TrimSuffix(host, "."))
for _, n := range names {
n = strings.ToLower(strings.TrimSuffix(n, "."))
if n == host {
return true
}
if rest, ok := strings.CutPrefix(n, "*."); ok {
if _, parent, ok := strings.Cut(host, "."); ok && parent == rest {
return true
}
}
}
return false
}
+18
View File
@@ -113,3 +113,21 @@ func TestHealth(t *testing.T) {
t.Errorf("nothing listening: %+v", h.Probes[0]) t.Errorf("nothing listening: %+v", h.Probes[0])
} }
} }
func TestRequireNames(t *testing.T) {
h := &Health{Probes: []Probe{
{Kind: "dot", OK: true, CertNames: []string{"dns.example.com", "*.example.net"}},
{Kind: "doh", OK: false, Problem: "nothing listens", CertNames: nil},
}}
h.RequireNames([]string{"dns.example.com", "doh.example.net"})
if !h.Probes[0].OK {
t.Errorf("covered names: %+v", h.Probes[0])
}
h.RequireNames([]string{"dns.example.com", "doh.example.com"})
if p := h.Probes[0]; p.OK || !strings.Contains(p.Problem, "doesn't cover doh.example.com") {
t.Errorf("missing name: %+v", p)
}
if h.Probes[1].Problem != "nothing listens" {
t.Errorf("other problem replaced: %+v", h.Probes[1])
}
}
+2 -1
View File
@@ -25,7 +25,8 @@ func DiffOps(from, to *model.State) []model.Op {
s := to.Settings s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName, add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse))}, "restore settings") Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse)),
EncryptedDNSHosts: model.Ptr(slices.Clone(s.EncryptedDNSHosts))}, "restore settings")
} }
for _, k := range sortedKeys(to.Keys) { for _, k := range sortedKeys(to.Keys) {
+2
View File
@@ -314,6 +314,8 @@ type SettingsInput struct {
// DismissedReverse are dismissed reverse zone suggestions (without // DismissedReverse are dismissed reverse zone suggestions (without
// views). // views).
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"` DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
// EncryptedDNSHosts are the encrypted DNS setup's host names.
EncryptedDNSHosts *[]string `json:"encrypted_dns_hosts,omitempty"`
} }
// DeleteInput carries options for delete operations. // DeleteInput carries options for delete operations.
+3
View File
@@ -1414,6 +1414,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.DismissedReverse != nil { if in.DismissedReverse != nil {
s.DismissedReverse = slices.Clone(*in.DismissedReverse) s.DismissedReverse = slices.Clone(*in.DismissedReverse)
} }
if in.EncryptedDNSHosts != nil {
s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts)
}
s.Version++ s.Version++
return nil return nil
} }
+4 -1
View File
@@ -275,7 +275,10 @@ type Settings struct {
// DismissedReverse are dismissed reverse zone suggestions (without // DismissedReverse are dismissed reverse zone suggestions (without
// views; see View). // views; see View).
DismissedReverse []string `json:"dismissed_reverse,omitempty"` DismissedReverse []string `json:"dismissed_reverse,omitempty"`
Version int `json:"version"` // EncryptedDNSHosts are the host names clients use for DNS over TLS
// and HTTPS (docs/encrypted-dns.md); the first is checked first.
EncryptedDNSHosts []string `json:"encrypted_dns_hosts,omitempty"`
Version int `json:"version"`
} }
// BlockingPaused reports whether blocking is paused, for a time or until // BlockingPaused reports whether blocking is paused, for a time or until
+18 -2
View File
@@ -9,6 +9,7 @@ import (
"jarl/internal/api" "jarl/internal/api"
"jarl/internal/auth" "jarl/internal/auth"
"jarl/internal/certs" "jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/model" "jarl/internal/model"
) )
@@ -21,6 +22,9 @@ type certView struct {
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use) Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
// NamedUse says what of named's configuration uses it: "DNS over TLS
// on 10.0.10.5 port 853" (list and get only).
NamedUse []string `json:"named_use,omitempty"`
// IssuingSince is set while Jarl is obtaining it. // IssuingSince is set while Jarl is obtaining it.
IssuingSince *time.Time `json:"issuing_since,omitempty"` IssuingSince *time.Time `json:"issuing_since,omitempty"`
} }
@@ -79,9 +83,15 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
if err != nil { if err != nil {
return err return err
} }
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
out := []certView{} out := []certView{}
for _, c := range all { for _, c := range all {
out = append(out, s.presentCert(c)) v := s.presentCert(c)
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
out = append(out, v)
} }
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())}) writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
return nil return nil
@@ -96,7 +106,13 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
if err != nil { if err != nil {
return certErr(err) return certErr(err)
} }
writeJSON(w, http.StatusOK, s.presentCert(c)) st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
v := s.presentCert(c)
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
writeJSON(w, http.StatusOK, v)
return nil return nil
} }
+41 -10
View File
@@ -39,8 +39,13 @@ type encCert struct {
type encStatus struct { type encStatus struct {
encdns.Status encdns.Status
// Hostname is the name clients connect to (the certificate's first). // Hostnames are the names clients connect to (the setup's, else the
Hostname string `json:"hostname,omitempty"` // certificate's first); Hostname is the first.
Hostnames []string `json:"hostnames"`
Hostname string `json:"hostname,omitempty"`
// AddressViews are, per address, the views whose records give a host
// name that address.
AddressViews map[string][]string `json:"address_views"`
// DoTHost and DoHURL are what to give clients. // DoTHost and DoHURL are what to give clients.
DoTHost string `json:"dot_host,omitempty"` DoTHost string `json:"dot_host,omitempty"`
DoHURL string `json:"doh_url,omitempty"` DoHURL string `json:"doh_url,omitempty"`
@@ -92,6 +97,15 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
} }
} }
} }
if hs := st.Settings.EncryptedDNSHosts; len(hs) > 0 {
out.Hostname = hs[0]
out.Hostnames = hs
} else if out.Hostname != "" {
out.Hostnames = []string{out.Hostname}
}
if out.Hostnames == nil {
out.Hostnames = []string{}
}
if out.Hostname != "" { if out.Hostname != "" {
if len(out.DoT) > 0 { if len(out.DoT) > 0 {
out.DoTHost = out.Hostname out.DoTHost = out.Hostname
@@ -120,17 +134,25 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)}) out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
} }
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) }) slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".") hosts := out.Hostnames
if host == "" { if q := r.URL.Query().Get("hostname"); q != "" {
host = out.Hostname hosts = nil
for _, h := range strings.Split(q, ",") {
if h = strings.TrimSuffix(strings.TrimSpace(h), "."); h != "" {
hosts = append(hosts, h)
}
}
} }
// Where the records of these host names give each address now, the
// setup's or not: what the form starts from.
out.AddressViews = encdns.CurrentAddressViews(st, hosts)
for _, x := range s.eng.Exposures(st) { for _, x := range s.eng.Exposures(st) {
if x.View == "" { if x.View == "" {
continue continue
} }
v := encView{Name: x.View, Public: x.Public} v := encView{Name: x.View, Public: x.Public}
if host != "" { if vv := st.ViewByName(x.View); vv != nil {
if vv := st.ViewByName(x.View); vv != nil { for _, host := range hosts {
for _, z := range st.ZonesInView(vv.ID) { for _, z := range st.ZonesInView(vv.ID) {
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) { if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
v.HasZone = true v.HasZone = true
@@ -204,13 +226,17 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name) return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
case c.NotAfter == nil: case c.NotAfter == nil:
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message) return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}): case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()):
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname) return badRequest("certificate %s is for %s, not all of %s: clients would reject it (add the names to it: jarl cert update %s --domain …)", c.Name, strings.Join(c.Names, ", "), strings.Join(in.Hosts(), ", "), c.Name)
} }
st, err := s.store.LoadState(r.Context()) st, err := s.store.LoadState(r.Context())
if err != nil { if err != nil {
return err return err
} }
in.PublicViews = map[string]bool{}
for _, x := range s.eng.Exposures(st) {
in.PublicViews[x.View] = x.Public
}
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir("")) reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
if err != nil { if err != nil {
return badRequest("%v", err) return badRequest("%v", err)
@@ -222,7 +248,7 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
if in.DoH { if in.DoH {
what = append(what, "DNS over HTTPS") what = append(what, "DNS over HTTPS")
} }
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes) return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+strings.Join(in.Hosts(), ", "), reqs, nil, notes)
} }
// encryptedDNSDisable: DELETE /encrypted-dns. // encryptedDNSDisable: DELETE /encrypted-dns.
@@ -274,7 +300,12 @@ func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
var h *encdns.Health var h *encdns.Health
if st.Enabled { if st.Enabled {
if host, serial := s.encTarget(ctx, st); host != "" { if host, serial := s.encTarget(ctx, st); host != "" {
hosts := state.Settings.EncryptedDNSHosts
if len(hosts) > 0 {
host = hosts[0]
}
h = encdns.Check(ctx, st, host, serial, s.encRoots) h = encdns.Check(ctx, st, host, serial, s.encRoots)
h.RequireNames(append([]string{host}, hosts...))
} }
} }
s.encHealth.Lock() s.encHealth.Lock()
@@ -33,7 +33,8 @@ export async function render(el) {
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) }, { label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
{ label: "Used for", render: (c) => [ { label: "Used for", render: (c) => [
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ", c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
c.uses.includes("dns") ? badge("named", "") : null, c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] }, !c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) }, { label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") }, { label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
@@ -79,6 +80,7 @@ function details(c) {
modal("Certificate " + c.name, h("div", null, modal("Certificate " + c.name, h("div", null,
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])), h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
c.files ? h("div", null, h("h3", null, "For named"), c.files ? h("div", null, h("h3", null, "For named"),
(c.named_use || []).length ? h("ul", { class: "small" }, c.named_use.map((u) => h("li", null, u))) : h("p", { class: "small muted" }, "named doesn't use it yet (Encrypted DNS sets that up)."),
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."), h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true }); copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
} }
+66 -37
View File
@@ -26,7 +26,10 @@ export async function render(el) {
function statusPanel(s) { function statusPanel(s) {
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", "); const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
const rows = [ const rows = [
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : null), h("dt", null, s.hostnames.length > 1 ? "Host names" : "Host name"), h("dd", { class: "mono" }, s.hostnames.join(", ") || "?"),
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?")),
Object.keys(s.address_views || {}).length ? [h("dt", null, "Records"), h("dd", null, Object.entries(s.address_views).filter(([a]) => [...s.dot, ...s.doh].some((e) => e.address === a))
.map(([a, vs]) => h("div", null, h("span", { class: "mono" }, a), h("span", { class: "muted" }, " in " + vs.join(", ")))))] : null,
s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : null, s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : null,
s.doh.length ? [h("dt", null, "DNS over HTTPS"), h("dd", { class: "mono" }, eps(s.doh))] : null, s.doh.length ? [h("dt", null, "DNS over HTTPS"), h("dd", { class: "mono" }, eps(s.doh))] : null,
]; ];
@@ -58,7 +61,8 @@ function statusPanel(s) {
} }
function setupPanel(s) { function setupPanel(s) {
const host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } }); const hostIn = input({ value: (s.hostnames || []).join(", "), placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "420px" } });
const hosts = () => hostIn.value.split(/[\s,]+/).map((x) => x.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
const certSel = h("select"); const certSel = h("select");
const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true); const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true);
const doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true); const doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true);
@@ -69,40 +73,64 @@ function setupPanel(s) {
const portOut = h("div"); const portOut = h("div");
const err = h("div"); const err = h("div");
const progress = h("div", { class: "muted small" }); const progress = h("div", { class: "muted small" });
let views = s.views;
// Per address, the views where the host names get it: what their records
// say now (the setup's, or ones made by hand, which would otherwise be
// taken out), nothing else.
const viewsFor = new Map(s.addresses.map((a) => [a.address, new Set((s.address_views || {})[a.address] || [])]));
const isPrivate = new Map(s.addresses.map((a) => [a.address, a.private]));
// Addresses: one per row, nothing ticked (but the current setup's). const viewCells = new Map();
const addrRows = s.addresses.map((a) => pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private") const drawViewCell = (a) => {
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global") const cell = viewCells.get(a);
: badge("public", "warn")], if (!cell) return;
"Listen on " + a.address, () => checkPorts())); if (!picked.has(a)) return mount(cell);
// Views for the host name's records: the private ones that have its zone. const set = viewsFor.get(a);
const viewPicked = new Set(); const warn = isPrivate.get(a) && views.some((v) => v.public && set.has(v.name));
const viewBody = h("tbody"); mount(cell, h("div", { class: "view-chips" }, h("span", { class: "muted small" }, "records in: "),
const drawViews = (views) => { views.map((v) => {
viewPicked.clear(); const box = h("input", { type: "checkbox", checked: set.has(v.name), disabled: !v.has_zone && !set.has(v.name) });
mount(viewBody, views.map((v) => { box.addEventListener("change", () => { if (box.checked) set.add(v.name); else set.delete(v.name); drawViewCell(a); });
if (!v.public && v.has_zone) viewPicked.add(v.name); return h("label", { class: "chip" + (v.public ? " public" : ""), title: v.has_zone ? (v.public ? "A public view: the internet's clients get these records" : "") : "View " + v.name + " has no zone for the host names" }, box, " " + v.name, v.public ? h("span", { class: "muted small" }, " (public)") : null);
return pickRow(v.name, viewPicked, [v.public ? badge("public", "warn") : h("span", { class: "muted" }, "private"), " ", }),
v.has_zone ? h("span", { class: "muted small" }, "has the zone") : h("span", { class: "muted small" }, "no zone for this name: skipped")], "Add the host name to view " + v.name); !views.length ? h("span", { class: "muted small" }, "no views") : null),
})); warn ? h("div", { class: "msg warn small" }, a + " is a private address: clients of a public view can't reach it.") : null);
}; };
drawViews(s.views); const addrRows = s.addresses.map((a) => {
const cell = h("td", { class: "addr-views" });
// The row toggles the address; clicks on its views shouldn't.
cell.addEventListener("click", (e) => e.stopPropagation());
viewCells.set(a.address, cell);
const tr = pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
: badge("public", "warn")],
"Listen on " + a.address, () => { drawViewCell(a.address); checkPorts(); });
tr.append(cell);
drawViewCell(a.address);
return tr;
});
const redrawViews = () => { for (const a of viewCells.keys()) drawViewCell(a); };
const fillCerts = () => { const fillCerts = () => {
const name = host.value.trim().toLowerCase().replace(/\.$/, ""); const names = hosts();
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name)); const fit = s.certificates.filter((c) => names.length && names.every((n) => covers([...(c.names || []), ...(c.domains || [])], n)));
mount(certSel, mount(certSel,
fit.map((c) => h("option", { value: c.name, selected: c.name === s.certificate }, c.name + " (" + (c.names || c.domains).join(", ") + (c.obtained ? "" : ", not obtained yet") + ")")), fit.map((c) => h("option", { value: c.name, selected: c.name === s.certificate }, c.name + " (" + (c.names || c.domains).join(", ") + (c.obtained ? "" : ", not obtained yet") + ")")),
name ? h("option", { value: "" }, "Obtain a new certificate for " + name) : h("option", { value: "" }, "Type the host name first")); names.length ? h("option", { value: "" }, "Obtain a new certificate for " + names.join(", ")) : h("option", { value: "" }, "Type the host names first"));
}; };
fillCerts(); fillCerts();
let viewTimer = null; let viewTimer = null;
host.addEventListener("input", () => { hostIn.addEventListener("input", () => {
fillCerts(); fillCerts();
clearTimeout(viewTimer); clearTimeout(viewTimer);
viewTimer = setTimeout(async () => { viewTimer = setTimeout(async () => {
const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null); const r = await get("/encrypted-dns", { hostname: hosts().join(",") }).catch(() => null);
if (r) drawViews(r.views); if (r) {
views = r.views;
// Records the new host names already have stay ticked.
for (const [a, vs] of Object.entries(r.address_views || {})) if (viewsFor.has(a)) vs.forEach((v) => viewsFor.get(a).add(v));
redrawViews();
}
}, 300); }, 300);
}); });
@@ -129,23 +157,26 @@ function setupPanel(s) {
if (picked.size) checkPorts(); if (picked.size) checkPorts();
const body = (full) => { const body = (full) => {
const b = { hostname: host.value.trim(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 }; const b = { hostnames: hosts(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
if (full) { b.certificate = certSel.value; b.views = [...viewPicked]; } if (full) {
b.certificate = certSel.value;
b.address_views = Object.fromEntries([...picked].map((a) => [a, [...(viewsFor.get(a) || [])]]));
}
return b; return b;
}; };
const submit = async (btn) => { const submit = async (btn) => {
mount(err); mount(err);
const b = body(true); const b = body(true);
if (!b.hostname) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to.")); if (!b.hostnames.length) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on.")); if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on."));
btn.disabled = true; btn.disabled = true;
try { try {
if (!b.certificate) { if (!b.certificate) {
// Obtain one for the host name, for named, and wait for it. // Obtain one for the host names, for named, and wait for it.
b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-"); b.certificate = b.hostnames[0].split(".")[0].replace(/[^a-z0-9_-]/g, "-");
mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…"); mount(progress, "Obtaining a certificate for " + b.hostnames.join(", ") + " from the CA…");
await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } }); await api("POST", "/certificates", { body: { name: b.certificate, domains: b.hostnames, uses: ["dns"] } });
await waitObtained(b.certificate, progress); await waitObtained(b.certificate, progress);
} else { } else {
const c = await get("/certificates/" + encodeURIComponent(b.certificate)); const c = await get("/certificates/" + encodeURIComponent(b.certificate));
@@ -170,14 +201,12 @@ function setupPanel(s) {
return h("div", { class: "panel" }, return h("div", { class: "panel" },
h("h2", null, s.enabled ? "Change the setup" : "Set up"), h("h2", null, s.enabled ? "Change the setup" : "Set up"),
h("div", { class: "form-grid" }, h("div", { class: "form-grid" },
field("Host name", host, "The name clients connect to. The certificate is for it, and it gets this server's addresses in the views below."), field("Host names", hostIn, "The names clients connect to, separated by commas: dns.adbraun.lol, or several (dns.adbraun.lol, doh.adbraun.lol). The certificate must be for all of them."),
field("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."), field("Certificate", certSel, "One for all the host names, or a new one Jarl obtains and renews (docs/certificates.md)."),
field("Listen on", h("div", null, h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, addrRows))), portOut), field("Listen on", h("div", null, h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, addrRows))), portOut),
"This server's addresses on your own networks. A public address works too, but then anyone who reaches it can use these listeners, as with port 53 there."), "This server's addresses on your own networks. For each, tick the views where the host names get it (A or AAAA records, local to the view): a view's clients should only be given an address they can reach."),
field("Serve", h("div", { class: "enc-ports" }, h("div", null, dot, " on port ", dotPort), h("div", null, doh, " on port ", dohPort, h("span", { class: "muted small" }, " at /dns-query"))), field("Serve", h("div", { class: "enc-ports" }, h("div", null, dot, " on port ", dotPort), h("div", null, doh, " on port ", dohPort, h("span", { class: "muted small" }, " at /dns-query"))),
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there."), "Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there. Ports can be changed later: clients set up with another port need it changed too.")),
field("Host name in views", h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, viewBody)),
"Where the host name gets the chosen addresses (A and AAAA), local to the view in a linked zone. Clients outside these views can't find it.")),
err, progress, err, progress,
h("div", { class: "buttons" }, go)); h("div", { class: "buttons" }, go));
} }
+5
View File
@@ -590,3 +590,8 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; }
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } .size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; } .cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
.cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Encrypted DNS: per listen address, the views that get its records. */
td.addr-views { width: 100%; }
.view-chips { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
.view-chips label.chip { white-space: nowrap; font-size: 0.9em; }
+7
View File
@@ -901,6 +901,13 @@ if [ -n "$PEBBLE" ]; then
sleep 16 # /metrics caches Jarl's state for 15 seconds sleep 16 # /metrics caches Jarl's state for 15 seconds
run "health metric" 0 curl -sf http://127.0.0.1:$HTTP/metrics run "health metric" 0 curl -sf http://127.0.0.1:$HTTP/metrics
contains "^jarl_encrypted_dns_up{kind=\"dot\",address=\"127.0.0.1\",port=\"$EDOT\"} 1$" contains "^jarl_encrypted_dns_up{kind=\"dot\",address=\"127.0.0.1\",port=\"$EDOT\"} 1$"
run "cert show: what named uses it for" 0 $J cert show ns1
contains "^named: *DNS over TLS on 127.0.0.1 port $EDOT"
contains "^named: *DNS over HTTPS on 127.0.0.1 port $EDOH"
run "per address views: 127.0.0.1 only in internal" 0 $J encrypted-dns setup --hostname ns1.adbraun.test --address 127.0.0.1=internal --dot-port $EDOT --doh-port $EDOH
run "status shows where the records are" 0 $J encrypted-dns
contains "^Records: *127.0.0.1 in internal$"
contains "^Host names: *ns1.adbraun.test$"
run "a certificate named uses can't be deleted (exit 6)" 6 $J cert delete ns1 --yes run "a certificate named uses can't be deleted (exit 6)" 6 $J cert delete ns1 --yes
contains "take it out first" contains "take it out first"
run "encrypted-dns disable" 0 $J encrypted-dns disable run "encrypted-dns disable" 0 $J encrypted-dns disable