Files
jarl/internal/server/h_encdns.go
T
adamandClaude Opus 5.5 fef916b7ab 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>
2026-10-13 17:49:49 +01:00

352 lines
10 KiB
Go

package server
import (
"context"
"net/http"
"net/netip"
"slices"
"strconv"
"strings"
"jarl/internal/auth"
"jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/model"
"jarl/internal/nscheck"
)
// DNS over TLS and HTTPS (docs/encrypted-dns.md).
type encAddress struct {
Address string `json:"address"`
Private bool `json:"private"`
}
type encView struct {
Name string `json:"name"`
Public bool `json:"public"`
HasZone bool `json:"has_zone,omitempty"` // has a zone for the host name
}
type encCert struct {
Name string `json:"name"`
Names []string `json:"names"`
Domains []string `json:"domains"`
DNS bool `json:"dns"` // has the dns use
Status certs.Status `json:"status"`
Obtained bool `json:"obtained"`
}
type encStatus struct {
encdns.Status
// Hostnames are the names clients connect to (the setup's, else the
// certificate's first); Hostname is the first.
Hostnames []string `json:"hostnames"`
Hostname string `json:"hostname,omitempty"`
// AddressViews are, per address, the views whose records give a host
// name that address.
AddressViews map[string][]string `json:"address_views"`
// DoTHost and DoHURL are what to give clients.
DoTHost string `json:"dot_host,omitempty"`
DoHURL string `json:"doh_url,omitempty"`
// Health is the last check of the listeners (every 10 minutes).
Health *encdns.Health `json:"health,omitempty"`
// For the setup form.
Addresses []encAddress `json:"addresses"`
Views []encView `json:"views"`
Certificates []encCert `json:"certificates"`
}
func (s *Server) encReady(p *auth.Principal, write bool) error {
if err := require(p, auth.Action{Resource: auth.ResOptions, Write: write}); err != nil {
return err
}
if s.cfg.Certs == nil {
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
}
return nil
}
// localAddrs returns this host's addresses (a test may replace it).
var localAddrs = nscheck.LocalAddrs
// encryptedDNS: GET /encrypted-dns.
func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, false); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
m := s.cfg.Certs
out := encStatus{Status: encdns.GetStatus(st, m.FilesDir("")), Addresses: []encAddress{}, Views: []encView{}, Certificates: []encCert{}}
all, err := m.List(r.Context())
if err != nil {
return err
}
for _, c := range all {
out.Certificates = append(out.Certificates, encCert{Name: c.Name, Names: c.Names, Domains: c.Domains, DNS: c.HasUse(model.CertUseDNS),
Status: m.StatusOf(c), Obtained: c.NotAfter != nil})
if c.Name == out.Certificate {
for _, n := range append(c.Names, c.Domains...) {
if !strings.HasPrefix(n, "*.") {
out.Hostname = n
break
}
}
}
}
if hs := st.Settings.EncryptedDNSHosts; len(hs) > 0 {
out.Hostname = hs[0]
out.Hostnames = hs
} else if out.Hostname != "" {
out.Hostnames = []string{out.Hostname}
}
if out.Hostnames == nil {
out.Hostnames = []string{}
}
if out.Hostname != "" {
if len(out.DoT) > 0 {
out.DoTHost = out.Hostname
if out.DoT[0].Port != encdns.DoTPort {
out.DoTHost += ":" + strconv.Itoa(out.DoT[0].Port)
}
}
if len(out.DoH) > 0 {
out.DoHURL = "https://" + out.Hostname
if out.DoH[0].Port != 443 {
out.DoHURL += ":" + strconv.Itoa(out.DoH[0].Port)
}
out.DoHURL += "/dns-query"
}
}
if out.Enabled {
out.Health = s.encryptedDNSHealth()
}
seen := map[netip.Addr]bool{}
for _, a := range localAddrs() {
a = a.Unmap()
if seen[a] || a.IsLinkLocalUnicast() {
continue
}
seen[a] = true
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
}
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
hosts := out.Hostnames
if q := r.URL.Query().Get("hostname"); q != "" {
hosts = nil
for _, h := range strings.Split(q, ",") {
if h = strings.TrimSuffix(strings.TrimSpace(h), "."); h != "" {
hosts = append(hosts, h)
}
}
}
// Where the records of these host names give each address now, the
// setup's or not: what the form starts from.
out.AddressViews = encdns.CurrentAddressViews(st, hosts)
for _, x := range s.eng.Exposures(st) {
if x.View == "" {
continue
}
v := encView{Name: x.View, Public: x.Public}
if vv := st.ViewByName(x.View); vv != nil {
for _, host := range hosts {
for _, z := range st.ZonesInView(vv.ID) {
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
v.HasZone = true
}
}
}
}
out.Views = append(out.Views, v)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// encryptedDNSCheck: POST /encrypted-dns/check {"addresses", "dot", "doh",
// "doh_port"} says what already listens where the listeners would go.
func (s *Server) encryptedDNSCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, false); err != nil {
return err
}
var in encdns.Setup
if err := decodeJSON(r, &in); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
if in.DoHPort == 0 {
in.DoHPort = 443
}
if in.DoTPort == 0 {
in.DoTPort = encdns.DoTPort
}
var ports []int
if in.DoT {
ports = append(ports, in.DoTPort)
}
if in.DoH {
ports = append(ports, in.DoHPort)
}
conflicts, err := encdns.PortCheck(st, in.Addresses, ports, s.cfg.Listen)
out := map[string]any{"conflicts": conflicts}
if err != nil {
out["error"] = "can't see which ports are in use: " + err.Error()
}
if conflicts == nil {
out["conflicts"] = []encdns.Conflict{}
}
if in.DoH {
out["free_doh_port"] = encdns.FreeDoHPort(st, in.Addresses, s.cfg.Listen)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// encryptedDNSSetup: POST /encrypted-dns stages (or applies) the setup.
func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, true); err != nil {
return err
}
var in encdns.Setup
if err := decodeJSON(r, &in); err != nil {
return err
}
c, err := s.cfg.Certs.Get(r.Context(), in.Certificate)
if err != nil {
return certErr(err)
}
switch {
case !c.HasUse(model.CertUseDNS):
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
case c.NotAfter == nil:
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()):
return badRequest("certificate %s is for %s, not all of %s: clients would reject it (add the names to it: jarl cert update %s --domain …)", c.Name, strings.Join(c.Names, ", "), strings.Join(in.Hosts(), ", "), c.Name)
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
in.PublicViews = map[string]bool{}
for _, x := range s.eng.Exposures(st) {
in.PublicViews[x.View] = x.Public
}
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
if err != nil {
return badRequest("%v", err)
}
what := []string{}
if in.DoT {
what = append(what, "DNS over TLS")
}
if in.DoH {
what = append(what, "DNS over HTTPS")
}
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+strings.Join(in.Hosts(), ", "), reqs, nil, notes)
}
// encryptedDNSDisable: DELETE /encrypted-dns.
func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, true); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
reqs := encdns.Disable(st)
if len(reqs) == 0 {
return conflict("encrypted DNS isn't set up")
}
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
}
// encTarget returns the host name clients connect to and the serial of
// the certificate Jarl last wrote for named.
func (s *Server) encTarget(ctx context.Context, st encdns.Status) (host, serial string) {
if s.cfg.Certs == nil || st.Certificate == "" {
return "", ""
}
c, err := s.cfg.Certs.Get(ctx, st.Certificate)
if err != nil {
return "", ""
}
for _, n := range append(c.Names, c.Domains...) {
if !strings.HasPrefix(n, "*.") {
host = n
break
}
}
return host, c.Serial
}
// checkEncryptedDNS checks the DoT and DoH listeners as a client would and
// keeps the result; a change for the worse is logged.
func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
if s.cfg.Certs == nil {
return nil
}
state, err := s.store.LoadState(ctx)
if err != nil {
return nil
}
st := encdns.GetStatus(state, s.cfg.Certs.FilesDir(""))
var h *encdns.Health
if st.Enabled {
if host, serial := s.encTarget(ctx, st); host != "" {
hosts := state.Settings.EncryptedDNSHosts
if len(hosts) > 0 {
host = hosts[0]
}
h = encdns.Check(ctx, st, host, serial, s.encRoots)
h.RequireNames(append([]string{host}, hosts...))
}
}
s.encHealth.Lock()
prev := s.encHealth.h
s.encHealth.h = h
s.encHealth.Unlock()
if h != nil {
for _, p := range h.Probes {
if p.OK {
continue
}
was := false
if prev != nil {
for _, q := range prev.Probes {
was = was || q.Kind == p.Kind && q.Address == p.Address && q.Port == p.Port && !q.OK
}
}
if !was {
s.log.Warn("encrypted DNS isn't working", "kind", p.Kind, "address", p.Address, "port", p.Port, "problem", p.Problem)
}
}
}
return h
}
func (s *Server) encryptedDNSHealth() *encdns.Health {
s.encHealth.Lock()
defer s.encHealth.Unlock()
return s.encHealth.h
}
// encryptedDNSCheckNow: POST /encrypted-dns/health checks the listeners now.
func (s *Server) encryptedDNSCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, false); err != nil {
return err
}
h := s.checkEncryptedDNS(r.Context())
if h == nil {
return conflict("encrypted DNS isn't set up")
}
writeJSON(w, http.StatusOK, h)
return nil
}