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>
150 lines
3.7 KiB
Go
150 lines
3.7 KiB
Go
package encdns
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"net/netip"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"jarl/internal/model"
|
||
)
|
||
|
||
// Socket is a listening TCP socket on this host.
|
||
type Socket struct {
|
||
Addr netip.Addr
|
||
Port int
|
||
}
|
||
|
||
// ListeningSockets reads the host's listening TCP sockets from
|
||
// /proc/net/tcp and tcp6 (Linux; nil elsewhere). It needs no privileges
|
||
// and binds nothing. Tests replace it.
|
||
var ListeningSockets = func() ([]Socket, error) {
|
||
var out []Socket
|
||
for _, f := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
||
socks, err := parseProcNet(f)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
continue
|
||
}
|
||
return nil, err
|
||
}
|
||
out = append(out, socks...)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func parseProcNet(path string) ([]Socket, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
var out []Socket
|
||
sc := bufio.NewScanner(f)
|
||
sc.Scan() // header
|
||
for sc.Scan() {
|
||
fields := strings.Fields(sc.Text())
|
||
if len(fields) < 4 || fields[3] != "0A" { // 0A: LISTEN
|
||
continue
|
||
}
|
||
s, err := parseProcAddr(fields[1])
|
||
if err == nil {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out, sc.Err()
|
||
}
|
||
|
||
// parseProcAddr parses "0100007F:0035": the address in 32-bit words of
|
||
// host byte order (little-endian here), then the port.
|
||
func parseProcAddr(s string) (Socket, error) {
|
||
ha, hp, ok := strings.Cut(s, ":")
|
||
if !ok {
|
||
return Socket{}, fmt.Errorf("bad address %q", s)
|
||
}
|
||
b, err := hex.DecodeString(ha)
|
||
if err != nil || (len(b) != 4 && len(b) != 16) {
|
||
return Socket{}, fmt.Errorf("bad address %q", s)
|
||
}
|
||
for i := 0; i+4 <= len(b); i += 4 {
|
||
b[i], b[i+1], b[i+2], b[i+3] = b[i+3], b[i+2], b[i+1], b[i]
|
||
}
|
||
addr, _ := netip.AddrFromSlice(b)
|
||
port, err := strconv.ParseUint(hp, 16, 16)
|
||
if err != nil {
|
||
return Socket{}, err
|
||
}
|
||
return Socket{Addr: addr.Unmap(), Port: int(port)}, nil
|
||
}
|
||
|
||
// Conflict is something else listening where a listener would go.
|
||
type Conflict struct {
|
||
Address string `json:"address"`
|
||
Port int `json:"port"`
|
||
What string `json:"what"` // who it is, when Jarl knows
|
||
}
|
||
|
||
// PortCheck says which of addresses × ports something else listens on
|
||
// already. named's own listeners (the setup's, now) and jarld's (known
|
||
// listen addresses) are told apart.
|
||
func PortCheck(st *model.State, addresses []string, ports []int, jarld []string) ([]Conflict, error) {
|
||
socks, err := ListeningSockets()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// What named listens on now with DoT or DoH (it will again).
|
||
ours := map[string]bool{}
|
||
for _, l := range Listeners(st) {
|
||
if l.Kind() != "plain" {
|
||
for _, a := range l.Addrs {
|
||
ours[a+"#"+strconv.Itoa(l.EffectivePort())] = true
|
||
}
|
||
}
|
||
}
|
||
jarldOn := map[string]bool{}
|
||
for _, l := range jarld {
|
||
jarldOn[l] = true
|
||
}
|
||
var out []Conflict
|
||
for _, as := range addresses {
|
||
a, err := netip.ParseAddr(as)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
a = a.Unmap()
|
||
for _, p := range ports {
|
||
if ours[a.String()+"#"+strconv.Itoa(p)] {
|
||
continue
|
||
}
|
||
for _, s := range socks {
|
||
if s.Port != p || !(s.Addr == a || s.Addr.IsUnspecified() && s.Addr.Is4() == a.Is4() || s.Addr.IsUnspecified() && s.Addr.Is6()) {
|
||
continue
|
||
}
|
||
what := "another program"
|
||
for l := range jarldOn {
|
||
if strings.HasSuffix(l, ":"+strconv.Itoa(p)) {
|
||
what = "jarld (Jarl's web UI and API)"
|
||
}
|
||
}
|
||
out = append(out, Conflict{Address: a.String(), Port: p, What: what})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// FreeDoHPort returns the first of the usual DoH ports nothing listens on
|
||
// at the addresses (0 when none is free).
|
||
func FreeDoHPort(st *model.State, addresses []string, jarld []string) int {
|
||
for _, p := range []int{443, 8443, 4443, 9443} {
|
||
if c, err := PortCheck(st, addresses, []int{p}, jarld); err == nil && len(c) == 0 {
|
||
return p
|
||
}
|
||
}
|
||
return 0
|
||
}
|