Files
ctrld/cmd/cli/log_header.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
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.
2026-09-28 16:49:05 +07:00

231 lines
6.9 KiB
Go

package cli
import (
"bytes"
"net"
"net/netip"
"os"
"runtime"
"slices"
"strconv"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
// processStartTime is the time this process started. A reader compares it with
// the time of the header line to tell a restart from a rotation.
var processStartTime = time.Now()
const (
// logHeaderMessage is the message of the header line. Log tools select the
// header by this message.
logHeaderMessage = "Log header"
// interceptModeOff is the mode word for a ctrld that does not intercept.
interceptModeOff = "off"
// logHeaderTriggerSend marks the header that a log request renders. It
// tells a reader that the line came from an upload, not from a file open.
logHeaderTriggerSend = "send"
// upstreamTypeUnspecified names an upstream that has no type in the config.
// ctrld resolves the type at start, so the header must not claim one.
upstreamTypeUnspecified = "unspecified"
)
// headerOSVersion reads the OS version once. The read starts a subprocess on
// macOS, and a header refresh follows every network change.
var headerOSVersion = sync.OnceValue(osVersion)
// logHeaderInput holds every value of a header line. The caller collects the
// values, so the renderer stays free of locks and of system calls.
type logHeaderInput struct {
Version string
Commit string
OS string
Arch string
PID int
StartTime time.Time
InterceptMode string
Listeners []string
UpstreamCount int
UpstreamTypes []string
ResolverUID string
LogFiles []string
Network networkSnapshot
Trigger string
}
// renderLogHeader renders the header as one log line. A file writer stores the
// bytes and writes them itself, so the header reaches a file that the logger
// never fills.
func renderLogHeader(in logHeaderInput) []byte {
var buf bytes.Buffer
logger := zerolog.New(&buf).With().Timestamp().Logger()
event := journal(logHeaderEvent(&logger)).
Str("version", in.Version).
Str("commit", in.Commit).
Str("os", in.OS).
Str("arch", in.Arch).
Int("pid", in.PID).
Str("start_time", in.StartTime.Format(time.RFC3339)).
Str("intercept_mode", in.InterceptMode).
Strs("listeners", in.Listeners).
Int("upstream_count", in.UpstreamCount).
Strs("upstream_types", in.UpstreamTypes).
Str("resolver_uid", in.ResolverUID).
Strs("log_files", in.LogFiles).
Dict("network", snapshotDict(in.Network))
if in.Trigger != "" {
event = event.Str("trigger", in.Trigger)
}
event.Msg(logHeaderMessage)
// The header is the first line of every file and of every upload, and it
// bypasses the writers, so it passes the journal redaction here.
return redactRetainedLine(buf.Bytes())
}
// logHeaderEvent starts the header event at info level. The global level sits
// above info on a default start, and a log file must hold its header at every
// level, so the event names its level itself.
func logHeaderEvent(logger *zerolog.Logger) *zerolog.Event {
return logger.Log().Str(zerolog.LevelFieldName, zerolog.LevelInfoValue)
}
// logHeaderInput collects every header value of the running program.
func (p *prog) logHeaderInput() logHeaderInput {
return p.logHeaderInputWith(p.networkSnapshot())
}
// logHeaderInputWith collects every header value around one network snapshot.
func (p *prog) logHeaderInputWith(network networkSnapshot) logHeaderInput {
in := logHeaderInput{
Version: curVersion(),
Commit: commit,
OS: headerOSVersion(),
Arch: runtime.GOARCH,
PID: os.Getpid(),
StartTime: processStartTime,
// The resolver UID is a secret that the provisioning code strips from
// every artifact, so the header keeps its first characters only.
ResolverUID: redactToken(cdUID),
LogFiles: p.logHeaderFiles(),
Network: network,
}
p.headerConfigInto(&in)
return in
}
// headerConfigInto reads the config under p.mu, because a reload replaces the
// config under that lock while log view and log send render a header.
func (p *prog) headerConfigInto(in *logHeaderInput) {
p.mu.Lock()
defer p.mu.Unlock()
logHeaderFromConfig(p.cfg, in)
}
// sendLogHeader renders the header that leads one upload. The stored state can
// be older than the request, and support reads this line to learn the network
// of the moment the user asked.
func (p *prog) sendLogHeader() []byte {
in := p.logHeaderInputWith(buildNetworkSnapshot(p.freshSnapshotInputs()))
in.Trigger = logHeaderTriggerSend
return renderLogHeader(in)
}
// logHeaderFiles names the files that this run writes, debug first.
func (p *prog) logHeaderFiles() []string {
files := make([]string, 0, 3)
for _, rf := range p.openLogFiles() {
files = append(files, rf.currentPath())
}
return files
}
// refreshLogHeader renders the header and hands the bytes to every open file.
// The caller must hold no writer lock, because the render reads the config
// and the network.
func (p *prog) refreshLogHeader() {
header := renderLogHeader(p.logHeaderInput())
for _, rf := range p.openLogFiles() {
rf.setHeader(header)
}
}
// writeLogHeaders puts the header at the append point of each open file. A
// header that cannot land leaves the file usable, so the error only earns a
// warning.
func (p *prog) writeLogHeaders() {
for _, rf := range p.openLogFiles() {
if err := rf.writeHeader(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not write log header")
}
}
}
func prefixStrings(prefixes []netip.Prefix) []string {
ips := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
ips = append(ips, prefix.String())
}
return ips
}
// logHeaderFromConfig fills the header fields that the config holds. It keeps
// every upstream endpoint, name, and bootstrap IP out of them, because a token
// can hide in each of them.
func logHeaderFromConfig(cfg *ctrld.Config, in *logHeaderInput) {
if cfg == nil {
in.InterceptMode = interceptModeOff
return
}
in.InterceptMode = headerInterceptMode(cfg)
in.Listeners = listenerAddresses(cfg)
in.UpstreamCount = len(cfg.Upstream)
in.UpstreamTypes = upstreamTypeWords(cfg)
}
// headerInterceptMode names the mode for the header. An unset mode means that
// ctrld does not intercept, which reads as off.
func headerInterceptMode(cfg *ctrld.Config) string {
if mode := listenerInterceptMode(cfg); mode != "" {
return mode
}
return interceptModeOff
}
func listenerAddresses(cfg *ctrld.Config) []string {
addresses := make([]string, 0, len(cfg.Listener))
for _, listener := range cfg.Listener {
if listener == nil {
continue
}
addresses = append(addresses, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)))
}
slices.Sort(addresses)
return addresses
}
func upstreamTypeWords(cfg *ctrld.Config) []string {
words := make([]string, 0, len(cfg.Upstream))
for _, upstream := range cfg.Upstream {
if upstream == nil {
continue
}
word := upstream.Type
if word == "" {
word = upstreamTypeUnspecified
}
if !slices.Contains(words, word) {
words = append(words, word)
}
}
slices.Sort(words)
return words
}