pax_global_header00006660000000000000000000000064137612413750014523gustar00rootroot0000000000000052 comment=0f0aeabb34a72c64d5898d0eb9c2cf40fdd219cc filter-rspamd-0.1.7/000077500000000000000000000000001376124137500143015ustar00rootroot00000000000000filter-rspamd-0.1.7/.github/000077500000000000000000000000001376124137500156415ustar00rootroot00000000000000filter-rspamd-0.1.7/.github/FUNDING.yml000066400000000000000000000000441376124137500174540ustar00rootroot00000000000000github: [poolpOrg] patreon: gilles filter-rspamd-0.1.7/.github/workflows/000077500000000000000000000000001376124137500176765ustar00rootroot00000000000000filter-rspamd-0.1.7/.github/workflows/go.yml000066400000000000000000000010601376124137500210230ustar00rootroot00000000000000name: Go on: [push] jobs: build: name: Build runs-on: ubuntu-latest steps: - name: Set up Go 1.12 uses: actions/setup-go@v1 with: go-version: 1.12 id: go - name: Check out code into the Go module directory uses: actions/checkout@v1 - name: Get dependencies run: | go get -v -t -d ./... if [ -f Gopkg.toml ]; then curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh dep ensure fi - name: Build run: go build -v . filter-rspamd-0.1.7/.gitignore000066400000000000000000000000161376124137500162660ustar00rootroot00000000000000filter-rspamd filter-rspamd-0.1.7/LICENSE000066400000000000000000000014301376124137500153040ustar00rootroot00000000000000/* * Copyright (c) 2019 Gilles Chehade * * Permission to use, copy, modify, and distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ filter-rspamd-0.1.7/README.md000066400000000000000000000051001376124137500155540ustar00rootroot00000000000000# filter-rspamd ## Description This filter implements the Rspamd protocol and allows OpenSMTPD to request an Rspamd analysis of an SMTP transaction before a message is committed to queue. ## Features The filter currently supports: - greylisting - adding X-Spam related headers to a message - rewriting Subject - DKIM-signing message - Rspamd-provided SMTP replies - Allow Rspamd to add and remove headers ## Dependencies The filter is written in Golang and doesn't have any dependencies beyond the Go extended standard library. It requires OpenSMTPD 6.6.0 or higher. ## How to install Install from your operating system's preferred package manager if available. On OpenBSD: ``` $ doas pkg_add opensmtpd-filter-rspamd quirks-3.167 signed on 2019-08-11T14:18:58Z opensmtpd-filter-rspamd-0.1.x: ok $ ``` Install using Go: ``` $ GO111MODULE=on go get github.com/poolpOrg/filter-rspamd $ doas install -m 0555 ~/go/bin/filter-rspamd /usr/local/libexec/smtpd/filter-rspamd ``` Alternatively, clone the repository, build and install the filter: ``` $ cd filter-rspamd/ $ go build $ doas install -m 0555 filter-rspamd /usr/local/libexec/smtpd/filter-rspamd ``` On Ubuntu the directory to install to is different: ``` $ sudo install -m 0555 filter-rspamd /usr/libexec/opensmtpd/filter-rspamd ``` ## How to configure The filter itself requires no configuration. It must be declared in smtpd.conf and attached to a listener for sessions to go through rspamd: ``` filter "rspamd" proc-exec "filter-rspamd" listen on all filter "rspamd" ``` A remote rspamd instance can be specified by providing the -url parameter to the filter: ``` filter "rspamd" proc-exec "filter-rspamd -url http://example.org:11333" listen on all filter "rspamd" ``` Optionally a `-settings-id` parameter can be used to select a specific rspamd setting. One usecase is for example to apply different rspamd rules to incoming and outgoing emails: ``` filter "rspamd-incoming" proc-exec "filter-rspamd" filter "rspamd-outgoing" proc-exec "filter-rspamd -settings-id outgoing" listen on all filter "rspamd-incoming" listen on all port submission filter "rspamd-outgoing" ``` And in `rspamd/local.d/settings.conf`: ``` outgoing { id = "outgoing"; apply { enable_groups = ["dkim"]; actions { reject = 100.0; greylist = 100.0; "add header" = 100.0; } } } ``` Every email passed through the `rspamd-outgoing` filter will use the rspamd `outgoing` rule instead of the default rule. Any configuration with regard to thresholds or enabled modules must be done in rspamd itself. filter-rspamd-0.1.7/filter-rspamd.8000066400000000000000000000042641376124137500171510ustar00rootroot00000000000000.\" Copyright (C) 2020 Ryan Kavanagh .\" All rights reserved. .\" Permission to use, copy, modify, and distribute this software for any .\" purpose with or without fee is hereby granted, provided that the above .\" copyright notice and this permission notice appear in all copies. .\" .\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES .\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF .\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR .\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES .\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN .\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF .\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. .Dd April 24, 2020 .Dt FILTER-RSPAMD 8 .Os .Sh NAME .Nm filter-rspamd .Nd Rspamd filter for OpenSMTPD .Sh SYNOPSIS .Nm filter-rspamd .Op Fl url Ar url .Sh DESCRIPTION The .Nm filter for the OpenSMTPD .Pq Xr smtpd 8 server filters sessions through an rspamd daemon. Its options are: .Bl -tag -width url .It Fl url Ar url Connect to the remote rspamd instance located at .Ar url . This flag is optional. If unspecified, .Nm will connect to the rspamd instance located at .Lk http://localhost:11333 . .El .Pp All other rspamd-related configuration, e.g., regarding thresholds or enabled modules, must be done in rspamd itself. .Sh EXIT STATUS .Ex -std .Sh EXAMPLES Adding the following to .Pa smtpd.conf enables .Nm for all incoming connections. In this configuration, the local rspamd daemon is used. .Bd -literal -offset indent filter "rspamd" proc-exec "filter-rspamd" listen on all filter "rspamd" .Ed .Pp The following enables .Nm for all incoming connections using a remote rspamd daemon. .Bd -literal -offset indent filter "rspamd" proc-exec "filter-rspamd -url http://example.org:11333" listen on all filter "rspamd" .Ed .Sh SEE ALSO .Xr smtpd.conf 5 , .Xr rspamd 8 .Sh AUTHORS .Nm is Copyright \(co 2019 .An -nosplit .An Gilles Chehade Aq Mt gilles@poolp.org . This man page is Copyright \(co 2020 .An Ryan Kavanagh Aq Mt rak@debian.org . Both are distributed under the ISC license. .Sh BUGS None known. filter-rspamd-0.1.7/filter-rspamd.go000066400000000000000000000310561376124137500174060ustar00rootroot00000000000000// // Copyright (c) 2019 Gilles Chehade // // Permission to use, copy, modify, and distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // package main import ( "bufio" "flag" "fmt" "os" "sort" "strings" "encoding/json" "log" "net/http" ) var rspamdURL *string var rspamdSettingsId *string var version string var outputChannel chan string type tx struct { msgid string mailFrom string rcptTo []string message []string action string response string } type session struct { id string rdns string src string heloName string userName string mtaName string tx tx } type rspamd struct { Score float32 RequiredScore float32 `json:"required_score"` Subject string Action string Messages struct { SMTP string `json:"smtp_message"` } `json:"messages"` DKIMSig interface{} `json:"dkim-signature"` Headers struct { Remove map[string]int8 `json:"remove_headers"` Add map[string]interface{} `json:"add_headers"` } `json:"milter"` Symbols map[string]struct { Score float32 } `json:"symbols"` } var sessions = make(map[string]*session) var reporters = map[string]func(*session, []string){ "link-connect": linkConnect, "link-disconnect": linkDisconnect, "link-greeting": linkGreeting, "link-identify": linkIdentify, "link-auth": linkAuth, "tx-reset": txReset, "tx-begin": txBegin, "tx-mail": txMail, "tx-rcpt": txRcpt, } var filters = map[string]func(*session, []string){ "data-line": dataLine, "commit": dataCommit, } func linkConnect(s *session, params []string) { if len(params) != 4 { log.Fatal("invalid input, shouldn't happen") } s.rdns = params[0] s.src = params[2] } func linkDisconnect(s *session, params []string) { if len(params) != 0 { log.Fatal("invalid input, shouldn't happen") } delete(sessions, s.id) } func linkGreeting(s *session, params []string) { if len(params) != 1 { log.Fatal("invalid input, shouldn't happen") } s.mtaName = params[0] } func linkIdentify(s *session, params []string) { if len(params) != 2 { log.Fatal("invalid input, shouldn't happen") } s.heloName = params[1] } func linkAuth(s *session, params []string) { if len(params) != 2 { log.Fatal("invalid input, shouldn't happen") } if params[1] != "pass" { return } s.userName = params[0] } func txReset(s *session, params []string) { if len(params) != 1 { log.Fatal("invalid input, shouldn't happen") } s.tx = tx{} } func txBegin(s *session, params []string) { if len(params) != 1 { log.Fatal("invalid input, shouldn't happen") } s.tx.msgid = params[0] } func txMail(s *session, params []string) { if len(params) < 3 { log.Fatal("invalid input, shouldn't happen") } var status string var mailaddr string if version < "0.6" { _ = params[0] mailaddr = strings.Join(params[1:len(params)-1], "|") status = params[len(params)-1] } else { _ = params[0] status = params[1] mailaddr = strings.Join(params[2:], "|") } if status != "ok" { return } s.tx.mailFrom = mailaddr } func txRcpt(s *session, params []string) { if len(params) < 3 { log.Fatal("invalid input, shouldn't happen") } var status string var mailaddr string if version < "0.6" { _ = params[0] mailaddr = strings.Join(params[1:len(params)-1], "|") status = params[len(params)-1] } else { _ = params[0] status = params[1] mailaddr = strings.Join(params[2:], "|") } if status != "ok" { return } s.tx.rcptTo = append(s.tx.rcptTo, mailaddr) } func dataLine(s *session, params []string) { if len(params) < 2 { log.Fatal("invalid input, shouldn't happen") } token := params[0] line := strings.Join(params[1:], "|") if line == "." { go rspamdQuery(s, token) return } // Input is raw SMTP data - unescape leading dots. line = strings.TrimPrefix(line, ".") s.tx.message = append(s.tx.message, line) } func produceOutput(msgType string, sessionId string, token string, format string, a ...interface{}) { var out string if version < "0.5" { out = msgType + "|" + token + "|" + sessionId } else { out = msgType + "|" + sessionId + "|" + token } out += "|" + fmt.Sprintf(format, a...) outputChannel <- out } func dataCommit(s *session, params []string) { if len(params) != 2 { log.Fatal("invalid input, shouldn't happen") } token := params[0] switch s.tx.action { case "tempfail": if s.tx.response == "" { s.tx.response = "server internal error" } produceOutput("filter-result", s.id, token, "reject|421 %s", s.tx.response) case "reject": if s.tx.response == "" { s.tx.response = "message rejected" } produceOutput("filter-result", s.id, token, "reject|550 %s", s.tx.response) case "soft reject": if s.tx.response == "" { s.tx.response = "try again later" } produceOutput("filter-result", s.id, token, "reject|451 %s", s.tx.response) default: produceOutput("filter-result", s.id, token, "proceed") } } func filterInit() { for k := range reporters { fmt.Printf("register|report|smtp-in|%s\n", k) } for k := range filters { fmt.Printf("register|filter|smtp-in|%s\n", k) } fmt.Println("register|ready") } func flushMessage(s *session, token string) { for _, line := range s.tx.message { writeLine(s, token, line) } produceOutput("filter-dataline", s.id, token, ".") } func writeLine(s *session, token string, line string) { prefix := "" // Output raw SMTP data - escape leading dots. if strings.HasPrefix(line, ".") { prefix = "." } produceOutput("filter-dataline", s.id, token, "%s%s", prefix, line) } func writeHeader(s *session, token string, h string, t string) { for i, line := range strings.Split(t, "\n") { if i == 0 { produceOutput("filter-dataline", s.id, token, "%s: %s", h, line) } else { produceOutput("filter-dataline", s.id, token, "%s", line) } } } func rspamdTempFail(s *session, token string, log string) { s.tx.action = "tempfail" s.tx.response = "server internal error" flushMessage(s, token) fmt.Fprintln(os.Stderr, log) } func rspamdQuery(s *session, token string) { r := strings.NewReader(strings.Join(s.tx.message, "\n")) client := &http.Client{} req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkv2", *rspamdURL), r) if err != nil { rspamdTempFail(s, token, "failed to initialize HTTP request") return } req.Header.Add("Pass", "All") if !strings.HasPrefix(s.src, "unix:") { if s.src[0] == '[' { ip := strings.Split(strings.Split(s.src, "]")[0], "[")[1] req.Header.Add("Ip", ip) } else { ip := strings.Split(s.src, ":")[0] req.Header.Add("Ip", ip) } } else { req.Header.Add("Ip", "127.0.0.1") } req.Header.Add("Hostname", s.rdns) req.Header.Add("Helo", s.heloName) req.Header.Add("MTA-Name", s.mtaName) req.Header.Add("Queue-Id", s.tx.msgid) req.Header.Add("From", s.tx.mailFrom) if *rspamdSettingsId != "" { req.Header.Add("Settings-ID", *rspamdSettingsId) } if s.userName != "" { req.Header.Add("User", s.userName) } for _, rcptTo := range s.tx.rcptTo { req.Header.Add("Rcpt", rcptTo) } resp, err := client.Do(req) if err != nil { rspamdTempFail(s, token, "failed to receive a response from daemon") return } defer resp.Body.Close() rr := &rspamd{} if err := json.NewDecoder(resp.Body).Decode(rr); err != nil { rspamdTempFail(s, token, "failed to decode JSON response") return } switch rr.Action { case "reject": fallthrough case "soft reject": s.tx.action = rr.Action s.tx.response = rr.Messages.SMTP flushMessage(s, token) return } switch v := rr.DKIMSig.(type) { case []interface{}: if len(v) > 0 { for _, h := range v { h, ok := h.(string) if ok && h != "" { writeHeader(s, token, "DKIM-Signature", h) } } } case string: if v != "" { writeHeader(s, token, "DKIM-Signature", v) } default: } if rr.Action == "add header" { produceOutput("filter-dataline", s.id, token, "%s: %s", "X-Spam", "yes") produceOutput("filter-dataline", s.id, token, "%s: %v / %v", "X-Spam-Score", rr.Score, rr.RequiredScore) if len(rr.Symbols) != 0 { symbols := make([]string, len(rr.Symbols)) buf := &strings.Builder{} i := 0 produceOutput("filter-dataline", s.id, token, "%s: %s, score=%.3f required=%.3f", "X-Spam-Status", "Yes", rr.Score, rr.RequiredScore) for k := range rr.Symbols { symbols[i] = k i++ } sort.Strings(symbols) buf.WriteString("tests=[") for i, k := range symbols { sym := fmt.Sprintf("%s=%.3f", k, rr.Symbols[k].Score) if buf.Len() > 0 && len(sym)+buf.Len() > 68 { produceOutput("filter-dataline", s.id, token, "\t%s", buf.String()) buf.Reset() } if buf.Len() > 0 && i > 0 { buf.WriteString(", ") } buf.WriteString(sym) } produceOutput("filter-dataline", s.id, token, "\t%s]", buf.String()) buf.Reset() } } if len(rr.Headers.Add) > 0 { authHeaders := map[string]string{} for h, t := range rr.Headers.Add { switch v := t.(type) { /** * Authentication headers from Rspamd are in the form of: * ARC-Seal : { order : 1, value : text } * ARC-Message-Signature : { order : 1, value : text } * Unfortunately they all have an order of 1, so we * make a map of them and print them in proper order. */ case map[string]interface{}: if h != "" { v, ok := v["value"].(string) if ok { authHeaders[h] = v } } /** * Regular X-Spam headers from Rspamd are plain strings. * Insert these at the top. */ case string: writeHeader(s, token, h, v) default: } } /** * Prefix auth headers to incoming mail in proper order. */ if len(authHeaders) > 0 { hdrs := []string{ "ARC-Seal", "ARC-Message-Signature", "ARC-Authentication-Results", "Authentication-Results"} for _, h := range hdrs { if authHeaders[h] != "" { writeHeader(s, token, h, authHeaders[h]) } } } } inhdr := true rmhdr := false LOOP: for _, line := range s.tx.message { if line == "" { inhdr = false rmhdr = false } if inhdr && rmhdr && (strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ")) { continue } else { rmhdr = false } if inhdr && len(rr.Headers.Remove) > 0 { for h := range rr.Headers.Remove { if strings.HasPrefix(line, fmt.Sprintf("%s:", h)) { rmhdr = true continue LOOP } } } if rr.Action == "rewrite subject" && inhdr && strings.HasPrefix(line, "Subject: ") { produceOutput("filter-dataline", s.id, token, "Subject: %s", rr.Subject) } else { writeLine(s, token, line) } } produceOutput("filter-dataline", s.id, token, ".") } func trigger(actions map[string]func(*session, []string), atoms []string) { if atoms[4] == "link-connect" { // special case to simplify subsequent code s := session{} s.id = atoms[5] sessions[s.id] = &s } s, ok := sessions[atoms[5]] if !ok { log.Fatalf("invalid session ID: %s", atoms[5]) } if v, ok := actions[atoms[4]]; ok { v(s, atoms[6:]) } else { log.Fatalf("invalid phase: %s", atoms[4]) } } func skipConfig(scanner *bufio.Scanner) { for { if !scanner.Scan() { os.Exit(0) } line := scanner.Text() if line == "config|ready" { return } } } func main() { rspamdURL = flag.String("url", "http://localhost:11333", "rspamd base url") rspamdSettingsId = flag.String("settings-id", "", "rspamd Settings-ID") flag.Parse() PledgePromises("stdio rpath inet dns unveil") Unveil("/etc/resolv.conf", "r") Unveil("/etc/hosts", "r") UnveilBlock() scanner := bufio.NewScanner(os.Stdin) skipConfig(scanner) filterInit() outputChannel = make(chan string) go func() { for line := range outputChannel { fmt.Println(line) } }() for { if !scanner.Scan() { os.Exit(0) } line := scanner.Text() atoms := strings.Split(line, "|") if len(atoms) < 6 { log.Fatalf("missing atoms: %s", line) } version = atoms[1] switch atoms[0] { case "report": trigger(reporters, atoms) case "filter": trigger(filters, atoms) default: log.Fatalf("invalid stream: %s", atoms[0]) } } } filter-rspamd-0.1.7/go.mod000066400000000000000000000001571376124137500154120ustar00rootroot00000000000000module github.com/poolpOrg/filter-rspamd go 1.15 require golang.org/x/sys v0.0.0-20200821140526-fda516888d29 filter-rspamd-0.1.7/go.sum000066400000000000000000000003171376124137500154350ustar00rootroot00000000000000golang.org/x/sys v0.0.0-20200821140526-fda516888d29 h1:mNuhGagCf3lDDm5C0376C/sxh6V7fy9WbdEu/YDNA04= golang.org/x/sys v0.0.0-20200821140526-fda516888d29/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= filter-rspamd-0.1.7/pledge.go000066400000000000000000000003041376124137500160650ustar00rootroot00000000000000// +build !openbsd package main func PledgePromises(promises string) error { return nil } func Unveil(path string, flags string) error { return nil } func UnveilBlock() error { return nil }filter-rspamd-0.1.7/pledge_openbsd.go000066400000000000000000000004161376124137500176030ustar00rootroot00000000000000package main import "golang.org/x/sys/unix" func PledgePromises(promises string) error { return unix.PledgePromises(promises) } func Unveil(path string, flags string) error { return unix.Unveil(path, flags) } func UnveilBlock() error { return unix.UnveilBlock() }