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.
206 lines
5.7 KiB
Go
206 lines
5.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"maps"
|
|
"slices"
|
|
"sync"
|
|
"tailscale.com/net/netmon"
|
|
"time"
|
|
)
|
|
|
|
// noiseSummaryInterval bounds a storm of noise deltas to one journal line,
|
|
// because AirDrop and virtual adapters can churn for hours.
|
|
const noiseSummaryInterval = 10 * time.Minute
|
|
|
|
// wakeReportInterval merges the reports of the wake sources. netmon polls for
|
|
// a time jump every 15 s, so one wake can reach ctrld over half a minute.
|
|
const wakeReportInterval = 30 * time.Second
|
|
|
|
// repeatLogger holds the last value of each named line, so a line that
|
|
// repeats every few seconds reaches the log on change only.
|
|
type repeatLogger struct {
|
|
mu sync.Mutex
|
|
last map[string]string
|
|
repeats map[string]uint64
|
|
}
|
|
|
|
// changed reports whether the value of a key differs from the value before it.
|
|
// The repeat count tells how many lines the caller held back, so the next line
|
|
// it writes can carry them.
|
|
func (r *repeatLogger) changed(key, value string) (changed bool, repeats uint64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.last == nil {
|
|
r.last = make(map[string]string)
|
|
r.repeats = make(map[string]uint64)
|
|
}
|
|
if previous, seen := r.last[key]; seen && previous == value {
|
|
r.repeats[key]++
|
|
return false, r.repeats[key]
|
|
}
|
|
repeats = r.repeats[key]
|
|
r.last[key] = value
|
|
r.repeats[key] = 0
|
|
return true, repeats
|
|
}
|
|
|
|
// wakeHoldInterval is the time that a wake report without a gap waits for the
|
|
// detector. The detector ticks every two seconds, so it reports well inside
|
|
// this hold when it runs.
|
|
const wakeHoldInterval = 8 * time.Second
|
|
|
|
// wakeReport is one wake as a source saw it.
|
|
type wakeReport struct {
|
|
source string
|
|
gap time.Duration
|
|
state *netmon.State
|
|
}
|
|
|
|
// wakeReporter keeps one report per wake. netmon measures no gap, so its
|
|
// report waits for the detector, which measures one. When no detector report
|
|
// comes, the held report goes out as it is.
|
|
type wakeReporter struct {
|
|
mu sync.Mutex
|
|
last time.Time
|
|
held *wakeReport
|
|
// after arms the hold timer. nil means time.AfterFunc, and a test fires
|
|
// the hold itself.
|
|
after func(time.Duration, func())
|
|
}
|
|
|
|
// note takes one report of a wake and hands the report that the journal gets
|
|
// to emit. The first source of a window reports. A report with a gap replaces
|
|
// a held report without one. Every other report of the window is the same
|
|
// wake and drops.
|
|
func (w *wakeReporter) note(now time.Time, report wakeReport, emit func(wakeReport)) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
gapKnown := report.gap > 0
|
|
if !w.last.IsZero() && now.Sub(w.last) < wakeReportInterval {
|
|
if w.held != nil && gapKnown {
|
|
w.held = nil
|
|
emit(report)
|
|
}
|
|
return
|
|
}
|
|
w.last = now
|
|
if gapKnown {
|
|
w.held = nil
|
|
emit(report)
|
|
return
|
|
}
|
|
held := &wakeReport{source: report.source, gap: report.gap, state: report.state}
|
|
w.held = held
|
|
w.afterFunc()(wakeHoldInterval, func() { w.release(held, emit) })
|
|
}
|
|
|
|
// release emits a held report when no source with a gap replaced it.
|
|
func (w *wakeReporter) release(held *wakeReport, emit func(wakeReport)) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.held != held {
|
|
return
|
|
}
|
|
w.held = nil
|
|
emit(*held)
|
|
}
|
|
|
|
func (w *wakeReporter) afterFunc() func(time.Duration, func()) {
|
|
if w.after != nil {
|
|
return w.after
|
|
}
|
|
return func(d time.Duration, fn func()) { time.AfterFunc(d, fn) }
|
|
}
|
|
|
|
// noiseSummary describes the noise deltas that one journal line covers.
|
|
type noiseSummary struct {
|
|
Count int
|
|
First time.Time
|
|
Last time.Time
|
|
Interfaces []string
|
|
}
|
|
|
|
// noiseCoalescer counts the noise deltas of a storm and reports when the
|
|
// journal needs a line for them. The count, the first time, and the names
|
|
// cover the open window; last covers the storm, so a quiet period ends it.
|
|
type noiseCoalescer struct {
|
|
mu sync.Mutex
|
|
count int
|
|
first time.Time
|
|
last time.Time
|
|
emitted time.Time
|
|
names map[string]struct{}
|
|
}
|
|
|
|
// add records one noise delta and reports whether to log a summary now. The
|
|
// summary covers the deltas since the last one, so a storm of any length
|
|
// keeps to one line per interval.
|
|
func (c *noiseCoalescer) add(interfaces []string, now time.Time) (summary noiseSummary, emit bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
newRun := c.stormEnded(now)
|
|
if newRun {
|
|
c.clearWindow()
|
|
}
|
|
c.record(interfaces, now)
|
|
if !newRun && now.Sub(c.emitted) < noiseSummaryInterval {
|
|
return noiseSummary{}, false
|
|
}
|
|
summary = c.summary()
|
|
c.emitted = now
|
|
c.clearWindow()
|
|
return summary, true
|
|
}
|
|
|
|
// flush reports the open window and closes it. A storm ends with a delta that
|
|
// the daemon acts on, so the last window of the storm reaches the journal
|
|
// there instead of waiting for the next storm.
|
|
func (c *noiseCoalescer) flush(now time.Time) (summary noiseSummary, emit bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.count == 0 {
|
|
return noiseSummary{}, false
|
|
}
|
|
summary = c.summary()
|
|
c.emitted = now
|
|
c.clearWindow()
|
|
return summary, true
|
|
}
|
|
|
|
// stormEnded is true when the deltas stopped for longer than the interval, so
|
|
// a new storm does not inherit the count of the storm before it.
|
|
func (c *noiseCoalescer) stormEnded(now time.Time) bool {
|
|
return c.last.IsZero() || now.Sub(c.last) > noiseSummaryInterval
|
|
}
|
|
|
|
func (c *noiseCoalescer) record(interfaces []string, now time.Time) {
|
|
if c.count == 0 {
|
|
c.first = now
|
|
}
|
|
c.count++
|
|
c.last = now
|
|
if c.names == nil {
|
|
c.names = make(map[string]struct{})
|
|
}
|
|
for _, name := range interfaces {
|
|
c.names[name] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// summary sorts the names, because a log line that keeps its order reads as
|
|
// one value across a capture.
|
|
func (c *noiseCoalescer) summary() noiseSummary {
|
|
return noiseSummary{
|
|
Count: c.count,
|
|
First: c.first,
|
|
Last: c.last,
|
|
Interfaces: slices.Sorted(maps.Keys(c.names)),
|
|
}
|
|
}
|
|
|
|
func (c *noiseCoalescer) clearWindow() {
|
|
c.count = 0
|
|
c.first = time.Time{}
|
|
c.names = nil
|
|
}
|