Files
ctrld/cmd/cli/pf_diagnostic.go
T

193 lines
5.6 KiB
Go

package cli
import (
"bytes"
"context"
"errors"
"io"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
const (
pfDiagnosticInterval = time.Minute
pfDiagnosticTimeout = 500 * time.Millisecond
pfDiagnosticMaxBytes = 64 << 10
)
// Only these two exact, ctrld-generated rules are identified. PF's @N indexes
// are not stable identifiers. No rule text or other anchor contents are logged.
var pfDiagnosticRule = regexp.MustCompile(`^@[0-9]+ block drop out quick on ! ?lo0 inet6 proto (udp|tcp) from any to any port = 53$`)
var pfDiagnosticCounters = regexp.MustCompile(`^\[ Evaluations: +([0-9]+) +Packets: +([0-9]+) +Bytes: +([0-9]+) +States: +([0-9]+) +\]$`)
type pfDiagnosticRuleCount struct {
Installed bool
Known bool
Packets uint64
}
type pfDiagnosticSnapshot struct {
Code string
UDP, TCP pfDiagnosticRuleCount
RouteV4, RouteV6 string
AddressCode string
NativeIPv4, CLAT bool
}
// A missing or unrecognized rule is not a zero counter. A counter describes all
// traffic matching that rule since its last reset, not delivery of our UDP4 probe.
func parsePFDiagnostic(data []byte) pfDiagnosticSnapshot {
s := pfDiagnosticSnapshot{Code: "rules_not_observed"}
var current *pfDiagnosticRuleCount
duplicates := false
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if strings.HasPrefix(line, "@") {
current = nil
if m := pfDiagnosticRule.FindStringSubmatch(line); m != nil {
current = &s.UDP
if m[1] == "tcp" {
current = &s.TCP
}
if current.Installed {
duplicates = true
}
current.Installed = true
}
continue
}
if current == nil {
continue
}
if strings.HasPrefix(line, "[ Evaluations:") {
m := pfDiagnosticCounters.FindStringSubmatch(line)
if m != nil {
valid := true
for _, value := range m[1:] {
if _, err := strconv.ParseUint(value, 10, 64); err != nil {
valid = false
}
}
if valid {
current.Packets, _ = strconv.ParseUint(m[2], 10, 64)
current.Known = true
}
}
current = nil // never attach a later rule's counters to this rule
}
}
switch {
case duplicates:
s.Code = "ambiguous_rules"
s.UDP.Known, s.TCP.Known = false, false
case s.UDP.Known && s.TCP.Known:
s.Code = "ok"
case s.UDP.Installed || s.TCP.Installed:
s.Code = "partial_or_malformed"
}
return s
}
var errPFDiagnosticLimit = errors.New("PF diagnostic output limit")
type pfDiagnosticBuffer struct {
data bytes.Buffer // do not embed: promoted ReadFrom would bypass Write's cap
limited bool
}
func (b *pfDiagnosticBuffer) Len() int { return b.data.Len() }
func (b *pfDiagnosticBuffer) Write(p []byte) (int, error) {
if len(p) > pfDiagnosticMaxBytes-b.Len() {
b.limited = true
return 0, errPFDiagnosticLimit
}
return b.data.Write(p)
}
// Stdout is bounded, stderr is discarded, and no raw command error escapes.
// WaitDelay also bounds inherited stdout pipes if a helper fails to close them.
func runPFDiagnosticCommand(ctx context.Context, cmd *exec.Cmd) pfDiagnosticSnapshot {
var out pfDiagnosticBuffer
cmd.Stdout, cmd.Stderr = &out, io.Discard
cmd.WaitDelay = 100 * time.Millisecond
err := cmd.Run()
code := ""
switch {
case ctx.Err() != nil:
code = pfProbeContextCode(ctx)
case out.limited:
code = "output_limit"
case err != nil:
code = "read_failed"
}
if code != "" {
return pfDiagnosticSnapshot{Code: code}
}
return parsePFDiagnostic(out.data.Bytes())
}
type pfDiagnosticEvent struct {
Snapshot pfDiagnosticSnapshot
Observation pfProbeObservation
Generation uint64
Age time.Duration
Cached bool
UDPDelta, TCPDelta uint64
UDPDeltaCode, TCPDeltaCode string
}
// Deltas are shared-traffic observations, never per-probe attribution. Even an
// increasing counter may span an unobserved reload, so it is not an exact total.
func pfDiagnosticDelta(before, after pfDiagnosticRuleCount) (uint64, string) {
if !before.Known || !after.Known {
return 0, "unknown"
}
if after.Packets < before.Packets {
return 0, "reset"
}
return after.Packets - before.Packets, "shared_traffic"
}
type pfDiagnosticState struct {
mu sync.Mutex
active bool
captured time.Time
generation uint64
snapshot pfDiagnosticSnapshot
lastCondition pfProbeCondition
}
// Healthy polls do no work. Failures capture at most once a minute; a restoration
// inside that interval names the cached capture and its age, not a fresh reading.
// Identical outcomes and snapshots are quiet. This state never controls repair.
func (s *pfDiagnosticState) observe(now time.Time, o pfProbeObservation, capture func() pfDiagnosticSnapshot) (pfDiagnosticEvent, bool) {
if o.code == "canceled" || !s.mu.TryLock() {
return pfDiagnosticEvent{}, false
}
defer s.mu.Unlock()
if o.result == pfProbeIntercepted && !s.active {
return pfDiagnosticEvent{}, false
}
condition := pfProbeCondition{o.result, o.stage, o.code, o.target}
e := pfDiagnosticEvent{Observation: o, Cached: true, UDPDeltaCode: "unknown", TCPDeltaCode: "unknown"}
changed := condition != s.lastCondition
if s.captured.IsZero() || now.Sub(s.captured) >= pfDiagnosticInterval {
next := capture()
changed = changed || next != s.snapshot
e.UDPDelta, e.UDPDeltaCode = pfDiagnosticDelta(s.snapshot.UDP, next.UDP)
e.TCPDelta, e.TCPDeltaCode = pfDiagnosticDelta(s.snapshot.TCP, next.TCP)
s.snapshot, s.captured = next, now
s.generation++
e.Cached = false
}
s.active = o.result != pfProbeIntercepted
s.lastCondition = condition
e.Snapshot, e.Generation, e.Age = s.snapshot, s.generation, now.Sub(s.captured)
return e, changed
}