diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..80a23e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +/* + * 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. + */ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6138712 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# filter-senderscore + +## Description +This filter performs a SenderScore lookup and allows OpenSMTPD to either block or slow down a +session based on the reputation of the source IP address. + + +## Features +The filter currently supports: + +- blocking hosts with reputation below a certain value +- apply to a session a time penalty proportional to the IP reputation + + +## Dependencies +The filter is written in Golang and doesn't have any dependencies beyond 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 filter-senderscore +quirks-3.167 signed on 2019-08-11T14:18:58Z +filter-senderscore-v0.1.0: ok +$ +``` + +Alternatively, clone the repository, build and install the filter: +``` +$ cd filter-senderscore/ +$ go build +$ doas install -m 0555 filter-senderscore /usr/local/bin/filter-senderscore +``` + +## How to configure +The filter itself requires no configuration. + +It must be declared in smtpd.conf and attached to a listener: +``` +filter "senderscore" proc-exec "/usr/local/bin/filter-senderscore -blockBelow 50 -slowFactor 1000" + +listen on all filter "senderscore" +``` + +`-blockBelow` will display am error banner for sessions with reputation score below value then disconnect. + +`-slowFactor` will delay all answers to a reputation-related percentage of its value in milliseconds. + diff --git a/filter-senderscore.go b/filter-senderscore.go new file mode 100644 index 0000000..4316b6d --- /dev/null +++ b/filter-senderscore.go @@ -0,0 +1,203 @@ +// +// 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" + "net" + "os" + "strconv" + "strings" + + "time" + "log" +) + +var blockBelow *int +var slowFactor *int + + +type session struct { + id string + + category int8 + score int8 +} + +var sessions = make(map[string]session) + +var reporters = map[string]func(string, []string) { + "link-connect": linkConnect, + "link-disconnect": linkDisconnect, +} + +var filters = map[string]func(string, []string) { + "connect": filterConnect, + + "helo": delayedAnswer, + "ehlo": delayedAnswer, + "starttls": delayedAnswer, + "auth": delayedAnswer, + "mail-from": delayedAnswer, + "rcpt-to": delayedAnswer, + "data": delayedAnswer, + "commit": delayedAnswer, + "quit": delayedAnswer, +} + +func linkConnect(sessionId string, params []string) { + if len(params) != 4 { + log.Fatal("invalid input, shouldn't happen") + } + + s := session{} + s.score = -1 + sessions[sessionId] = s + + addr := net.ParseIP(strings.Split(params[2], ":")[0]) + if addr == nil || strings.Contains(addr.String(), ":") { + return + } + + atoms := strings.Split(addr.String(), ".") + addrs, _ := net.LookupIP(fmt.Sprintf("%s.%s.%s.%s.score.senderscore.com", + atoms[3], atoms[2], atoms[1], atoms[0])) + + if len(addrs) != 1 { + return + } + + resolved := addrs[0].String() + atoms = strings.Split(resolved, ".") + category, _ := strconv.ParseInt(atoms[2], 10, 8) + score, _ := strconv.ParseInt(atoms[3], 10, 8) + + s.category = int8(category) + s.score = int8(score) + + fmt.Fprintf(os.Stderr, "senderscore(%s) -> %s\n", addr, resolved) + sessions[sessionId] = s +} + +func linkDisconnect(sessionId string, params []string) { + if len(params) != 0 { + log.Fatal("invalid input, shouldn't happen") + } + delete(sessions, sessionId) +} + +func filterConnect(sessionId string, params[] string) { + token := params[0] + s := sessions[sessionId] + sessions[sessionId] = s + + if (s.score != -1 && s.score < int8(*blockBelow)) { + fmt.Printf("filter-result|%s|%s|disconnect|550 your IP reputation is too low for this MX\n", token, sessionId) + } else { + delayedAnswer(sessionId, params) + } +} + +func delayedAnswer(sessionId string, params[] string) { + token := params[0] + s := sessions[sessionId] + + // no slow factor, neutral or 100% good IP + if (*slowFactor == -1 || s.score == -1 || s.score == 100) { + fmt.Printf("filter-result|%s|%s|proceed\n", token, sessionId) + return + } + + delay := *slowFactor - ((*slowFactor / 100) * int(s.score)) + + go waitAndProceed(sessionId, token, delay) +} + + +func waitAndProceed(sessionId string, token string, delay int) { + time.Sleep(time.Duration(delay) * time.Millisecond) + fmt.Printf("filter-result|%s|%s|proceed\n", token, sessionId) + return +} + +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 trigger(currentSlice map[string]func(string, []string), atoms []string) { + found := false + for k, v := range currentSlice { + if k == atoms[4] { + v(atoms[5], atoms[6:]) + found = true + break + } + } + if !found { + os.Exit(1) + } +} + +func skipConfig(scanner *bufio.Scanner) { + for { + if !scanner.Scan() { + os.Exit(0) + } + line := scanner.Text() + if line == "config|ready" { + return + } + } +} + +func main() { + blockBelow = flag.Int("blockBelow", -1, "score below which session is blocked") + slowFactor = flag.Int("slowFactor", -1, "delay factor to apply to sessions") + + flag.Parse() + scanner := bufio.NewScanner(os.Stdin) + skipConfig(scanner) + filterInit() + + for { + if !scanner.Scan() { + os.Exit(0) + } + + atoms := strings.Split(scanner.Text(), "|") + if len(atoms) < 6 { + os.Exit(1) + } + + switch atoms[0] { + case "report": + trigger(reporters, atoms) + case "filter": + trigger(filters, atoms) + default: + os.Exit(1) + } + } +}