mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-12 02:01:13 +00:00
feat: log to syslog with per-entry severity (#2277)
This commit is contained in:
+8
-1
@@ -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], ":")
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user