// 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" "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 { // Hostname is the name clients connect to (the certificate's). Hostname string `json:"hostname"` // 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) // Views get the hostname's A/AAAA records (default: none). In a // linked zone they are local to the view. Views []string `json:"views"` } // 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") } host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), ".")) if host == "" || !strings.Contains(host, ".") { return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com") } 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 dir := filepath.Join(certDir, s.Certificate) tlsValue := fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`, tlsName, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem")) 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 name's addresses, in the views asked for. fqdn := host + "." for _, vn := range s.Views { v := st.ViewByName(vn) if v == 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 } var local *bool if z.LinkGroup != 0 { local = model.Ptr(true) } for _, set := range []struct { typ string addrs []string }{{"A", v4}, {"AAAA", v6}} { if len(set.addrs) == 0 { continue } reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local})) } } 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 } // 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)) } } 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) }