Compare commits

...

27 Commits

Author SHA1 Message Date
a82d27004f docs: ENV-options + tests refactoring 2023-06-05 21:55:04 +02:00
d395344401 kubernetes via kustomize 2023-06-05 19:40:36 +02:00
a25ee7fd11 refactoring + purge 2023-06-05 18:56:00 +02:00
686a5de1a0 docs: fix for docker-image uri 2023-01-31 00:40:11 +01:00
fadf6966be Make X-MS-Exchange-CrossTenant-Id header optional - take 2 2023-01-31 00:29:44 +01:00
3e9409717f docs + some OCI enhancements 2022-10-20 21:19:50 +02:00
Dominik Chilla
6c844f22e7
Merge pull request #47 from chillout2k/oci-patch
OCI build plan changed
2022-10-20 20:38:40 +02:00
Dominik Chilla
931570677b
OCI build plan changed 2022-10-20 20:38:20 +02:00
Dominik Chilla
7b60d6a845
Merge pull request #46 from chillout2k/devel
Make X-MS-Exchange-CrossTenant-Id header optional
2022-08-31 13:21:34 +02:00
ca703bee9c Make X-MS-Exchange-CrossTenant-Id header optional 2022-08-31 13:15:13 +02:00
Dominik Chilla
0dc507a425
Merge pull request #45 from chillout2k/devel
systemd install typo
2022-08-24 21:52:14 +02:00
010e9eadfa systemd install typo 2022-08-24 21:51:31 +02:00
Dominik Chilla
f589488299
Merge pull request #44 from chillout2k/devel
LDAP thread safe
2022-08-24 17:36:00 +02:00
8853dda414 LDAP thread safe 2022-08-23 20:38:23 +02:00
Dominik Chilla
350487405c
Merge pull request #43 from chillout2k/devel
#40
2022-06-07 00:39:39 +02:00
2377e45468 https://github.com/chillout2k/ExOTA-Milter/issues/40 2022-06-07 00:32:23 +02:00
5add6c5616 https://github.com/chillout2k/ExOTA-Milter/issues/40 2022-06-07 00:31:05 +02:00
90a1fef48c https://github.com/chillout2k/ExOTA-Milter/issues/40 2022-06-07 00:30:10 +02:00
Dominik Chilla
b3ea759197
Merge pull request #42 from chillout2k/devel
more documentation
2022-06-06 15:57:57 +02:00
fd12859242 more documentation 2022-06-06 15:54:07 +02:00
Dominik Chilla
f46ae9b6e9
Merge pull request #41 from chillout2k/devel
more docs
2022-06-06 12:33:24 +02:00
3821467511 more documentation 2022-06-06 12:30:50 +02:00
7cef0b937c more documentation 2022-06-06 12:26:20 +02:00
Dominik Chilla
c0d74c7cc9
Merge pull request #38 from chillout2k/devel
Bugfix: debug-logging of Tenant-ID returns None
2022-02-04 20:04:12 +01:00
9f6080e0a7 Bugfix: debug-logging of Tenant-ID returns None 2022-02-04 20:03:39 +01:00
Dominik Chilla
8b8c327022
Merge pull request #37 from chillout2k/devel
Bugfix: do not compare domain data case-sensitive!
2022-02-04 18:39:06 +01:00
cd8572cfe0 Bugfix: do not compare domain data case-sensitive! 2022-02-04 18:36:25 +01:00
31 changed files with 640 additions and 209 deletions

122
INSTALL/README.md Normal file
View File

@ -0,0 +1,122 @@
# How to install ExOTA-Milter
#### Table of contents
[docker-compose](#docker-compose)
[kubernetes](#kubernetes)
[systemd](#systemd)
## docker-compose <a name="docker-compose"/>
```
~/src/ExOTA-Milter/INSTALL/docker-compose$ docker-compose up
[+] Running 2/2
⠿ Network docker-compose_default Created 0.8s
⠿ Container docker-compose-exota-milter-1 Created 0.1s
Attaching to docker-compose-exota-milter-1
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,503: INFO 140529821924168 Logger initialized
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,503: INFO 140529821924168 ENV[MILTER_NAME]: exota-milter
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,503: INFO 140529821924168 ENV[MILTER_SOCKET]: inet:4321@0.0.0.0
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_REJECT_MESSAGE]: CUSTOMIZE THIS! - Security policy violation!!
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_TMPFAIL_MESSAGE]: Service temporarily not available! Please try again later.
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_TRUSTED_AUTHSERVID]: dkimauthservid
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_DKIM_ALIGNMENT_REQUIRED]: True
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_DKIM_ENABLED]: True
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_X509_TRUSTED_CN]: mail.protection.outlook.com
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_X509_IP_WHITELIST]: ['127.0.0.1', '::1']
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,504: INFO 140529821924168 ENV[MILTER_X509_ENABLED]: True
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,505: INFO 140529821924168 ENV[MILTER_AUTHSERVID]: ThisAuthservID
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,505: INFO 140529821924168 ENV[MILTER_ADD_HEADER]: True
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,505: INFO 140529821924168 ENV[MILTER_POLICY_SOURCE]: file
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,505: INFO 140529821924168 ENV[MILTER_POLICY_FILE]: /data/exota-milter-policy.json
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,511: INFO 140529821924168 JSON policy backend initialized
docker-compose-exota-milter-1 | 2022-06-06 21:54:04,511: INFO 140529821924168 Startup exota-milter@socket: inet:4321@0.0.0.0
```
## kubernetes <a name="kubernetes"/>
By default this example installs the Exota-milter workload into the `exota-milter` namespace, which must be created in advance:
```
kubectl create ns exota-milter
namespace/exota-milter created
```
Deploy stateless workload (type `Deployment`) with `kustomize`:
```
~/src/ExOTA-Milter/INSTALL/kubernetes$ kubectl apply -k .
configmap/exota-milter-policy-cmap created
service/exota-milter created
deployment.apps/exota-milter created
```
Check status of pods, replica-sets and cluster internal service:
```
~/src/ExOTA-Milter/INSTALL/kubernetes$ kubectl -n exota-milter get all
NAME READY STATUS RESTARTS AGE
pod/exota-milter-547dbccd8b-j69mn 1/1 Running 0 64s
pod/exota-milter-547dbccd8b-7hl6c 1/1 Running 0 64s
pod/exota-milter-547dbccd8b-k4ng8 1/1 Running 0 64s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/exota-milter ClusterIP 10.43.78.163 <none> 4321/TCP 61s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/exota-milter 3/3 3 3 64s
NAME DESIRED CURRENT READY AGE
replicaset.apps/exota-milter-547dbccd8b 3 3 3 65s
```
Get logs of the pods:
```
~/src/ExOTA-Milter/INSTALL/kubernetes$ kubectl -n exota-milter logs -l app=exota-milter
2022-06-06 21:57:03,515: INFO Logger initialized
2022-06-06 21:57:03,515: INFO ENV[MILTER_NAME]: exota-milter
2022-06-06 21:57:03,515: INFO ENV[MILTER_SOCKET]: inet:4321@127.0.0.1
2022-06-06 21:57:03,515: INFO ENV[MILTER_REJECT_MESSAGE]: Security policy violation!!
2022-06-06 21:57:03,515: INFO ENV[MILTER_TMPFAIL_MESSAGE]: Service temporarily not available! Please try again later.
2022-06-06 21:57:03,515: INFO ENV[MILTER_TRUSTED_AUTHSERVID]: dkimauthservid
2022-06-06 21:57:03,515: INFO ENV[MILTER_DKIM_ALIGNMENT_REQUIRED]: True
2022-06-06 21:57:03,515: INFO ENV[MILTER_DKIM_ENABLED]: True
2022-06-06 21:57:03,515: INFO ENV[MILTER_X509_TRUSTED_CN]: mail.protection.outlook.com
2022-06-06 21:57:03,515: INFO ENV[MILTER_X509_IP_WHITELIST]: ['127.0.0.1', '::1']
2022-06-06 21:57:03,515: INFO ENV[MILTER_X509_ENABLED]: True
2022-06-06 21:57:03,516: INFO ENV[MILTER_AUTHSERVID]: some-auth-serv-id
2022-06-06 21:57:03,516: INFO ENV[MILTER_ADD_HEADER]: True
2022-06-06 21:57:03,516: INFO ENV[MILTER_POLICY_SOURCE]: file
2022-06-06 21:57:03,516: INFO ENV[MILTER_POLICY_FILE]: /data/exota-milter-policy.json
2022-06-06 21:57:03,516: INFO JSON policy backend initialized
2022-06-06 21:57:03,516: INFO Startup exota-milter@socket: inet:4321@127.0.0.1
```
Remove workload from cluster:
```
~/src/ExOTA-Milter/INSTALL/kubernetes$ kubectl delete -k .
configmap "exota-milter-policy-cmap" deleted
service "exota-milter" deleted
deployment.apps "exota-milter" deleted
~/src/ExOTA-Milter/INSTALL/kubernetes$ kubectl delete ns exota-milter
namespace "exota-milter" deleted
```
## systemd <a name="systemd"/>
If you do not want to run the ExOTA-Milter in a containerized environment but directly as a systemd-unit/-service, first you´ll need to install all necessary python and build dependencies. Start with build deps (examples refere to ubuntu/debian):
```
sudo apt install --no-install-recommends gcc libpython3-dev libmilter-dev python3-pip
```
Now install all python dependencies:
```
~/src/ExOTA-Milter/INSTALL/systemd# sudo pip3 install -r ../../requirements.txt
Requirement already satisfied: authres==1.2.0 in /usr/local/lib/python3.8/dist-packages (from -r ../../requirements.txt (line 1)) (1.2.0)
Requirement already satisfied: pymilter==1.0.4 in /usr/local/lib/python3.8/dist-packages (from -r ../../requirements.txt (line 2)) (1.0.4)
Requirement already satisfied: ldap3 in /usr/local/lib/python3.8/dist-packages (from -r ../../requirements.txt (line 3)) (2.9.1)
Requirement already satisfied: pyasn1>=0.4.6 in /usr/local/lib/python3.8/dist-packages (from ldap3->-r ../../requirements.txt (line 3)) (0.4.8)
```
At last uninstall all build dependencies, as they are not needed anymore:
```
apt purge gcc libpython3-dev libmilter-dev python3-pip
```
Next you should be able to install the ExOTA-Milter as well as the systemd-stuff by running the `install.sh` script:
```
~/src/ExOTA-Milter/INSTALL/systemd$ sudo ./install.sh
Created symlink /etc/systemd/system/multi-user.target.wants/exota-milter.service → /lib/systemd/system/exota-milter.service.
```
Use the `uninstall.sh` script to uninstall the ExOTA-Milter from your systemd environment. Files under `/etc/exota-milter/` (config and policy) are kept undeleted!

View File

@ -0,0 +1,7 @@
{
"example.com": {
"tenant_id": "abcd1234-18c5-45e8-88de-987654321cba",
"dkim_enabled": true,
"dkim_alignment_required": true
}
}

View File

@ -0,0 +1,22 @@
version: '2.4'
services:
exota-milter:
image: chillout2k/exota-milter
restart: unless-stopped
environment:
LOG_LEVEL: 'debug'
MILTER_SOCKET: 'inet:4321@0.0.0.0'
MILTER_POLICY_FILE: '/data/exota-milter-policy.json'
MILTER_DKIM_ENABLED: 'True'
MILTER_DKIM_ALIGNMENT_REQUIRED: 'True'
MILTER_TRUSTED_AUTHSERVID: 'DKIMAuthservID'
MILTER_X509_ENABLED: 'True'
MILTER_X509_TRUSTED_CN: 'mail.protection.outlook.com'
MILTER_X509_IP_WHITELIST: '127.0.0.1,::1'
MILTER_ADD_HEADER: 'True'
MILTER_AUTHSERVID: 'ThisAuthservID'
MILTER_REJECT_MESSAGE: 'CUSTOMIZE THIS! - Security policy violation!!'
volumes:
- "./data/:/data/:ro"
ports:
- "127.0.0.1:4321:4321"

View File

@ -0,0 +1,14 @@
---
kind: ConfigMap
apiVersion: v1
metadata:
name: exota-milter-policy-cmap
data:
exota-milter-policy: |
{
"example.com": {
"tenant_id": "abcd1234-18c5-45e8-88de-987654321cba",
"dkim_enabled": true,
"dkim_alignment_required": true
}
}

View File

@ -0,0 +1,81 @@
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: exota-milter
labels:
app: exota-milter
spec:
replicas: 3
selector:
matchLabels:
app: exota-milter
template:
metadata:
labels:
app: exota-milter
spec:
# Do not deploy more than one pods per node
topologySpreadConstraints:
- labelSelector:
matchLabels:
app: exota-milter
maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
# Pod eviction toleration overrides
tolerations:
- key: "node.kubernetes.io/unreachable"
operator: "Exists"
effect: "NoExecute"
tolerationSeconds: 30
- key: "node.kubernetes.io/not-ready"
operator: "Exists"
effect: "NoExecute"
tolerationSeconds: 30
restartPolicy: Always
terminationGracePeriodSeconds: 10
volumes:
- name: exota-milter-policy-volume
configMap:
name: exota-milter-policy-cmap
items:
- key: exota-milter-policy
path: 'exota-milter-policy.json'
containers:
- name: exota-milter
image: chillout2k/exota-milter
imagePullPolicy: Always
volumeMounts:
- mountPath: /data
name: exota-milter-policy-volume
startupProbe:
exec:
command: ["nc", "-v", "-w1", "127.0.0.1", "4321"]
initialDelaySeconds: 5
periodSeconds: 10
env:
- name: LOG_LEVEL
value: 'info'
- name: MILTER_SOCKET
value: 'inet:4321@127.0.0.1'
- name: MILTER_POLICY_FILE
value: '/data/exota-milter-policy.json'
- name: MILTER_DKIM_ENABLED
value: 'True'
- name: MILTER_DKIM_ALIGNMENT_REQUIRED
value: 'True'
- name: MILTER_TRUSTED_AUTHSERVID
value: 'DKIMAuthservID'
- name: MILTER_X509_ENABLED
value: 'True'
- name: MILTER_X509_TRUSTED_CN
value: 'mail.protection.outlook.com'
- name: MILTER_X509_IP_WHITELIST
value: '127.0.0.1,::1'
- name: MILTER_ADD_HEADER
value: 'True'
- name: MILTER_AUTHSERVID
value: 'some-auth-serv-id'
- name: MILTER_REJECT_MESSAGE
value: 'Security policy violation!!'

View File

@ -0,0 +1,12 @@
---
apiVersion: v1
kind: Service
metadata:
name: exota-milter
spec:
selector:
app: exota-milter
ports:
- protocol: TCP
port: 4321
targetPort: 4321

View File

@ -0,0 +1,7 @@
namespace: exota-milter
commonLabels:
app: exota-milter
resources:
- 01_config-map.yaml
- 02_deployment.yaml
- 03_service.yaml

View File

@ -0,0 +1,7 @@
{
"example.com": {
"tenant_id": "abcd1234-18c5-45e8-88de-987654321cba",
"dkim_enabled": true,
"dkim_alignment_required": true
}
}

View File

@ -0,0 +1,20 @@
export LOG_LEVEL='info'
export MILTER_SOCKET='inet:4321@127.0.0.1'
export MILTER_POLICY_FILE='/etc/exota-milter/exota-milter-policy.json'
export MILTER_DKIM_ENABLED='True'
export MILTER_DKIM_ALIGNMENT_REQUIRED='True'
export MILTER_TRUSTED_AUTHSERVID='DKIMAuthservID'
export MILTER_X509_ENABLED='True'
export MILTER_X509_TRUSTED_CN='mail.protection.outlook.com'
export MILTER_X509_IP_WHITELIST='127.0.0.1,::1'
export MILTER_ADD_HEADER='True'
export MILTER_AUTHSERVID='some-auth-serv-id'
#export MILTER_REJECT_MESSAGE='Security policy violation!!'
# LDAP integration
#export MILTER_POLICY_SOURCE=ldap
#export MILTER_LDAP_SERVER_URI=ldaps://your.ldap.server
#export MILTER_LDAP_SEARCH_BASE=ou=your-customer-domains,dc=example,dc=org
#export MILTER_LDAP_QUERY='(domainNameAttr=%d)'
#export MILTER_LDAP_BINDDN=uid=exota-milter,ou=apps,dc=example,dc=org
#export MILTER_LDAP_BINDPW='$uPer§ecRet1!'

View File

@ -0,0 +1,9 @@
[Unit]
Description=ExOTA-Milter
[Service]
Restart=always
ExecStart=/usr/local/sbin/exota-milter.sh
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,15 @@
#!/bin/sh
if [ ! -e /etc/exota-milter/exota-milter.conf ]; then
echo "Missing /etc/exota-milter/exota-milter.conf!"
exit 1;
fi
if [ ! -e /etc/exota-milter/exota-milter-policy.json ]; then
echo "Missing /etc/exota-milter/exota-milter-policy.json!"
exit 1;
fi
. /etc/exota-milter/exota-milter.conf
exec /usr/bin/python3 /usr/local/exota-milter/exota-milter.py 2>&1

View File

@ -0,0 +1,24 @@
#!/bin/sh
if [ "$(id -u)" != "0" ]; then
echo "You must be root!"
exit 1
fi
install -d /usr/local/exota-milter/
install ../../app/*.py /usr/local/exota-milter/
install -m 750 exota-milter.sh /usr/local/sbin/exota-milter.sh
install -d -m 660 /etc/exota-milter
if [ -e /etc/exota-milter/exota-milter-policy.json ]; then
echo "Found existing /etc/exota-milter/exota-milter-policy.json - skipping"
else
install -m 660 exota-milter-policy.json /etc/exota-milter/exota-milter-policy.json
fi
if [ -e /etc/exota-milter/exota-milter.conf ]; then
echo "Found existing /etc/exota-milter/exota-milter.conf - skipping"
else
install -m 750 exota-milter.conf /etc/exota-milter/exota-milter.conf
fi
install -m 660 exota-milter.service /lib/systemd/system/exota-milter.service
systemctl daemon-reload
systemctl enable exota-milter.service

View File

@ -0,0 +1,14 @@
#!/bin/sh
if [ "$(id -u)" != "0" ]; then
echo "You must be root!"
exit 1
fi
systemctl disable exota-milter.service
systemctl stop exota-milter.service
rm -rf /usr/local/exota-milter/
rm -f /usr/local/sbin/exota-milter.sh
rm -f /lib/systemd/system/exota-milter.service
systemctl daemon-reload
echo "/etc/exota-milter/ was kept undeleted!"

View File

@ -1,12 +1,13 @@
ARG ARCH
FROM $ARCH/alpine
ARG PARENT_IMAGE=alpine:3.16
FROM ${PARENT_IMAGE}
LABEL maintainer="Dominik Chilla <dominik@zwackl.de>"
LABEL git_repo="https://github.com/chillout2k/exota-milter"
ADD ./requirements.txt /requirements.txt
RUN apk update \
&& apk add python3 python3-dev py3-pip gcc libc-dev libmilter-dev \
&& apk add --no-cache python3 python3-dev py3-pip \
gcc libc-dev libmilter-dev \
&& pip3 install -r requirements.txt \
&& apk del gcc libc-dev libmilter-dev python3-dev py3-pip \
&& apk add libmilter \
@ -23,4 +24,4 @@ RUN chown -R exota-milter /app /cmd \
VOLUME [ "/socket", "/data" ]
USER exota-milter
CMD [ "/cmd" ]
CMD [ "/cmd" ]

View File

@ -2,7 +2,11 @@
![OSSAR](https://github.com/chillout2k/ExOTA-Milter/workflows/OSSAR/badge.svg?branch=master)
# ExOTA-Milter - Exchange Online Tenant Authorisation Milter (Mail-Filter)
# ExOTA-Milter - Exchange Online Tenant Authorisation Milter (Mail-Filter)
![ExOTA-Milter use case](use-case.png)
*Diagram created with: https://app.diagrams.net/*
The **ExOTA-[Milter](https://en.wikipedia.org/wiki/Milter)** application is written in python3 and derives from **[sdgathman´s pymilter](https://github.com/sdgathman/pymilter)**.
@ -95,8 +99,8 @@ By the way, the global setting `ENV[MILTER_DKIM_ALIGNMENT_REQUIRED]` can be over
}
```
## X-MS-Exchange-CrossTenant-Id header (policy binding)
Further each Microsoft Exchange-Online tenant has a unique tenant-ID in form of a UUID ([RFC 4122](https://tools.ietf.org/html/rfc4122)). **ExOTA-Milter** extracts the tenant-ID from the *X-MS-Exchange-CrossTenant-Id* email header and uses it as a *mandatory* authentication factor.
## X-MS-Exchange-CrossTenant-Id header (OPTIONAL!)
Further each Microsoft Exchange-Online tenant has a unique tenant-ID in form of a UUID ([RFC 4122](https://tools.ietf.org/html/rfc4122)). **ExOTA-Milter** extracts the tenant-ID from the *X-MS-Exchange-CrossTenant-Id* email header and uses it as a *mandatory* authentication factor. Since September 2022 Microsoft did not set this header anymore reliably.
```
[...]
X-MS-Exchange-CrossTenant-Id: <UUID-of-tenant>
@ -121,17 +125,18 @@ Finally it´s the combination of all of the above discussed aspects which may re
* matching for client certificate´s CN (ExOTA-Milter)
* verification of DKIM signatures providing *Authentication-Results* header (another milter, e.g. OpenDKIM)
* consideration of DKIM verification results per sender domain (ExOTA-Milter)
* matching for tenant-id provided in *X-MS-Exchange-CrossTenant-Id* header (ExOTA-Milter)
* *OPTIONAL* matching for tenant-id provided in *X-MS-Exchange-CrossTenant-Id* header (ExOTA-Milter)
![Activity policy](http://www.plantuml.com/plantuml/png/5SKn3W8W30NGg-W1f8cZcuEZSN4tM8aq5ahAhyhjZMzvM-ciyIZXkgd0c0SYpv_q5DIunopErb4w4biZhg9gWVsBJj_BzRWxYw8ujJp_POQy1UisJ8LN6j7q1m00)
![Activity policy](http://www.plantuml.com/plantuml/png/bPLHJzi-5CNVyoaERqKY_FzZuc7fmyYI8BhHTae3GffGPUBRriArkzXXyEsNrWsrIa3T8xw-zvppezUvC9PLjbxAm0eh2Tdpk8Z3eP2MAXWgwqhO5woq5EKBPbB_GR3f2A9X4QFKIb5fYVSH1D5LcaT8j9JDaL1pC2bHaQGdfYmMn3XLfXyeRGcIPZR2PQMN9uXhko1bHSciq2hCoTJIcXFSXSD9c3sN2wRc52QLDgOWrSmA1xnLowdKSoNCMiwGQXJ0zP89vUkWO8-aC6lKa5ycvv_FpaxNqbjFO8h_fwlNKcE06X7lnkcszkcq6ItH8_L4Kg_e6C9WD2vUKnurlhBnCCArrezhJ_MgPISK7bZP-E2-DNnZXZYqUbNVk7GPWa3CqFkvPQzhnRyUOyqAlHSonm6mhhl_LOJdy_-_e9IYyOvaX791vSO2AVOWwKqh4ErCxZNtDtNFPJQw_JKSN1TPFhrhRawB_6PIPEaq6Tq7WFDnkJO8MzN64jyR-5QSf27qb7P_0L6UVS-ImYa3nkgLkwUuc0N-_TsPdpxzCHWUaDa3kc3ciAvzLct4xj_jxeSEl4-n1HZV55UJBByTf0va1qfdCix3xU0kUpwT3fuUKPyfqC6GIJ5NIrBs42xTHCFTZ0zRWIXfDSJCjPgcazcw8WZZl13VXV1nEhkJT91YsO02_QwPXDNmDsdgDkae07p30-E5xRBNXRKJRGTU7t7tdBRULVLhVkNmLR3RHp9jtxT_UWhQTi4XrFRnwdSl-KwfdNZ0GZltJld3KDw9DymDSZUYd9RwgVtIt8Nks_NnMgvJhAhD2zyFV6gCy-sTVJnHp7aZSfX0FtXlvUCqdLvbSPhbzxjiRP9aF2e6evki7spoNJJ7zLB-1G00)
# How about using LDAP as policy backend?
For small setups, with not so many domains, the JSON-file policy backend (default) may be sufficient. If you´re an email service provider (ESP) maintaining a lot of customer domains in a LDAP server, you may want to use the LDAP backend instead. Details regarding the LDAP backend can be found [in the LDAP readme](LDAP/README.md).
# How about a docker/OCI image?
## Using prebuilt images from [dockerhub](https://hub.docker.com/)
* AMD64: https://hub.docker.com/r/chillout2k/exota-milter-amd64
* ARM32v6: https://hub.docker.com/r/chillout2k/exota-milter-arm32v6
* **OBSOLETE!** ~~AMD64: https://hub.docker.com/r/chillout2k/exota-milter-amd64~~
* **OBSOLETE!** ~~ARM32v6: https://hub.docker.com/r/chillout2k/exota-milter-arm32v6~~
* **NEW multi-architecture image:** https://hub.docker.com/r/chillout2k/exota-milter
The images are built on a weekly basis. The corresponding *Dockerfile* is located [here](OCI/Dockerfile)
@ -140,3 +145,35 @@ Take a look [here](OCI/README.md)
# How to test?
First of all please take a look at how to set up the testing environment, which is described [here](tests/README.md)
# How to install on docker/kubernetes/systemd?
The installation procedure is documented [here](INSTALL/README.md)
# How to *configure* the ExOTA-Milter?
|ENV variable|type|default|description|
|---|---|---|---|
|MILTER_NAME|`string`|`exota-milter`|Name of the milter instance. Base for socket path. Name appears in logs |
|MILTER_SOCKET|`string`|`/socket/<ENV[MITLER_NAME]>`|Defines the filesystem path of milter socket. The milter can be also exposed as a tcp-socket like `inet:4321@127.0.0.1`|
|MILTER_REJECT_MESSAGE|`string`|`Security policy violation!`|Milter reject (SMTP 5xx code) message presented to the calling MTA|
|MILTER_TMPFAIL_MESSAGE|`string`|`Service temporarily not available! Please try again later.`|Milter temporary fail (SMTP 4xx code) message presentetd to the calling MTA.|
|MILTER_TENANT_ID_REQUIRED|`bool`|`false`|Controls the requirement of the presence of the unofficial `X-MS-Exchange-CrossTenant-Id` header. Used as additional authentication factor.|
|MILTER_DKIM_ENABLED|`bool`|`false`|Enables/disables the checking of DKIM authentication results. Used as additional but strong authentication factor.|
|MILTER_DKIM_ALIGNMENT_REQUIRED|`bool`|`false`|Enables/disables the alighment checks of DKIM SDID with RFC-5322.from_domain. Requires ENV[MILTER_DKIM_ENABLED] = `true`|
|MILTER_TRUSTED_AUTHSERVID|`string`|`invalid`|Specifies the trusted DKIM-signature validating entity (DKIM-validator - producer of Authentication-Results header). The DKIM-validator must place exactly the same string as configured here into the Authentication-Results header! Requires ENV[MILTER_DKIM_ENABLED] = `true`|
|MILTER_POLICY_SOURCE|`string`|`file`|Policy source - Possible values `file` (JSON) or `ldap`|
|MILTER_POLICY_FILE|`string`|`/data/policy.json`|Filesystem path to the (JSON) policy file. Requires ENV[MILTER_POLICY_SOURCE] = `file`|
|MILTER_X509_ENABLED|`bool`|`false`|Enables/disables the checking of client x509-certificate. Used as additional authentication factor.|
|MILTER_X509_TRUSTED_CN|`string`|`mail.protection.outlook.com`|FQDN of authenticating client MTA. Requires ENV[MILTER_X509_ENABLED] = `true`|
|MILTER_X509_IP_WHITELIST|Whitespace or comma separated list of `string`|`127.0.0.1,::1`|List of IP-addresses for which the ExOTA-Milter skips x509 checks. Requires ENV[MILTER_X509_ENABLED] = `true`|
|MILTER_ADD_HEADER|`bool`|`false`|Controls if the ExOTA-Milter should write an additional `X-ExOTA-Authentication-Results` header with authentication information|
|MILTER_AUTHSERVID|`string`|empty|Provides ID of authenticating entity within `X-ExOTA-Authentication-Results` header to further validating instances. Required when ENV[MILTER_ADD_HEADER] = `true`|
|MILTER_LDAP_SERVER_URI|`string`|empty|LDAP-URI of LDAP server holding ExOTA policies. Required when ENV[MILTER_POLICY_SOURCE] = `ldap`|
|MILTER_LDAP_RECEIVE_TIMEOUT|`int`|5|Timespan the ExOTA-Milter waits for the LDAP server to respond to a request. This NOT the TCP-connect timeout! Requires ENV[MILTER_POLICY_SOURCE] = `ldap`|
|MILTER_LDAP_BINDDN|`string`|empty|Distinguished name of the binding (authenticating) *user*|
|MILTER_LDAP_BINDPW|`string`|empty|Password of the binding (authenticating) *user*|
|MILTER_LDAP_SEARCH_BASE|`string`|empty|Search base-DN on the LDAP server. Required when ENV[MILTER_POLICY_SOURCE] = `ldap`|
|MILTER_LDAP_QUERY|`string`|empty|LDAP query/filter used to match for a ExOTA-policy. A placeholder must be used to filter for the authenticating domain (`%d`), e.g. `(domain_attribute=%d)`|
|MILTER_LDAP_TENANT_ID_ATTR|`string`|`exotaMilterTenantId`|Custom LDAP attribute name unless using the ExOTA-milter LDAP schema|
|MILTER_LDAP_DKIM_ENABLED_ATTR|`string`|`exotaMilterDkimEnabled`|Custom LDAP attribute name unless using the ExOTA-milter LDAP schema|
|MILTER_LDAP_DKIM_ALIGNMENT_REQIRED_ATTR|`string`|`exotaMilterDkimAlignmentRequired`|Custom LDAP attribute name unless using the ExOTA-milter LDAP schema|

View File

@ -50,11 +50,14 @@ if (Policy found?) then (yes)
endif
else (no)
endif
:Looking up tenant-id in policy;
if (Found trusted tenant-ID?) then (no)
:REJECT;
stop
else (yes)
if (Milter: tenant-ID header checking enabled?) then (yes)
:Looking up tenant-id in policy;
if (Found trusted tenant-ID?) then (no)
:REJECT;
stop
else (yes)
endif
else (no)
endif
else (no)
:REJECT;

View File

@ -25,6 +25,8 @@ g_milter_socket = '/socket/' + g_milter_name
g_milter_reject_message = 'Security policy violation!'
# ENV[MILTER_TMPFAIL_MESSAGE]
g_milter_tmpfail_message = 'Service temporarily not available! Please try again later.'
# ENV[MILTER_TENANT_ID_REQUIRED]
g_milter_tenant_id_required = False
# ENV[MILTER_DKIM_ENABLED]
g_milter_dkim_enabled = False
# ENV[MILTER_DKIM_ALIGNMENT_REQUIRED]
@ -163,12 +165,11 @@ class ExOTAMilter(Milter.Base):
return self.smfir_continue()
def header(self, name, hval):
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Header: {0}, Value: {1}".format(name, hval)
)
# Parse RFC-5322-From header
if(name.lower() == "From".lower()):
if(name.lower() == "from"):
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Header: {0}, Value: {1}".format(name, hval)
)
hdr_5322_from = email.utils.parseaddr(hval)
self.hdr_from = hdr_5322_from[1].lower()
m = re.match(g_re_domain, self.hdr_from)
@ -185,7 +186,10 @@ class ExOTAMilter(Milter.Base):
)
# Parse RFC-5322-Resent-From header (Forwarded)
if(name.lower() == "Resent-From".lower()):
if(name.lower() == "resent-from"):
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Header: {0}, Value: {1}".format(name, hval)
)
hdr_5322_resent_from = email.utils.parseaddr(hval)
self.hdr_resent_from = hdr_5322_resent_from[1].lower()
m = re.match(g_re_domain, self.hdr_resent_from)
@ -200,24 +204,37 @@ class ExOTAMilter(Milter.Base):
self.hdr_resent_from, self.hdr_resent_from_domain
)
)
# Parse non-standardized X-MS-Exchange-CrossTenant-Id header
elif(name.lower() == "X-MS-Exchange-CrossTenant-Id".lower()):
elif(name.lower() == "x-ms-exchange-crosstenant-id"):
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Tenant-ID: {0}".format(self.hdr_tenant_id)
)
if self.hdr_tenant_id_count > 0:
if not self.hdr_tenant_id == hval.lower():
self.hdr_different_tenant_id = True
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Different Tenant-IDs found!"
"/HDR: Header: {0}, Value: {1}".format(name, hval)
)
if g_milter_tenant_id_required == True:
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Tenant-ID: {0}".format(hval.lower())
)
else:
self.hdr_tenant_id_count += 1
self.hdr_tenant_id = hval.lower()
if self.hdr_tenant_id_count > 0:
if not self.hdr_tenant_id == hval.lower():
self.hdr_different_tenant_id = True
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Different Tenant-IDs found!"
)
else:
self.hdr_tenant_id_count += 1
self.hdr_tenant_id = hval.lower()
# Just for debugging cases
elif(name.lower() == "dkim-signature"):
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Header: {0}, Value: {1}".format(name, hval)
)
# Parse RFC-7601 Authentication-Results header
elif(name.lower() == "Authentication-Results".lower()):
elif(name.lower() == "authentication-results"):
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Header: {0}, Value: {1}".format(name, hval)
)
if g_milter_dkim_enabled == True:
ar = None
try:
@ -229,7 +246,7 @@ class ExOTAMilter(Milter.Base):
if ar_result.method == 'dkim':
if ar_result.result == 'pass':
self.passed_dkim_results.append({
"sdid": ar_result.header_d
"sdid": ar_result.header_d.lower()
})
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: DKIM passed SDID {0}".format(ar_result.header_d)
@ -245,6 +262,9 @@ class ExOTAMilter(Milter.Base):
)
elif(name == "X-ExOTA-Authentication-Results"):
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Header: {0}, Value: {1}".format(name, hval)
)
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
"/HDR: Found X-ExOTA-Authentication-Results header. Marking for deletion."
)
@ -300,16 +320,17 @@ class ExOTAMilter(Milter.Base):
reason = '5322.from header missing'
)
if self.hdr_different_tenant_id == True:
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: Multiple/different tenant-ID headers found for {0} - action=reject".format(
self.hdr_from_domain
if g_milter_tenant_id_required == True:
if self.hdr_different_tenant_id == True:
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: Multiple/different tenant-ID headers found for {0} - action=reject".format(
self.hdr_from_domain
)
)
return self.smfir_reject(
queue_id = self.getsymval('i'),
reason = 'Multiple/different tenant-ID headers found!'
)
)
return self.smfir_reject(
queue_id = self.getsymval('i'),
reason = 'Multiple/different tenant-IDs headers found!'
)
# Get policy for 5322.from_domain
policy = None
@ -395,28 +416,29 @@ class ExOTAMilter(Milter.Base):
reason = "No policy for 5322.from_domain {0}".format(self.hdr_from_domain)
)
if self.hdr_tenant_id is None:
log_error(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: exception: could not determine X-MS-Exchange-CrossTenant-Id - action=reject"
)
return self.smfir_reject(
queue_id = self.getsymval('i'),
reason = 'Tenant-ID is missing!'
)
if self.hdr_tenant_id == policy.get_tenant_id():
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: tenant_id={0} status=match".format(self.hdr_tenant_id)
)
else:
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: tenant_id={0} status=no_match - action=reject".format(
self.hdr_tenant_id
if g_milter_tenant_id_required == True:
if self.hdr_tenant_id is None:
log_error(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: exception: could not determine X-MS-Exchange-CrossTenant-Id - action=reject"
)
return self.smfir_reject(
queue_id = self.getsymval('i'),
reason = 'Tenant-ID is missing!'
)
if self.hdr_tenant_id == policy.get_tenant_id():
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: tenant_id={0} status=match".format(self.hdr_tenant_id)
)
else:
log_info(self.mconn_id + "/" + str(self.getsymval('i')) +
"/EOM: tenant_id={0} status=no_match - action=reject".format(
self.hdr_tenant_id
)
)
return self.smfir_reject(
queue_id = self.getsymval('i'),
reason = 'No policy match for tenant-id'
)
)
return self.smfir_reject(
queue_id = self.getsymval('i'),
reason = 'No policy match for tenant-id'
)
if g_milter_dkim_enabled and policy.is_dkim_enabled():
log_debug(self.mconn_id + "/" + str(self.getsymval('i')) +
@ -529,6 +551,9 @@ if __name__ == "__main__":
if 'MILTER_TMPFAIL_MESSAGE' in os.environ:
g_milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE']
log_info("ENV[MILTER_TMPFAIL_MESSAGE]: {0}".format(g_milter_tmpfail_message))
if 'MILTER_TENANT_ID_REQUIRED' in os.environ:
g_milter_tenant_id_required = True
log_info("ENV[MILTER_TENANT_ID_REQUIRED]: {0}".format(g_milter_tenant_id_required))
if 'MILTER_DKIM_ENABLED' in os.environ:
g_milter_dkim_enabled = True
if 'MILTER_TRUSTED_AUTHSERVID' in os.environ:

View File

@ -14,10 +14,6 @@ def init_logger():
elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE):
log_level = logging.DEBUG
log_format = '%(asctime)s: %(levelname)s %(message)s '
if log_level == logging.DEBUG:
# Log thread-ID too. This helps to correlate DEBUG logs,
# as Backend-logs do not have a queue_id nor a mconn_id!
log_format = '%(asctime)s: %(levelname)s %(thread)d %(message)s '
logging.basicConfig(
filename = None, # log to stdout
format = log_format,

View File

@ -4,7 +4,8 @@ import re
from uuid import UUID
from ldap3.core.exceptions import LDAPException
from ldap3 import (
Server, Connection, NONE, set_config_parameter
Server, Connection, NONE, set_config_parameter,
SAFE_RESTARTABLE
)
from logger import log_debug
@ -24,7 +25,10 @@ class ExOTAPolicyBackendException(Exception):
class ExOTAPolicy():
def __init__(self, policy_dict):
self.tenant_id = policy_dict['tenant_id']
if 'tenant_id' in policy_dict:
self.tenant_id = policy_dict['tenant_id']
else:
self.tenant_id = ''
if 'dkim_enabled' in policy_dict:
self.dkim_enabled = policy_dict['dkim_enabled']
else:
@ -49,18 +53,11 @@ class ExOTAPolicy():
@staticmethod
def check_policy(policy_dict):
if 'tenant_id' not in policy_dict:
raise ExOTAPolicyInvalidException(
"Policy must have a 'tenant_id' key!"
)
if policy_dict['tenant_id'] is None:
raise ExOTAPolicyInvalidException(
"'tenant_id' needs a value!"
)
for policy_key in policy_dict:
if policy_key == 'tenant_id':
try:
UUID(policy_dict[policy_key])
if policy_dict[policy_key] != '':
UUID(policy_dict[policy_key])
except ValueError as e:
raise ExOTAPolicyInvalidException(
"Invalid 'tenant_id': {0}".format(str(e))
@ -166,7 +163,7 @@ class ExOTAPolicyBackendLDAP(ExOTAPolicyBackend):
self.ldap_bindpw,
auto_bind = True,
raise_exceptions = True,
client_strategy = 'RESTARTABLE',
client_strategy = 'SAFE_RESTARTABLE',
receive_timeout = self.ldap_receive_timeout
)
except LDAPException as e:
@ -185,34 +182,38 @@ class ExOTAPolicyBackendLDAP(ExOTAPolicyBackend):
log_debug("LDAP-QUERY-Template: {0}".format(self.query_template))
log_debug("LDAP-QUERY: {0}".format(ldap_query))
try:
self.conn.search(
_, _, response, _ = self.conn.search(
self.search_base,
ldap_query,
attributes=[
attributes = [
self.tenant_id_attr,
self.dkim_enabled_attr,
self.dkim_alignment_required_attr
]
)
log_debug("LDAP ENTRIES: {0}".format(self.conn.entries))
if len(self.conn.entries) == 1:
entry = self.conn.entries[0]
log_debug("LDAP ENTRY: {0}".format(response))
if len(response) == 1:
entry = response[0]['attributes']
policy_dict = {}
if self.tenant_id_attr in entry:
policy_dict['tenant_id'] = entry[self.tenant_id_attr].value
if len(entry[self.tenant_id_attr]) > 0:
policy_dict['tenant_id'] = entry[self.tenant_id_attr][0]
else:
policy_dict['tenant_id'] = ''
if self.dkim_enabled_attr in entry:
if entry[self.dkim_enabled_attr].value == 'TRUE':
if entry[self.dkim_enabled_attr][0] == 'TRUE':
policy_dict['dkim_enabled'] = True
else:
policy_dict['dkim_enabled'] = False
if self.dkim_alignment_required_attr in entry:
if entry[self.dkim_alignment_required_attr].value == 'TRUE':
if entry[self.dkim_alignment_required_attr][0] == 'TRUE':
policy_dict['dkim_alignment_required'] = True
else:
policy_dict['dkim_alignment_required'] = False
log_debug("POLICY_DICT: {}".format(policy_dict))
ExOTAPolicy.check_policy(policy_dict)
return ExOTAPolicy(policy_dict)
elif len(self.conn.entries) > 1:
elif len(response) > 1:
raise ExOTAPolicyInvalidException(
"Multiple policies found for domain={0}!".format(from_domain)
)

View File

@ -1,64 +0,0 @@
Return-Path: <>
Received: from DEU01-FR2-obe.outbound.protection.outlook.com (mail-fr2deu01lp2173.outbound.protection.outlook.com [104.47.11.173])
(using TLSv1.2 with cipher ECDHE-ECDSA-AES256-GCM-SHA384 (256/256 bits))
(Client CN "mail.protection.outlook.com", Issuer "GlobalSign Organization Validation CA - SHA256 - G3" (verified OK))
by outbound.connector.blahblubb.de (Postfix) with ESMTPS id 4CjqCQ2WRCzGjg6
for <some.recipient@example.org>; Sat, 28 Nov 2020 12:34:26 +0100 (CET)
ARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none;
b=RuGSfIN1OzQHDqrF0erLAHZ3fyhtmoE5Sllj+Qp6CtbcNUkkmdhR44b8capz/J1mBpyb13udY1mhkPZCK1Cmt+mpg9yFXgkv5BxY+dV9647Fq+MboUE60Psn84d4vXFvyrWDrFW1jWZi7/NdXhjLcCqTHpAzDaRfAOfGhG/VWYJAXnD/EBpCzPfd8hh9ZOONI2UN2HQfRnx0P3WXyeVSGilP4RGPdmcCZV5ZzpjlQoKUshjq293+ZltXaeKfF/LHGX0yScHhKO2f9O+qY3hnH0P+NGwFvhIky3IyszfxpANaJnz2Jpp0sK1W16rwGSTI2gl9bpJsj+wzKLGkJV75+Q==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com;
s=arcselector9901;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;
bh=KWHUKEKZxeQyFbKe45TV1PJMC2XrVCMTFkdwSYWR6o0=;
b=go8dFv6srV3NnETxQxaANld1if9BOsIgrhjefC4WkRrrgwEjZSNnm9DyO+GC2ZZo60At5JHOVLjqN9kjz2pFdAG0qnFEj3Wx/6NnuTfBUk0n4s32RoFuhADu8BC+aOU9Ec909uu2QQ9ucEMiVSjuyQ3QpGS5DR0yCAZLZ12B61hmoMgkXJ9ah6rluUV4GeMGKTsUn16u6mrJycXp0OoD4n19JomPpQo5o8gouK3Zz4F7DxX4lshNJ+VCsOznqS+FI4rQ2LSyU8Y0AZa9clyCSN94AJa6K0TiDgQ/gLZEWsZ1tZkgPrdMlyqi58ONW/dNQ7lyrEFz6deB4YmsusJPbQ==
ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none
action=none header.from=lalalulu.onmicrosoft.com; dkim=none (message not
signed); arc=none
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=lalalulu.onmicrosoft.com; s=selector1-lalalulu-onmicrosoft-com;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;
bh=KWHUKEKZxeQyFbKe45TV1PJMC2XrVCMTFkdwSYWR6o0=;
b=DYTLJtLFjvVrSZtZQagTwuEe5PQYqrNGi7hR5bkhO+GYUV4dcQZnDO4hAPzJkOWhz8JCVJ+/yt5K8L/exegk80g9m0GJjZzJBxMy0ZE/7wg8yqiHNE+iQqWhJLtwsD23kx2+09G5dBSDI1QVqFKkL0YKBWVffSuXi+tjM4/BztffZ7ok7XZdKCFfKzK3TLdiAWYTRIp1214zdnIE0CLBhnOIWC4gnML2fXsVZsWb/CMgaW0vBsZGI/yaSivaNFPZloSb0/sEnMFMEbv2GXt9mN913M0thwCi/+NLwzaW6TNlw2Vz7l4SGRVvciGaa4s2sFnJ0ANMD2u5qBbJ8j8Z0w==
Authentication-Results: blahblubb.de; dkim=none (message not signed)
header.d=none;blahblubb.de; dmarc=none action=none
header.from=lalalulu.onmicrosoft.com;
Received: from AM6P193CA0087.EURP193.PROD.OUTLOOK.COM (2603:10a6:209:88::28)
by BEXP281MB0216.DEUP281.PROD.OUTLOOK.COM (2603:10a6:b10:6::12) with
Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.3632.6; Sat, 28 Nov
2020 11:34:25 +0000
Received: from BE0P281MB0257.DEUP281.PROD.OUTLOOK.COM
(2603:10a6:209:88:cafe::a2) by AM6P193CA0087.outlook.office365.com
(2603:10a6:209:88::28) with Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.3611.20 via Frontend
Transport; Sat, 28 Nov 2020 11:34:24 +0000
From: O365ConnectorValidation@lalalulu.onmicrosoft.com
Date: Sat, 28 Nov 2020 11:34:24 +0000
Message-Id: <b6d9c673-d0f3-4538-bb4e-9e099fb9a388@substrate-int.office.com>
To: some.recipient@example.org
Subject: Test email for connector validation
MIME-Version: 1.0
Content-Type: text/plain; charset=us-ascii
X-MS-PublicTrafficType: Email
X-MS-Office365-Filtering-Correlation-Id: abcd1234-abcd-471a-1234-08d893918edd
X-MS-TrafficTypeDiagnostic: BEXP281MB0216:
X-Microsoft-Antispam-PRVS:
<BEXP281MB021624EF3E3FC35524889C2AB8F70@BEXP281MB0216.DEUP281.PROD.OUTLOOK.COM>
X-MS-Oob-TLC-OOBClassifiers: OLM:2733;
X-MS-Exchange-SenderADCheck: 1
X-Microsoft-Antispam: BCL:0;
X-Microsoft-Antispam-Message-Info:
P2dut4iALZ4EsHFmDE6p0OBg/Q4PvbmhUGI6BnGbHo/u7Vza6tyXE6BPK0VrJQ8WnCYXNx7lEKtiZs8nakJ9EghgxvFRNuYyRBJcGAdlN2TJAb2/7Wp5m7vzuGp1JJhES0RC/hypLDL8miRoP1xYl/pQHZVUGczSddujsZT6im0EgDJvAB0L1vzyKvZJ1QH3vTWDKMAgetlQHiPvCfzZmUgY92g1+sfF9UwGTRXDj8cd83H+TLI7GL8kZF1H219l+DLDiZ3u+qUdprwMn9XDEBljZpczY8BhiFdmnbyJ26ePVNa5JluRboz2Gfaa6GZE+ar8FyKtepxFOyNlI+hyL/vcWNwmnjL+pyYFVPPHnODjxu8JixWg00ThTUiZbclJ
X-Forefront-Antispam-Report:
CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:BE0P281MB0257.DEUP281.PROD.OUTLOOK.COM;PTR:;CAT:NONE;SFS:(376002)(346002)(39830400003)(34036004)(366004)(396003)(136003)(31686004)(78352004)(6916009)(508600001)(5660300002)(42882007)(2906002)(8936002)(558084003)(31696002)(17440700003)(316002)(9686003)(85236043)(68406010)(8676002)(83380400001)(16130700016)(100380200003)(20230700015);DIR:OUT;SFP:1501;
X-OriginatorOrg: lalalulu.onmicrosoft.com
X-MS-Exchange-CrossTenant-OriginalArrivalTime: 28 Nov 2020 11:34:24.7460
(UTC)
X-MS-Exchange-CrossTenant-Network-Message-Id: abcd1234-abcd-471a-1234-08d893918edd
X-MS-Exchange-CrossTenant-AuthSource: AM6P193CA0087.EURP193.PROD.OUTLOOK.COM
X-MS-Exchange-CrossTenant-AuthAs: Internal
X-MS-Exchange-CrossTenant-Id: 1234abcd-18c5-45e8-88de-123456789abc
X-MS-Exchange-CrossTenant-FromEntityHeader: Internet
X-MS-Exchange-Transport-CrossTenantHeadersStamped: BEXP281MB0216
This test email message was sent from Office 365 to check that email can be delivered to you using your new or modified connector. No need to reply.

View File

@ -1,2 +0,0 @@
From: from2@example.com
From: from1@example.org

View File

@ -1,16 +0,0 @@
import sys
import email, email.header
from email.utils import getaddresses
f = open("../samples/exo_validator.eml", "r")
email = email.message_from_file(f)
from_hdr = email.get_all("From")
print("from_hdr: " + str(from_hdr))
if(len(from_hdr) > 1):
print("Multiple From-headers found!")
sys.exit(1)
elif(len(from_hdr) == 1):
print("Exactly one From-header found :)")
print(from_hdr)
from_addr = getaddresses(from_hdr)
print(str(from_addr[0][1]))

View File

@ -33,41 +33,49 @@ end
--if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@yad.onmicrosoft.com>') ~= nil then
-- error "mt.header(From) failed"
--end
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2k.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "resent-fRoM", '"Blah Blubb" <blah@yad.onmicrosoft.COM>') ~= nil then
error "mt.header(From) failed"
error "mt.header(Resent-From) failed"
end
if mt.header(conn, "x-mS-EXCHANGE-crosstenant-id", "1234abcd-18c5-45e8-88de-123456789abc") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(X-MS-Exchange-CrossTenant-Id) failed"
end
dkim_sig = "v=1; a=rsa-sha256; c=relaxed/simple; d=staging.zwackl.de;\n"
.."\ts=selector-xyz; t=1685872089;\n"
.."\tbh=5/ZUJAdcuyAn6J+J6apWtAaJLbDCKkI5Ie31qVKiY0w=;\n"
.."\th=Date:From:To:Subject:MIME-Version:Content-Type;\n"
.."\tb=Bn/xAbFFjAg1b9bBFPHAYSaupsnL4pzPPDUauetfGB0hu0Qz0Dio+4Z2Vi6PMOesA\n"
.."\t72VbehuxG+b++XVL/hs3+K6p7vTgVAWiWAZLvfs5bHE5HAalsCrNenpKTk6RUcSYtw\n"
.."\tLiiYhvw0TR5LbyNoSPG2J16mXEcS+k2q+K7WfwMg="
if mt.header(conn, "DKIM-Signature", dkim_sig) ~= nil then
error "mt.header(DKIM-Signature) failed"
end
--if mt.header(conn, "X-MS-Exchange-CrossTenant-Id", "4321abcd-18c5-45e8-88de-blahblubb") ~= nil then
-- error "mt.header(Subject) failed"
--end
if mt.header(conn, "Authentication-Results", "another-wrong-auth-serv-id;\n dkim=fail header.d=yad.onmicrosoft.com header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(Authentication-Results) failed"
end
if mt.header(conn, "Authentication-Results", "wrong-auth-serv-id;\n dkim=pass header.d=yad.onmicrosoft.com header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(Authentication-Results) failed"
end
if mt.header(conn, "Authentication-Results", "my-auth-serv-id;\n exota=pass") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(Authentication-Results) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=yad.onmicrosoft.comx header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(Authentication-Results) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=chillout2k.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Authentication-Results) failed"
end
if mt.header(conn, "Authentication-Results", "my-auth-serv-id;\n dkim=fail header.d=yad.onmicrosoft.com header.s=selector2-asdf header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(Authentication-Results) failed"
end
if mt.header(conn, "Authentication-Results", "some-validating-host;\n dkim=pass header.d=paypal.de header.s=pp-dkim1 header.b=PmTtUzer;\n dmarc=pass (policy=reject) header.from=paypal.de;\n spf=pass (some-validating-host: domain of service@paypal.de designates 173.0.84.226 as permitted sender) smtp.mailfrom=service@paypal.de") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(Authentication-Results) failed"
end
if mt.header(conn, "X-ExOTA-Authentication-Results", "my-auth-serv-id;\n exota=pass") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(X-ExOTA-Authentication-Results) failed"
end
-- EOM

View File

@ -33,7 +33,7 @@ end
--if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@yad.onmicrosoft.com>') ~= nil then
-- error "mt.header(From) failed"
--end
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2k.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "resent-fRoM", '"Blah Blubb" <blah@yad.onmicrosoft.COM>') ~= nil then
@ -57,7 +57,7 @@ end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=yad.onmicrosoft.comx header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=chillout2k.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-Results", "my-auth-serv-id;\n dkim=fail header.d=yad.onmicrosoft.com header.s=selector2-asdf header.b=mmmjFpv8") ~= nil then
@ -107,13 +107,13 @@ if mt.getreply(conn) ~= SMFIR_CONTINUE then
end
-- HEADER
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2k.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "x-mS-EXCHANGE-crosstenant-id", "1234abcd-18c5-45e8-88de-123456789abc") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=chillout2k.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end

View File

@ -31,17 +31,17 @@ if mt.getreply(conn) ~= SMFIR_CONTINUE then
end
-- HEADER
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2k.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "x-mS-EXCHANGE-crosstenant-id", "1234abcd-18c5-45e8-88de-123456789abcXXX") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(x-mS-EXCHANGE-crosstenant-id) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=fail header.d=chillout2k.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=fail header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Authentication-RESULTS) failed"
end
if mt.header(conn, "X-ExOTA-Authentication-Results", "my-auth-serv-id;\n exota=pass") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(X-ExOTA-Authentication-Results) failed"
end
-- EOM
@ -81,14 +81,14 @@ if mt.getreply(conn) ~= SMFIR_CONTINUE then
end
-- HEADER
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2k.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "x-mS-EXCHANGE-crosstenant-id", "1234abcd-18c5-45e8-88de-123456789abc") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(x-mS-EXCHANGE-crosstenant-id) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=chillout2k.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Authentication-RESULTS) failed"
end
-- EOM
@ -127,17 +127,17 @@ if mt.getreply(conn) ~= SMFIR_CONTINUE then
end
-- HEADER
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2k.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "x-mS-EXCHANGE-crosstenant-id", "1234abcd-18c5-45e8-88de-123456789abcXXX") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(x-mS-EXCHANGE-crosstenant-id) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=fail header.d=chillout2k.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=fail header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Authentication-RESULTS) failed"
end
if mt.header(conn, "X-ExOTA-Authentication-Results", "my-auth-serv-id;\n exota=pass") ~= nil then
error "mt.header(Subject) failed"
error "mt.header(X-ExOTA-Authentication-Results) failed"
end
-- EOM

View File

@ -30,7 +30,7 @@ if mt.getreply(conn) ~= SMFIR_CONTINUE then
end
-- HEADER
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2kx.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwacklx.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "x-mS-EXCHANGE-crosstenant-id", "1234abcd-18c5-45e8-88de-123456789abc") ~= nil then

View File

@ -30,7 +30,7 @@ if mt.getreply(conn) ~= SMFIR_CONTINUE then
end
-- HEADER
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@chillout2k.de>') ~= nil then
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "x-mS-EXCHANGE-crosstenant-id", "1234abcd-18c5-45e8-88de-123456789abc") ~= nil then
@ -42,7 +42,7 @@ end
if mt.header(conn, "X-MS-Exchange-CrossTenant-Id", "4321abcd-18c5-45e8-88de-blahblubb") ~= nil then
error "mt.header(tenant-id fail) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=chillout2k.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(DKIM-AR) failed"
end
if mt.header(conn, "X-ExOTA-Authentication-Results", "my-auth-serv-id;\n exota=pass") ~= nil then

View File

@ -0,0 +1,82 @@
-- 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
if mt.conninfo(conn, "localhost", "::1") ~= nil then
error "mt.conninfo() failed"
end
mt.set_timeout(60)
-- 5321.FROM
if mt.mailfrom(conn, "envelope.sender@example.org") ~= 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", "4CgSNs5Q9sz7SllQ", '{cert_subject}', "mail.protection.outlook.comx")
if mt.rcptto(conn, "<envelope.recipient@example.com>") ~= nil then
error "mt.rcptto() failed"
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
error "mt.rcptto() unexpected reply"
end
-- HEADER
if mt.header(conn, "fRoM", '"Blah Blubb" <O365ConnectorValidation@staging.zwackl.de>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "resent-fRoM", '"Blah Blubb" <blah@yad.onmicrosoft.COM>') ~= nil then
error "mt.header(From) failed"
end
if mt.header(conn, "Authentication-Results", "another-wrong-auth-serv-id;\n dkim=fail header.d=yad.onmicrosoft.com header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-Results", "wrong-auth-serv-id;\n dkim=pass header.d=yad.onmicrosoft.com header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-Results", "my-auth-serv-id;\n exota=pass") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=yad.onmicrosoft.comx header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=staging.zwackl.de header.s=selector1-yad-onmicrosoft-com header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-Results", "my-auth-serv-id;\n dkim=fail header.d=yad.onmicrosoft.com header.s=selector2-asdf header.b=mmmjFpv8") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "Authentication-Results", "some-validating-host;\n dkim=pass header.d=paypal.de header.s=pp-dkim1 header.b=PmTtUzer;\n dmarc=pass (policy=reject) header.from=paypal.de;\n spf=pass (some-validating-host: domain of service@paypal.de designates 173.0.84.226 as permitted sender) smtp.mailfrom=service@paypal.de") ~= nil then
error "mt.header(Subject) failed"
end
if mt.header(conn, "X-ExOTA-Authentication-Results", "my-auth-serv-id;\n exota=pass") ~= nil then
error "mt.header(Subject) failed"
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-ExOTA-Authentication-Results") then
mt.echo("no header added")
else
mt.echo("X-ExOTA-Authentication-Results header added")
end
-- DISCONNECT
mt.disconnect(conn)

View File

@ -7,5 +7,10 @@
"example.com": {
"tenant_id": "abcd1234-18c5-45e8-88de-987654321cba",
"dkim_enabled": false
},
"staging.zwackl.de": {
"tenant_id": "",
"dkim_enabled": true,
"dkim_alignment_required": true
}
}

BIN
use-case.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

1
use-case.xml Normal file
View File

@ -0,0 +1 @@
<mxfile host="app.diagrams.net" modified="2022-06-06T13:43:35.273Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36 Edg/101.0.1210.53" etag="VvPNUPgHryv2Mcg-PFl7" version="19.0.0" type="device"><diagram id="JJzjw4aGYAxIZ4NaUIE7" name="Page-1">7Vxtc5s4EP41nul9iMdIgOFjnJdeesklc+ldm37JYJBtXTBikJzY/fUngYRBwrGTgJ1em8wk1iIE7D67z+6ipAdP5suPWZDOrkiE4h4YRMsePO0B4HsW/ykEq0Lg2F4hmGY4KkTWWnCLvyMpHEjpAkeI1iYyQmKG07owJEmCQlaTBVlGnurTJiSuXzUNpsgQ3IZBbEq/4IjNpNRy/fWB3xGezuSlPTAsDswDNVk+CZ0FEXmqiOBZD55khLDi03x5gmKhO6WX4rzzDUfLG8tQwnY5YeUsk+TbRfTXn99nR9eA3YWfj4+Umh+DeCGf+CJhKEsQ6wE3mKc9OErGVPzqifV/id4mcmNuq9E445+mLJfk0GArhTduMw5tPhhxvKRCGMZkwU04epphhm7TIBTCJ+5sXDZj85iPrHKlKhwkQh5RxtCyIpLw+IjIHLFsxafIow4ozpCuClwJjqc18KFy31kF85avHE4627Rceo1H/kFC8gXwBAY6r3CYEUomHJ4D6DrC83P9NGj2/Uveke2twaBufNP29nBg2h7Yg45s7xu2NxSm1ISWQp8RWYzzI0IlGVkkEYrkqHv9Adup6c839ccdrMF3gN2R/mzbBFjEuU0OE5Kgmp4GfEQyNiNTkgTxJSGp1M6/iLGVZOZgwUhdd1xl2eprdXAnFus7ani6lIsXo5UcURZk7FgwdI7zgFIcKvE5jreahpJFFqLnqE3ihS84Rew5RcmQJrTzrKkzFAcMP9Yzg9btpu77WeA/IBbOpCZTggVlnz1yFVGptjLZEBOigM5KE1dMR1lGHtAJiUm2BsSE616JegA6A/HN5XEwRvENoZhhkgiTIXFRfkD4COYJ06U2YUwYI/PKhOMYT8UBJpA1IgsW44RfXeVt4iKBnFIurjx8vpyK9LJPJhMcon6EHvkv2o+DtFitBQ924bDmwRbwDRd2GtgPduXAFtwOhICmhfYmeClMPEpRhvnlhfJOJZ/crEVV6zfpegNmKI+cOJl+zkMC5AI8z9Nm9fsUz6f8EWM85j+DUPjIfYQzfmdE6OE8jBeUX+Oeoozbo08fpy0FXatOWn6DxRo4y+mKsiznh+Is6Np1zm8gLQe4DaTvwa4SPne7Bqsstiass7W0RVIreWs3UkNLzEou5J8rZ/HR+iQxUOe8geGGOzKc5bfNcPmpnL2DVWVCTkS0svKNEFTqC1jHGxxaVcBsnW9DTwNYcQdruJWP8gYE7pB3dppHvRBye4APeGcJ0tCw0FWA44JczDLrA+pP+0JFhDLOkr8Z1tx72PU1N7AtI+xaoIG33K54C+zAW11gvvsawN8V4uB9QdwMQmfLa37gCsesEeRcuTOuHp5ni/T7jSDXaoAoQN4kNAoGfsQNPTSetNR9cuvJnD3wDu0WZiV2Q2Icrkz9j4PwASUNbagPl6fHN/zqn26v/zStUvZ5VrwWilAGt9tmXBjyclwK+KWnuXmvi5JKOVfhdZZzKIMOtewc+tAwqNdgTx90ZE/bP2wuuebyuxqVb8glxahSu+2na6LaUT9axFT3vYOvHnMTzfMwKTLImwsDFPspqickYRXPs8FwOBqpRsuodOrKlEn+JU+VeLPAS+tzPJ6LKj1NY0kWVAxLndyL12u0vRrdU6eoKOCY2Y7dEAU66yurbLaClNM/Lq6Kh8IT/FwOeZ2iREw+fBKps+Xhk0hovkq849GGSxZU6PQ8XFAOMvHxVzez1s0U+qH9Oa9h7nNVtcS+sAYQS/WVt7QzHb8rfJhFRpAQnrUKiIjiTXR0N7teyGMe18YRDQMet08E8yRhtkqLOC7G/X7//fmlPXQP7ZdmuXzYNOhHaKm13CkzW1uW1tqytaS3uFN51nM9Mg1vzkDz3yKRMxbad+9O3VenvTtoUjvMogIKTDwtEkE2DzfhIsO5SEYeMSkjjzjKw9GHVcFcIZmnQcJv6dyMLKzo4L+EgaTI4AWdU+Y4inKva4pXdU/MU0F5Uw1bgl4cv2xtE4hjm0UbhA3vBGBX8cs2eYOzbV4Gm2wht4ERM8nYe5fPqyvSck0iaNpQ0VneC81uxi1fWlxK0bCuzbW7mD6yiac/inMaOh0/eY5XdKdpn8ej+zwa3ef5nmxat7OFZ6hxiuoi1vbw7DHjs83M42wZzoJkKqBEkrxZ9T7DagvmABozN3S/yk1X+wmkJjtKB9d9mddpmWgcHDqG6iq0wIFjqGPWuH/nxe0g5SlxEB9cY65f1xhQMeFg5Ydq9xyo2uhmV5oqStaFSNnR7aAo2bknq+DZdfXiQas/4F/AHwLXgV690wA9fhTatgeGjueCwbC+/K6VzdB4HwTzi6ov7a431DltlRYqLd7bLoCXvSx463uBdwOtoaOFfH/wOvhALRJa+q6vrgFj7t5bly2DDcTLskCki//fpMiGGj/ZDfy016zIMV8c4eRnN5OlpxF2Qxdzv2bapYuZRGYIrBijrqZXv5dt5H6VFbTN/ar7sX2P3546l9DWM0xtL+iuAdretlDXAdoshwxElcU8orR4o1qBU2M/wLT083De7pLVvyBp8DjVSH6jWY+0zRpHWtZGJhOKurHDLhsuW/Fsa4tnv95LVW/tAH9rssG5QP2PGYDlvtJLtT3i5ev7PXmpa1bce/RSBc1fXupaO9ih2uNt1Lu254V/n4t7GE2zIMJofUxmRcb2tPIEredb7oypNY5f2ec1crHqzkht50319UveDta3Dunt4OCJ2v0MFe53EYr7GfFh8UmbtcgTvhbSOaj30WyzM6xCWC2bA2/P5pKRd5Syh6/frq2nxe23x3/ST1+OdgDTC0P+K5Ky7W2oxljd8Wb7Quftp23a6yjb9V5JCNsWaumFs+3Uicd2av/e4M0vkBtxCbojG8Nb6zja7CYH4xpYN/SRZudWuKbxsc3OCc33Vp9nKETigcR/18AxNWzznurvLfbesotI74I1bN5y2ym/hVrL/0FSWHD9j1zg2X8=</diagram></mxfile>