feat: log to syslog with per-entry severity (#2277)

This commit is contained in:
Greelan
2026-10-09 20:39:48 +00:00
committed by GitHub
parent 1900670c18
commit ca450f8789
13 changed files with 963 additions and 42 deletions
+8 -1
View File
@@ -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], ":")
+20
View File
@@ -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:",
+13
View File
@@ -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())
+45
View File
@@ -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
+12
View File
@@ -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
+29 -6
View File
@@ -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.
+13
View File
@@ -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")
}
+202 -35
View File
@@ -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() {}
}
+238
View File
@@ -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()
}
+55
View File
@@ -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"))
})
})
})
+96
View File
@@ -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
}
}
+220
View File
@@ -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)
}
+12
View File
@@ -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")
}