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>
340 lines
11 KiB
Go
340 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"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"`
|
|
DoTHost string `json:"dot_host"`
|
|
DoHURL string `json:"doh_url"`
|
|
DoT []encEndpoint `json:"dot"`
|
|
DoH []encEndpoint `json:"doh"`
|
|
Other bool `json:"other"`
|
|
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, "Certificate:\t%s\n", s.Certificate)
|
|
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.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 host, certName string
|
|
var addrs, views []string
|
|
var noDoT, noDoH bool
|
|
var dohPort, dotPort int
|
|
setup := &cobra.Command{
|
|
Use: "setup --hostname NAME --address ADDR...",
|
|
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 name's A and AAAA records, in the private views that have its
|
|
zone (or --view), local to the view when the zone is linked.
|
|
|
|
It is one change, staged or applied like any other. Running it again
|
|
replaces the earlier setup.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
|
if host == "" {
|
|
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
|
}
|
|
var s encStatus
|
|
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil {
|
|
return err
|
|
}
|
|
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 == "" {
|
|
for _, ci := range s.Certificates {
|
|
if coversName(append(ci.Names, ci.Domains...), host) {
|
|
certName = ci.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if certName == "" {
|
|
certName = strings.SplitN(host, ".", 2)[0]
|
|
fmt.Printf("Creating certificate %s for %s\n", certName, host)
|
|
var r struct {
|
|
Certificate certInfo `json:"certificate"`
|
|
}
|
|
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "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{"hostname": host, "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)
|
|
}
|
|
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.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)")
|
|
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 (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 to add the host name's records to (default: the private views that have its zone)")
|
|
|
|
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)
|
|
},
|
|
}
|
|
cmd.AddCommand(setup, check, disable)
|
|
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
|
|
}
|