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>
256 lines
11 KiB
Go
256 lines
11 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)
|
|
}
|
|
}
|
|
|
|
// Each address gets its own views; a view no longer given loses it, and
|
|
// records of the host names that aren't the setup's stay.
|
|
func TestSetupAddressViews(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
|
e.submit(engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{Zone: "example.com", View: "external", Name: "doh.example.com.", Type: "A", Data: []string{"198.51.100.9"}, Local: model.Ptr(true)}))
|
|
hosts := func(st *model.State, view string) []string {
|
|
z := st.FindZone("example.com.", st.ViewByName(view).ID)
|
|
var out []string
|
|
for _, r := range st.ZoneRecords(z.ID) {
|
|
if strings.HasPrefix(r.Name, "dns.") || strings.HasPrefix(r.Name, "doh.") {
|
|
out = append(out, strings.SplitN(r.Name, ".", 2)[0]+" "+r.Type+" "+r.Data)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
setup := Setup{Hostnames: []string{"dns.example.com", "doh.example.com"}, Certificate: "dns", DoT: true, DoH: true,
|
|
Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
|
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}, "10.0.10.6": {"external"}},
|
|
PublicViews: map[string]bool{"external": true}}
|
|
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.ContainsFunc(notes, func(n string) bool {
|
|
return strings.Contains(n, "view external is reachable from the internet, but gets the private address 10.0.10.6")
|
|
}) {
|
|
t.Errorf("notes %q", notes)
|
|
}
|
|
e.submit(reqs...)
|
|
st := e.state()
|
|
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5", "doh A 10.0.10.5"}; !slices.Equal(got, want) {
|
|
t.Errorf("internal %q, want %q", got, want)
|
|
}
|
|
// The external doh record that isn't the setup's stays.
|
|
if got, want := hosts(st, "external"), []string{"dns A 10.0.10.6", "dns A 203.0.113.5", "doh A 10.0.10.6", "doh A 198.51.100.9", "doh A 203.0.113.5"}; !slices.Equal(got, want) {
|
|
t.Errorf("external %q, want %q", got, want)
|
|
}
|
|
if !slices.Equal(st.Settings.EncryptedDNSHosts, []string{"dns.example.com", "doh.example.com"}) {
|
|
t.Errorf("hosts %q", st.Settings.EncryptedDNSHosts)
|
|
}
|
|
av := CurrentAddressViews(st, st.Settings.EncryptedDNSHosts)
|
|
if !slices.Equal(av["10.0.10.5"], []string{"internal"}) || !slices.Equal(av["10.0.10.6"], []string{"external"}) {
|
|
t.Errorf("address views %v", av)
|
|
}
|
|
uses := CertUses(st, filepath.Join(e.certDir, "dns"))
|
|
if len(uses) != 2 || !strings.HasPrefix(uses[0], "DNS over TLS on 10.0.10.5, 203.0.113.5, 10.0.10.6 port 853") || !strings.HasPrefix(uses[1], "DNS over HTTPS on") {
|
|
t.Errorf("cert uses %q", uses)
|
|
}
|
|
|
|
// 10.0.10.6 no longer in external, doh.example.com no longer a host name.
|
|
setup = Setup{Hostname: "dns.example.com", Certificate: "dns", DoT: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
|
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}}}
|
|
reqs, _, err = Plan(st, setup, e.certDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.submit(reqs...)
|
|
st = e.state()
|
|
if got, want := hosts(st, "external"), []string{"dns A 203.0.113.5", "doh A 198.51.100.9"}; !slices.Equal(got, want) {
|
|
t.Errorf("external after %q, want %q", got, want)
|
|
}
|
|
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5"}; !slices.Equal(got, want) {
|
|
t.Errorf("internal after %q, want %q", got, want)
|
|
}
|
|
}
|