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) }