Encrypted DNS: several host names, views per listen address, what named uses a certificate for

The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 17:49:49 +01:00
1 parent bfab83da6e
commit fef916b7ab
19 files changed
+587 -122

No files matched your search

+183 -25
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"net/netip"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -208,8 +209,10 @@ func GetStatus(st *model.State, certDir string) Status {
// Setup is what to set up.
type Setup struct {
// Hostname is the name clients connect to (the certificate's).
Hostname string `json:"hostname"`
// 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.
@@ -218,9 +221,27 @@ type Setup struct {
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"`
// 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,
@@ -248,10 +269,15 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
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, ".") {
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))
@@ -325,32 +351,98 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
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 {
// 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)
}
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
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, ", ")))
}
}
}
var local *bool
if z.LinkGroup != 0 {
local = model.Ptr(true)
}
// 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
}
}
for _, set := range []struct {
typ string
addrs []string
}{{"A", v4}, {"AAAA", v6}} {
if len(set.addrs) == 0 {
}
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
}
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
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
}
@@ -369,6 +461,69 @@ func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
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 {
@@ -383,6 +538,9 @@ func Disable(st *model.State) []engine.Request {
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
}