diff --git a/config.go b/config.go index 8b5044f2..00957045 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,7 @@ package maddy import ( "errors" "fmt" + "net" "os" "path/filepath" @@ -53,13 +54,33 @@ func logOutput(_ *config.Map, node config.Node) (interface{}, error) { func LogOutputOption(args []string) (log.Output, error) { outs := make([]log.Output, 0, len(args)) - for i, arg := range args { + for i := 0; i < len(args); i++ { + arg := args[i] switch arg { case "stderr": outs = append(outs, log.WriterOutput(os.Stderr, false)) case "stderr_ts": outs = append(outs, log.WriterOutput(os.Stderr, true)) - case "syslog": + case "syslog", "syslog+tcp", "syslog+udp": + network := "udp" + if arg == "syslog+tcp" { + network = "tcp" + } + // Optional next arg: remote address in host:port form. + if i+1 < len(args) { + if _, _, err := net.SplitHostPort(args[i+1]); err == nil { + i++ + syslogOut, err := log.RemoteSyslogOutput(network, args[i]) + if err != nil { + return nil, fmt.Errorf("failed to connect to remote syslog at %s: %v", args[i], err) + } + outs = append(outs, syslogOut) + break + } + } + if arg != "syslog" { + return nil, fmt.Errorf("'%s' requires a remote address (host:port)", arg) + } syslogOut, err := log.SyslogOutput() if err != nil { return nil, fmt.Errorf("failed to connect to syslog daemon: %v", err) diff --git a/framework/log/log.go b/framework/log/log.go index 3ad291a6..7bd860cf 100644 --- a/framework/log/log.go +++ b/framework/log/log.go @@ -51,6 +51,11 @@ type Logger struct { // Additional fields that will be added // to the Msg output. Fields map[string]interface{} + + // LogFields are rendered as [key=value ...] before the message text so + // that individual mail flows can be identified and filtered with grep. + // Use With() to populate this. + LogFields []string } func (l *Logger) Zap() *zap.Logger { @@ -171,6 +176,17 @@ func fieldsToMap(fields []interface{}, out map[string]interface{}) { func (l *Logger) formatMsg(msg string, fields map[string]interface{}) string { formatted := strings.Builder{} + if len(l.LogFields) > 0 { + formatted.WriteRune('[') + for i, f := range l.LogFields { + if i > 0 { + formatted.WriteRune(' ') + } + formatted.WriteString(f) + } + formatted.WriteString("] ") + } + formatted.WriteString(msg) formatted.WriteRune('\t') @@ -252,6 +268,21 @@ func (l *Logger) Sublogger(name string) *Logger { } } +// With returns a copy of the logger with additional key=value pairs prepended +// to every log message as [key=value ...]. Multiple With calls accumulate. +// Use this to attach identifiers like domain or message-ID so log lines can +// be filtered with grep. +func (l *Logger) With(kvpairs ...interface{}) *Logger { + inherited := make([]string, len(l.LogFields), len(l.LogFields)+len(kvpairs)/2) + copy(inherited, l.LogFields) + for i := 0; i+1 < len(kvpairs); i += 2 { + inherited = append(inherited, fmt.Sprintf("%v=%v", kvpairs[i], kvpairs[i+1])) + } + newL := *l + newL.LogFields = inherited + return &newL +} + // DefaultLogger is the global Logger object that is used by // package-level logging functions. // diff --git a/framework/log/syslog.go b/framework/log/syslog.go index d608f557..5305bfc8 100644 --- a/framework/log/syslog.go +++ b/framework/log/syslog.go @@ -60,3 +60,11 @@ func SyslogOutput() (Output, error) { w, err := syslog.New(syslog.LOG_MAIL|syslog.LOG_INFO, "maddy") return syslogOut{w}, err } + +// RemoteSyslogOutput returns a log.Output implementation that sends +// messages to a remote syslog daemon at addr (host:port) using network +// ("udp" or "tcp"). +func RemoteSyslogOutput(network, addr string) (Output, error) { + w, err := syslog.Dial(network, addr, syslog.LOG_MAIL|syslog.LOG_INFO, "maddy") + return syslogOut{w}, err +} diff --git a/framework/log/syslog_stub.go b/framework/log/syslog_stub.go index bc486166..40d48281 100644 --- a/framework/log/syslog_stub.go +++ b/framework/log/syslog_stub.go @@ -35,3 +35,7 @@ import ( func SyslogOutput() (Output, error) { return nil, errors.New("log: syslog output is not supported on windows") } + +func RemoteSyslogOutput(network, addr string) (Output, error) { + return nil, errors.New("log: syslog output is not supported on windows") +} diff --git a/internal/endpoint/smtp/session.go b/internal/endpoint/smtp/session.go index c6adfa0c..1c6432b5 100644 --- a/internal/endpoint/smtp/session.go +++ b/internal/endpoint/smtp/session.go @@ -95,7 +95,8 @@ type Session struct { delivery module.Delivery deliveryErr error - log *log.Logger + log *log.Logger + connLog *log.Logger // connection-scoped logger; log is temporarily replaced per message } func (s *Session) AuthMechanisms() []string { @@ -149,6 +150,9 @@ func (s *Session) abort(ctx context.Context) { func (s *Session) cleanSession() { s.releaseLimits() + if s.connLog != nil { + s.log = s.connLog + } s.mailFrom = "" s.opts = smtp.MailOptions{} s.msgMeta = nil @@ -271,6 +275,14 @@ func (s *Session) startDelivery(ctx context.Context, from string, opts smtp.Mail s.mailFrom = cleanFrom s.delivery = delivery + if s.connLog != nil { + if domain != "" { + s.log = s.connLog.With("msg_id", msgMeta.ID, "from_domain", domain) + } else { + s.log = s.connLog.With("msg_id", msgMeta.ID) + } + } + return msgMeta.ID, nil } diff --git a/internal/endpoint/smtp/smtp.go b/internal/endpoint/smtp/smtp.go index acfcca3b..9ed9935f 100644 --- a/internal/endpoint/smtp/smtp.go +++ b/internal/endpoint/smtp/smtp.go @@ -402,6 +402,7 @@ func (endp *Endpoint) newSession(conn *smtp.Conn) *Session { s := &Session{ endp: endp, log: endp.log, + connLog: endp.log, sessionCtx: context.Background(), } diff --git a/internal/target/delivery.go b/internal/target/delivery.go index 75959136..99d9f5c5 100644 --- a/internal/target/delivery.go +++ b/internal/target/delivery.go @@ -19,17 +19,20 @@ along with this program. If not, see . package target import ( + "strings" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" ) +// DeliveryLogger returns a logger annotated with msg_id and from_domain so +// every log line can be filtered by message ID or sender domain. func DeliveryLogger(parent *log.Logger, msgMeta *module.MsgMetadata) *log.Logger { - l := parent.Sublogger("") - fields := make(map[string]interface{}, len(l.Fields)+1) - for k, v := range l.Fields { - fields[k] = v + kvpairs := []interface{}{"msg_id", msgMeta.ID} + if msgMeta.OriginalFrom != "" { + if _, domain, ok := strings.Cut(msgMeta.OriginalFrom, "@"); ok && domain != "" { + kvpairs = append(kvpairs, "from_domain", domain) + } } - fields["msg_id"] = msgMeta.ID - l.Fields = fields - return l + return parent.Sublogger("").With(kvpairs...) }