diff --git a/CHANGELOG.md b/CHANGELOG.md index b7249dc..a67144b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **Dismiss these N** dismisses everything a search found in one change (`--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]`, and a **Compare** button on each record. diff --git a/cmd/jarl/certs.go b/cmd/jarl/certs.go index b5e490d..06a525e 100644 --- a/cmd/jarl/certs.go +++ b/cmd/jarl/certs.go @@ -45,6 +45,7 @@ type certInfo struct { NextAttempt *time.Time `json:"next_attempt,omitempty"` IssuingSince *time.Time `json:"issuing_since,omitempty"` Serving bool `json:"serving,omitempty"` + NamedUse []string `json:"named_use,omitempty"` Files *struct { Chain string `json:"chain"` 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, ", "))) + for _, u := range ci.NamedUse { + fmt.Fprintf(w, "named:\t%s\n", u) + } if ci.Comment != "" { fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment) } diff --git a/cmd/jarl/encdns.go b/cmd/jarl/encdns.go index 77bcb7d..5df6389 100644 --- a/cmd/jarl/encdns.go +++ b/cmd/jarl/encdns.go @@ -17,15 +17,17 @@ type encEndpoint struct { } type encStatus struct { - Enabled bool `json:"enabled"` - Certificate string `json:"certificate"` - Hostname string `json:"hostname"` - DoTHost string `json:"dot_host"` - DoHURL string `json:"doh_url"` - DoT []encEndpoint `json:"dot"` - DoH []encEndpoint `json:"doh"` - Other bool `json:"other"` - Health *encHealth `json:"health"` + Enabled bool `json:"enabled"` + Certificate string `json:"certificate"` + Hostname string `json:"hostname"` + Hostnames []string `json:"hostnames"` + AddrViews map[string][]string `json:"address_views"` + DoTHost string `json:"dot_host"` + DoHURL string `json:"doh_url"` + DoT []encEndpoint `json:"dot"` + DoH []encEndpoint `json:"doh"` + Other bool `json:"other"` + Health *encHealth `json:"health"` Addresses []struct { Address string `json:"address"` Private bool `json:"private"` @@ -87,7 +89,14 @@ views.`, if !s.Enabled { fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)") } else { + fmt.Fprintf(w, "Host names:\t%s\n", strings.Join(s.Hostnames, ", ")) 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 { 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 noDoT, noDoH bool var dohPort, dotPort int 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)", 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: @@ -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 listen-on { any; } when there was none, as the first listen-on would otherwise stop named listening on port 53 everywhere); - - the host name's A and AAAA records, in the private views that have its - zone (or --view), local to the view when the zone is linked. + - the host names' A and AAAA records, local to each view: per address, + 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 -replaces the earlier setup.`, +Several --hostname: the certificate must be for all of them. It is one +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, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } - host = strings.ToLower(strings.TrimSuffix(host, ".")) - if host == "" { + var hosts []string + 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)") } + host := hosts[0] 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 } + // 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 { var list []string for _, a := range s.Addresses { @@ -163,20 +205,24 @@ replaces the earlier setup.`, } // The certificate. if certName == "" { + certs: for _, ci := range s.Certificates { - if coversName(append(ci.Names, ci.Domains...), host) { - certName = ci.Name - break + for _, h := range hosts { + if !coversName(append(ci.Names, ci.Domains...), h) { + continue certs + } } + certName = ci.Name + break } } if certName == "" { 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 { 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 } 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") { body["doh_port"] = dohPort } @@ -234,14 +280,24 @@ replaces the earlier setup.`, if chk.Error != "" && !flagQuiet { fmt.Println("Note:", chk.Error) } - if len(views) == 0 { - for _, v := range s.Views { - if !v.Public && v.HasZone { - views = append(views, v.Name) + switch { + case perAddress: + for _, a := range addrs { + 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) if err != nil { return err @@ -250,14 +306,14 @@ replaces the earlier setup.`, }, } 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.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(&noDoH, "no-doh", false, "no 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.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 cDoH int diff --git a/docs/certificates.md b/docs/certificates.md index ec1a852..6c217ed 100644 --- a/docs/certificates.md +++ b/docs/certificates.md @@ -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 ``` +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 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 diff --git a/docs/encrypted-dns.md b/docs/encrypted-dns.md index 74bfe32..e9dd822 100644 --- a/docs/encrypted-dns.md +++ b/docs/encrypted-dns.md @@ -18,6 +18,7 @@ encrypted-dns`. In the API: `/api/v1/encrypted-dns`. ```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=internal --address 203.0.113.5=external ``` This: @@ -47,25 +48,37 @@ This: `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 for IPv6) to keep plain DNS where it was. -4. **adds the host name's A and AAAA records** with the chosen addresses, - in the private views that have a zone for it (`--view` to choose). In a - linked zone they are local to the view, so the name doesn't reach your - external view. +4. **adds the host names' A and AAAA records**, each address in the views + you give it: `--address 10.0.10.5=internal,lab`, or every address in + the views of `--view` (default: the private views that have a zone for + 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 -or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable` -takes the listeners and the `tls` statement out; the certificate and the -records stay. +**Several host names** (`--hostname dns.adbraun.lol --hostname +doh.adbraun.lol`, or comma separated): each gets the records, the +certificate must be for all of them (a new one is obtained with all of +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 | | |--|--|--| -| `--hostname` | | The name clients connect to; the certificate is for it | -| `--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 | +| `--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), 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 | | `--no-dot`, `--no-doh` | both on | | | `--dot-port` | 853 | Clients expect 853 | | `--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 free port, and obtains the certificate before setting up. @@ -150,8 +163,8 @@ What it can say: | 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` | `{"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 | | 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` | diff --git a/internal/encdns/encdns.go b/internal/encdns/encdns.go index 030da1b..9924555 100644 --- a/internal/encdns/encdns.go +++ b/internal/encdns/encdns.go @@ -9,6 +9,7 @@ import ( "fmt" "net/netip" "path/filepath" + "slices" "strconv" "strings" @@ -208,8 +209,10 @@ func GetStatus(st *model.State, certDir string) Status { // Setup is what to set up. type Setup struct { - // Hostname is the name clients connect to (the certificate's). - Hostname string `json:"hostname"` + // Hostnames are the names clients connect to; the certificate must be + // 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 string `json:"certificate"` // Addresses are the addresses of this server to listen on. @@ -218,9 +221,27 @@ type Setup struct { DoH bool `json:"doh"` DoHPort int `json:"doh_port"` // default 443 DoTPort int `json:"dot_port"` // default 853 (clients expect it) - // Views get the hostname's A/AAAA records (default: none). In a - // linked zone they are local to the view. - Views []string `json:"views"` + // AddressViews says, per listen address, the views where the host + // names get it (A or AAAA): a private address shouldn't reach a public + // 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, @@ -248,10 +269,15 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string, if s.Certificate == "" { return nil, nil, fmt.Errorf("choose the certificate to serve") } - host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), ".")) - if host == "" || !strings.Contains(host, ".") { + hosts := s.Hosts() + if len(hosts) == 0 { 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 for _, a := range s.Addresses { 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-v6", v6) - // The host name's addresses, in the views asked for. - fqdn := host + "." - for _, vn := range s.Views { - v := st.ViewByName(vn) - if v == nil { + // The host names' addresses, in the views asked for, per address. + want := map[string][]string{} // view -> addresses + all := append(append([]string{}, v4...), v6...) + if s.AddressViews != 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) } - z := zoneFor(st, fqdn, v.ID) - if z == nil { - 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 + if s.PublicViews[vn] { + for _, a := range addrs { + if ip, err := netip.ParseAddr(a); err == nil && Private(ip) { + 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 { - local = model.Ptr(true) + } + // Ours are the addresses this setup (or the one before) gives the host + // 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 - addrs []string - }{{"A", v4}, {"AAAA", v6}} { - if len(set.addrs) == 0 { + } + names := slices.Clone(hosts) + for _, h := range st.Settings.EncryptedDNSHosts { + if !slices.Contains(names, h) { + 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 } - 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 } @@ -369,6 +461,69 @@ func zoneFor(st *model.State, name string, viewID int64) *model.Zone { 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 // tls statement. Plain listeners and the host name's records stay. 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)) } } + if len(reqs) > 0 && len(st.Settings.EncryptedDNSHosts) > 0 { + reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &[]string{}})) + } return reqs } diff --git a/internal/encdns/encdns_test.go b/internal/encdns/encdns_test.go index 0e91add..dc61471 100644 --- a/internal/encdns/encdns_test.go +++ b/internal/encdns/encdns_test.go @@ -186,3 +186,70 @@ func TestPorts(t *testing.T) { 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) + } +} diff --git a/internal/encdns/health.go b/internal/encdns/health.go index 8de4d2f..edee51c 100644 --- a/internal/encdns/health.go +++ b/internal/encdns/health.go @@ -206,3 +206,40 @@ func describe(err error, e Endpoint) string { } 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 +} diff --git a/internal/encdns/health_test.go b/internal/encdns/health_test.go index f8ab44b..60f5def 100644 --- a/internal/encdns/health_test.go +++ b/internal/encdns/health_test.go @@ -113,3 +113,21 @@ func TestHealth(t *testing.T) { 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]) + } +} diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go index 804b7bc..f3a8353 100644 --- a/internal/engine/diffops.go +++ b/internal/engine/diffops.go @@ -25,7 +25,8 @@ func DiffOps(from, to *model.State) []model.Op { s := to.Settings 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, - 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) { diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index 8f41e2a..798b504 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -314,6 +314,8 @@ type SettingsInput struct { // DismissedReverse are dismissed reverse zone suggestions (without // views). 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. diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 4515161..05abe6f 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -1414,6 +1414,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error { if in.DismissedReverse != nil { s.DismissedReverse = slices.Clone(*in.DismissedReverse) } + if in.EncryptedDNSHosts != nil { + s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts) + } s.Version++ return nil } diff --git a/internal/model/model.go b/internal/model/model.go index 46e6f60..54b7acb 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -275,7 +275,10 @@ type Settings struct { // DismissedReverse are dismissed reverse zone suggestions (without // views; see View). 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 diff --git a/internal/server/h_certs.go b/internal/server/h_certs.go index 7e87549..7716737 100644 --- a/internal/server/h_certs.go +++ b/internal/server/h_certs.go @@ -9,6 +9,7 @@ import ( "jarl/internal/api" "jarl/internal/auth" "jarl/internal/certs" + "jarl/internal/encdns" "jarl/internal/model" ) @@ -21,6 +22,9 @@ type certView struct { 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 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 *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 { return err } + st, err := s.store.LoadState(r.Context()) + if err != nil { + return err + } out := []certView{} 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())}) return nil @@ -96,7 +106,13 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip if err != nil { 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 } diff --git a/internal/server/h_encdns.go b/internal/server/h_encdns.go index be06880..b80e9d4 100644 --- a/internal/server/h_encdns.go +++ b/internal/server/h_encdns.go @@ -39,8 +39,13 @@ type encCert struct { type encStatus struct { encdns.Status - // Hostname is the name clients connect to (the certificate's first). - Hostname string `json:"hostname,omitempty"` + // Hostnames are the names clients connect to (the setup's, else the + // 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 string `json:"dot_host,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 len(out.DoT) > 0 { 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)}) } slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) }) - host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".") - if host == "" { - host = out.Hostname + hosts := out.Hostnames + if q := r.URL.Query().Get("hostname"); q != "" { + 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) { if x.View == "" { continue } 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) { if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) { 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) 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) - case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}): - return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname) + case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()): + 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()) if err != nil { 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("")) if err != nil { return badRequest("%v", err) @@ -222,7 +248,7 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au if in.DoH { 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. @@ -274,7 +300,12 @@ func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health { var h *encdns.Health if st.Enabled { 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.RequireNames(append([]string{host}, hosts...)) } } s.encHealth.Lock() diff --git a/internal/web/static/assets/pages/certificates.js b/internal/web/static/assets/pages/certificates.js index b30d688..c8ef383 100644 --- a/internal/web/static/assets/pages/certificates.js +++ b/internal/web/static/assets/pages/certificates.js @@ -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: "Used for", render: (c) => [ 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] }, { 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" }, "–") }, @@ -79,6 +80,7 @@ function details(c) { modal("Certificate " + c.name, h("div", null, 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.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."), copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true }); } diff --git a/internal/web/static/assets/pages/encdns.js b/internal/web/static/assets/pages/encdns.js index 4531ae8..67e8259 100644 --- a/internal/web/static/assets/pages/encdns.js +++ b/internal/web/static/assets/pages/encdns.js @@ -26,7 +26,10 @@ export async function render(el) { function statusPanel(s) { const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", "); 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.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) { - 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 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); @@ -69,40 +73,64 @@ function setupPanel(s) { const portOut = h("div"); const err = h("div"); 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 addrRows = s.addresses.map((a) => 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, () => checkPorts())); - // Views for the host name's records: the private ones that have its zone. - const viewPicked = new Set(); - const viewBody = h("tbody"); - const drawViews = (views) => { - viewPicked.clear(); - mount(viewBody, views.map((v) => { - if (!v.public && v.has_zone) viewPicked.add(v.name); - 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); - })); + const viewCells = new Map(); + const drawViewCell = (a) => { + const cell = viewCells.get(a); + if (!cell) return; + if (!picked.has(a)) return mount(cell); + const set = viewsFor.get(a); + const warn = isPrivate.get(a) && views.some((v) => v.public && set.has(v.name)); + mount(cell, h("div", { class: "view-chips" }, h("span", { class: "muted small" }, "records in: "), + views.map((v) => { + const box = h("input", { type: "checkbox", checked: set.has(v.name), disabled: !v.has_zone && !set.has(v.name) }); + box.addEventListener("change", () => { if (box.checked) set.add(v.name); else set.delete(v.name); drawViewCell(a); }); + 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); + }), + !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 name = host.value.trim().toLowerCase().replace(/\.$/, ""); - const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name)); + const names = hosts(); + const fit = s.certificates.filter((c) => names.length && names.every((n) => covers([...(c.names || []), ...(c.domains || [])], n))); 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") + ")")), - 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(); let viewTimer = null; - host.addEventListener("input", () => { + hostIn.addEventListener("input", () => { fillCerts(); clearTimeout(viewTimer); viewTimer = setTimeout(async () => { - const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null); - if (r) drawViews(r.views); + const r = await get("/encrypted-dns", { hostname: hosts().join(",") }).catch(() => null); + 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); }); @@ -129,23 +157,26 @@ function setupPanel(s) { if (picked.size) checkPorts(); 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 }; - if (full) { b.certificate = certSel.value; b.views = [...viewPicked]; } + 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.address_views = Object.fromEntries([...picked].map((a) => [a, [...(viewsFor.get(a) || [])]])); + } return b; }; const submit = async (btn) => { mount(err); 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.")); btn.disabled = true; try { if (!b.certificate) { - // Obtain one for the host name, for named, and wait for it. - b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-"); - mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…"); - await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } }); + // Obtain one for the host names, for named, and wait for it. + b.certificate = b.hostnames[0].split(".")[0].replace(/[^a-z0-9_-]/g, "-"); + mount(progress, "Obtaining a certificate for " + b.hostnames.join(", ") + " from the CA…"); + await api("POST", "/certificates", { body: { name: b.certificate, domains: b.hostnames, uses: ["dns"] } }); await waitObtained(b.certificate, progress); } else { const c = await get("/certificates/" + encodeURIComponent(b.certificate)); @@ -170,14 +201,12 @@ function setupPanel(s) { return h("div", { class: "panel" }, h("h2", null, s.enabled ? "Change the setup" : "Set up"), 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("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."), + 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 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), - "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"))), - "Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there."), - 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.")), + "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.")), err, progress, h("div", { class: "buttons" }, go)); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 1e5d23f..271047e 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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); } .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; } + +/* 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; } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index b68e232..3b8f405 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -901,6 +901,13 @@ if [ -n "$PEBBLE" ]; then sleep 16 # /metrics caches Jarl's state for 15 seconds 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$" + 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 contains "take it out first" run "encrypted-dns disable" 0 $J encrypted-dns disable