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>
237 lines
6.7 KiB
Go
237 lines
6.7 KiB
Go
package server
|
|
|
|
import (
|
|
"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
|
|
// Hostname is the name clients connect to (the certificate's first).
|
|
Hostname string `json:"hostname,omitempty"`
|
|
// DoTHost and DoHURL are what to give clients.
|
|
DoTHost string `json:"dot_host,omitempty"`
|
|
DoHURL string `json:"doh_url,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 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"
|
|
}
|
|
}
|
|
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) })
|
|
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
|
|
if host == "" {
|
|
host = out.Hostname
|
|
}
|
|
for _, x := range s.eng.Exposures(st) {
|
|
if x.View == "" {
|
|
continue
|
|
}
|
|
v := encView{Name: x.View, Public: x.Public}
|
|
if host != "" {
|
|
if vv := st.ViewByName(x.View); vv != nil {
|
|
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 in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}):
|
|
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
|
|
}
|
|
st, err := s.store.LoadState(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 "+in.Hostname, 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)
|
|
}
|