From f263ad8b2ac6b0a1820faa8f4141167d4466caab Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 29 Nov 2023 11:09:26 +0100 Subject: [PATCH] TLS now in production closes #47 --- CHANGELOG.md | 4 + README.md | 33 ++++++++ docker-compose-dev.yml | 3 + docker-compose.yml | 3 + docker/Dockerfile | 2 +- docs/Dev.md | 4 +- python/mailserver.py | 179 ----------------------------------------- python/mailserver3.py | 17 ++-- version.txt | 1 - 9 files changed, 56 insertions(+), 190 deletions(-) delete mode 100755 python/mailserver.py delete mode 100644 version.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e80ea..0216acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 983dffd..4f0f5a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 969dbc5..59d09d2 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -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' diff --git a/docker-compose.yml b/docker-compose.yml index 319528d..d1ee80c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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' diff --git a/docker/Dockerfile b/docker/Dockerfile index a1dd9e2..690ad00 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] diff --git a/docs/Dev.md b/docs/Dev.md index f63059a..579acc4 100644 --- a/docs/Dev.md +++ b/docs/Dev.md @@ -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 ``` diff --git a/python/mailserver.py b/python/mailserver.py deleted file mode 100755 index 3b0c8a7..0000000 --- a/python/mailserver.py +++ /dev/null @@ -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() diff --git a/python/mailserver3.py b/python/mailserver3.py index 17235d3..f0deaa4 100644 --- a/python/mailserver3.py +++ b/python/mailserver3.py @@ -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)) diff --git a/version.txt b/version.txt deleted file mode 100644 index 9001211..0000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -dev \ No newline at end of file