mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
The journal records the network state of the host and each change of that state. The support team reads one file to see the interfaces, the routes, the resolvers, and the recoveries around an incident. The same file shows the health of the query path. A change that touches only AirDrop or virtual interfaces runs no DNS work.
599 lines
20 KiB
Go
599 lines
20 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const scutilDNSFixture = `DNS configuration
|
|
|
|
resolver #1
|
|
search domain[0] : corp.example
|
|
nameserver[0] : 192.168.50.200
|
|
nameserver[1] : 192.168.50.1
|
|
if_index : 16 (en0)
|
|
flags : Request A records, Request AAAA records
|
|
reach : 0x00020002 (Reachable,Directly Reachable Address)
|
|
order : 200000
|
|
|
|
resolver #2
|
|
nameserver[0] : 100.100.100.100
|
|
if_index : 28 (utun4)
|
|
flags : Supplemental, Request A records, Request AAAA records
|
|
reach : 0x00000003 (Reachable,Transient Connection)
|
|
order : 101600
|
|
|
|
resolver #3
|
|
domain : local
|
|
options : mdns
|
|
timeout : 5
|
|
flags : Request A records
|
|
reach : 0x00000000 (Not Reachable)
|
|
order : 300000
|
|
|
|
DNS configuration (for scoped queries)
|
|
|
|
resolver #1
|
|
nameserver[0] : 192.168.50.200
|
|
if_index : 16 (en0)
|
|
flags : Scoped, Request A records
|
|
reach : 0x00020002 (Reachable,Directly Reachable Address)
|
|
`
|
|
|
|
const scutilDNSScopedOnlyFixture = `DNS configuration
|
|
|
|
DNS configuration (for scoped queries)
|
|
|
|
resolver #1
|
|
nameserver[0] : 192.168.50.200
|
|
if_index : 16 (en0)
|
|
flags : Scoped, Request A records
|
|
reach : 0x00020002 (Reachable,Directly Reachable Address)
|
|
`
|
|
|
|
var errStubResolverRun = errors.New("scutil failed")
|
|
|
|
func wifiResolverEntry() dnsResolverEntry {
|
|
return dnsResolverEntry{
|
|
Order: 200000,
|
|
Nameservers: []string{"192.168.50.200", "192.168.50.1"},
|
|
IfIndex: 16,
|
|
Interface: "en0",
|
|
Flags: "Request A records, Request AAAA records",
|
|
SearchDomains: []string{"corp.example"},
|
|
Reachable: "Reachable,Directly Reachable Address",
|
|
}
|
|
}
|
|
|
|
func vpnResolverEntry() dnsResolverEntry {
|
|
return dnsResolverEntry{
|
|
Order: 101600,
|
|
Nameservers: []string{"100.100.100.100"},
|
|
IfIndex: 28,
|
|
Interface: "utun4",
|
|
Flags: "Supplemental, Request A records, Request AAAA records",
|
|
Reachable: "Reachable,Transient Connection",
|
|
}
|
|
}
|
|
|
|
func mdnsResolverEntry() dnsResolverEntry {
|
|
return dnsResolverEntry{
|
|
Order: 300000,
|
|
Flags: "Request A records",
|
|
Reachable: "Not Reachable",
|
|
}
|
|
}
|
|
|
|
func Test_parseSCUtilDNS(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []dnsResolverEntry
|
|
}{
|
|
{
|
|
name: "both tables",
|
|
input: scutilDNSFixture,
|
|
want: []dnsResolverEntry{wifiResolverEntry(), vpnResolverEntry(), mdnsResolverEntry(), {
|
|
Nameservers: []string{"192.168.50.200"},
|
|
IfIndex: 16,
|
|
Interface: "en0",
|
|
Flags: "Scoped, Request A records",
|
|
Reachable: "Reachable,Directly Reachable Address",
|
|
Scoped: true,
|
|
}},
|
|
},
|
|
{
|
|
name: "scoped table when the first is empty",
|
|
input: scutilDNSScopedOnlyFixture,
|
|
want: []dnsResolverEntry{{
|
|
Nameservers: []string{"192.168.50.200"},
|
|
IfIndex: 16,
|
|
Interface: "en0",
|
|
Flags: "Scoped, Request A records",
|
|
Reachable: "Reachable,Directly Reachable Address",
|
|
Scoped: true,
|
|
}},
|
|
},
|
|
{
|
|
name: "no output",
|
|
input: "",
|
|
want: nil,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := parseSCUtilDNS(strings.NewReader(tc.input))
|
|
if err != nil {
|
|
t.Fatalf("parseSCUtilDNS() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("parseSCUtilDNS() = %+v, want %+v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_diffResolverTables(t *testing.T) {
|
|
changedWiFi := wifiResolverEntry()
|
|
changedWiFi.Nameservers = []string{"10.0.0.1"}
|
|
changedWiFi.Action = resolverActionChanged
|
|
vanishedVPN := vpnResolverEntry()
|
|
vanishedVPN.Nameservers = nil
|
|
vanishedVPN.Action = resolverActionRemoved
|
|
appearedVPN := vpnResolverEntry()
|
|
appearedVPN.Action = resolverActionAdded
|
|
|
|
tests := []struct {
|
|
name string
|
|
before []dnsResolverEntry
|
|
after []dnsResolverEntry
|
|
want []dnsResolverEntry
|
|
}{
|
|
{
|
|
name: "no change",
|
|
before: []dnsResolverEntry{wifiResolverEntry(), vpnResolverEntry()},
|
|
after: []dnsResolverEntry{wifiResolverEntry(), vpnResolverEntry()},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "one resolver changed",
|
|
before: []dnsResolverEntry{wifiResolverEntry(), vpnResolverEntry()},
|
|
after: []dnsResolverEntry{changedWiFi, vpnResolverEntry()},
|
|
want: []dnsResolverEntry{changedWiFi},
|
|
},
|
|
{
|
|
name: "one resolver vanished",
|
|
before: []dnsResolverEntry{wifiResolverEntry(), vpnResolverEntry()},
|
|
after: []dnsResolverEntry{wifiResolverEntry()},
|
|
want: []dnsResolverEntry{vanishedVPN},
|
|
},
|
|
{
|
|
name: "one resolver appeared",
|
|
before: []dnsResolverEntry{wifiResolverEntry()},
|
|
after: []dnsResolverEntry{wifiResolverEntry(), vpnResolverEntry()},
|
|
want: []dnsResolverEntry{appearedVPN},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := diffResolverTables(tc.before, tc.after)
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("diffResolverTables() = %+v, want %+v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_diffResolverTablesKeepsTheInput(t *testing.T) {
|
|
before := []dnsResolverEntry{vpnResolverEntry()}
|
|
diffResolverTables(before, nil)
|
|
if !reflect.DeepEqual(before[0], vpnResolverEntry()) {
|
|
t.Fatalf("before entry = %+v, want %+v", before[0], vpnResolverEntry())
|
|
}
|
|
}
|
|
|
|
// resolverRunResult is one answer of the stub runner.
|
|
type resolverRunResult struct {
|
|
table []dnsResolverEntry
|
|
err error
|
|
}
|
|
|
|
// stubResolverRunner answers one result per call and repeats the last one.
|
|
type stubResolverRunner struct {
|
|
mu sync.Mutex
|
|
results []resolverRunResult
|
|
calls int
|
|
}
|
|
|
|
func (s *stubResolverRunner) run() ([]dnsResolverEntry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
result := s.results[min(s.calls, len(s.results)-1)]
|
|
s.calls++
|
|
return result.table, result.err
|
|
}
|
|
|
|
func Test_dnsConfigPollerNextDelay(t *testing.T) {
|
|
base := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
activity bool
|
|
elapsed time.Duration
|
|
want time.Duration
|
|
}{
|
|
{name: "nine minutes after activity", activity: true, elapsed: 9 * time.Minute, want: dnsConfigFastInterval},
|
|
{name: "eleven minutes after activity", activity: true, elapsed: 11 * time.Minute, want: dnsConfigSlowInterval},
|
|
{name: "no activity yet", elapsed: time.Minute, want: dnsConfigSlowInterval},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
poller := newDNSConfigPoller(func() ([]dnsResolverEntry, error) { return nil, nil })
|
|
if tc.activity {
|
|
poller.noteActivity(base)
|
|
}
|
|
if got := poller.nextDelay(base.Add(tc.elapsed)); got != tc.want {
|
|
t.Fatalf("nextDelay() = %s, want %s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_dnsConfigPollerPollOnce(t *testing.T) {
|
|
now := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
|
|
baseline := []dnsResolverEntry{wifiResolverEntry(), vpnResolverEntry()}
|
|
changedWiFi := wifiResolverEntry()
|
|
changedWiFi.Nameservers = []string{"10.0.0.1"}
|
|
changedWiFi.Action = resolverActionChanged
|
|
runner := &stubResolverRunner{results: []resolverRunResult{
|
|
{table: baseline},
|
|
{err: errStubResolverRun},
|
|
{table: baseline},
|
|
{table: []dnsResolverEntry{changedWiFi, vpnResolverEntry()}},
|
|
}}
|
|
poller := newDNSConfigPoller(runner.run)
|
|
|
|
if got := poller.pollOnce(now); got != nil {
|
|
t.Fatalf("baseline poll = %+v, want nil", got)
|
|
}
|
|
if got := poller.pollOnce(now.Add(time.Minute)); got != nil {
|
|
t.Fatalf("failed poll = %+v, want nil", got)
|
|
}
|
|
if got := poller.pollOnce(now.Add(2 * time.Minute)); got != nil {
|
|
t.Fatalf("poll after a failure = %+v, want nil", got)
|
|
}
|
|
want := []dnsResolverEntry{changedWiFi}
|
|
if got := poller.pollOnce(now.Add(3 * time.Minute)); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("changed poll = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func Test_dnsConfigPollerLoop(t *testing.T) {
|
|
previous := dnsConfigDelayFn
|
|
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration { return time.Millisecond }
|
|
t.Cleanup(func() { dnsConfigDelayFn = previous })
|
|
|
|
changedWiFi := wifiResolverEntry()
|
|
changedWiFi.Nameservers = []string{"10.0.0.1"}
|
|
changedWiFi.Action = resolverActionChanged
|
|
runner := &stubResolverRunner{results: []resolverRunResult{
|
|
{table: []dnsResolverEntry{wifiResolverEntry()}},
|
|
{table: []dnsResolverEntry{changedWiFi}},
|
|
}}
|
|
poller := newDNSConfigPoller(runner.run)
|
|
|
|
emitted := make(chan []dnsResolverEntry, 4)
|
|
stop := make(chan struct{})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
poller.loop(stop, func(changed []dnsResolverEntry) { emitted <- changed })
|
|
}()
|
|
|
|
select {
|
|
case got := <-emitted:
|
|
want := []dnsResolverEntry{changedWiFi}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("emitted = %+v, want %+v", got, want)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the loop emitted no change")
|
|
}
|
|
|
|
close(stop)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the loop did not stop")
|
|
}
|
|
}
|
|
|
|
// Test_dnsConfigPollerLoopWakesOnActivity proves that a network event does not
|
|
// wait for a sleeping timer. The delay is one hour until the event arrives, so
|
|
// a loop without the kick would poll after the test deadline.
|
|
func Test_dnsConfigPollerLoopWakesOnActivity(t *testing.T) {
|
|
var kicked atomic.Bool
|
|
parked := make(chan struct{}, 1)
|
|
previous := dnsConfigDelayFn
|
|
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration {
|
|
if kicked.Load() {
|
|
return time.Millisecond
|
|
}
|
|
select {
|
|
case parked <- struct{}{}:
|
|
default:
|
|
}
|
|
return time.Hour
|
|
}
|
|
t.Cleanup(func() { dnsConfigDelayFn = previous })
|
|
|
|
changedWiFi := wifiResolverEntry()
|
|
changedWiFi.Nameservers = []string{"10.0.0.1"}
|
|
changedWiFi.Action = resolverActionChanged
|
|
runner := &stubResolverRunner{results: []resolverRunResult{
|
|
{table: []dnsResolverEntry{wifiResolverEntry()}},
|
|
{table: []dnsResolverEntry{changedWiFi}},
|
|
}}
|
|
poller := newDNSConfigPoller(runner.run)
|
|
|
|
emitted := make(chan []dnsResolverEntry, 4)
|
|
stop := make(chan struct{})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
poller.loop(stop, func(changed []dnsResolverEntry) { emitted <- changed })
|
|
}()
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
<-done
|
|
})
|
|
|
|
// The kick matters only once the loop waits on the long delay.
|
|
select {
|
|
case <-parked:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the loop did not park on the long delay")
|
|
}
|
|
kicked.Store(true)
|
|
poller.noteActivity(time.Now())
|
|
|
|
select {
|
|
case got := <-emitted:
|
|
want := []dnsResolverEntry{changedWiFi}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("emitted = %+v, want %+v", got, want)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the loop polled no table after the activity")
|
|
}
|
|
}
|
|
|
|
// Test_dnsConfigPollerLoopTakesABaseline proves that the loop reads the table
|
|
// once at entry. Without that read the first change after the start reports
|
|
// the whole table.
|
|
func Test_dnsConfigPollerLoopTakesABaseline(t *testing.T) {
|
|
previous := dnsConfigDelayFn
|
|
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration { return time.Hour }
|
|
t.Cleanup(func() { dnsConfigDelayFn = previous })
|
|
|
|
runner := &stubResolverRunner{results: []resolverRunResult{{table: []dnsResolverEntry{wifiResolverEntry()}}}}
|
|
poller := newDNSConfigPoller(runner.run)
|
|
|
|
stop := make(chan struct{})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
poller.loop(stop, func([]dnsResolverEntry) { t.Error("the baseline read reported a change") })
|
|
}()
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
runner.mu.Lock()
|
|
calls := runner.calls
|
|
runner.mu.Unlock()
|
|
if calls > 0 {
|
|
break
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
close(stop)
|
|
<-done
|
|
|
|
runner.mu.Lock()
|
|
defer runner.mu.Unlock()
|
|
if runner.calls != 1 {
|
|
t.Fatalf("reads at the loop entry: got %d, want 1", runner.calls)
|
|
}
|
|
if !poller.started {
|
|
t.Fatal("the loop took no baseline")
|
|
}
|
|
}
|
|
|
|
// oversizedSCUtilDNS is a table with one line that no scanner buffer holds. A
|
|
// stalled or hostile process produces output of this shape.
|
|
func oversizedSCUtilDNS() string {
|
|
return "DNS configuration\n\nresolver #1\n nameserver[0] : " + strings.Repeat("9", 70*1024) + "\n"
|
|
}
|
|
|
|
// Test_parseSCUtilDNSRejectsAnOversizedLine proves that a line the scanner
|
|
// cannot hold ends the read with an error. A part of a table must not pass for
|
|
// the table of the host.
|
|
func Test_parseSCUtilDNSRejectsAnOversizedLine(t *testing.T) {
|
|
got, err := parseSCUtilDNS(strings.NewReader(oversizedSCUtilDNS()))
|
|
if err == nil {
|
|
t.Fatal("parseSCUtilDNS accepted a 70 KiB line")
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("parseSCUtilDNS() = %+v, want nil", got)
|
|
}
|
|
}
|
|
|
|
// Test_parseSCUtilDNSRejectsAnOversizedOutput proves that output past the
|
|
// bound ends the read with an error instead of a table cut in the middle.
|
|
func Test_parseSCUtilDNSRejectsAnOversizedOutput(t *testing.T) {
|
|
block := "resolver #1\n nameserver[0] : 192.168.50.200\n order : 200000\n"
|
|
repeats := scutilDNSOutputLimit/len(block) + 2
|
|
got, err := parseSCUtilDNS(strings.NewReader("DNS configuration\n\n" + strings.Repeat(block, repeats)))
|
|
if err == nil {
|
|
t.Fatal("parseSCUtilDNS accepted output past the bound")
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("parseSCUtilDNS() = %+v, want nil", got)
|
|
}
|
|
}
|
|
|
|
// Test_dnsConfigPollerKeepsTheTableOnAnOversizedRead proves that a read the
|
|
// parser rejects reports nothing and keeps the table of the read before.
|
|
func Test_dnsConfigPollerKeepsTheTableOnAnOversizedRead(t *testing.T) {
|
|
now := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
|
|
outputs := []string{scutilDNSFixture, oversizedSCUtilDNS(), scutilDNSFixture}
|
|
calls := 0
|
|
poller := newDNSConfigPoller(func() ([]dnsResolverEntry, error) {
|
|
output := outputs[min(calls, len(outputs)-1)]
|
|
calls++
|
|
return parseSCUtilDNS(strings.NewReader(output))
|
|
})
|
|
|
|
if got := poller.pollOnce(now); got != nil {
|
|
t.Fatalf("baseline poll = %+v, want nil", got)
|
|
}
|
|
if got := poller.pollOnce(now.Add(time.Minute)); got != nil {
|
|
t.Fatalf("oversized poll = %+v, want nil", got)
|
|
}
|
|
if got := poller.pollOnce(now.Add(2 * time.Minute)); got != nil {
|
|
t.Fatalf("poll after the oversized read = %+v, want nil", got)
|
|
}
|
|
}
|
|
|
|
// scutilTwoTables renders a host with one unscoped resolver and one scoped
|
|
// resolver, the shape of a Mac with two active services.
|
|
func scutilTwoTables(unscoped, scoped string) string {
|
|
return "DNS configuration\n\nresolver #1\n nameserver[0] : " + unscoped + "\n if_index : 14 (en0)\n" +
|
|
" flags : Request A records\n reach : 0x00020002 (Reachable,Directly Reachable Address)\n\n" +
|
|
"DNS configuration (for scoped queries)\n\nresolver #1\n nameserver[0] : " + scoped + "\n if_index : 15 (en1)\n" +
|
|
" flags : Scoped, Request A records\n reach : 0x00020002 (Reachable,Directly Reachable Address)\n"
|
|
}
|
|
|
|
// Test_diffResolverTablesSeesAScopedChange changes the scoped resolver of a
|
|
// second service while the unscoped resolver stays. The diff must report the
|
|
// scoped change, because the two tables are not copies of each other.
|
|
func Test_diffResolverTablesSeesAScopedChange(t *testing.T) {
|
|
before, err := parseSCUtilDNS(strings.NewReader(scutilTwoTables("192.168.1.1", "10.0.0.1")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := parseSCUtilDNS(strings.NewReader(scutilTwoTables("192.168.1.1", "9.9.9.9")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := diffResolverTables(before, after)
|
|
if len(changed) != 1 || len(changed[0].Nameservers) != 1 || changed[0].Nameservers[0] != "9.9.9.9" {
|
|
t.Fatalf("changed resolvers = %+v, want the scoped resolver with 9.9.9.9", changed)
|
|
}
|
|
if !changed[0].Scoped || before[0].Scoped || len(before) != 2 {
|
|
t.Fatalf("scope marks: changed %v, before %+v; want the scoped resolver marked and the unscoped one not", changed[0].Scoped, before)
|
|
}
|
|
}
|
|
|
|
// Test_dnsConfigPollerPollsUnderContinuousActivity kicks the poller faster
|
|
// than its fast interval. A kick must not push the read back again and again,
|
|
// or a host in a storm of transitions never reports a resolver change.
|
|
func Test_dnsConfigPollerPollsUnderContinuousActivity(t *testing.T) {
|
|
var reads atomic.Int64
|
|
changed := wifiResolverEntry()
|
|
changed.Nameservers = []string{"9.9.9.9"}
|
|
p := newDNSConfigPoller(func() ([]dnsResolverEntry, error) {
|
|
if reads.Add(1) == 1 {
|
|
return []dnsResolverEntry{wifiResolverEntry()}, nil
|
|
}
|
|
return []dnsResolverEntry{changed}, nil
|
|
})
|
|
origDelay := dnsConfigDelayFn
|
|
t.Cleanup(func() { dnsConfigDelayFn = origDelay })
|
|
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration { return 50 * time.Millisecond }
|
|
|
|
var emits atomic.Int64
|
|
stop := make(chan struct{})
|
|
done := p.startLoop(stop, func([]dnsResolverEntry) { emits.Add(1) })
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
p.noteActivity(time.Now())
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
close(stop)
|
|
<-done
|
|
|
|
if emits.Load() == 0 {
|
|
t.Fatalf("no resolver event during 500 ms of activity (reads %d)", reads.Load())
|
|
}
|
|
}
|
|
|
|
// scutilScopedTable renders the scoped table the way macOS prints it: one
|
|
// resolver per interface and no order line.
|
|
func scutilScopedTable(resolvers ...[2]string) string {
|
|
var b strings.Builder
|
|
b.WriteString("DNS configuration\n\nresolver #1\n nameserver[0] : 100.100.100.100\n if_index : 28 (utun4)\n flags : Request A records, Request AAAA records\n reach : 0x00000003 (Reachable,Transient Connection)\n order : 200000\n\n")
|
|
b.WriteString("DNS configuration (for scoped queries)\n\n")
|
|
for i, resolver := range resolvers {
|
|
fmt.Fprintf(&b, "resolver #%d\n nameserver[0] : %s\n if_index : %s\n flags : Scoped, Request A records\n reach : 0x00020002 (Reachable,Directly Reachable Address)\n\n", i+1, resolver[1], resolver[0])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Test_diffResolverTablesKeysScopedResolversByInterface inserts a scoped
|
|
// resolver between two others and removes it again. The scoped table has no
|
|
// order line, so the interface is the identity, and the diff must name the
|
|
// resolver that came and went, not its neighbors.
|
|
func Test_diffResolverTablesKeysScopedResolversByInterface(t *testing.T) {
|
|
en0 := [2]string{"16 (en0)", "192.168.50.200"}
|
|
en7 := [2]string{"30 (en7)", "10.9.9.1"}
|
|
utun := [2]string{"28 (utun4)", "100.100.100.100"}
|
|
before, err := parseSCUtilDNS(strings.NewReader(scutilScopedTable(en0, utun)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := parseSCUtilDNS(strings.NewReader(scutilScopedTable(en0, en7, utun)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
inserted := diffResolverTables(before, after)
|
|
if len(inserted) != 1 || inserted[0].Interface != "en7" || inserted[0].Nameservers == nil {
|
|
t.Fatalf("insert of en7 reported %+v, want en7 alone with its nameserver", inserted)
|
|
}
|
|
removed := diffResolverTables(after, before)
|
|
if len(removed) != 1 || removed[0].Interface != "en7" || removed[0].Nameservers != nil {
|
|
t.Fatalf("removal of en7 reported %+v, want en7 alone without nameservers", removed)
|
|
}
|
|
}
|
|
|
|
// Test_diffResolverTablesNamesTheAction tells a resolver that came from one
|
|
// that went. An mDNS resolver has no nameservers, so without the action the
|
|
// two events read the same.
|
|
func Test_diffResolverTablesNamesTheAction(t *testing.T) {
|
|
mdns := "DNS configuration\n\nresolver #1\n domain : local\n options : mdns\n timeout : 5\n flags : Request A records, Request AAAA records\n reach : 0x00000000 (Not Reachable)\n order : 300000\n"
|
|
table, err := parseSCUtilDNS(strings.NewReader(mdns))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appeared := diffResolverTables(nil, table)
|
|
vanished := diffResolverTables(table, nil)
|
|
if len(appeared) != 1 || appeared[0].Action != resolverActionAdded {
|
|
t.Fatalf("appeared = %+v, want one resolver with action added", appeared)
|
|
}
|
|
if len(vanished) != 1 || vanished[0].Action != resolverActionRemoved {
|
|
t.Fatalf("vanished = %+v, want one resolver with action removed", vanished)
|
|
}
|
|
moved := wifiResolverEntry()
|
|
moved.Nameservers = []string{"9.9.9.9"}
|
|
changed := diffResolverTables([]dnsResolverEntry{wifiResolverEntry()}, []dnsResolverEntry{moved})
|
|
if len(changed) != 1 || changed[0].Action != resolverActionChanged {
|
|
t.Fatalf("changed = %+v, want one resolver with action changed", changed)
|
|
}
|
|
}
|