// Package encdns sets up DNS over TLS (DoT) and DNS over HTTPS (DoH) in // named (docs/encrypted-dns.md): a tls statement naming a certificate Jarl // manages, and listen-on statements for port 853 (DoT) and the DoH port. // Its state is read from named's configuration, so what the setup stages // stays ordinary, visible configuration. package encdns import ( "fmt" "net/netip" "path/filepath" "slices" "strconv" "strings" "jarl/internal/engine" "jarl/internal/model" "jarl/internal/namedconf" ) // Prefix starts the names of the tls statements the setup writes. const Prefix = "jarl-" // DoTPort is the port for DNS over TLS (RFC 7858). const DoTPort = 853 // Listener is a listen-on or listen-on-v6 statement. type Listener struct { OptionID int64 `json:"option_id"` V6 bool `json:"v6"` Port int `json:"port"` // as written; 0 when it isn't TLS string `json:"tls,omitempty"` // tls statement ("" for plain DNS) HTTP string `json:"http,omitempty"` // http endpoints: DNS over HTTPS Addrs []string `json:"addresses"` // the address match list } // EffectivePort is the port named listens on: as written, or its // default for the kind of listener. func (l Listener) EffectivePort() int { switch { case l.Port != 0: return l.Port case l.HTTP != "" && l.TLS != "" && l.TLS != "none": return 443 case l.HTTP != "": return 80 case l.TLS != "": return DoTPort } return 53 } // Kind is plain, dot or doh. func (l Listener) Kind() string { switch { case l.HTTP != "": return "doh" case l.TLS != "" && l.TLS != "none": return "dot" } return "plain" } // Managed reports whether the setup wrote the listener. func (l Listener) Managed() bool { return strings.HasPrefix(l.TLS, Prefix) } // Listeners returns the server's listen-on and listen-on-v6 statements. func Listeners(st *model.State) []Listener { var out []Listener for _, o := range st.ScopedOptions(model.ScopeOptions, 0) { if o.Name != "listen-on" && o.Name != "listen-on-v6" { continue } l, err := parseListener(o.Name, o.Value) if err != nil { continue } l.OptionID, l.V6 = o.ID, o.Name == "listen-on-v6" out = append(out, l) } return out } func parseListener(name, value string) (Listener, error) { var l Listener stmt, err := namedconf.ParseOptionValue(name, value) if err != nil { return l, err } items := stmt.Items[1:] for i := 0; i < len(items); i++ { it := items[i] if it.Block != nil { for _, s := range it.Block.Stmts { l.Addrs = append(l.Addrs, namedconf.Compact(s.Items)) } continue } if it.Value == nil || i+1 >= len(items) || items[i+1].Value == nil { continue } arg := items[i+1].Value.Text switch strings.ToLower(it.Value.Text) { case "port": l.Port, _ = strconv.Atoi(arg) case "tls": l.TLS = arg case "http": l.HTTP = arg } i++ } return l, nil } // TLSBlock is a top-level tls statement. type TLSBlock struct { OptionID int64 `json:"option_id"` Name string `json:"name"` CertFile string `json:"cert_file"` KeyFile string `json:"key_file"` } // TLSBlocks returns the top-level tls statements. func TLSBlocks(st *model.State) []TLSBlock { var out []TLSBlock for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) { if o.Name != "tls" { continue } stmt, err := namedconf.ParseOptionValue("tls", o.Value) if err != nil { continue } b := TLSBlock{OptionID: o.ID, Name: stmt.Arg(0)} if blk := stmt.FirstBlock(); blk != nil { for _, s := range blk.Stmts { switch s.Keyword() { case "cert-file": b.CertFile = s.Arg(0) case "key-file": b.KeyFile = s.Arg(0) } } } out = append(out, b) } return out } // Endpoint is an address and port named serves DoT or DoH on. type Endpoint struct { Address string `json:"address"` Port int `json:"port"` } // Status is what is set up. type Status struct { Enabled bool `json:"enabled"` // TLS is the tls statement the setup wrote, and Certificate the // certificate it names (a Jarl certificate's name, when it is one). TLS *TLSBlock `json:"tls,omitempty"` Certificate string `json:"certificate,omitempty"` DoT []Endpoint `json:"dot"` DoH []Endpoint `json:"doh"` // Listeners are all listen-on statements, the setup's and others. Listeners []Listener `json:"listeners"` // Other are DoT or DoH listeners written by hand. Other bool `json:"other,omitempty"` } // GetStatus reads the setup from the configuration. certDir is where Jarl // writes certificates for named (certificates.dir). func GetStatus(st *model.State, certDir string) Status { s := Status{Listeners: Listeners(st), DoT: []Endpoint{}, DoH: []Endpoint{}} if s.Listeners == nil { s.Listeners = []Listener{} } for _, b := range TLSBlocks(st) { if strings.HasPrefix(b.Name, Prefix) { b := b s.TLS = &b if dir := filepath.Dir(b.CertFile); filepath.Dir(dir) == filepath.Clean(certDir) { s.Certificate = filepath.Base(dir) } break } } for _, l := range s.Listeners { if l.Kind() == "plain" { continue } if !l.Managed() { s.Other = true continue } for _, a := range l.Addrs { e := Endpoint{Address: a, Port: l.EffectivePort()} if l.Kind() == "doh" { s.DoH = append(s.DoH, e) } else { s.DoT = append(s.DoT, e) } } } s.Enabled = len(s.DoT)+len(s.DoH) > 0 return s } // Setup is what to set up. type Setup struct { // 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. Addresses []string `json:"addresses"` DoT bool `json:"dot"` DoH bool `json:"doh"` DoHPort int `json:"doh_port"` // default 443 DoTPort int `json:"dot_port"` // default 853 (clients expect it) // 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, // replacing an earlier setup, and notes for the user. certDir is where // the certificate's files are. func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string, error) { var notes []string if !s.DoT && !s.DoH { return nil, nil, fmt.Errorf("choose DNS over TLS, DNS over HTTPS or both") } if s.DoHPort == 0 { s.DoHPort = 443 } if s.DoTPort == 0 { s.DoTPort = DoTPort } for _, p := range []int{s.DoHPort, s.DoTPort} { if p < 1 || p > 65535 || p == 53 { return nil, nil, fmt.Errorf("port %d: use a free port other than 53", p) } } if s.DoT && s.DoH && s.DoHPort == s.DoTPort { return nil, nil, fmt.Errorf("DoT and DoH need different ports (both are %d)", s.DoHPort) } if s.Certificate == "" { return nil, nil, fmt.Errorf("choose the certificate to serve") } 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)) if err != nil { return nil, nil, fmt.Errorf("%q isn't an address of this server", a) } ip = ip.Unmap() if ip.IsUnspecified() { return nil, nil, fmt.Errorf("listen on this server's addresses, not on %s (that would include public ones)", ip) } if ip.Is4() { v4 = append(v4, ip.String()) } else { v6 = append(v6, ip.String()) } } if len(v4)+len(v6) == 0 { return nil, nil, fmt.Errorf("choose at least one address to listen on") } tlsName := Prefix + s.Certificate tlsValue := TLSValue(s.Certificate, certDir) var reqs []engine.Request // Take out the earlier setup's listeners and tls statements. current := Listeners(st) for _, l := range current { if l.Managed() { reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil)) } } haveTLS := false for _, b := range TLSBlocks(st) { switch { case b.Name == tlsName: haveTLS = true reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(tlsValue)})) case strings.HasPrefix(b.Name, Prefix): reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil)) } } if !haveTLS { reqs = append(reqs, option(model.ScopeTopLevel, "tls", tlsValue)) } // named listens on port 53 everywhere only while there is no // listen-on statement at all: adding the first one for DoT would end // that, so keep it explicitly. plain := func(v6 bool) bool { for _, l := range current { if l.V6 == v6 && !l.Managed() && l.Kind() == "plain" { return true } } return false } add := func(name string, addrs []string) { if len(addrs) == 0 { return } list := "{ " + strings.Join(addrs, "; ") + "; }" if !plain(name == "listen-on-v6") { reqs = append(reqs, option(model.ScopeOptions, name, "{ any; }")) notes = append(notes, fmt.Sprintf("named listened on port 53 on every address by default; %s { any; } keeps that, as the first %s statement would otherwise end it", name, name)) } if s.DoT { reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s %s", s.DoTPort, tlsName, list))) } if s.DoH { reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s http default %s", s.DoHPort, tlsName, list))) } } add("listen-on", v4) add("listen-on-v6", v6) // 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) } 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, ", "))) } } } } // 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 } } } 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 } 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 } func option(scope, name, value string) engine.Request { return engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(scope), Name: model.Ptr(name), Value: model.Ptr(value)}) } // zoneFor returns the primary zone in a view that holds name. func zoneFor(st *model.State, name string, viewID int64) *model.Zone { var best *model.Zone for _, z := range st.ZonesInView(viewID) { if z.Type == model.ZonePrimary && (name == z.Name || strings.HasSuffix(name, "."+z.Name)) && (best == nil || len(z.Name) > len(best.Name)) { best = z } } 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 { var reqs []engine.Request for _, l := range Listeners(st) { if l.Managed() { reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil)) } } for _, b := range TLSBlocks(st) { if strings.HasPrefix(b.Name, Prefix) { 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 } // Private reports whether an address is private (RFC 1918, ULA, ...). func Private(a netip.Addr) bool { a = a.Unmap() return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a) } // TLSValue is the tls statement the setup writes for a certificate whose // files are in certDir/NAME. func TLSValue(cert, certDir string) string { dir := filepath.Join(certDir, cert) return fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`, Prefix+cert, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem")) }