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>
189 lines
7.3 KiB
Go
189 lines
7.3 KiB
Go
package encdns
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"jarl/internal/auth"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/model"
|
|
"jarl/internal/render"
|
|
"jarl/internal/store"
|
|
)
|
|
|
|
type env struct {
|
|
t *testing.T
|
|
eng *engine.Engine
|
|
st *store.Store
|
|
certDir string
|
|
}
|
|
|
|
func newEnv(t *testing.T) *env {
|
|
dir := t.TempDir()
|
|
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
eng := engine.New(st, nil, engine.Config{
|
|
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
|
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
e := &env{t: t, eng: eng, st: st, certDir: filepath.Join(dir, "tls")}
|
|
os.MkdirAll(filepath.Join(e.certDir, "dns"), 0o755)
|
|
os.WriteFile(filepath.Join(e.certDir, "dns", "fullchain.pem"), []byte("x"), 0o644)
|
|
os.WriteFile(filepath.Join(e.certDir, "dns", "privkey.pem"), []byte("x"), 0o600)
|
|
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
|
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
|
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
|
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
|
return e
|
|
}
|
|
|
|
func (e *env) submit(reqs ...engine.Request) *engine.Result {
|
|
e.t.Helper()
|
|
res, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{})
|
|
if err != nil {
|
|
e.t.Fatalf("%v (%+v)", err, res)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func (e *env) state() *model.State {
|
|
st, err := e.st.LoadState(context.Background())
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func values(st *model.State, scope, name string) []string {
|
|
var out []string
|
|
for _, o := range st.ScopedOptions(scope, 0) {
|
|
if o.Name == name {
|
|
out = append(out, o.Value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestSetup(t *testing.T) {
|
|
e := newEnv(t)
|
|
setup := Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5", "2001:db8::5"}, DoT: true, DoH: true, Views: []string{"internal", "external"}}
|
|
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.submit(reqs...)
|
|
st := e.state()
|
|
tls := values(st, model.ScopeTopLevel, "tls")
|
|
if len(tls) != 1 || !strings.HasPrefix(tls[0], "jarl-dns {") || !strings.Contains(tls[0], filepath.Join(e.certDir, "dns", "fullchain.pem")) {
|
|
t.Errorf("tls = %q", tls)
|
|
}
|
|
// The plain listeners named had by default are kept explicitly.
|
|
want4 := []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }", "port 443 tls jarl-dns http default { 10.0.10.5; }"}
|
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, want4) {
|
|
t.Errorf("listen-on = %q, want %q", got, want4)
|
|
}
|
|
if got := values(st, model.ScopeOptions, "listen-on-v6"); len(got) != 3 || got[0] != "{ any; }" {
|
|
t.Errorf("listen-on-v6 = %q", got)
|
|
}
|
|
if len(notes) != 3 || !strings.Contains(strings.Join(notes, " "), "external has no zone") {
|
|
t.Errorf("notes = %q", notes)
|
|
}
|
|
// The host name's addresses in the internal view.
|
|
z := st.FindZone("example.com.", st.ViewByName("internal").ID)
|
|
var addrs []string
|
|
for _, r := range st.ZoneRecords(z.ID) {
|
|
if r.Name == "dns.example.com." {
|
|
addrs = append(addrs, r.Type+" "+r.Data)
|
|
}
|
|
}
|
|
if !slices.Equal(addrs, []string{"A 10.0.10.5", "AAAA 2001:db8::5"}) {
|
|
t.Errorf("records = %q", addrs)
|
|
}
|
|
s := GetStatus(st, e.certDir)
|
|
if !s.Enabled || s.Certificate != "dns" || len(s.DoT) != 2 || len(s.DoH) != 2 || s.DoH[0].Port != 443 || s.Other {
|
|
t.Errorf("status = %+v", s)
|
|
}
|
|
|
|
// Setting up again replaces the setup: DoT only, one address, and the
|
|
// plain listeners aren't doubled.
|
|
reqs, _, err = Plan(st, Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, e.certDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.submit(reqs...)
|
|
st = e.state()
|
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }"}) {
|
|
t.Errorf("after the second setup: %q", got)
|
|
}
|
|
if got := values(st, model.ScopeOptions, "listen-on-v6"); !slices.Equal(got, []string{"{ any; }"}) {
|
|
t.Errorf("listen-on-v6 after the second setup: %q", got)
|
|
}
|
|
if got := values(st, model.ScopeTopLevel, "tls"); len(got) != 1 {
|
|
t.Errorf("tls statements: %q", got)
|
|
}
|
|
|
|
// Off: the setup's listeners and tls statement go, the rest stays.
|
|
e.submit(Disable(st)...)
|
|
st = e.state()
|
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }"}) || len(values(st, model.ScopeTopLevel, "tls")) != 0 || GetStatus(st, e.certDir).Enabled {
|
|
t.Errorf("after disabling: listen-on %q tls %q", got, values(st, model.ScopeTopLevel, "tls"))
|
|
}
|
|
}
|
|
|
|
func TestSetupRefuses(t *testing.T) {
|
|
e := newEnv(t)
|
|
st := e.state()
|
|
for _, s := range []Setup{
|
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}}, // neither DoT nor DoH
|
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"0.0.0.0"}, DoT: true}, // every address
|
|
{Hostname: "dns.example.com", Certificate: "dns", DoT: true}, // no address
|
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoH: true, DoHPort: 53}, // port 53
|
|
{Hostname: "dns", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, // not a host name
|
|
} {
|
|
if _, _, err := Plan(st, s, e.certDir); err == nil {
|
|
t.Errorf("accepted %+v", s)
|
|
}
|
|
}
|
|
// A tls statement whose files are missing is refused by validation.
|
|
_, err := e.eng.Submit(context.Background(), []engine.Request{engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{
|
|
Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(`x { cert-file "/nonexistent/c.pem"; key-file "/nonexistent/k.pem"; }`)})},
|
|
auth.System, "test", "", engine.ApplyOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "doesn't exist") {
|
|
t.Errorf("missing files: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPorts(t *testing.T) {
|
|
s, err := parseProcAddr("0100007F:0035")
|
|
if err != nil || s.Addr != netip.MustParseAddr("127.0.0.1") || s.Port != 53 {
|
|
t.Errorf("v4: %+v %v", s, err)
|
|
}
|
|
s, err = parseProcAddr("00000000000000000000000001000000:01BB")
|
|
if err != nil || s.Addr != netip.MustParseAddr("::1") || s.Port != 443 {
|
|
t.Errorf("v6: %+v %v", s, err)
|
|
}
|
|
e := newEnv(t)
|
|
old := ListeningSockets
|
|
t.Cleanup(func() { ListeningSockets = old })
|
|
ListeningSockets = func() ([]Socket, error) {
|
|
return []Socket{{netip.MustParseAddr("0.0.0.0"), 443}, {netip.MustParseAddr("10.0.10.5"), 8443}, {netip.MustParseAddr("::"), 853}}, nil
|
|
}
|
|
c, err := PortCheck(e.state(), []string{"10.0.10.5"}, []int{853, 443, 8443}, []string{"0.0.0.0:443"})
|
|
if err != nil || len(c) != 3 || c[1].What != "jarld (Jarl's web UI and API)" || c[2].What != "another program" {
|
|
t.Errorf("conflicts = %+v %v", c, err)
|
|
}
|
|
if p := FreeDoHPort(e.state(), []string{"10.0.10.5"}, nil); p != 4443 {
|
|
t.Errorf("free DoH port = %d, want 4443", p)
|
|
}
|
|
}
|