Files
jarl/internal/encdns/encdns.go
T
adamandClaude Opus 5.5 66dcf53684 DNS over TLS and HTTPS set up in one step
jarl encrypted-dns setup (and the Encrypted DNS page) finds or obtains a
certificate for the host name, checks that nothing else listens on the
DoT and DoH ports at the chosen addresses (offering a free DoH port), and
stages named's tls statement and listeners as one change, keeping port 53
where it was when there was no listen-on, plus the host name's records in
the private views (local to the view in a linked zone). Status shows what
to give Android, Linux, Firefox, Chrome and Windows clients. A tls
statement with missing files is refused (tls_file_missing), and a
certificate named uses can't be deleted. docs/encrypted-dns.md, e2e with
real DoT and DoH queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:09:14 +01:00

396 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
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)
}