jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
402 lines
12 KiB
Go
402 lines
12 KiB
Go
// 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
|
|
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 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)
|
|
}
|
|
|
|
// 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"))
|
|
}
|