Files
ctrld/cmd/cli/log_journal.go
T

147 lines
5.0 KiB
Go

package cli
import (
"io"
"regexp"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// journalField marks an event for the retained journal stream. Both packages
// share the name, so one core keeps the lines of both.
const journalField = ctrld.JournalField
// retainedURLPath matches the part of a URL that follows the host, for every
// scheme: a DoH, DoH3, or DoQ endpoint carries its token in the path. The
// class stops the match at the characters that end a value inside a log line.
var retainedURLPath = regexp.MustCompile(`([a-z][a-z0-9+.-]*://[^/?"\\\s]+)[/?][^"\\\s]*`)
// retainedControlDHost matches a Control D resolver host. The first label of
// the host is the resolver ID, which a DoT or DoQ endpoint carries in place of
// a path.
var retainedControlDHost = regexp.MustCompile(`([A-Za-z0-9-]+)(\.dns\.controld\.(?:com|dev))`)
// redactRetainedLine strips the secrets of one line that the journal keeps.
// Support downloads the journal whole, and any error text can carry a DoH URL
// with the resolver path and the packed query of the user.
func redactRetainedLine(p []byte) []byte {
stripped := retainedURLPath.ReplaceAll(p, []byte(`${1}/[redacted]`))
stripped = retainedControlDHost.ReplaceAll(stripped, []byte(`[redacted]${2}`))
return []byte(redactSecrets(string(stripped), journalSecrets()...))
}
// journalSecrets names the values that no retained line may hold. The client
// ID stays out: it is a device label, not a secret. redactSecrets drops the
// values that are too short to redact.
func journalSecrets() []string {
uid, _ := controld.ParseRawUID(cdUID)
return []string{cdUID, cdOrg, uid}
}
// journal marks an event for the retained journal stream. The event keeps
// its own level, so an info event stays info on the console.
func journal(e *ctrld.LogEvent) *ctrld.LogEvent {
return ctrld.Journal(e)
}
// journalTimeLayout is the time format of a journal line: RFC 3339 with
// milliseconds.
const journalTimeLayout = "2006-01-02T15:04:05.000Z07:00"
// newJournalEncoder is the encoder of the journal and of the header line: one
// JSON object for each line, with the field names that the log tools read.
// The debug stream keeps its console layout for the operator who reads it on
// the host.
func newJournalEncoder() zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "time"
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(journalTimeLayout)
encoderConfig.LevelKey = "level"
encoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder
encoderConfig.MessageKey = "message"
return zapcore.NewJSONEncoder(encoderConfig)
}
// journalCore writes the lines that the journal keeps and drops the rest, so
// the journal holds a small history that survives a restart. It keeps every
// line at warn level or above, and every line that carries the journal field.
// Notice shares its level value with warn, so a notice line stays too.
type journalCore struct {
zapcore.LevelEnabler
encoder zapcore.Encoder
out zapcore.WriteSyncer
// marked is set when With bound the journal field, so every line of that
// logger belongs to the journal.
marked bool
}
var _ zapcore.Core = (*journalCore)(nil)
// newJournalCore makes the core of the journal stream. It accepts every level,
// because a debug line with the journal field belongs to the journal too.
func newJournalCore(w io.Writer) *journalCore {
return &journalCore{
LevelEnabler: zapcore.DebugLevel,
encoder: newJournalEncoder(),
out: zapcore.AddSync(w),
}
}
func (c *journalCore) With(fields []zapcore.Field) zapcore.Core {
clone := &journalCore{
LevelEnabler: c.LevelEnabler,
encoder: c.encoder.Clone(),
out: c.out,
marked: c.marked || hasJournalField(fields),
}
for _, field := range fields {
field.AddTo(clone.encoder)
}
return clone
}
func (c *journalCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return checked.AddCore(entry, c)
}
return checked
}
// Write keeps or drops one line. A dropped line is not an error.
func (c *journalCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
if !c.retains(entry, fields) {
return nil
}
buf, err := c.encoder.EncodeEntry(entry, fields)
if err != nil {
return err
}
defer buf.Free()
_, err = c.out.Write(redactRetainedLine(buf.Bytes()))
return err
}
func (c *journalCore) Sync() error {
return c.out.Sync()
}
// retains reads the level first, because a compare costs less than a scan of
// the fields.
func (c *journalCore) retains(entry zapcore.Entry, fields []zapcore.Field) bool {
return c.marked || entry.Level >= zapcore.WarnLevel || hasJournalField(fields)
}
// hasJournalField reports whether fields carry the journal marker.
func hasJournalField(fields []zapcore.Field) bool {
for _, field := range fields {
if field.Key == journalField && field.Type == zapcore.BoolType && field.Integer == 1 {
return true
}
}
return false
}