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.
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"io"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// dnsConfigFastInterval is the poll period that follows a network event.
|
|
dnsConfigFastInterval = 15 * time.Second
|
|
|
|
// dnsConfigSlowInterval is the poll period of a quiet host.
|
|
dnsConfigSlowInterval = 5 * time.Minute
|
|
|
|
// dnsConfigFastWindow is how long the fast period lasts. A resolver change
|
|
// that follows a network event arrives late, after the OS settles.
|
|
dnsConfigFastWindow = 10 * time.Minute
|
|
)
|
|
|
|
// scutilTablePrefix starts a resolver table. macOS prints the table for scoped
|
|
// queries after the main one, with the same line.
|
|
const scutilTablePrefix = "DNS configuration"
|
|
|
|
// scutilResolverPrefix starts one resolver block inside a table.
|
|
const scutilResolverPrefix = "resolver #"
|
|
|
|
// scutilDNSOutputLimit bounds the text of one read. A resolver table of a host
|
|
// is a few kilobytes, so a larger output does not come from a table.
|
|
const scutilDNSOutputLimit = 1 << 20
|
|
|
|
// errSCUtilDNSTooLarge ends a read that passed the bound.
|
|
var errSCUtilDNSTooLarge = errors.New("scutil --dns output is too large")
|
|
|
|
// dnsResolverEntry is one resolver of the DNS configuration of the host.
|
|
type dnsResolverEntry struct {
|
|
Order int
|
|
Nameservers []string
|
|
IfIndex int
|
|
Interface string
|
|
Flags string
|
|
SearchDomains []string
|
|
Reachable string
|
|
// Scoped marks a resolver of the table for scoped queries, which serves one
|
|
// interface. A second service keeps its own resolver there while the
|
|
// unscoped table stays the same.
|
|
Scoped bool
|
|
// Action tells what the diff saw: added, changed, or removed. A resolver
|
|
// without nameservers looks the same whether it came or went, so the event
|
|
// needs the word.
|
|
Action string
|
|
}
|
|
|
|
// The actions of a changed resolver.
|
|
const (
|
|
resolverActionAdded = "added"
|
|
resolverActionChanged = "changed"
|
|
resolverActionRemoved = "removed"
|
|
)
|
|
|
|
// parseSCUtilDNS reads the resolver tables of "scutil --dns": the unscoped
|
|
// table that answers a query of the host, then the table for scoped queries
|
|
// with one resolver per interface. Both tables count, because a second
|
|
// service can change its resolver while the unscoped table stays the same.
|
|
//
|
|
// A read that ends early or passes the bound gives an error and no table. A
|
|
// part of a table looks like resolvers that went away, and the caller must
|
|
// keep the table of the read before instead.
|
|
func parseSCUtilDNS(r io.Reader) ([]dnsResolverEntry, error) {
|
|
var entries []dnsResolverEntry
|
|
scoped := false
|
|
var entry *dnsResolverEntry
|
|
closeEntry := func() {
|
|
if entry == nil {
|
|
return
|
|
}
|
|
entry.Scoped = scoped
|
|
entries = append(entries, *entry)
|
|
entry = nil
|
|
}
|
|
read := 0
|
|
scanner := bufio.NewScanner(io.LimitReader(r, scutilDNSOutputLimit+1))
|
|
for scanner.Scan() {
|
|
read += len(scanner.Bytes()) + 1
|
|
if read > scutilDNSOutputLimit {
|
|
return nil, errSCUtilDNSTooLarge
|
|
}
|
|
line := strings.TrimSpace(scanner.Text())
|
|
switch {
|
|
case strings.HasPrefix(line, scutilTablePrefix):
|
|
closeEntry()
|
|
scoped = strings.Contains(line, "scoped")
|
|
case strings.HasPrefix(line, scutilResolverPrefix):
|
|
closeEntry()
|
|
entry = &dnsResolverEntry{}
|
|
case entry != nil:
|
|
applySCUtilField(entry, line)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
closeEntry()
|
|
return entries, nil
|
|
}
|
|
|
|
func applySCUtilField(entry *dnsResolverEntry, line string) {
|
|
name, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
return
|
|
}
|
|
name, value = strings.TrimSpace(name), strings.TrimSpace(value)
|
|
switch {
|
|
case strings.HasPrefix(name, "nameserver["):
|
|
entry.Nameservers = append(entry.Nameservers, value)
|
|
case strings.HasPrefix(name, "search domain["):
|
|
entry.SearchDomains = append(entry.SearchDomains, value)
|
|
case name == "if_index":
|
|
entry.IfIndex, entry.Interface = parseSCUtilIfIndex(value)
|
|
case name == "flags":
|
|
entry.Flags = value
|
|
case name == "reach":
|
|
entry.Reachable = scutilBracketText(value)
|
|
case name == "order":
|
|
entry.Order, _ = strconv.Atoi(value)
|
|
}
|
|
}
|
|
|
|
// parseSCUtilIfIndex reads an "if_index" value of the form "14 (en0)".
|
|
func parseSCUtilIfIndex(value string) (int, string) {
|
|
number, name, _ := strings.Cut(value, " ")
|
|
index, _ := strconv.Atoi(number)
|
|
return index, scutilBracketText(name)
|
|
}
|
|
|
|
// scutilBracketText returns the text in brackets, where scutil puts the
|
|
// readable form of a number.
|
|
func scutilBracketText(value string) string {
|
|
start := strings.Index(value, "(")
|
|
end := strings.LastIndex(value, ")")
|
|
if start < 0 || end < start {
|
|
return ""
|
|
}
|
|
return value[start+1 : end]
|
|
}
|
|
|
|
// resolverKey identifies one resolver across two reads. An unscoped resolver
|
|
// has an order, and several can share it, so the count of earlier entries
|
|
// with that order completes the key. A scoped resolver has no order line and
|
|
// serves one interface, so its interface index is its identity. The two
|
|
// tables are two key spaces.
|
|
type resolverKey struct {
|
|
scoped bool
|
|
order int
|
|
ifIndex int
|
|
occurrence int
|
|
}
|
|
|
|
func resolverKeys(entries []dnsResolverEntry) []resolverKey {
|
|
counts := make(map[resolverKey]int, len(entries))
|
|
keys := make([]resolverKey, len(entries))
|
|
for i, entry := range entries {
|
|
base := resolverKey{scoped: entry.Scoped, order: entry.Order}
|
|
if entry.Scoped {
|
|
base = resolverKey{scoped: true, ifIndex: entry.IfIndex}
|
|
}
|
|
key := base
|
|
key.occurrence = counts[base]
|
|
keys[i] = key
|
|
counts[base]++
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func equalResolverEntry(a, b dnsResolverEntry) bool {
|
|
return a.IfIndex == b.IfIndex &&
|
|
a.Interface == b.Interface &&
|
|
a.Flags == b.Flags &&
|
|
a.Reachable == b.Reachable &&
|
|
slices.Equal(a.Nameservers, b.Nameservers) &&
|
|
slices.Equal(a.SearchDomains, b.SearchDomains)
|
|
}
|
|
|
|
// diffResolverTables returns the resolvers that differ between two reads. A
|
|
// resolver that vanished comes back without nameservers, so the journal shows
|
|
// that the host dropped it.
|
|
func diffResolverTables(before, after []dnsResolverEntry) []dnsResolverEntry {
|
|
beforeKeys, afterKeys := resolverKeys(before), resolverKeys(after)
|
|
previous := make(map[resolverKey]dnsResolverEntry, len(before))
|
|
for i, key := range beforeKeys {
|
|
previous[key] = before[i]
|
|
}
|
|
current := make(map[resolverKey]struct{}, len(after))
|
|
for _, key := range afterKeys {
|
|
current[key] = struct{}{}
|
|
}
|
|
var changed []dnsResolverEntry
|
|
for i, key := range afterKeys {
|
|
known, ok := previous[key]
|
|
if ok && equalResolverEntry(known, after[i]) {
|
|
continue
|
|
}
|
|
entry := after[i]
|
|
entry.Action = resolverActionChanged
|
|
if !ok {
|
|
entry.Action = resolverActionAdded
|
|
}
|
|
changed = append(changed, entry)
|
|
}
|
|
for i, key := range beforeKeys {
|
|
if _, ok := current[key]; ok {
|
|
continue
|
|
}
|
|
vanished := before[i]
|
|
vanished.Nameservers = nil
|
|
vanished.Action = resolverActionRemoved
|
|
changed = append(changed, vanished)
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// dnsConfigPoller reads the DNS configuration of the host on a timer and
|
|
// reports the resolvers that changed since the read before.
|
|
type dnsConfigPoller struct {
|
|
run func() ([]dnsResolverEntry, error)
|
|
now func() time.Time
|
|
mu sync.Mutex
|
|
last []dnsResolverEntry
|
|
lastActivity time.Time
|
|
started bool
|
|
readFailed bool
|
|
// kick wakes a loop that waits on the slow delay. One pending signal is
|
|
// enough, so a storm of events does not queue polls.
|
|
kick chan struct{}
|
|
}
|
|
|
|
func newDNSConfigPoller(run func() ([]dnsResolverEntry, error)) *dnsConfigPoller {
|
|
return &dnsConfigPoller{run: run, now: time.Now, kick: make(chan struct{}, 1)}
|
|
}
|
|
|
|
// noteActivity keeps the poll fast for a while after the network moved, and
|
|
// wakes a loop that waits on the slow delay.
|
|
func (p *dnsConfigPoller) noteActivity(now time.Time) {
|
|
p.markActivity(now)
|
|
select {
|
|
case p.kick <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// markActivity records the time of the last network event.
|
|
func (p *dnsConfigPoller) markActivity(now time.Time) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.lastActivity = now
|
|
}
|
|
|
|
func (p *dnsConfigPoller) nextDelay(now time.Time) time.Duration {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.lastActivity.IsZero() || now.Sub(p.lastActivity) >= dnsConfigFastWindow {
|
|
return dnsConfigSlowInterval
|
|
}
|
|
return dnsConfigFastInterval
|
|
}
|
|
|
|
// pollOnce returns the resolvers that changed. The first table is the baseline
|
|
// of the process and reports no change. A failed read keeps the table of the
|
|
// read before, so a short failure does not report the whole table twice. The
|
|
// first failure and the read that ends it log once, so a host whose read
|
|
// never works says so.
|
|
func (p *dnsConfigPoller) pollOnce(now time.Time) []dnsResolverEntry {
|
|
table, err := p.run()
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if err != nil {
|
|
if !p.readFailed {
|
|
mainLog.Load().Warn().Err(err).Msg("DNS configuration read failed; the journal keeps the last table")
|
|
}
|
|
p.readFailed = true
|
|
return nil
|
|
}
|
|
if p.readFailed {
|
|
mainLog.Load().Info().Msg("DNS configuration read works again")
|
|
p.readFailed = false
|
|
}
|
|
if !p.started {
|
|
p.started = true
|
|
p.last = table
|
|
return nil
|
|
}
|
|
changed := diffResolverTables(p.last, table)
|
|
p.last = table
|
|
return changed
|
|
}
|
|
|
|
// dnsConfigDelayFn lets a test shorten the wait between two polls.
|
|
var dnsConfigDelayFn = (*dnsConfigPoller).nextDelay
|
|
|
|
// startLoop runs the poll loop and returns a channel that closes when the loop
|
|
// returns. A caller joins that channel before it reads the log.
|
|
func (p *dnsConfigPoller) startLoop(stop <-chan struct{}, emit func([]dnsResolverEntry)) <-chan struct{} {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
p.loop(stop, emit)
|
|
}()
|
|
return done
|
|
}
|
|
|
|
// loop polls until stop closes. The read starts a subprocess on macOS, so the
|
|
// delay follows the last activity and a quiet host pays little. The start of a
|
|
// run counts as activity, because the table of the host settles after the
|
|
// daemon comes up, and the read at entry is the baseline of the process.
|
|
func (p *dnsConfigPoller) loop(stop <-chan struct{}, emit func([]dnsResolverEntry)) {
|
|
now := p.now()
|
|
p.markActivity(now)
|
|
p.pollOnce(now)
|
|
delay := dnsConfigDelayFn(p, now)
|
|
deadline := now.Add(delay)
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-p.kick:
|
|
// noteActivity set the time just now, so the delay is the fast one.
|
|
// A kick only moves a deadline closer; a storm of kicks must not
|
|
// push the read back again and again.
|
|
now := p.now()
|
|
wanted := now.Add(dnsConfigDelayFn(p, now))
|
|
if !wanted.Before(deadline) {
|
|
continue
|
|
}
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
timer.Reset(wanted.Sub(now))
|
|
deadline = wanted
|
|
continue
|
|
case <-timer.C:
|
|
}
|
|
now := p.now()
|
|
if changed := p.pollOnce(now); len(changed) > 0 {
|
|
emit(changed)
|
|
}
|
|
delay := dnsConfigDelayFn(p, now)
|
|
deadline = now.Add(delay)
|
|
timer.Reset(delay)
|
|
}
|
|
}
|