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>
This commit is contained in:
1 parent
ebd3a3dfbe
commit
66dcf53684
21 files changed
+1790
-8
No files matched your search
@@ -0,0 +1,395 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user