TLS now in production closes #47

This commit is contained in:
Chris 2023-11-29 11:09:26 +01:00
parent bf05b15bca
commit f263ad8b2a
9 changed files with 56 additions and 190 deletions

View file

@ -1,5 +1,9 @@
# Changelog
## V1.3.0
- Added TLS and STARTTLS support
- Various bug fixes and docs updates
## V1.2.6
- Fixed link to raw email in RSS template
- Added version string to branding part of the nav

View file

@ -67,6 +67,9 @@ Just edit the `config.ini` You can use the following settings
- `PASSWORD` -> If configured, site and API can't be used without providing it via form, POST/GET variable `password` or http header `PWD` (eg: `curl -H "PWD: 123456" http://localhost:8080/json...`)
- `ALLOWED_IPS` -> Comma separated list of IPv4 or IPv6 CIDR addresses that are allowed to use the web UI or API
- `ATTACHMENTS_MAX_SIZE` -> Max size for each individual attachment of an email in Bytes
- `MAILPORT_TLS` -> If set to something higher than 0, this port will be used for TLSC (TLS on Connect). Which means plaintext auth will not be possible. Usually set to `465`. Needs `TLS_CERTIFICATE` and `TLS_PRIVATE_KEY` to work
- `TLS_CERTIFICATE` -> Path to the certificate (chain). Can be relative to the /python directory or absolute
- `TLS_PRIVATE_KEY` -> Path to the private key of the certificate. Can be relative to the /python directory or absolute
## Docker env vars
In Docker you can use the following environment variables:
@ -83,6 +86,36 @@ In Docker you can use the following environment variables:
| PASSWORD | If configured, site and API can't be used without providing it via form, POST/GET variable `password` or http header `PWD` | yousrstrongpassword |
| ALLOWED_IPS | Comma separated list of IPv4 or IPv6 CIDR addresses that are allowed to use the web UI or API | `192.168.5.0/24,2a02:ab:cd:ef::/60,172.16.0.0/16` |
| ATTACHMENTS_MAX_SIZE | Max size for each individual attachment of an email in Bytes | `2000000` = 2MB |
| MAILPORT_TLS | If set to something higher than 0, this port will be used for TLSC (TLS on Connect). Which means plaintext auth will not be possible. Usually set to `465`. Needs `TLS_CERTIFICATE` and `TLS_PRIVATE_KEY` to work | `465` |
| TLS_CERTIFICATE | Path to the certificate (chain). Can be relative to the /python directory or absolute | `/certs/cert.pem` or `cert.pem` if it's inside the python directory |
| TLS_PRIVATE_KEY | Path to the private key of the certificate. Can be relative to the /python directory or absolute | `/certs/privkey.pem` or `key.pem` if it's inside the python directory |
## TLS
Since v1.3.0 TLS and STARTTLS are supported by OpenTrashmail.
### What you should know
Be aware there are two ways to use TLS with email
1. STARTTLS
2. TLS on Connect (TLSC)
**STARTTLS** does not require a specific port as it starts out as plaintext and then upgrades to TLS if the server advertises the "STARTTLS" command (which OpenTrashmail does automatically if the Certificate and key settings are configured). Since it's run on the default `MAILPORT` you don't need to open other ports for it to work.
**TLS on connect** is wrapping TLS around the exposed ports so it's not possible to talk to it in plaintext and therefore it needs a different port to work. Usually port 465 is used for this.
### About the certificates
For TLS to work you first need a certificate that corresponds with the hostname of the SMTP server. This can be done using Lets'encrypt and even works with wildcard certificates.
For testing environments you can create a certificate by running the following command from inside the python folder:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
```
You then need to set the settings for `MAILPORT_TLS` (not needed if you only want to support STARTTLS), `TLS_CERTIFICATE` and `TLS_PRIVATE_KEY`.
### Testing TLS
The [/docs/Dev.md](/docs/Dev.md) file contains a few hints on how to debug and test TLS and TLSC connections. It uses the tool `swaks` which should be avaialable in every package manager.
# Roadmap
- [x] Mail server

View file

@ -18,6 +18,9 @@ services:
# - PASSWORD=123456
# - ALLOWED_IPS=192.168.0.0/16,2a02:ab:cd:ef::/60
# - ATTACHMENTS_MAX_SIZE=10000000
# - MAILPORT_TLS=465
# - TLS_CERTIFICATE=cert.pem
# - TLS_PRIVATE_KEY=key.pem
ports:
- '2525:25'

View file

@ -14,6 +14,9 @@ services:
# - PASSWORD=123456
# - ALLOWED_IPS=192.168.0.0/16,2a02:ab:cd:ef::/60
# - ATTACHMENTS_MAX_SIZE=10000000
# - MAILPORT_TLS=465
# - TLS_CERTIFICATE=cert.pem
# - TLS_PRIVATE_KEY=key.pem
ports:
- '2525:25'

View file

@ -25,7 +25,7 @@ WORKDIR /var/www/opentrashmail
VOLUME /var/www/opentrashmail/data
VOLUME /var/www/opentrashmail/logs
EXPOSE 80 25
EXPOSE 80 25 465
#CMD ["/bin/ash"]
ENTRYPOINT ["/etc/start.sh"]

View file

@ -37,7 +37,7 @@ Testing with the TLS version (non-plaintext).
Needs config options `MAILPORT_TLS`, `TLS_CERTIFICATE` and `TLS_PRIVATE_KEY` set.
```bash
echo 'Testing' | swaks --to test@example.com --from "something@example.com" --server localhost --port 465 -tlsc
echo 'Testing' | swaks --to test@example.com --from "something@example.com" --server localhost --port 2525 -tlsc
```
### Via STARTTLS
@ -47,5 +47,5 @@ Needs config options `TLS_CERTIFICATE` and `TLS_PRIVATE_KEY` set.
Testing STARTTLS version
```bash
echo 'Testing' | swaks --to test@example.com --from "something@example.com" --server localhost -tlsc
echo 'Testing' | swaks --to test@example.com --from "something@example.com" --server localhost --port 465 -tlsc
```

View file

@ -1,179 +0,0 @@
import smtpd
import asyncore
import logging
import email
from email.header import decode_header
from email.Utils import parseaddr
import re
#import requests
import ConfigParser
import time
import os, sys
import json
logger = logging.getLogger(__name__)
# globals for settings
DISCARD_UNKNOWN = False
DELETE_OLDER_THAN_DAYS = False
DOMAINS = []
LAST_CLEANUP = 0
def cleanup():
if(DELETE_OLDER_THAN_DAYS == False or time.time() - LAST_CLEANUP < 86400):
return
logger.info("Cleaning up")
rootdir = '../data/'
for subdir, dirs, files in os.walk(rootdir):
for file in files:
if(file.endswith(".json")):
filepath = os.path.join(subdir, file)
file_modified = os.path.getmtime(filepath)
if(time.time() - file_modified > (DELETE_OLDER_THAN_DAYS * 86400)):
os.remove(filepath)
logger.info("Deleted file: " + filepath)
class CustomSMTPServer(smtpd.SMTPServer):
def process_message(self, peer, mailfrom, rcpttos, data):
try:
mailfrom = parseaddr(mailfrom)[1]
logger.debug('Receiving message from: %s:%d' % peer)
logger.debug('Message addressed from: %s' % mailfrom)
logger.debug('Message addressed to: %s' % str(rcpttos))
msg = email.message_from_string(data)
subject = ''
for encoded_string, charset in decode_header(msg.get('Subject')):
try:
if charset is not None:
subject += encoded_string.decode(charset)
else:
subject += encoded_string
except:
logger.exception('Error reading part of subject: %s charset %s' %
(encoded_string, charset))
logger.debug('Subject: %s' % subject)
text_parts = []
html_parts = []
attachments = {}
#logger.debug('Headers: %s' % msg.items())
# YOU CAN DO SOME SECURITY CONTROLS HERE
#if (not mailfrom.endswith("@hankenfeld.at") or
# not msg.get('Mail-Header') == 'expected value'):
# raise Exception("Email not trusted")
# loop on the email parts
for part in msg.walk():
if part.get_content_maintype() == 'multipart':
continue
c_type = part.get_content_type()
c_disp = part.get('Content-Disposition')
# text parts will be appended to text_parts
if c_type == 'text/plain' and c_disp == None:
text_parts.append(part.get_payload(decode=True).strip())
# ignore html part
elif c_type == 'text/html':
html_parts.append(part.get_payload(decode=True).strip())
# attachments will be sent as files in the POST request
else:
filename = part.get_filename()
filecontent = part.get_payload(decode=True)
if filecontent is not None:
if filename is None:
filename = 'untitled'
attachments['file%d' % len(attachments)] = (filename,
filecontent)
body = '\n'.join(text_parts)
htmlbody = '\n'.join(html_parts)
except:
logger.exception('Error reading incoming email')
else:
edata = {
'subject': subject,
'body': body,
'htmlbody': htmlbody,
'from': mailfrom,
'attachments':[]
}
savedata = {'sender_ip':peer[0],'from':mailfrom,'rcpts':rcpttos,'raw':data,'parsed':edata}
filenamebase = str(int(round(time.time() * 1000)))
for em in rcpttos:
em = em.lower()
if not re.match(r"[^@\s]+@[^@\s]+\.[a-zA-Z0-9]+$", em):
logger.exception('Invalid recipient: %s' % em)
continue
domain = em.split('@')[1]
found = False
for x in DOMAINS:
if "*" in x and domain.endswith(x.replace('*', '')):
found = True
elif domain == x:
found = True
if(DISCARD_UNKNOWN and found==False):
logger.info('Discarding email for unknown domain: %s' % domain)
continue
if not os.path.exists("../data/"+em):
os.mkdir( "../data/"+em, 0o755 )
#same attachments if any
for att in attachments:
if not os.path.exists("../data/"+em+"/attachments"):
os.mkdir( "../data/"+em+"/attachments", 0o755 )
attd = attachments[att]
file = open("../data/"+em+"/attachments/"+filenamebase+"-"+attd[0], 'wb')
file.write(attd[1])
file.close()
edata["attachments"].append(filenamebase+"-"+attd[0])
# save actual json data
with open("../data/"+em+"/"+filenamebase+".json", "w") as outfile:
json.dump(savedata, outfile)
#print edata
cleanup()
return
if __name__ == '__main__':
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
logger.setLevel(logging.DEBUG)
logger.addHandler(ch)
if not os.path.isfile("../config.ini"):
print "[ERR] Config.ini not found. Rename example.config.ini to config.ini. Defaulting to port 25"
port = 25
else :
Config = ConfigParser.ConfigParser(allow_no_value=True)
Config.read("../config.ini")
port = int(Config.get("MAILSERVER","MAILPORT"))
if("discard_unknown" in Config.options("MAILSERVER")):
DISCARD_UNKNOWN = (Config.get("MAILSERVER","DISCARD_UNKNOWN").lower() == "true")
DOMAINS = Config.get("GENERAL","DOMAINS").lower().split(",")
if("CLEANUP" in Config.sections() and "delete_older_than_days" in Config.options("CLEANUP")):
DELETE_OLDER_THAN_DAYS = (Config.get("CLEANUP","DELETE_OLDER_THAN_DAYS").lower() == "true")
print "[i] Starting Mailserver on port",port
print "[i] Discard unknown domains:",DISCARD_UNKNOWN
print "[i] Listening for domains:",DOMAINS
server = CustomSMTPServer(('0.0.0.0', port), None) # use your public IP here
print "[i] Ready to receive Emails"
print ""
asyncore.loop()

View file

@ -28,15 +28,18 @@ TLS_CERTIFICATE = ""
TLS_PRIVATE_KEY = ""
class CustomHandler:
connection_type = ''
def __init__(self,conntype='Plaintext'):
self.connection_type = conntype
async def handle_DATA(self, server, session, envelope):
peer = session.peer
rcpts = []
for rcpt in envelope.rcpt_tos:
rcpts.append(rcpt)
if(server.tls_context != None):
logger.debug('Receiving message from: %s:%d (STARTTLS)' % peer)
else:
logger.debug('Receiving message from: %s:%d (Plaintext (or TLS))' % peer)
logger.debug('Receiving message from: %s (%s)', peer,self.connection_type)
logger.debug('Message addressed from: %s' % envelope.mail_from)
logger.debug('Message addressed to: %s' % str(rcpts))
@ -187,16 +190,16 @@ async def run(port):
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(TLS_CERTIFICATE, TLS_PRIVATE_KEY)
if MAILPORT_TLS > 0:
controller_tls = Controller(CustomHandler(), hostname='0.0.0.0', port=MAILPORT_TLS, ssl_context=context)
controller_tls = Controller(CustomHandler("TLS"), hostname='0.0.0.0', port=MAILPORT_TLS, ssl_context=context)
controller_tls.start()
controller_plaintext = Controller(CustomHandler(), hostname='0.0.0.0', port=port,tls_context=context)
controller_plaintext = Controller(CustomHandler("Plaintext or STARTTLS"), hostname='0.0.0.0', port=port,tls_context=context)
controller_plaintext.start()
logger.info("[i] Starting TLS only Mailserver on port " + str(MAILPORT_TLS))
logger.info("[i] Starting plaintext Mailserver (with STARTTLS support) on port " + str(port))
else:
controller_plaintext = Controller(CustomHandler(), hostname='0.0.0.0', port=port)
controller_plaintext = Controller(CustomHandler("Plaintext"), hostname='0.0.0.0', port=port)
controller_plaintext.start()
logger.info("[i] Starting plaintext Mailserver on port " + str(port))

View file

@ -1 +0,0 @@
dev