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>
466 lines
15 KiB
Go
466 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type encEndpoint struct {
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
type encStatus struct {
|
|
Enabled bool `json:"enabled"`
|
|
Certificate string `json:"certificate"`
|
|
Hostname string `json:"hostname"`
|
|
Hostnames []string `json:"hostnames"`
|
|
AddrViews map[string][]string `json:"address_views"`
|
|
DoTHost string `json:"dot_host"`
|
|
DoHURL string `json:"doh_url"`
|
|
DoT []encEndpoint `json:"dot"`
|
|
DoH []encEndpoint `json:"doh"`
|
|
Other bool `json:"other"`
|
|
Health *encHealth `json:"health"`
|
|
Addresses []struct {
|
|
Address string `json:"address"`
|
|
Private bool `json:"private"`
|
|
} `json:"addresses"`
|
|
Views []struct {
|
|
Name string `json:"name"`
|
|
Public bool `json:"public"`
|
|
HasZone bool `json:"has_zone"`
|
|
} `json:"views"`
|
|
Certificates []struct {
|
|
Name string `json:"name"`
|
|
Names []string `json:"names"`
|
|
Domains []string `json:"domains"`
|
|
DNS bool `json:"dns"`
|
|
Obtained bool `json:"obtained"`
|
|
} `json:"certificates"`
|
|
}
|
|
|
|
type encConflict struct {
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
What string `json:"what"`
|
|
}
|
|
|
|
func endpoints(es []encEndpoint) string {
|
|
var out []string
|
|
for _, e := range es {
|
|
out = append(out, fmt.Sprintf("%s port %d", e.Address, e.Port))
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
func encryptedDNSCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "encrypted-dns",
|
|
Aliases: []string{"encdns"},
|
|
Short: "DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md)",
|
|
Long: `Shows whether named serves DNS over TLS (DoT) and DNS over HTTPS (DoH),
|
|
where, and what to give clients. 'setup' sets it up: a certificate for the
|
|
host name (obtained if needed), named's tls statement and listeners on the
|
|
addresses you choose, and the host name's address records in your private
|
|
views.`,
|
|
Example: ` jarl encrypted-dns
|
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --no-doh
|
|
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
|
jarl encrypted-dns disable`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var s encStatus
|
|
if err := c.Get(ctx(), "/encrypted-dns", nil, &s); err != nil {
|
|
return err
|
|
}
|
|
return output(s, func(w io.Writer) {
|
|
if !s.Enabled {
|
|
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
|
|
} else {
|
|
fmt.Fprintf(w, "Host names:\t%s\n", strings.Join(s.Hostnames, ", "))
|
|
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
|
|
for _, e := range append(append([]encEndpoint{}, s.DoT...), s.DoH...) {
|
|
if vs, ok := s.AddrViews[e.Address]; ok {
|
|
fmt.Fprintf(w, "Records:\t%s in %s\n", e.Address, strings.Join(vs, ", "))
|
|
delete(s.AddrViews, e.Address)
|
|
}
|
|
}
|
|
if len(s.DoT) > 0 {
|
|
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
|
|
}
|
|
if len(s.DoH) > 0 {
|
|
fmt.Fprintf(w, "DNS over HTTPS:\t%s\n", endpoints(s.DoH))
|
|
}
|
|
if s.DoTHost != "" {
|
|
fmt.Fprintf(w, "For DoT clients:\t%s (Android: Private DNS hostname; Linux: DNS=ADDRESS#%s, DNSOverTLS=yes)\n", s.DoTHost, s.Hostname)
|
|
}
|
|
if s.DoHURL != "" {
|
|
fmt.Fprintf(w, "For DoH clients:\t%s (Firefox, Chrome, Windows 11)\n", s.DoHURL)
|
|
}
|
|
}
|
|
if s.Enabled {
|
|
printHealth(w, s.Health)
|
|
}
|
|
if s.Other {
|
|
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
|
|
}
|
|
var addrs []string
|
|
for _, a := range s.Addresses {
|
|
t := a.Address
|
|
if !a.Private {
|
|
t += " (public)"
|
|
}
|
|
addrs = append(addrs, t)
|
|
}
|
|
fmt.Fprintf(w, "This server's addresses:\t%s\n", strings.Join(addrs, ", "))
|
|
})
|
|
},
|
|
}
|
|
var hostList []string
|
|
var certName string
|
|
var addrs, views []string
|
|
var noDoT, noDoH bool
|
|
var dohPort, dotPort int
|
|
setup := &cobra.Command{
|
|
Use: "setup --hostname NAME... --address ADDR[=VIEW,...]...",
|
|
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
|
|
Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses
|
|
given, which should be this server's addresses on your own networks:
|
|
|
|
- the certificate: --certificate, or one with the dns use whose names
|
|
cover the host name, or a new one Jarl obtains for it (and waits for);
|
|
- a check that nothing else listens on those ports there;
|
|
- named's tls statement and listen-on statements (and an explicit
|
|
listen-on { any; } when there was none, as the first listen-on would
|
|
otherwise stop named listening on port 53 everywhere);
|
|
- the host names' A and AAAA records, local to each view: per address,
|
|
the views after = (--address 10.0.10.5=internal,lab), or the views of
|
|
--view for every address (default: the private views that have the
|
|
zone). A view no longer given loses the address.
|
|
|
|
Several --hostname: the certificate must be for all of them. It is one
|
|
change, staged or applied like any other. Running it again replaces the
|
|
earlier setup, ports included.`,
|
|
Example: ` jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address fdab:abab:abab:10::5=internal
|
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --hostname doh.adbraun.lol --address 10.0.10.5 --view internal --dot-port 8853`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var hosts []string
|
|
for _, h := range hostList {
|
|
for _, x := range strings.Split(h, ",") {
|
|
if x = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(x), ".")); x != "" {
|
|
hosts = append(hosts, x)
|
|
}
|
|
}
|
|
}
|
|
if len(hosts) == 0 {
|
|
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
|
}
|
|
host := hosts[0]
|
|
var s encStatus
|
|
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", strings.Join(hosts, ",")), &s); err != nil {
|
|
return err
|
|
}
|
|
// ADDR=VIEW,VIEW gives an address its own views.
|
|
addrViews := map[string][]string{}
|
|
perAddress := false
|
|
for i, a := range addrs {
|
|
addr, vs, ok := strings.Cut(a, "=")
|
|
addrs[i] = strings.TrimSpace(addr)
|
|
if ok {
|
|
perAddress = true
|
|
addrViews[addrs[i]] = nil
|
|
for _, v := range strings.Split(vs, ",") {
|
|
if v = strings.TrimSpace(v); v != "" {
|
|
addrViews[addrs[i]] = append(addrViews[addrs[i]], v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if perAddress && len(views) > 0 {
|
|
return errors.New("give the views per address (--address ADDR=VIEW,...) or for all of them (--view), not both")
|
|
}
|
|
if len(addrs) == 0 {
|
|
var list []string
|
|
for _, a := range s.Addresses {
|
|
list = append(list, a.Address)
|
|
}
|
|
return fmt.Errorf("give --address (this server's addresses on your own networks); it has %s", strings.Join(list, ", "))
|
|
}
|
|
// The certificate.
|
|
if certName == "" {
|
|
certs:
|
|
for _, ci := range s.Certificates {
|
|
for _, h := range hosts {
|
|
if !coversName(append(ci.Names, ci.Domains...), h) {
|
|
continue certs
|
|
}
|
|
}
|
|
certName = ci.Name
|
|
break
|
|
}
|
|
}
|
|
if certName == "" {
|
|
certName = strings.SplitN(host, ".", 2)[0]
|
|
fmt.Printf("Creating certificate %s for %s\n", certName, strings.Join(hosts, ", "))
|
|
var r struct {
|
|
Certificate certInfo `json:"certificate"`
|
|
}
|
|
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": hosts, "uses": []string{"dns"}}, &r); err != nil {
|
|
return err
|
|
}
|
|
if err := waitIssue(c, certName); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
var ci certInfo
|
|
if err := c.Get(ctx(), certPath(certName), nil, &ci); err != nil {
|
|
return err
|
|
}
|
|
if !contains(ci.Uses, "dns") {
|
|
fmt.Printf("Adding the dns use to certificate %s\n", certName)
|
|
if err := c.Do(ctx(), "PATCH", certPath(certName), nil, map[string]any{"uses": append(ci.Uses, "dns")}, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if ci.NotAfter == nil {
|
|
if ci.IssuingSince == nil {
|
|
if err := c.Do(ctx(), "POST", certPath(certName)+"/issue", nil, map[string]any{}, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := waitIssue(c, certName); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
body := map[string]any{"hostnames": hosts, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
|
|
if cmd.Flags().Changed("doh-port") {
|
|
body["doh_port"] = dohPort
|
|
}
|
|
if cmd.Flags().Changed("dot-port") {
|
|
body["dot_port"] = dotPort
|
|
}
|
|
// Nothing else may listen there.
|
|
var chk struct {
|
|
Conflicts []encConflict `json:"conflicts"`
|
|
FreeDoHPort int `json:"free_doh_port"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, body, &chk); err != nil {
|
|
return err
|
|
}
|
|
if len(chk.Conflicts) > 0 {
|
|
var msgs []string
|
|
for _, cf := range chk.Conflicts {
|
|
msgs = append(msgs, fmt.Sprintf("%s listens on %s port %d", cf.What, cf.Address, cf.Port))
|
|
}
|
|
hint := ""
|
|
if chk.FreeDoHPort != 0 && !noDoH {
|
|
hint = fmt.Sprintf("; for DoH, port %d is free there (--doh-port %d)", chk.FreeDoHPort, chk.FreeDoHPort)
|
|
}
|
|
return fmt.Errorf("%s%s", strings.Join(msgs, "; "), hint)
|
|
}
|
|
if chk.Error != "" && !flagQuiet {
|
|
fmt.Println("Note:", chk.Error)
|
|
}
|
|
switch {
|
|
case perAddress:
|
|
for _, a := range addrs {
|
|
if _, ok := addrViews[a]; !ok {
|
|
addrViews[a] = []string{} // no records for it
|
|
}
|
|
}
|
|
body["address_views"] = addrViews
|
|
default:
|
|
if len(views) == 0 {
|
|
for _, v := range s.Views {
|
|
if !v.Public && v.HasZone {
|
|
views = append(views, v.Name)
|
|
}
|
|
}
|
|
}
|
|
body["views"] = views
|
|
}
|
|
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
f := setup.Flags()
|
|
f.StringArrayVar(&hostList, "hostname", nil, "a name clients connect to (repeat, or comma separated, for several; the certificate must be for all)")
|
|
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
|
|
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on, with the views that get it: 10.0.10.5=internal,lab (repeat for several)")
|
|
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
|
|
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
|
|
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
|
|
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
|
|
f.StringArrayVar(&views, "view", nil, "views where every address gets the host names' records (default: the private views that have the zone); per address: --address ADDR=VIEW")
|
|
|
|
var cAddrs []string
|
|
var cDoH int
|
|
check := &cobra.Command{
|
|
Use: "check --address ADDR...",
|
|
Short: "See whether something already listens on the DoT and DoH ports",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var r struct {
|
|
Conflicts []encConflict `json:"conflicts"`
|
|
FreeDoHPort int `json:"free_doh_port"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, map[string]any{"addresses": cAddrs, "dot": true, "doh": true, "doh_port": cDoH}, &r); err != nil {
|
|
return err
|
|
}
|
|
return output(r, func(w io.Writer) {
|
|
if r.Error != "" {
|
|
fmt.Fprintln(w, r.Error)
|
|
}
|
|
if len(r.Conflicts) == 0 {
|
|
fmt.Fprintln(w, "The ports are free.")
|
|
}
|
|
for _, cf := range r.Conflicts {
|
|
fmt.Fprintf(w, "%s port %d:\t%s listens there\n", cf.Address, cf.Port, cf.What)
|
|
}
|
|
if r.FreeDoHPort != 0 {
|
|
fmt.Fprintf(w, "Free for DoH:\tport %d\n", r.FreeDoHPort)
|
|
}
|
|
})
|
|
},
|
|
}
|
|
check.Flags().StringArrayVar(&cAddrs, "address", nil, "address to check (repeat for several)")
|
|
check.Flags().IntVar(&cDoH, "doh-port", 443, "port for DNS over HTTPS")
|
|
|
|
disable := &cobra.Command{
|
|
Use: "disable",
|
|
Short: "Turn DNS over TLS and HTTPS off (the certificate and records stay)",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.Mutate(ctx(), "DELETE", "/encrypted-dns", url.Values{}, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
health := &cobra.Command{
|
|
Use: "health",
|
|
Short: "Check the DoT and DoH listeners now, as a client would",
|
|
Long: `Connects to each listener, checks the certificate it serves (for the
|
|
host name, current, the one Jarl last wrote) and asks a question. jarld
|
|
also does this every 10 minutes; the dashboard and Prometheus
|
|
(jarl_encrypted_dns_up) show the result. Exits 1 when a listener doesn't
|
|
work.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var h encHealth
|
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/health", nil, map[string]any{}, &h); err != nil {
|
|
return err
|
|
}
|
|
if err := output(h, func(w io.Writer) { printHealth(w, &h) }); err != nil {
|
|
return err
|
|
}
|
|
ok := true
|
|
for _, p := range h.Probes {
|
|
ok = ok && p.OK
|
|
}
|
|
if !ok {
|
|
return errors.New("encrypted DNS isn't working everywhere")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.AddCommand(setup, check, disable, health)
|
|
return cmd
|
|
}
|
|
|
|
// coversName reports whether certificate names cover host (a wildcard
|
|
// covers one label).
|
|
func coversName(names []string, host string) bool {
|
|
for _, n := range names {
|
|
n = strings.ToLower(strings.TrimSuffix(n, "."))
|
|
if n == host {
|
|
return true
|
|
}
|
|
if base, ok := strings.CutPrefix(n, "*."); ok {
|
|
if _, rest, ok := strings.Cut(host, "."); ok && rest == base {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type encHealth struct {
|
|
Checked time.Time `json:"checked"`
|
|
Probes []struct {
|
|
Kind string `json:"kind"`
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
OK bool `json:"ok"`
|
|
Problem string `json:"problem"`
|
|
Warning string `json:"warning"`
|
|
RCode string `json:"rcode"`
|
|
Ms int64 `json:"ms"`
|
|
} `json:"probes"`
|
|
}
|
|
|
|
func printHealth(w io.Writer, h *encHealth) bool {
|
|
if h == nil {
|
|
fmt.Fprintln(w, "Health:\tnot checked yet (jarl encrypted-dns health)")
|
|
return true
|
|
}
|
|
ok := true
|
|
for _, p := range h.Probes {
|
|
kind := map[string]string{"dot": "DoT", "doh": "DoH"}[p.Kind]
|
|
state := fmt.Sprintf("working (%s in %d ms)", p.RCode, p.Ms)
|
|
if !p.OK {
|
|
state, ok = "NOT WORKING: "+p.Problem, false
|
|
} else if p.Warning != "" {
|
|
state += "; " + p.Warning
|
|
}
|
|
fmt.Fprintf(w, "%s %s port %d:\t%s\n", kind, p.Address, p.Port, state)
|
|
}
|
|
fmt.Fprintf(w, "Checked:\t%s\n", h.Checked.Local().Format("2006-01-02 15:04:05"))
|
|
return ok
|
|
}
|