From ca450f8789fc5ce397c03472ff63039f4c75aaa7 Mon Sep 17 00:00:00 2001 From: Greelan <53196309+Greelan@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:39:48 +1100 Subject: [PATCH] feat: log to syslog with per-entry severity (#2277) --- cmd/root.go | 9 +- cmd/root_test.go | 20 +++ config/schema/validate_test.go | 13 ++ docs/config.schema.json | 45 +++++++ docs/config.yml | 12 ++ docs/configuration.md | 35 ++++- log/log_suite_test.go | 13 ++ log/logger.go | 237 +++++++++++++++++++++++++++----- log/logger_enum.go | 238 +++++++++++++++++++++++++++++++++ log/logger_test.go | 55 ++++++++ log/syslog.go | 96 +++++++++++++ log/syslog_test.go | 220 ++++++++++++++++++++++++++++++ log/syslog_unsupported.go | 12 ++ 13 files changed, 963 insertions(+), 42 deletions(-) create mode 100644 log/log_suite_test.go create mode 100644 log/logger_test.go create mode 100644 log/syslog.go create mode 100644 log/syslog_test.go create mode 100644 log/syslog_unsupported.go diff --git a/cmd/root.go b/cmd/root.go index c5dced8a..284d94ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -101,7 +101,14 @@ func initConfig() error { return fmt.Errorf("unable to load configuration file '%s': %w", configPath, err) } - log.Configure(&cfg.Log) + // a command answers in the terminal, so it never logs to syslog; serve + // configures logging again from the full config when the server starts + logCfg := cfg.Log + if logCfg.Target == log.TargetTypeSyslog { + logCfg.Target = log.TargetTypeStdout + } + + log.Configure(&logCfg) if len(cfg.Ports.HTTP) != 0 { split := strings.Split(cfg.Ports.HTTP[0], ":") diff --git a/cmd/root_test.go b/cmd/root_test.go index f1937e7d..1401fe4e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "io" "net/http" "os" @@ -83,6 +84,25 @@ var _ = Describe("root command", func() { Expect(configPath).Should(Equal(tmpFile.Path)) }) + It("should keep command output in the terminal when the server logs to syslog", func() { + syslogConfig := tmpDir.CreateStringFile("config_with_syslog", + "upstreams:", + " groups:", + " default:", + " - 1.1.1.1", + "log:", + " target: syslog", + " syslog:", + " network: udp", + " address: 127.0.0.1:1", + ) + + configPath = syslogConfig.Path + + Expect(initConfig()).Should(Succeed()) + Expect(fmt.Sprintf("%T", log.Log().Out)).ShouldNot(ContainSubstring("syslog")) + }) + It("should handle config with HTTP port", func() { configWithHTTP := tmpDir.CreateStringFile("config_with_http", "upstreams:", diff --git a/config/schema/validate_test.go b/config/schema/validate_test.go index 020674d7..ce0a3756 100644 --- a/config/schema/validate_test.go +++ b/config/schema/validate_test.go @@ -31,6 +31,19 @@ var _ = Describe("ValidateYAML", func() { Expect(errs[0].Path).Should(ContainSubstring("connectIPVersion")) }) + It("validates syslog facilities against the documented enum", func() { + for _, facility := range []string{"daemon", "user", "local0", "local1", "local2", "local3", "local4", "local5", "local6", "local7"} { + errs, err := schema.ValidateYAML([]byte("log:\n target: syslog\n syslog:\n facility: " + facility + "\n")) + Expect(err).Should(Succeed()) + Expect(errs).Should(BeEmpty(), "facility %q should be allowed", facility) + } + + errs, err := schema.ValidateYAML([]byte("log:\n target: syslog\n syslog:\n facility: invalid\n")) + Expect(err).Should(Succeed()) + Expect(errs).ShouldNot(BeEmpty()) + Expect(errs[0].Path).Should(ContainSubstring("facility")) + }) + It("returns an error for unparseable YAML", func() { _, err := schema.ValidateYAML([]byte("\tnot: [valid")) Expect(err).Should(HaveOccurred()) diff --git a/docs/config.schema.json b/docs/config.schema.json index aa486823..7a70387b 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -901,6 +901,51 @@ "description": "- `text`: Human-readable text.\n- `json`: Structured JSON.", "default": "text" }, + "target": { + "type": "string", + "enum": [ + "stdout", + "stderr", + "syslog" + ], + "description": "- `stdout`: Standard output.\n- `stderr`: Standard error.\n- `syslog`: System log, each entry at the priority matching its level.", + "default": "stdout" + }, + "syslog": { + "properties": { + "network": { + "type": "string", + "default": "" + }, + "address": { + "type": "string", + "default": "" + }, + "tag": { + "type": "string", + "default": "blocky" + }, + "facility": { + "type": "string", + "enum": [ + "daemon", + "user", + "local0", + "local1", + "local2", + "local3", + "local4", + "local5", + "local6", + "local7" + ], + "description": "- `daemon`: System daemon messages.\n- `user`: User-level messages.\n- `local0`: Local use facility 0.\n- `local1`: Local use facility 1.\n- `local2`: Local use facility 2.\n- `local3`: Local use facility 3.\n- `local4`: Local use facility 4.\n- `local5`: Local use facility 5.\n- `local6`: Local use facility 6.\n- `local7`: Local use facility 7.", + "default": "daemon" + } + }, + "additionalProperties": false, + "type": "object" + }, "privacy": { "type": "boolean", "default": false diff --git a/docs/config.yml b/docs/config.yml index 7fbe0587..a4922d51 100644 --- a/docs/config.yml +++ b/docs/config.yml @@ -465,6 +465,18 @@ log: level: info # optional: Log format (text or json). Default: text format: text + # optional: where log entries go (stdout, stderr or syslog). Default: stdout + target: stdout + # optional: used when target is syslog + syslog: + # optional: empty for the local syslog socket, else udp or tcp. Default: empty + network: "" + # optional: host:port of the syslog server, when network is set + address: "" + # optional: tag each entry is logged under. Default: blocky + tag: blocky + # optional: syslog facility (daemon, user or local0-local7). Default: daemon + facility: daemon # optional: log timestamps. Default: true timestamp: true # optional: obfuscate log output (replace all alphanumeric characters with *) for user sensitive data like request domains or responses to increase privacy. Default: false diff --git a/docs/configuration.md b/docs/configuration.md index 924540a2..af04cb01 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,12 +126,17 @@ All values in this section are optional. All logging options are optional. -| Parameter | Type | Default value | Description | -| ------------- | -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| log.level | enum (trace, debug, info, warn, error) | info | Log level | -| log.format | enum (text, json) | text | Log format (text or json). | -| log.timestamp | bool | true | Log timestamps (true or false). | -| log.privacy | bool | false | Obfuscate log output (replace all alphanumeric characters with \*) for user sensitive data like request domains or responses to increase privacy. | +| Parameter | Type | Default value | Description | +| ------------------- | -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| log.level | enum (trace, debug, info, warn, error) | info | Log level | +| log.format | enum (text, json) | text | Log format (text or json). | +| log.target | enum (stdout, stderr, syslog) | stdout | Where log entries are written. `syslog` logs each entry at the priority matching its level. | +| log.syslog.network | string | | Empty for the local syslog socket, otherwise `udp` or `tcp`. | +| log.syslog.address | string | | `host:port` of the syslog server. Requires `network`. | +| log.syslog.tag | string | blocky | Tag each entry is logged under. | +| log.syslog.facility | enum (daemon, user, local0-local7) | daemon | Syslog facility. | +| log.timestamp | bool | true | Log timestamps (true or false). | +| log.privacy | bool | false | Obfuscate log output (replace all alphanumeric characters with \*) for user sensitive data like request domains or responses to increase privacy. | !!! example @@ -143,6 +148,24 @@ All logging options are optional. privacy: true ``` +!!! example "logging to syslog" + + ```yaml + log: + target: syslog + syslog: + tag: blocky + facility: daemon + ``` + + Entries are mapped to syslog priorities: `error` to `err`, `warn` to `warning`, + `info` to `info`, `debug` and `trace` to `debug`, and `fatal` and `panic` to `crit`. + Each record carries the message alone: syslog supplies the timestamp and the + severity itself, so `log.timestamp` is ignored and the level is not repeated in + the text. Use `format: json` if you want the level in the message as well. + Only the server logs to syslog: CLI commands such as `blocky query` still print + to the terminal. + ## Init Strategy A couple of features use an "init/loading strategy" which configures behavior at Blocky startup. diff --git a/log/log_suite_test.go b/log/log_suite_test.go new file mode 100644 index 00000000..d86557e8 --- /dev/null +++ b/log/log_suite_test.go @@ -0,0 +1,13 @@ +package log + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestLog(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Log Suite") +} diff --git a/log/logger.go b/log/logger.go index d80e9148..80fae285 100644 --- a/log/logger.go +++ b/log/logger.go @@ -3,9 +3,11 @@ package log //go:generate go tool go-enum -f=$GOFILE --marshal --names --template ../tools/schemagen/templates/enum_description.tmpl import ( + "fmt" "io" "maps" "os" + "slices" "strings" "sync" "sync/atomic" @@ -32,14 +34,46 @@ var ( // ) type FormatType int +// TargetType destination for logging ENUM( +// stdout // Standard output. +// stderr // Standard error. +// syslog // System log, each entry at the priority matching its level. +// ) +type TargetType int + // Config defines all logging configurations type Config struct { - Level logrus.Level `default:"info" yaml:"level"` - Format FormatType `default:"text" yaml:"format"` - Privacy bool `default:"false" yaml:"privacy"` - Timestamp bool `default:"true" yaml:"timestamp"` + Level logrus.Level `default:"info" yaml:"level"` + Format FormatType `default:"text" yaml:"format"` + Target TargetType `default:"stdout" yaml:"target"` + Syslog SyslogConfig `yaml:"syslog"` + Privacy bool `default:"false" yaml:"privacy"` + Timestamp bool `default:"true" yaml:"timestamp"` } +// SyslogConfig defines where and as what blocky logs when the target is syslog +type SyslogConfig struct { + // Network is empty for the local syslog socket, otherwise "udp" or "tcp" + Network string `default:"" yaml:"network"` + Address string `default:"" yaml:"address"` + Tag string `default:"blocky" yaml:"tag"` + Facility SyslogFacility `default:"daemon" yaml:"facility"` +} + +// SyslogFacility is the facility used for syslog messages. ENUM( +// daemon // System daemon messages. +// user // User-level messages. +// local0 // Local use facility 0. +// local1 // Local use facility 1. +// local2 // Local use facility 2. +// local3 // Local use facility 3. +// local4 // Local use facility 4. +// local5 // Local use facility 5. +// local6 // Local use facility 6. +// local7 // Local use facility 7. +// ) +type SyslogFacility string + // DefaultConfig returns a new Config initialized with default values. func DefaultConfig() *Config { cfg := new(Config) @@ -109,38 +143,164 @@ func Configure(cfg *Config) { func ConfigureLogger(logger *logrus.Logger, cfg *Config) { logger.SetLevel(cfg.Level) - switch cfg.Format { - case FormatTypeText: - // Respect NO_COLOR env var (https://no-color.org/) - noColor := os.Getenv("NO_COLOR") != "" + if cfg.Target != TargetTypeSyslog { + setOutput(logger, newFormatter(cfg), newOutput(cfg.Target)) - logFormatter := &prefixed.TextFormatter{ - TimestampFormat: "2006-01-02 15:04:05", - FullTimestamp: true, - ForceFormatting: true, - ForceColors: false, - QuoteEmptyFields: true, - DisableTimestamp: !cfg.Timestamp, - DisableColors: noColor, - } - - logFormatter.SetColorScheme(&prefixed.ColorScheme{ - PrefixStyle: "blue+b", - TimestampStyle: "white+h", - }) - - logger.SetFormatter(logFormatter) - - if noColor { - logger.SetOutput(os.Stdout) - } else { - // Windows does not support ANSI colors - logger.SetOutput(colorable.NewColorableStdout()) - } - - case FormatTypeJson: - logger.SetFormatter(&logrus.JSONFormatter{}) + return } + + writer, err := newSyslogWriter(cfg.Syslog) + if err != nil { + setOutput(logger, newFormatter(cfg), newOutput(TargetTypeStderr)) + logger.Errorf("can't log to syslog, using stderr instead: %v", err) + + return + } + + setOutput(logger, levelFormatter{newSyslogFormatter(cfg)}, writer) +} + +// syslogOutput is a logger output writing to syslog, which must be closed once +// the logger no longer writes to it. +type syslogOutput interface { + io.Writer + closeSyslog() error +} + +// setOutput points logger at formatter and out, then closes the syslog output it +// replaces. logrus writes to Out under the lock SetOutput takes, so nothing writes +// to the old output once SetOutput has returned. An entry formatted for the old +// output but written to the new one while switching is logged either way. +func setOutput(logger *logrus.Logger, formatter logrus.Formatter, out io.Writer) { + old := logger.Out + + logger.SetFormatter(formatter) + logger.SetOutput(out) + + if oldSyslog, ok := old.(syslogOutput); ok && old != out { + _ = oldSyslog.closeSyslog() + } +} + +// newFormatter returns the formatter for the configured format. +func newFormatter(cfg *Config) logrus.Formatter { + if cfg.Format == FormatTypeJson { + return &logrus.JSONFormatter{} + } + + logFormatter := &prefixed.TextFormatter{ + TimestampFormat: "2006-01-02 15:04:05", + FullTimestamp: true, + ForceFormatting: true, + ForceColors: false, + QuoteEmptyFields: true, + DisableTimestamp: !cfg.Timestamp, + DisableColors: noColor(), + } + + logFormatter.SetColorScheme(&prefixed.ColorScheme{ + PrefixStyle: "blue+b", + TimestampStyle: "white+h", + }) + + return logFormatter +} + +// newSyslogFormatter returns the formatter for the configured format when logging +// to syslog. +func newSyslogFormatter(cfg *Config) logrus.Formatter { + if cfg.Format == FormatTypeJson { + return &logrus.JSONFormatter{} + } + + return syslogFormatter{} +} + +// levelMarker starts a formatted entry carrying its level, see levelFormatter. +const levelMarker = 0 + +// levelFormatter prefixes each entry with a marker and its level, so the syslog +// writer can pick the record's priority and strip both again. The writer only +// sees bytes, and logrus formats an entry after all hooks have run, so this is +// the last point that still knows the entry's final message and level. +type levelFormatter struct { + logrus.Formatter +} + +// Format implements `logrus.Formatter`. +func (f levelFormatter) Format(entry *logrus.Entry) ([]byte, error) { + line, err := f.Formatter.Format(entry) + if err != nil { + return nil, err + } + + return append([]byte{levelMarker, byte(entry.Level)}, line...), nil //nolint:gosec // levels are 0-6 +} + +// splitLevel returns the level levelFormatter prefixed to p, and the rest of p. +// Without the prefix, the entry is logged at info. +func splitLevel(p []byte) (logrus.Level, []byte) { + if len(p) >= 2 && p[0] == levelMarker { + return logrus.Level(p[1]), p[2:] + } + + return logrus.InfoLevel, p +} + +// syslogFormatter renders an entry as plain text carrying neither timestamp nor +// level: syslog records both itself, in the record's own header and priority. +type syslogFormatter struct{} + +// Format implements `logrus.Formatter`. +func (syslogFormatter) Format(entry *logrus.Entry) ([]byte, error) { + var out strings.Builder + + if prefix, ok := entry.Data[prefixField].(string); ok && prefix != "" { + out.WriteString(prefix) + out.WriteString(": ") + } + + out.WriteString(entry.Message) + + fields := make([]string, 0, len(entry.Data)) + + for field := range entry.Data { + if field != prefixField { + fields = append(fields, field) + } + } + + slices.Sort(fields) + + for _, field := range fields { + fmt.Fprintf(&out, " %s=%v", field, entry.Data[field]) + } + + out.WriteString("\n") + + return []byte(out.String()), nil +} + +func newOutput(target TargetType) io.Writer { + if target == TargetTypeStderr { + if noColor() { + return os.Stderr + } + + return colorable.NewColorableStderr() + } + + if noColor() { + return os.Stdout + } + + // Windows does not support ANSI colors + return colorable.NewColorableStdout() +} + +// Respect NO_COLOR env var (https://no-color.org/) +func noColor() bool { + return os.Getenv("NO_COLOR") != "" } // Silence disables the logger output @@ -172,7 +332,14 @@ func WithIndent(log *logrus.Entry, prefix string, callback func(*logrus.Entry)) // // The returned function must be called to remove the prefix. func indentMessages(prefix string, logger *logrus.Logger) func() { - if _, ok := logger.Formatter.(*prefixed.TextFormatter); !ok { + formatter := logger.Formatter + if level, ok := formatter.(levelFormatter); ok { + formatter = level.Formatter + } + + switch formatter.(type) { + case *prefixed.TextFormatter, syslogFormatter: + default: // log is not plaintext, do nothing return func() {} } diff --git a/log/logger_enum.go b/log/logger_enum.go index 99153172..e405ae81 100644 --- a/log/logger_enum.go +++ b/log/logger_enum.go @@ -106,3 +106,241 @@ func (FormatType) EnumDescriptions() map[string]string { func (FormatType) EnumValues() []string { return FormatTypeNames() } + +const ( + // SyslogFacilityDaemon is a SyslogFacility of type daemon. + // System daemon messages. + SyslogFacilityDaemon SyslogFacility = "daemon" + // SyslogFacilityUser is a SyslogFacility of type user. + // User-level messages. + SyslogFacilityUser SyslogFacility = "user" + // SyslogFacilityLocal0 is a SyslogFacility of type local0. + // Local use facility 0. + SyslogFacilityLocal0 SyslogFacility = "local0" + // SyslogFacilityLocal1 is a SyslogFacility of type local1. + // Local use facility 1. + SyslogFacilityLocal1 SyslogFacility = "local1" + // SyslogFacilityLocal2 is a SyslogFacility of type local2. + // Local use facility 2. + SyslogFacilityLocal2 SyslogFacility = "local2" + // SyslogFacilityLocal3 is a SyslogFacility of type local3. + // Local use facility 3. + SyslogFacilityLocal3 SyslogFacility = "local3" + // SyslogFacilityLocal4 is a SyslogFacility of type local4. + // Local use facility 4. + SyslogFacilityLocal4 SyslogFacility = "local4" + // SyslogFacilityLocal5 is a SyslogFacility of type local5. + // Local use facility 5. + SyslogFacilityLocal5 SyslogFacility = "local5" + // SyslogFacilityLocal6 is a SyslogFacility of type local6. + // Local use facility 6. + SyslogFacilityLocal6 SyslogFacility = "local6" + // SyslogFacilityLocal7 is a SyslogFacility of type local7. + // Local use facility 7. + SyslogFacilityLocal7 SyslogFacility = "local7" +) + +var ErrInvalidSyslogFacility = fmt.Errorf("not a valid SyslogFacility, try [%s]", strings.Join(_SyslogFacilityNames, ", ")) + +var _SyslogFacilityNames = []string{ + string(SyslogFacilityDaemon), + string(SyslogFacilityUser), + string(SyslogFacilityLocal0), + string(SyslogFacilityLocal1), + string(SyslogFacilityLocal2), + string(SyslogFacilityLocal3), + string(SyslogFacilityLocal4), + string(SyslogFacilityLocal5), + string(SyslogFacilityLocal6), + string(SyslogFacilityLocal7), +} + +// SyslogFacilityNames returns a list of possible string values of SyslogFacility. +func SyslogFacilityNames() []string { + tmp := make([]string, len(_SyslogFacilityNames)) + copy(tmp, _SyslogFacilityNames) + return tmp +} + +// String implements the Stringer interface. +func (x SyslogFacility) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x SyslogFacility) IsValid() bool { + _, err := ParseSyslogFacility(string(x)) + return err == nil +} + +var _SyslogFacilityValue = map[string]SyslogFacility{ + "daemon": SyslogFacilityDaemon, + "user": SyslogFacilityUser, + "local0": SyslogFacilityLocal0, + "local1": SyslogFacilityLocal1, + "local2": SyslogFacilityLocal2, + "local3": SyslogFacilityLocal3, + "local4": SyslogFacilityLocal4, + "local5": SyslogFacilityLocal5, + "local6": SyslogFacilityLocal6, + "local7": SyslogFacilityLocal7, +} + +// ParseSyslogFacility attempts to convert a string to a SyslogFacility. +func ParseSyslogFacility(name string) (SyslogFacility, error) { + if x, ok := _SyslogFacilityValue[name]; ok { + return x, nil + } + return SyslogFacility(""), fmt.Errorf("%s is %w", name, ErrInvalidSyslogFacility) +} + +// MarshalText implements the text marshaller method. +func (x SyslogFacility) MarshalText() ([]byte, error) { + return []byte(string(x)), nil +} + +// UnmarshalText implements the text unmarshaller method. +func (x *SyslogFacility) UnmarshalText(text []byte) error { + tmp, err := ParseSyslogFacility(string(text)) + if err != nil { + return err + } + *x = tmp + return nil +} + +// AppendText appends the textual representation of itself to the end of b +// (allocating a larger slice if necessary) and returns the updated slice. +// +// Implementations must not retain b, nor mutate any bytes within b[:len(b)]. +func (x *SyslogFacility) AppendText(b []byte) ([]byte, error) { + return append(b, x.String()...), nil +} + +// EnumDescriptions returns each enum value's description, taken from the +// `// comment` in the ENUM(...) declaration. Generated; do not edit. +func (SyslogFacility) EnumDescriptions() map[string]string { + return map[string]string{ + "daemon": "System daemon messages.", + "user": "User-level messages.", + "local0": "Local use facility 0.", + "local1": "Local use facility 1.", + "local2": "Local use facility 2.", + "local3": "Local use facility 3.", + "local4": "Local use facility 4.", + "local5": "Local use facility 5.", + "local6": "Local use facility 6.", + "local7": "Local use facility 7.", + } +} + +// EnumValues returns the enum's accepted string values, used to build the JSON +// schema enum constraint. Generated; do not edit. +func (SyslogFacility) EnumValues() []string { + return SyslogFacilityNames() +} + +const ( + // TargetTypeStdout is a TargetType of type Stdout. + // Standard output. + TargetTypeStdout TargetType = iota + // TargetTypeStderr is a TargetType of type Stderr. + // Standard error. + TargetTypeStderr + // TargetTypeSyslog is a TargetType of type Syslog. + // System log, each entry at the priority matching its level. + TargetTypeSyslog +) + +var ErrInvalidTargetType = fmt.Errorf("not a valid TargetType, try [%s]", strings.Join(_TargetTypeNames, ", ")) + +const _TargetTypeName = "stdoutstderrsyslog" + +var _TargetTypeNames = []string{ + _TargetTypeName[0:6], + _TargetTypeName[6:12], + _TargetTypeName[12:18], +} + +// TargetTypeNames returns a list of possible string values of TargetType. +func TargetTypeNames() []string { + tmp := make([]string, len(_TargetTypeNames)) + copy(tmp, _TargetTypeNames) + return tmp +} + +var _TargetTypeMap = map[TargetType]string{ + TargetTypeStdout: _TargetTypeName[0:6], + TargetTypeStderr: _TargetTypeName[6:12], + TargetTypeSyslog: _TargetTypeName[12:18], +} + +// String implements the Stringer interface. +func (x TargetType) String() string { + if str, ok := _TargetTypeMap[x]; ok { + return str + } + return fmt.Sprintf("TargetType(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x TargetType) IsValid() bool { + _, ok := _TargetTypeMap[x] + return ok +} + +var _TargetTypeValue = map[string]TargetType{ + _TargetTypeName[0:6]: TargetTypeStdout, + _TargetTypeName[6:12]: TargetTypeStderr, + _TargetTypeName[12:18]: TargetTypeSyslog, +} + +// ParseTargetType attempts to convert a string to a TargetType. +func ParseTargetType(name string) (TargetType, error) { + if x, ok := _TargetTypeValue[name]; ok { + return x, nil + } + return TargetType(0), fmt.Errorf("%s is %w", name, ErrInvalidTargetType) +} + +// MarshalText implements the text marshaller method. +func (x TargetType) MarshalText() ([]byte, error) { + return []byte(x.String()), nil +} + +// UnmarshalText implements the text unmarshaller method. +func (x *TargetType) UnmarshalText(text []byte) error { + name := string(text) + tmp, err := ParseTargetType(name) + if err != nil { + return err + } + *x = tmp + return nil +} + +// AppendText appends the textual representation of itself to the end of b +// (allocating a larger slice if necessary) and returns the updated slice. +// +// Implementations must not retain b, nor mutate any bytes within b[:len(b)]. +func (x *TargetType) AppendText(b []byte) ([]byte, error) { + return append(b, x.String()...), nil +} + +// EnumDescriptions returns each enum value's description, taken from the +// `// comment` in the ENUM(...) declaration. Generated; do not edit. +func (TargetType) EnumDescriptions() map[string]string { + return map[string]string{ + "stdout": "Standard output.", + "stderr": "Standard error.", + "syslog": "System log, each entry at the priority matching its level.", + } +} + +// EnumValues returns the enum's accepted string values, used to build the JSON +// schema enum constraint. Generated; do not edit. +func (TargetType) EnumValues() []string { + return TargetTypeNames() +} diff --git a/log/logger_test.go b/log/logger_test.go new file mode 100644 index 00000000..60ad9981 --- /dev/null +++ b/log/logger_test.go @@ -0,0 +1,55 @@ +package log + +import ( + "bytes" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" +) + +var _ = Describe("Logger", func() { + var cfg *Config + + BeforeEach(func() { + cfg = DefaultConfig() + }) + + Describe("target", func() { + It("defaults to stdout", func() { + Expect(cfg.Target).Should(Equal(TargetTypeStdout)) + }) + + It("parses each accepted value", func() { + Expect(ParseTargetType("stdout")).Should(Equal(TargetTypeStdout)) + Expect(ParseTargetType("stderr")).Should(Equal(TargetTypeStderr)) + Expect(ParseTargetType("syslog")).Should(Equal(TargetTypeSyslog)) + }) + + It("rejects anything else", func() { + _, err := ParseTargetType("nowhere") + Expect(err).Should(HaveOccurred()) + }) + }) + + Describe("syslog", func() { + It("defaults to the daemon facility and the blocky tag", func() { + Expect(cfg.Syslog.Facility).Should(Equal(SyslogFacility("daemon"))) + Expect(cfg.Syslog.Tag).Should(Equal("blocky")) + }) + + It("keeps logging when syslog can't be used", func() { + cfg.Target = TargetTypeSyslog + cfg.Syslog.Facility = "not-a-facility" + + logger := logrus.New() + ConfigureLogger(logger, cfg) + + out := new(bytes.Buffer) + logger.SetOutput(out) + logger.Warn("still here") + + Expect(out.String()).Should(ContainSubstring("still here")) + }) + }) +}) diff --git a/log/syslog.go b/log/syslog.go new file mode 100644 index 00000000..ef318589 --- /dev/null +++ b/log/syslog.go @@ -0,0 +1,96 @@ +//go:build !windows && !plan9 + +package log + +import ( + "fmt" + "log/syslog" + "slices" + "strings" + + "github.com/sirupsen/logrus" +) + +// Facilities a DNS server can sensibly claim: the system daemon facility and the +// eight reserved for local use. +// +//nolint:gochecknoglobals +var syslogFacilities = map[SyslogFacility]syslog.Priority{ + SyslogFacilityDaemon: syslog.LOG_DAEMON, + SyslogFacilityUser: syslog.LOG_USER, + SyslogFacilityLocal0: syslog.LOG_LOCAL0, + SyslogFacilityLocal1: syslog.LOG_LOCAL1, + SyslogFacilityLocal2: syslog.LOG_LOCAL2, + SyslogFacilityLocal3: syslog.LOG_LOCAL3, + SyslogFacilityLocal4: syslog.LOG_LOCAL4, + SyslogFacilityLocal5: syslog.LOG_LOCAL5, + SyslogFacilityLocal6: syslog.LOG_LOCAL6, + SyslogFacilityLocal7: syslog.LOG_LOCAL7, +} + +// newSyslogWriter connects to syslog and returns a writer logging each entry at +// the priority matching the level levelFormatter prefixed it with. +func newSyslogWriter(cfg SyslogConfig) (syslogOutput, error) { + facility, found := syslogFacilities[cfg.Facility] + if !found { + names := SyslogFacilityNames() + slices.Sort(names) + + return nil, fmt.Errorf("invalid syslog facility '%s', try one of: %s", + cfg.Facility, strings.Join(names, ", ")) + } + + // syslog.Dial ignores the address without a network and logs locally instead + if cfg.Network == "" && cfg.Address != "" { + return nil, fmt.Errorf("syslog address '%s' needs a network, set it to udp or tcp", cfg.Address) + } + + // an empty network dials the local syslog socket + writer, err := syslog.Dial(cfg.Network, cfg.Address, facility, cfg.Tag) + if err != nil { + return nil, fmt.Errorf("can't connect to syslog: %w", err) + } + + return &syslogWriter{writer: writer}, nil +} + +type syslogWriter struct { + writer *syslog.Writer +} + +func (w *syslogWriter) closeSyslog() error { + return w.writer.Close() +} + +// Write implements `io.Writer`. +func (w *syslogWriter) Write(p []byte) (int, error) { + level, line := splitLevel(p) + write := w.writeFunc(level) + + // a syslog record is a single line, so a multi-line entry becomes one record + // per line rather than one record containing newlines + for message := range strings.SplitSeq(strings.TrimRight(string(line), "\n"), "\n") { + if err := write(message); err != nil { + return 0, err + } + } + + return len(p), nil +} + +func (w *syslogWriter) writeFunc(level logrus.Level) func(string) error { + switch level { + case logrus.PanicLevel, logrus.FatalLevel: + return w.writer.Crit + case logrus.ErrorLevel: + return w.writer.Err + case logrus.WarnLevel: + return w.writer.Warning + case logrus.InfoLevel: + return w.writer.Info + case logrus.DebugLevel, logrus.TraceLevel: + return w.writer.Debug + default: + return w.writer.Info + } +} diff --git a/log/syslog_test.go b/log/syslog_test.go new file mode 100644 index 00000000..68cd6086 --- /dev/null +++ b/log/syslog_test.go @@ -0,0 +1,220 @@ +//go:build !windows && !plan9 + +package log + +import ( + "bytes" + "io" + "net" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + prefixed "github.com/x-cray/logrus-prefixed-formatter" +) + +// listen returns a UDP socket standing in for a syslog server, and the lines it receives. +func listen() (*net.UDPConn, string) { + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + Expect(err).Should(Succeed()) + + conn, err := net.ListenUDP("udp", addr) + Expect(err).Should(Succeed()) + + return conn, conn.LocalAddr().String() +} + +func receive(conn *net.UDPConn) string { + Expect(conn.SetReadDeadline(time.Now().Add(5 * time.Second))).Should(Succeed()) + + buf := make([]byte, 1024) + + n, _, err := conn.ReadFrom(buf) + Expect(err).Should(Succeed()) + + return string(buf[:n]) +} + +var _ = Describe("Syslog target", func() { + var ( + conn *net.UDPConn + logger *logrus.Logger + ) + + BeforeEach(func() { + var address string + + conn, address = listen() + DeferCleanup(conn.Close) + + cfg := DefaultConfig() + cfg.Level = logrus.TraceLevel + cfg.Target = TargetTypeSyslog + cfg.Syslog.Network = "udp" + cfg.Syslog.Address = address + + logger = logrus.New() + ConfigureLogger(logger, cfg) + }) + + It("maps every declared facility to a syslog priority", func() { + for _, facility := range SyslogFacilityNames() { + _, found := syslogFacilities[SyslogFacility(facility)] + Expect(found).Should(BeTrue(), "facility %q must have a syslog priority", facility) + } + }) + + // facility daemon (3) * 8 + severity + DescribeTable("logs each level at the matching priority", + func(write func(...any), priority string) { + write("hello") + + Expect(receive(conn)).Should(HavePrefix(priority)) + }, + Entry("error is err", func(args ...any) { logger.Error(args...) }, "<27>"), + Entry("warn is warning", func(args ...any) { logger.Warn(args...) }, "<28>"), + Entry("info is info", func(args ...any) { logger.Info(args...) }, "<30>"), + Entry("debug is debug", func(args ...any) { logger.Debug(args...) }, "<31>"), + Entry("trace is debug", func(args ...any) { logger.Trace(args...) }, "<31>"), + ) + + It("carries the message and the tag", func() { + logger.Info("a message") + + received := receive(conn) + Expect(received).Should(ContainSubstring("blocky")) + Expect(received).Should(ContainSubstring("a message")) + }) + + It("leaves the level and the timestamp to syslog", func() { + logger.Info("a message") + + received := receive(conn) + Expect(received).ShouldNot(ContainSubstring("INFO")) + // the record's own header is the only timestamp + Expect(received).ShouldNot(MatchRegexp(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`)) + }) + + It("keeps the prefix and any fields", func() { + PrefixedLogFor(logger, "resolver").WithField("upstream", "1.1.1.1").Warn("slow") + + Expect(receive(conn)).Should(ContainSubstring("resolver: slow upstream=1.1.1.1")) + }) + + It("applies indentation before formatting the syslog entry", func() { + WithIndent(logger.WithField("prefix", "resolver"), " ", func(entry *logrus.Entry) { + entry.Info("indented") + }) + + Expect(receive(conn)).Should(ContainSubstring("resolver: indented")) + }) + + It("stops sending to syslog when reconfigured away from it", func() { + ConfigureLogger(logger, DefaultConfig()) + + Expect(logger.Out).ShouldNot(BeAssignableToTypeOf(&syslogWriter{})) + + out := new(bytes.Buffer) + logger.SetOutput(out) + logger.Info("stdout") + Expect(out.String()).Should(ContainSubstring("stdout")) + + Expect(conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).Should(Succeed()) + _, _, err := conn.ReadFrom(make([]byte, 1024)) + Expect(err).Should(HaveOccurred(), "reconfigured logger must not send to its old syslog destination") + }) + + It("keeps the hooks registered before it", func() { + hook := &MockLoggerHook{} + hook.On("Fire").Return(nil) + logger.AddHook(hook) + + cfg := DefaultConfig() + cfg.Target = TargetTypeSyslog + cfg.Syslog.Network = "udp" + cfg.Syslog.Address = conn.LocalAddr().String() + ConfigureLogger(logger, cfg) + + logger.Info("hooked") + + Expect(hook.Messages).Should(ContainElement("hooked")) + Expect(receive(conn)).Should(ContainSubstring("hooked")) + }) + + DescribeTable("closes the syslog connection when reconfigured", + func(next func() *Config) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).Should(Succeed()) + DeferCleanup(listener.Close) + + cfg := DefaultConfig() + cfg.Target = TargetTypeSyslog + cfg.Syslog.Network = "tcp" + cfg.Syslog.Address = listener.Addr().String() + ConfigureLogger(logger, cfg) + + client, err := listener.Accept() + Expect(err).Should(Succeed()) + DeferCleanup(client.Close) + + ConfigureLogger(logger, next()) + + Expect(client.SetReadDeadline(time.Now().Add(time.Second))).Should(Succeed()) + _, err = client.Read(make([]byte, 1)) + Expect(err).Should(Equal(io.EOF)) + }, + Entry("to a stream", DefaultConfig), + Entry("to syslog again", func() *Config { + cfg := DefaultConfig() + cfg.Target = TargetTypeSyslog + cfg.Syslog.Network = "udp" + cfg.Syslog.Address = conn.LocalAddr().String() + + return cfg + }), + ) + + It("sends a multi-line entry as one record per line", func() { + logger.Info("first\nsecond") + + Expect(receive(conn)).Should(ContainSubstring("first")) + Expect(receive(conn)).Should(ContainSubstring("second")) + }) + + It("writes through its own output, never a discarding one", func() { + Expect(logger.Out).Should(BeAssignableToTypeOf(&syslogWriter{})) + Expect(logger.Out).ShouldNot(Equal(io.Discard)) + }) + + When("syslog can't be used", func() { + var cfg *Config + + BeforeEach(func() { + cfg = DefaultConfig() + cfg.Target = TargetTypeSyslog + }) + + It("rejects an address without a network", func() { + cfg.Syslog.Address = "loghost:514" + + _, err := newSyslogWriter(cfg.Syslog) + Expect(err).Should(MatchError(ContainSubstring("needs a network"))) + }) + + It("falls back to stderr with the regular text format", func() { + cfg.Syslog.Address = "loghost:514" + + ConfigureLogger(logger, cfg) + + Expect(logger.Out).ShouldNot(BeAssignableToTypeOf(&syslogWriter{})) + Expect(logger.Formatter).Should(BeAssignableToTypeOf(&prefixed.TextFormatter{})) + }) + }) +}) + +// PrefixedLogFor is PrefixedLog for a specific logger, so the spec can use the +// one pointed at the test's syslog socket. +func PrefixedLogFor(logger *logrus.Logger, prefix string) *logrus.Entry { + return logger.WithField("prefix", prefix) +} diff --git a/log/syslog_unsupported.go b/log/syslog_unsupported.go new file mode 100644 index 00000000..7cfbf946 --- /dev/null +++ b/log/syslog_unsupported.go @@ -0,0 +1,12 @@ +//go:build windows || plan9 + +package log + +import ( + "errors" +) + +// newSyslogWriter reports that this platform has no syslog. +func newSyslogWriter(SyslogConfig) (syslogOutput, error) { + return nil, errors.New("syslog is not available on this platform") +}