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,236 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user