mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
639 lines
22 KiB
Go
639 lines
22 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"tailscale.com/net/netmon"
|
|
)
|
|
|
|
// logHeaderTestSecret is the string that must never reach a header line.
|
|
const logHeaderTestSecret = "secret-token"
|
|
|
|
// logHeaderTestLine is the parsed form of a header line. The json tags name
|
|
// the fields that a log tool reads, so a renamed field fails the test.
|
|
type logHeaderTestLine struct {
|
|
Level string `json:"level"`
|
|
Time string `json:"time"`
|
|
Journal bool `json:"journal"`
|
|
Message string `json:"message"`
|
|
Version string `json:"version"`
|
|
Commit string `json:"commit"`
|
|
OS string `json:"os"`
|
|
Arch string `json:"arch"`
|
|
PID int `json:"pid"`
|
|
StartTime string `json:"start_time"`
|
|
InterceptMode string `json:"intercept_mode"`
|
|
Listeners []string `json:"listeners"`
|
|
UpstreamCount int `json:"upstream_count"`
|
|
UpstreamTypes []string `json:"upstream_types"`
|
|
ResolverUID string `json:"resolver_uid"`
|
|
LogFiles []string `json:"log_files"`
|
|
Network struct {
|
|
DefaultRouteV4 string `json:"default_route_v4"`
|
|
DefaultRouteV6 string `json:"default_route_v6"`
|
|
GatewayV4 string `json:"gateway_v4"`
|
|
GatewayV6 string `json:"gateway_v6"`
|
|
HaveV4 bool `json:"have_v4"`
|
|
HaveV6 bool `json:"have_v6"`
|
|
Interfaces []struct {
|
|
Name string `json:"name"`
|
|
Class string `json:"class"`
|
|
Up bool `json:"up"`
|
|
IPs []string `json:"ips"`
|
|
HardwarePort string `json:"hardware_port"`
|
|
Service string `json:"service"`
|
|
} `json:"interfaces"`
|
|
Resolvers []string `json:"resolvers"`
|
|
SourceIPv4 string `json:"source_ipv4"`
|
|
SourceIPv6 string `json:"source_ipv6"`
|
|
InterceptTarget string `json:"intercept_target"`
|
|
LinkType string `json:"link_type"`
|
|
Tethered bool `json:"tethered"`
|
|
} `json:"network"`
|
|
Trigger string `json:"trigger"`
|
|
}
|
|
|
|
func logHeaderTestInput() logHeaderInput {
|
|
return logHeaderInput{
|
|
Version: "1.5.8",
|
|
Commit: "abc1234",
|
|
OS: "darwin 15.6",
|
|
Arch: "arm64",
|
|
PID: 4242,
|
|
StartTime: time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC),
|
|
InterceptMode: "dns",
|
|
Listeners: []string{"127.0.0.1:53", "127.0.0.1:5354"},
|
|
UpstreamCount: 2,
|
|
UpstreamTypes: []string{"doh", "os"},
|
|
ResolverUID: redactToken("abcd1234"),
|
|
LogFiles: []string{"/tmp/ctrld.log", "/tmp/ctrld-journal.log"},
|
|
Network: networkSnapshot{
|
|
DefaultRouteV4: "en0",
|
|
GatewayV4: "192.0.2.1",
|
|
HaveV4: true,
|
|
Interfaces: []snapshotInterface{
|
|
{Name: "en0", Class: "hardware", Up: true, IPs: []string{"192.0.2.10/24"}, HardwarePort: "Wi-Fi", Service: "Wi-Fi"},
|
|
},
|
|
Resolvers: []string{"192.0.2.1:53"},
|
|
LinkType: "wifi",
|
|
},
|
|
}
|
|
}
|
|
|
|
// parseLogHeader checks that the render is one line and returns it twice: as
|
|
// values, and as raw fields for the tests that ask whether a field is there.
|
|
func parseLogHeader(t *testing.T, line []byte) (logHeaderTestLine, map[string]json.RawMessage) {
|
|
t.Helper()
|
|
if len(line) == 0 {
|
|
t.Fatal("renderLogHeader returned no bytes")
|
|
}
|
|
if line[len(line)-1] != '\n' {
|
|
t.Fatalf("header does not end with a newline: %q", line)
|
|
}
|
|
if count := strings.Count(string(line), "\n"); count != 1 {
|
|
t.Fatalf("header holds %d newlines, want 1: %q", count, line)
|
|
}
|
|
var parsed logHeaderTestLine
|
|
if err := json.Unmarshal(line, &parsed); err != nil {
|
|
t.Fatalf("parse header %q: %v", line, err)
|
|
}
|
|
fields := map[string]json.RawMessage{}
|
|
if err := json.Unmarshal(line, &fields); err != nil {
|
|
t.Fatalf("parse header fields %q: %v", line, err)
|
|
}
|
|
return parsed, fields
|
|
}
|
|
|
|
func Test_renderLogHeaderHoldsEveryField(t *testing.T) {
|
|
in := logHeaderTestInput()
|
|
|
|
parsed, fields := parseLogHeader(t, renderLogHeader(in))
|
|
|
|
checks := []struct {
|
|
field string
|
|
got any
|
|
want any
|
|
}{
|
|
{"level", parsed.Level, "info"},
|
|
{"journal", parsed.Journal, true},
|
|
{"message", parsed.Message, "Log header"},
|
|
{"version", parsed.Version, in.Version},
|
|
{"commit", parsed.Commit, in.Commit},
|
|
{"os", parsed.OS, in.OS},
|
|
{"arch", parsed.Arch, in.Arch},
|
|
{"pid", parsed.PID, in.PID},
|
|
{"start_time", parsed.StartTime, in.StartTime.Format(time.RFC3339)},
|
|
{"intercept_mode", parsed.InterceptMode, in.InterceptMode},
|
|
{"listeners", parsed.Listeners, in.Listeners},
|
|
{"upstream_count", parsed.UpstreamCount, in.UpstreamCount},
|
|
{"upstream_types", parsed.UpstreamTypes, in.UpstreamTypes},
|
|
// The resolver UID is a secret, so the header keeps the first four
|
|
// characters only.
|
|
{"resolver_uid", parsed.ResolverUID, "abcd***"},
|
|
{"log_files", parsed.LogFiles, in.LogFiles},
|
|
{"network.default_route_v4", parsed.Network.DefaultRouteV4, in.Network.DefaultRouteV4},
|
|
{"network.default_route_v6", parsed.Network.DefaultRouteV6, in.Network.DefaultRouteV6},
|
|
{"network.gateway_v4", parsed.Network.GatewayV4, in.Network.GatewayV4},
|
|
{"network.have_v4", parsed.Network.HaveV4, in.Network.HaveV4},
|
|
{"network.have_v6", parsed.Network.HaveV6, in.Network.HaveV6},
|
|
{"network.resolvers", parsed.Network.Resolvers, in.Network.Resolvers},
|
|
{"network.link_type", parsed.Network.LinkType, in.Network.LinkType},
|
|
{"network.tethered", parsed.Network.Tethered, in.Network.Tethered},
|
|
}
|
|
for _, c := range checks {
|
|
if !reflect.DeepEqual(c.got, c.want) {
|
|
t.Errorf("%s = %v, want %v", c.field, c.got, c.want)
|
|
}
|
|
}
|
|
|
|
if len(parsed.Network.Interfaces) != 1 {
|
|
t.Fatalf("network.interfaces holds %d entries, want 1", len(parsed.Network.Interfaces))
|
|
}
|
|
iface := parsed.Network.Interfaces[0]
|
|
if iface.Name != "en0" || iface.Class != "hardware" || !iface.Up || iface.HardwarePort != "Wi-Fi" {
|
|
t.Errorf("network.interfaces[0] = %+v, want the hardware port en0 up on Wi-Fi", iface)
|
|
}
|
|
if !reflect.DeepEqual(iface.IPs, []string{"192.0.2.10/24"}) {
|
|
t.Errorf("network.interfaces[0].ips = %v, want 192.0.2.10/24", iface.IPs)
|
|
}
|
|
if parsed.Time == "" {
|
|
t.Error("time is empty")
|
|
}
|
|
if _, ok := fields["trigger"]; ok {
|
|
t.Errorf("trigger is present for an empty Trigger: %s", fields["trigger"])
|
|
}
|
|
}
|
|
|
|
func Test_renderLogHeaderAddsTriggerWhenSet(t *testing.T) {
|
|
in := logHeaderTestInput()
|
|
in.Trigger = "send"
|
|
|
|
parsed, _ := parseLogHeader(t, renderLogHeader(in))
|
|
|
|
if parsed.Trigger != "send" {
|
|
t.Errorf("trigger = %q, want %q", parsed.Trigger, "send")
|
|
}
|
|
}
|
|
|
|
// Test_renderLogHeaderIgnoresConsoleLevel guards the promise that every log
|
|
// file starts with a header. The console core sits above info on a default
|
|
// start, and the header takes a core of its own.
|
|
func Test_renderLogHeaderIgnoresConsoleLevel(t *testing.T) {
|
|
parsed, _ := parseLogHeader(t, renderLogHeader(logHeaderTestInput()))
|
|
|
|
if parsed.Level != "info" {
|
|
t.Errorf("level = %q, want %q", parsed.Level, "info")
|
|
}
|
|
if parsed.Message != "Log header" {
|
|
t.Errorf("message = %q, want %q", parsed.Message, "Log header")
|
|
}
|
|
}
|
|
|
|
func Test_logHeaderFromConfigKeepsUpstreamSecretsOut(t *testing.T) {
|
|
old := interceptMode
|
|
t.Cleanup(func() { interceptMode = old })
|
|
interceptMode = ""
|
|
|
|
cfg := &ctrld.Config{
|
|
Listener: map[string]*ctrld.ListenerConfig{
|
|
"0": {IP: "127.0.0.1", Port: 53},
|
|
logHeaderTestSecret + "-l": {IP: "10.0.0.1", Port: 5354},
|
|
},
|
|
Upstream: map[string]*ctrld.UpstreamConfig{
|
|
"0": {
|
|
Name: logHeaderTestSecret + "-name",
|
|
Type: "doh",
|
|
Endpoint: "https://dns.example/" + logHeaderTestSecret,
|
|
BootstrapIP: "192.0.2.53",
|
|
},
|
|
"1": {Endpoint: "https://dns.example/" + logHeaderTestSecret},
|
|
},
|
|
}
|
|
cfg.Service.InterceptMode = "dns"
|
|
|
|
in := logHeaderTestInput()
|
|
logHeaderFromConfig(cfg, &in)
|
|
|
|
if in.InterceptMode != "dns" {
|
|
t.Errorf("intercept mode = %q, want %q", in.InterceptMode, "dns")
|
|
}
|
|
if want := []string{"10.0.0.1:5354", "127.0.0.1:53"}; !reflect.DeepEqual(in.Listeners, want) {
|
|
t.Errorf("listeners = %v, want %v", in.Listeners, want)
|
|
}
|
|
if in.UpstreamCount != 2 {
|
|
t.Errorf("upstream count = %d, want 2", in.UpstreamCount)
|
|
}
|
|
if want := []string{"doh", "unspecified"}; !reflect.DeepEqual(in.UpstreamTypes, want) {
|
|
t.Errorf("upstream types = %v, want %v", in.UpstreamTypes, want)
|
|
}
|
|
|
|
line := renderLogHeader(in)
|
|
if strings.Contains(string(line), logHeaderTestSecret) {
|
|
t.Errorf("header holds %q: %s", logHeaderTestSecret, line)
|
|
}
|
|
}
|
|
|
|
func Test_logHeaderFromConfigReportsOffWhenUnset(t *testing.T) {
|
|
old := interceptMode
|
|
t.Cleanup(func() { interceptMode = old })
|
|
interceptMode = ""
|
|
|
|
var in logHeaderInput
|
|
logHeaderFromConfig(&ctrld.Config{}, &in)
|
|
|
|
if in.InterceptMode != "off" {
|
|
t.Errorf("intercept mode = %q, want %q", in.InterceptMode, "off")
|
|
}
|
|
}
|
|
|
|
// Test_renderLogHeaderShowsTheSnapshotOfAState renders the header of one
|
|
// network state, so a reader of the file sees every interface of the host in
|
|
// one order.
|
|
func Test_renderLogHeaderShowsTheSnapshotOfAState(t *testing.T) {
|
|
stubHeaderSnapshotSources(t)
|
|
state := &netmon.State{
|
|
DefaultRouteInterface: "en0",
|
|
HaveV4: true,
|
|
Interface: map[string]netmon.Interface{
|
|
"en0": {Interface: &net.Interface{Name: "en0", Flags: net.FlagUp}},
|
|
"awdl0": {Interface: &net.Interface{Name: "awdl0"}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("192.0.2.10/24"), netip.MustParsePrefix("2001:db8::1/64")},
|
|
"awdl0": {netip.MustParsePrefix("fe80::2/64")},
|
|
},
|
|
}
|
|
|
|
in := logHeaderTestInput()
|
|
in.Network = buildNetworkSnapshot(snapshotInputs{
|
|
State: state,
|
|
RouteV4: defaultRoute{Gateway: "192.0.2.1", Interface: "en0"},
|
|
RouteV6: defaultRoute{Gateway: "fe80::1%en0", Interface: "en0"},
|
|
Meta: interfaceMetaFor,
|
|
})
|
|
parsed, _ := parseLogHeader(t, renderLogHeader(in))
|
|
|
|
if parsed.Network.DefaultRouteV4 != "en0" || parsed.Network.DefaultRouteV6 != "en0" {
|
|
t.Errorf("default route = %q, %q, want en0 for both families", parsed.Network.DefaultRouteV4, parsed.Network.DefaultRouteV6)
|
|
}
|
|
if len(parsed.Network.Interfaces) != 2 {
|
|
t.Fatalf("network.interfaces holds %d entries, want 2", len(parsed.Network.Interfaces))
|
|
}
|
|
if parsed.Network.Interfaces[0].Name != "awdl0" || parsed.Network.Interfaces[0].Class != "airdrop" || parsed.Network.Interfaces[0].Up {
|
|
t.Errorf("network.interfaces[0] = %+v, want awdl0 down in the airdrop class", parsed.Network.Interfaces[0])
|
|
}
|
|
if got := parsed.Network.Interfaces[1].IPs; !reflect.DeepEqual(got, []string{"192.0.2.10/24", "2001:db8::1/64"}) {
|
|
t.Errorf("network.interfaces[1].ips = %v", got)
|
|
}
|
|
}
|
|
|
|
// setupLogHeaderWiringTest puts ctrld in cd mode with a temporary home
|
|
// directory, so initInternalLogging writes its files there. Every global that
|
|
// the logging setup touches goes back when the test ends.
|
|
func setupLogHeaderWiringTest(t *testing.T) string {
|
|
t.Helper()
|
|
stubHeaderSnapshotSources(t)
|
|
origSilent, origCdUID, origHomedir, origVerbose := silent, cdUID, homedir, verbose
|
|
origMainLog := mainLog.Load()
|
|
origLogPathFile := logPathFile.Load()
|
|
t.Cleanup(func() {
|
|
silent, cdUID, homedir, verbose = origSilent, origCdUID, origHomedir, origVerbose
|
|
mainLog.Store(origMainLog)
|
|
logPathFile.Store(origLogPathFile)
|
|
})
|
|
|
|
homedir = t.TempDir()
|
|
cdUID = "header-wiring-uid"
|
|
silent, verbose = false, 0
|
|
mainLog.Store(ctrld.NopLogger)
|
|
logPathFile.Store(nil)
|
|
return homedir
|
|
}
|
|
|
|
// startLogHeaderWiringProg runs the internal logging setup and closes both
|
|
// files when the test ends.
|
|
func startLogHeaderWiringProg(t *testing.T) *prog {
|
|
t.Helper()
|
|
p := &prog{cfg: &ctrld.Config{}}
|
|
p.logger.Store(mainLog.Load())
|
|
p.initInternalLogging(nil)
|
|
t.Cleanup(func() {
|
|
p.internalLogWriter.closeLogFile()
|
|
p.internalJournalWriter.closeLogFile()
|
|
})
|
|
return p
|
|
}
|
|
|
|
// logHeaderFileLines parses every JSON line of a log file. It returns line 1
|
|
// and the number of header lines, so a test can tell a missing header from a
|
|
// repeated one. The debug stream writes console lines, which hold no header,
|
|
// so they do not count.
|
|
func logHeaderFileLines(t *testing.T, path string) (logHeaderTestLine, int) {
|
|
t.Helper()
|
|
content := strings.TrimRight(rotatingFileTestContent(t, path), "\n")
|
|
if content == "" {
|
|
t.Fatalf("%s is empty", path)
|
|
}
|
|
var firstLine logHeaderTestLine
|
|
headers := 0
|
|
for i, line := range strings.Split(content, "\n") {
|
|
if i > 0 && !strings.HasPrefix(line, "{") {
|
|
continue
|
|
}
|
|
var parsed logHeaderTestLine
|
|
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
|
t.Fatalf("parse line %d of %s (%q): %v", i+1, path, line, err)
|
|
}
|
|
if i == 0 {
|
|
firstLine = parsed
|
|
}
|
|
if parsed.Message == logHeaderMessage {
|
|
headers++
|
|
}
|
|
}
|
|
return firstLine, headers
|
|
}
|
|
|
|
func Test_logHeaderWiring_writesHeaderOnStart(t *testing.T) {
|
|
dir := setupLogHeaderWiringTest(t)
|
|
|
|
startLogHeaderWiringProg(t)
|
|
|
|
for _, name := range []string{logFileName, journalLogFileName} {
|
|
path := filepath.Join(dir, name)
|
|
firstLine, headers := logHeaderFileLines(t, path)
|
|
if firstLine.Message != logHeaderMessage {
|
|
t.Errorf("line 1 of %s = %q, want %q", path, firstLine.Message, logHeaderMessage)
|
|
}
|
|
if !firstLine.Journal {
|
|
t.Errorf("line 1 of %s carries no journal marker", path)
|
|
}
|
|
if want := redactToken(cdUID); firstLine.ResolverUID != want {
|
|
t.Errorf("resolver_uid in %s = %q, want %q", path, firstLine.ResolverUID, want)
|
|
}
|
|
if headers != 1 {
|
|
t.Errorf("%s holds %d header lines, want 1", path, headers)
|
|
}
|
|
}
|
|
}
|
|
|
|
func Test_logHeaderInput_keepsSecretsOut(t *testing.T) {
|
|
dir := setupLogHeaderWiringTest(t)
|
|
origInterceptMode := interceptMode
|
|
t.Cleanup(func() { interceptMode = origInterceptMode })
|
|
interceptMode = ""
|
|
p := &prog{cfg: &ctrld.Config{
|
|
Listener: map[string]*ctrld.ListenerConfig{
|
|
logHeaderTestSecret + "-listener": {IP: "127.0.0.1", Port: 53},
|
|
},
|
|
Upstream: map[string]*ctrld.UpstreamConfig{
|
|
"0": {
|
|
Name: logHeaderTestSecret + "-name",
|
|
Type: "doh",
|
|
Endpoint: "https://dns.example/" + logHeaderTestSecret,
|
|
BootstrapIP: "192.0.2.53",
|
|
Domain: logHeaderTestSecret + ".example",
|
|
},
|
|
},
|
|
}}
|
|
p.initInternalLogging(nil)
|
|
t.Cleanup(func() {
|
|
p.internalLogWriter.closeLogFile()
|
|
p.internalJournalWriter.closeLogFile()
|
|
})
|
|
|
|
if line := renderLogHeader(p.logHeaderInput()); strings.Contains(string(line), logHeaderTestSecret) {
|
|
t.Errorf("the header holds %q: %s", logHeaderTestSecret, line)
|
|
}
|
|
|
|
for _, name := range []string{logFileName, journalLogFileName} {
|
|
path := filepath.Join(dir, name)
|
|
if content := rotatingFileTestContent(t, path); strings.Contains(content, logHeaderTestSecret) {
|
|
t.Errorf("%s holds %q: %s", path, logHeaderTestSecret, content)
|
|
}
|
|
}
|
|
}
|
|
|
|
func Test_logHeaderWiring_secondInitAddsNoHeader(t *testing.T) {
|
|
dir := setupLogHeaderWiringTest(t)
|
|
p := startLogHeaderWiringProg(t)
|
|
|
|
p.initInternalLogging(nil)
|
|
|
|
for _, name := range []string{logFileName, journalLogFileName} {
|
|
path := filepath.Join(dir, name)
|
|
if _, headers := logHeaderFileLines(t, path); headers != 1 {
|
|
t.Errorf("%s holds %d header lines after a second start, want 1", path, headers)
|
|
}
|
|
}
|
|
}
|
|
|
|
func Test_logHeaderWiring_headerLeadsRotatedFile(t *testing.T) {
|
|
captureMainLog(t)
|
|
lw, path := newTestFileLogWriter(t, testLogWriterBudget)
|
|
header := string(renderLogHeader(logHeaderInput{Version: "v1"}))
|
|
|
|
rf := lw.rotating()
|
|
rf.setHeader([]byte(header))
|
|
if err := rf.writeHeader(); err != nil {
|
|
t.Fatalf("writeHeader: %v", err)
|
|
}
|
|
linesPerFile := int(testLogWriterBudget.maxSize) / rotatingFileTestLineSize
|
|
for seq := 1; seq <= linesPerFile+1; seq++ {
|
|
if _, err := lw.Write(rotatingFileTestLine(1, seq)); err != nil {
|
|
t.Fatalf("write line %d: %v", seq, err)
|
|
}
|
|
}
|
|
|
|
for _, name := range []string{path + ".1", path} {
|
|
if content := rotatingFileTestContent(t, name); !strings.HasPrefix(content, header) {
|
|
t.Errorf("%s starts with %q, want the header", name, strings.SplitN(content, "\n", 2)[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
func Test_logHeaderWiring_restartAppendsHeader(t *testing.T) {
|
|
dir := setupLogHeaderWiringTest(t)
|
|
path := filepath.Join(dir, logFileName)
|
|
const seed = `{"level":"info","message":"line of the run before"}`
|
|
if err := os.WriteFile(path, []byte(seed+"\n"), 0o600); err != nil {
|
|
t.Fatalf("seed %s: %v", path, err)
|
|
}
|
|
|
|
startLogHeaderWiringProg(t)
|
|
|
|
lines := strings.Split(strings.TrimRight(rotatingFileTestContent(t, path), "\n"), "\n")
|
|
if len(lines) < 2 {
|
|
t.Fatalf("%s holds %d lines, want the seed line and the header", path, len(lines))
|
|
}
|
|
if lines[0] != seed {
|
|
t.Errorf("line 1 of %s = %q, want the seed line", path, lines[0])
|
|
}
|
|
var header logHeaderTestLine
|
|
if err := json.Unmarshal([]byte(lines[1]), &header); err != nil {
|
|
t.Fatalf("parse line 2 of %s (%q): %v", path, lines[1], err)
|
|
}
|
|
if header.Message != logHeaderMessage {
|
|
t.Errorf("line 2 of %s = %q, want %q", path, header.Message, logHeaderMessage)
|
|
}
|
|
if _, headers := logHeaderFileLines(t, path); headers != 1 {
|
|
t.Errorf("%s holds %d header lines, want 1", path, headers)
|
|
}
|
|
}
|
|
|
|
func Test_logHeaderWiring_usesLastNetworkState(t *testing.T) {
|
|
stubHeaderSnapshotSources(t)
|
|
origRead := readNetworkSourceStateFn
|
|
t.Cleanup(func() { readNetworkSourceStateFn = origRead })
|
|
p := &prog{cfg: &ctrld.Config{}}
|
|
p.logger.Store(mainLog.Load())
|
|
|
|
p.lastNetworkState.Store(&netmon.State{
|
|
DefaultRouteInterface: "en7",
|
|
Interface: map[string]netmon.Interface{"en7": {Interface: &net.Interface{Name: "en7", Flags: net.FlagUp}}},
|
|
InterfaceIPs: map[string][]netip.Prefix{"en7": {netip.MustParsePrefix("192.0.2.7/24")}},
|
|
})
|
|
|
|
if got := p.logHeaderInput().Network.DefaultRouteV4; got != "en7" {
|
|
t.Errorf("default_route_v4 = %q, want %q", got, "en7")
|
|
}
|
|
if got := p.logHeaderInput().Network.GatewayV4; got != "192.0.2.1" {
|
|
t.Errorf("gateway_v4 = %q, want %q", got, "192.0.2.1")
|
|
}
|
|
|
|
p.lastNetworkState.Store(nil)
|
|
readNetworkSourceStateFn = func() (*netmon.State, error) {
|
|
return &netmon.State{
|
|
Interface: map[string]netmon.Interface{"en9": {Interface: &net.Interface{Name: "en9", Flags: net.FlagUp}}},
|
|
InterfaceIPs: map[string][]netip.Prefix{"en9": {netip.MustParsePrefix("192.0.2.9/24")}},
|
|
}, nil
|
|
}
|
|
|
|
network := p.logHeaderInput().Network
|
|
if len(network.Interfaces) != 1 || network.Interfaces[0].Name != "en9" || !network.Interfaces[0].Up {
|
|
t.Fatalf("interfaces = %+v, want one up en9", network.Interfaces)
|
|
}
|
|
if !network.HaveV4 {
|
|
t.Error("have_v4 = false, want true for a global unicast v4 address")
|
|
}
|
|
if network.HaveV6 {
|
|
t.Error("have_v6 = true, want false without a v6 address")
|
|
}
|
|
}
|
|
|
|
// stubHeaderSnapshotSources keeps the header snapshot away from the host. One
|
|
// render reads the hardware ports, the route table, and the NAT64 class, and no
|
|
// test may run the commands behind them.
|
|
func stubHeaderSnapshotSources(t *testing.T) {
|
|
t.Helper()
|
|
stubSnapshotDNS64(t)
|
|
stubSnapshotGateways(t, "192.0.2.1", "")
|
|
stubSnapshotPlatformMeta(t, nil)
|
|
stubSnapshotVirtualSet(t)
|
|
}
|
|
|
|
// stubLogHeaderNetworkRead makes the fresh network read return one up
|
|
// interface, so a header test never depends on the host network.
|
|
func stubLogHeaderNetworkRead(t *testing.T, name string) {
|
|
t.Helper()
|
|
stubHeaderSnapshotSources(t)
|
|
origRead := readNetworkSourceStateFn
|
|
t.Cleanup(func() { readNetworkSourceStateFn = origRead })
|
|
readNetworkSourceStateFn = func() (*netmon.State, error) {
|
|
return &netmon.State{
|
|
Interface: map[string]netmon.Interface{name: {Interface: &net.Interface{Name: name, Flags: net.FlagUp}}},
|
|
InterfaceIPs: map[string][]netip.Prefix{name: {netip.MustParsePrefix("192.0.2.9/24")}},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func Test_sendLogHeader_usesFreshNetworkAndTrigger(t *testing.T) {
|
|
stubHeaderSnapshotSources(t)
|
|
stubLogHeaderNetworkRead(t, "en9")
|
|
p := &prog{cfg: &ctrld.Config{}}
|
|
p.logger.Store(mainLog.Load())
|
|
p.lastNetworkState.Store(&netmon.State{
|
|
DefaultRouteInterface: "en7",
|
|
Interface: map[string]netmon.Interface{"en7": {Interface: &net.Interface{Name: "en7", Flags: net.FlagUp}}},
|
|
InterfaceIPs: map[string][]netip.Prefix{"en7": {netip.MustParsePrefix("192.0.2.7/24")}},
|
|
})
|
|
|
|
parsed, _ := parseLogHeader(t, p.sendLogHeader())
|
|
|
|
if parsed.Trigger != "send" {
|
|
t.Errorf("trigger = %q, want %q", parsed.Trigger, "send")
|
|
}
|
|
if parsed.Message != logHeaderMessage {
|
|
t.Errorf("message = %q, want %q", parsed.Message, logHeaderMessage)
|
|
}
|
|
names := make([]string, 0, len(parsed.Network.Interfaces))
|
|
for _, iface := range parsed.Network.Interfaces {
|
|
names = append(names, iface.Name)
|
|
}
|
|
if want := []string{"en9"}; !reflect.DeepEqual(names, want) {
|
|
t.Fatalf("network.interfaces = %v, want %v from the fresh read", names, want)
|
|
}
|
|
}
|
|
|
|
// Test_logHeaderInputReadsTheConfigUnderTheLock renders headers beside the
|
|
// config swap of a reload. The race detector fails the test when the render
|
|
// reads the config outside the lock.
|
|
func Test_logHeaderInputReadsTheConfigUnderTheLock(t *testing.T) {
|
|
stubLogHeaderNetworkRead(t, "en0")
|
|
p := &prog{cfg: &ctrld.Config{
|
|
Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}},
|
|
Upstream: map[string]*ctrld.UpstreamConfig{"0": {Type: "doh"}},
|
|
}}
|
|
reloaded := &ctrld.Config{
|
|
Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}},
|
|
Upstream: map[string]*ctrld.UpstreamConfig{"0": {Type: "doq"}, "1": {Type: "legacy"}},
|
|
}
|
|
|
|
stop := make(chan struct{})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
p.mu.Lock()
|
|
*p.cfg = *reloaded
|
|
p.mu.Unlock()
|
|
}
|
|
}()
|
|
for i := 0; i < 300; i++ {
|
|
if in := p.logHeaderInput(); len(in.Listeners) != 1 {
|
|
t.Fatalf("listeners = %v, want one", in.Listeners)
|
|
}
|
|
}
|
|
close(stop)
|
|
<-done
|
|
}
|
|
|
|
// Test_renderLogHeaderPassesTheSink puts a provisioning secret where the
|
|
// header does not expect one. The header is the first line of every file and
|
|
// of every upload, so it must pass the same redaction as every other line.
|
|
func Test_renderLogHeaderPassesTheSink(t *testing.T) {
|
|
origUID := cdUID
|
|
cdUID = "abcd1234SECRETUID"
|
|
t.Cleanup(func() { cdUID = origUID })
|
|
|
|
line := string(renderLogHeader(logHeaderInput{Version: "1.0", LogFiles: []string{"/var/log/" + cdUID + "/ctrld.log"}}))
|
|
|
|
if strings.Contains(line, cdUID) {
|
|
t.Fatalf("the header holds the secret: %s", line)
|
|
}
|
|
}
|