mirror of
https://github.com/chillout2k/sos-milter.git
synced 2025-12-11 01:30:19 +00:00
Compare commits
2 Commits
c0adadb60d
...
6f5718e352
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f5718e352 | ||
| e0e950cab8 |
27
README.md
27
README.md
@ -1,11 +1,22 @@
|
|||||||
# sos-milter
|
# SPF-on-submission-Milter - sos-milter
|
||||||
A lightweight, fast and thread-safe python3 [milter](http://www.postfix.org/MILTER_README.html) on top of [sdgathman/pymilter](https://github.com/sdgathman/pymilter).
|
A lightweight, fast and thread-safe python3 [milter](http://www.postfix.org/MILTER_README.html) on top of [sdgathman/pymilter](https://github.com/sdgathman/pymilter).
|
||||||
|
|
||||||
### Deployment paradigm
|
The main goal of the **sos-milter** is to check the SPF-policy of a senders domain in term of mail submission scenario. Especially when forwarding of messages comming from *foreign* domains with restrictive SPF-policies (-all) takes place. The milter is also designed to check the correctness of SPF-policies for *own* domains (such as customers domains). In this case the milter expects that all *own* (not foreign) domains are managed in a LDAP server so that the milter can recognize them as such. For those domains the milter enforces checks regarding the appearence of particular SPF statements (include/s, ip4, ip6, ...) in the domain name system (DNS). Herefor the milter uses a regular expression which is part of the configuration. In this way the email service provider (ESP) running the sos-milter becomes able to check if his/her customers did set SPF-TXT-records correctly (as documented/expected) on each mail submission attempt and not just during the setup phase.
|
||||||
The intention of this project is to deploy the milter ALWAYS AND ONLY as an [OCI compliant](https://www.opencontainers.org) container. In this case it´s [docker](https://www.docker.com). The main reason is that I´m not interested (and familiar with) in building distribution packages like .rpm, .deb, etc.. Furthermore I´m not really a fan of 'wild and uncontrollable' software deployments like: get the code, compile it and finaly install the results 'somewhere' in the filesystem. In terms of software deployment docker provides wonderful possibilities, which I don´t want to miss anymore... No matter if in development, QA or production stage.
|
|
||||||
|
|
||||||
### docker-compose.yml
|
Further the sos-milter can be run in `test` or `reject` mode. In `test` mode the milter only does log policy violations which may be turned into metrics and used for baselining. Thus the `test` mode is recommended for first steps in an productive environment before enabling reject mode (if ever). In `reject` mode the milter fulfills policy enforcement and rejects every email submission requests that does not meet the configured expectations (expected SPF statements as regular expression).
|
||||||
The following [docker-compose](https://docs.docker.com/compose/) file demonstrates how such a setup could be orchestrated on a single docker host or on a docker swarm cluster. In this context we use [postfix](http://www.postfix.org) as our milter-aware MTA.
|
|
||||||
|
### Deployment paradigm
|
||||||
|
Following the principles of [12-Factor-App](https://12factor.net/) for cloud native applications, the intention of this project is to deploy the milter as an [OCI compliant](https://www.opencontainers.org) container.
|
||||||
|
|
||||||
|
There´s nothing wrong to deploy the milter as a
|
||||||
|
* docker-compose deployment on a stand-alone docker host or a docker-swarm cluster
|
||||||
|
* stateless Kubernetes-workload (type: `Deployment`)
|
||||||
|
* local systemd unit, which is NOT a OCI-compliant was but works too ;-)
|
||||||
|
|
||||||
|
Please note, that according to the [3rd principle](https://12factor.net/config) of 12-Factor-App a cloud-native app is configured through environment variables, so this app does.
|
||||||
|
|
||||||
|
### Deployment with Docker - docker-compose.yml
|
||||||
|
The following [docker-compose](https://docs.docker.com/compose/) file demonstrates how such a setup could be orchestrated on a single docker host or on a docker swarm cluster. In this context we use [postfix](http://www.postfix.org) as our milter-aware MTA which connects to the milter via an UNIX-socket.
|
||||||
|
|
||||||
```
|
```
|
||||||
version: '3'
|
version: '3'
|
||||||
@ -15,7 +26,7 @@ volumes:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
sos-milter:
|
sos-milter:
|
||||||
image: "sos-milter/debian:19.06_master"
|
image: "sos-milter:<your_tag>"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# default: info, possible: info, warning, error, debug
|
# default: info, possible: info, warning, error, debug
|
||||||
@ -55,11 +66,11 @@ services:
|
|||||||
postfix:
|
postfix:
|
||||||
depends_on:
|
depends_on:
|
||||||
- sos-milter
|
- sos-milter
|
||||||
image: "postfix/alpine/amd64"
|
image: "your favorite postfix image"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
hostname: postfix
|
hostname: postfix
|
||||||
ports:
|
ports:
|
||||||
- "1587:587"
|
- "465:465"
|
||||||
volumes:
|
volumes:
|
||||||
- "./config/postfix:/etc/postfix:ro"
|
- "./config/postfix:/etc/postfix:ro"
|
||||||
- "sosm_socket:/socket/sos-milter/:rw"
|
- "sosm_socket:/socket/sos-milter/:rw"
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import random
|
|||||||
import re
|
import re
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
from ldap3 import (
|
from ldap3 import (
|
||||||
Server, Connection, NONE, set_config_parameter
|
Server, Connection, NONE, set_config_parameter,
|
||||||
|
SAFE_RESTARTABLE
|
||||||
)
|
)
|
||||||
from ldap3.core.exceptions import LDAPException
|
from ldap3.core.exceptions import LDAPException
|
||||||
|
|
||||||
@ -23,6 +24,9 @@ g_loglevel = logging.INFO
|
|||||||
g_milter_mode = 'test'
|
g_milter_mode = 'test'
|
||||||
g_ignored_next_hops = {}
|
g_ignored_next_hops = {}
|
||||||
g_ldap_conn = None
|
g_ldap_conn = None
|
||||||
|
g_ldap_server_uri = None
|
||||||
|
g_ldap_search_base = None
|
||||||
|
g_ldap_query_filter = None
|
||||||
g_ldap_binddn = ''
|
g_ldap_binddn = ''
|
||||||
g_ldap_bindpw = ''
|
g_ldap_bindpw = ''
|
||||||
|
|
||||||
@ -74,11 +78,15 @@ class SOSMilter(Milter.Base):
|
|||||||
self.reset()
|
self.reset()
|
||||||
self.client_ip = self.getsymval('{client_addr}')
|
self.client_ip = self.getsymval('{client_addr}')
|
||||||
if self.client_ip is None:
|
if self.client_ip is None:
|
||||||
logging.error(self.mconn_id + " FROM exception: could not retrieve milter-macro ({client_addr})!")
|
logging.error(self.mconn_id +
|
||||||
|
" FROM exception: could not retrieve milter-macro ({client_addr})!"
|
||||||
|
)
|
||||||
self.setreply('450','4.7.1', g_milter_tmpfail_message)
|
self.setreply('450','4.7.1', g_milter_tmpfail_message)
|
||||||
return Milter.TEMPFAIL
|
return Milter.TEMPFAIL
|
||||||
else:
|
else:
|
||||||
logging.debug(self.mconn_id + "/FROM client_ip={0}".format(self.client_ip))
|
logging.debug(self.mconn_id +
|
||||||
|
"/FROM client_ip={0}".format(self.client_ip)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
# DSNs/bounces are not relevant
|
# DSNs/bounces are not relevant
|
||||||
if(mailfrom == '<>'):
|
if(mailfrom == '<>'):
|
||||||
@ -100,17 +108,21 @@ class SOSMilter(Milter.Base):
|
|||||||
)
|
)
|
||||||
# Check if env_from_domain is in ldap
|
# Check if env_from_domain is in ldap
|
||||||
if(g_ldap_conn is not None):
|
if(g_ldap_conn is not None):
|
||||||
filter = os.environ['LDAP_QUERY_FILTER']
|
filter = g_ldap_query_filter
|
||||||
filter = filter.replace("%d", self.env_from_domain)
|
filter = filter.replace("%d", self.env_from_domain)
|
||||||
|
logging.debug(self.mconn_id + "/FROM " +
|
||||||
|
"LDAP query filter: {}".format(filter)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
g_ldap_conn.search(os.environ['LDAP_SEARCH_BASE'],
|
_, _, ldap_response, _ = g_ldap_conn.search(
|
||||||
|
g_ldap_search_base,
|
||||||
filter,
|
filter,
|
||||||
attributes=[]
|
attributes=[]
|
||||||
)
|
)
|
||||||
if len(g_ldap_conn.entries) != 0:
|
if len(ldap_response) != 0:
|
||||||
self.is_env_from_domain_in_ldap = True
|
self.is_env_from_domain_in_ldap = True
|
||||||
logging.info(self.mconn_id +
|
logging.info(self.mconn_id +
|
||||||
"/FROM Domain {0} found in LDAP".format(self.env_from_domain)
|
"/FROM 5321.from_domain={0} found in LDAP".format(self.env_from_domain)
|
||||||
)
|
)
|
||||||
except LDAPException:
|
except LDAPException:
|
||||||
logging.error(self.mconn_id + "/FROM " + traceback.format_exc())
|
logging.error(self.mconn_id + "/FROM " + traceback.format_exc())
|
||||||
@ -291,61 +303,75 @@ if __name__ == "__main__":
|
|||||||
if 'MILTER_MODE' in os.environ:
|
if 'MILTER_MODE' in os.environ:
|
||||||
if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE):
|
if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE):
|
||||||
g_milter_mode = os.environ['MILTER_MODE']
|
g_milter_mode = os.environ['MILTER_MODE']
|
||||||
|
logging.info("ENV[MILTER_MODE]: {}".format(g_milter_mode))
|
||||||
if 'MILTER_NAME' in os.environ:
|
if 'MILTER_NAME' in os.environ:
|
||||||
g_milter_name = os.environ['MILTER_NAME']
|
g_milter_name = os.environ['MILTER_NAME']
|
||||||
|
logging.info("ENV[MILTER_NAME]: {}".format(g_milter_name))
|
||||||
if 'MILTER_SOCKET' in os.environ:
|
if 'MILTER_SOCKET' in os.environ:
|
||||||
g_milter_socket = os.environ['MILTER_SOCKET']
|
g_milter_socket = os.environ['MILTER_SOCKET']
|
||||||
|
logging.info("ENV[MILTER_SOCKET]: {}".format(g_milter_socket))
|
||||||
if 'MILTER_REJECT_MESSAGE' in os.environ:
|
if 'MILTER_REJECT_MESSAGE' in os.environ:
|
||||||
g_milter_reject_message = os.environ['MILTER_REJECT_MESSAGE']
|
g_milter_reject_message = os.environ['MILTER_REJECT_MESSAGE']
|
||||||
|
logging.info("ENV[MILTER_REJECT_MESSAGE]: {}".format(g_milter_reject_message))
|
||||||
if 'MILTER_TMPFAIL_MESSAGE' in os.environ:
|
if 'MILTER_TMPFAIL_MESSAGE' in os.environ:
|
||||||
g_milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE']
|
g_milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE']
|
||||||
|
logging.info("ENV[MILTER_TMPFAIL_MESSAGE]: {}".format(g_milter_tmpfail_message))
|
||||||
if 'SPF_REGEX' in os.environ:
|
if 'SPF_REGEX' in os.environ:
|
||||||
try:
|
try:
|
||||||
g_re_spf_regex = re.compile(os.environ['SPF_REGEX'], re.IGNORECASE)
|
g_re_spf_regex = re.compile(os.environ['SPF_REGEX'], re.IGNORECASE)
|
||||||
except:
|
except:
|
||||||
logging.error("ENV[SPF_REGEX] exception: " + traceback.format_exc())
|
logging.error("ENV[SPF_REGEX] exception: " + traceback.format_exc())
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
logging.info("ENV[SPF_REGEX]: {}".format(g_re_spf_regex))
|
||||||
if 'IGNORED_NEXT_HOPS' in os.environ:
|
if 'IGNORED_NEXT_HOPS' in os.environ:
|
||||||
try:
|
try:
|
||||||
tmp_hops = os.environ['IGNORED_NEXT_HOPS'].split(',')
|
tmp_hops = os.environ['IGNORED_NEXT_HOPS'].split(',')
|
||||||
for next_hop in tmp_hops:
|
for next_hop in tmp_hops:
|
||||||
g_ignored_next_hops[next_hop] = 'ignore'
|
g_ignored_next_hops[next_hop] = 'ignore'
|
||||||
logging.debug("next-hops: " + str(g_ignored_next_hops))
|
|
||||||
except:
|
except:
|
||||||
logging.error("ENV[IGNORED_NEXT_HOPS] exception: " + traceback.format_exc())
|
logging.error("ENV[IGNORED_NEXT_HOPS] exception: " + traceback.format_exc())
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
logging.info("ENV[IGNORED_NEXT_HOPS]: {}".format(g_ignored_next_hops))
|
||||||
if 'LDAP_ENABLED' in os.environ:
|
if 'LDAP_ENABLED' in os.environ:
|
||||||
if 'LDAP_SERVER_URI' not in os.environ:
|
if 'LDAP_SERVER_URI' not in os.environ:
|
||||||
logging.error("ENV[LDAP_SERVER_URI] is mandatory!")
|
logging.error("ENV[LDAP_SERVER_URI] is mandatory!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
g_ldap_server_uri = os.environ['LDAP_SERVER_URI']
|
||||||
|
logging.info("ENV[LDAP_SERVER_URI]: {}".format(g_ldap_server_uri))
|
||||||
if 'LDAP_BINDDN' not in os.environ:
|
if 'LDAP_BINDDN' not in os.environ:
|
||||||
logging.info("ENV[LDAP_BINDDN] not set! Continue...")
|
logging.info("ENV[LDAP_BINDDN] not set! Continue...")
|
||||||
else:
|
else:
|
||||||
g_ldap_binddn = os.environ['LDAP_BINDDN']
|
g_ldap_binddn = os.environ['LDAP_BINDDN']
|
||||||
|
logging.info("ENV[LDAP_BINDDN]: {}".format("***"))
|
||||||
if 'LDAP_BINDPW' not in os.environ:
|
if 'LDAP_BINDPW' not in os.environ:
|
||||||
logging.info("ENV[LDAP_BINDPW] not set! Continue...")
|
logging.info("ENV[LDAP_BINDPW] not set! Continue...")
|
||||||
else:
|
else:
|
||||||
g_ldap_bindpw = os.environ['LDAP_BINDPW']
|
g_ldap_bindpw = os.environ['LDAP_BINDPW']
|
||||||
|
logging.info("ENV[LDAP_BINDPW]: {}".format("***"))
|
||||||
if 'LDAP_SEARCH_BASE' not in os.environ:
|
if 'LDAP_SEARCH_BASE' not in os.environ:
|
||||||
logging.error("ENV[LDAP_SEARCH_BASE] is mandatory!")
|
logging.error("ENV[LDAP_SEARCH_BASE] is mandatory!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
g_ldap_search_base = os.environ['LDAP_SEARCH_BASE']
|
||||||
|
logging.info("ENV[LDAP_SEARCH_BASE]: {}".format(g_ldap_search_base))
|
||||||
if 'LDAP_QUERY_FILTER' not in os.environ:
|
if 'LDAP_QUERY_FILTER' not in os.environ:
|
||||||
logging.error("ENV[LDAP_QUERY_FILTER] is mandatory!")
|
logging.error("ENV[LDAP_QUERY_FILTER] is mandatory!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
g_ldap_query_filter = os.environ['LDAP_QUERY_FILTER']
|
||||||
|
logging.info("ENV[LDAP_QUERY_FILTER]: {}".format(g_ldap_query_filter))
|
||||||
try:
|
try:
|
||||||
set_config_parameter("RESTARTABLE_SLEEPTIME", 2)
|
set_config_parameter("RESTARTABLE_SLEEPTIME", 2)
|
||||||
set_config_parameter("RESTARTABLE_TRIES", 2)
|
set_config_parameter("RESTARTABLE_TRIES", 2)
|
||||||
set_config_parameter('DEFAULT_SERVER_ENCODING', 'utf-8')
|
set_config_parameter('DEFAULT_SERVER_ENCODING', 'utf-8')
|
||||||
set_config_parameter('DEFAULT_CLIENT_ENCODING', 'utf-8')
|
set_config_parameter('DEFAULT_CLIENT_ENCODING', 'utf-8')
|
||||||
server = Server(os.environ['LDAP_SERVER_URI'], get_info=NONE)
|
server = Server(g_ldap_server_uri, get_info=NONE)
|
||||||
g_ldap_conn = Connection(server,
|
g_ldap_conn = Connection(server,
|
||||||
g_ldap_binddn,
|
g_ldap_binddn,
|
||||||
g_ldap_bindpw,
|
g_ldap_bindpw,
|
||||||
auto_bind=True,
|
auto_bind=True,
|
||||||
raise_exceptions=True,
|
raise_exceptions=True,
|
||||||
client_strategy='RESTARTABLE'
|
client_strategy=SAFE_RESTARTABLE
|
||||||
)
|
)
|
||||||
logging.info("LDAP-Connection established. PID: " + str(os.getpid()))
|
logging.info("LDAP connection established. PID: " + str(os.getpid()))
|
||||||
except LDAPException as e:
|
except LDAPException as e:
|
||||||
print("LDAP-Exception: " + traceback.format_exc())
|
print("LDAP-Exception: " + traceback.format_exc())
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
48
tests/miltertest_dsn.lua
Normal file
48
tests/miltertest_dsn.lua
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants
|
||||||
|
-- http://www.opendkim.org/miltertest.8.html
|
||||||
|
|
||||||
|
-- socket must be defined as miltertest global variable (-D)
|
||||||
|
conn = mt.connect(socket)
|
||||||
|
if conn == nil then
|
||||||
|
error "mt.connect() failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
mt.set_timeout(3)
|
||||||
|
|
||||||
|
-- 5321.FROM + MACROS
|
||||||
|
mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130")
|
||||||
|
if mt.mailfrom(conn, "<>") ~= nil then
|
||||||
|
error "mt.mailfrom() failed"
|
||||||
|
end
|
||||||
|
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||||
|
error "mt.mailfrom() unexpected reply"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 5321.RCPT + MACROS
|
||||||
|
mt.macro(conn, SMFIC_RCPT, "i", "TestQueueId-1",'{rcpt_host}', "test.next-hostx")
|
||||||
|
if mt.rcptto(conn, "some@recipient.somewhere") ~= nil then
|
||||||
|
error "mt.rcptto() failed"
|
||||||
|
end
|
||||||
|
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||||
|
error "mt.rcptto() unexpected reply"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- EOM
|
||||||
|
if mt.eom(conn) ~= nil then
|
||||||
|
error "mt.eom() failed"
|
||||||
|
end
|
||||||
|
mt.echo("EOM: " .. mt.getreply(conn))
|
||||||
|
if mt.getreply(conn) == SMFIR_CONTINUE then
|
||||||
|
mt.echo("EOM-continue")
|
||||||
|
elseif mt.getreply(conn) == SMFIR_REPLYCODE then
|
||||||
|
mt.echo("EOM-reject")
|
||||||
|
end
|
||||||
|
|
||||||
|
if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then
|
||||||
|
mt.echo("no header added")
|
||||||
|
else
|
||||||
|
mt.echo("X-SOS-Milter header added -> LDAP-Domain with broken SPF")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- DISCONNECT
|
||||||
|
mt.disconnect(conn)
|
||||||
Loading…
Reference in New Issue
Block a user