From 254cc4b1f49e120751d0443a52527f6c371cf3cc Mon Sep 17 00:00:00 2001 From: s3lph Date: Sun, 26 Sep 2021 17:11:05 +0200 Subject: [PATCH] Add LMTP server --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++--- ROADMAP.md | 1 - easywks/config.py | 13 +++++++++++ easywks/lmtpd.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++ easywks/main.py | 8 +++++-- easywks/process.py | 34 ++++++++++++++++------------- easywks/types.py | 14 ++++++++++-- setup.py | 1 + 8 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 easywks/lmtpd.py diff --git a/README.md b/README.md index 62cf95a..7fc1cfb 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ mailing_method: smtp # Configure smtp client options smtp: # Connect to this SMTP server to send a mail. - hostname: localhost + host: localhost port: 25 # if tls=True, starttls is ignored tls: false @@ -98,13 +98,17 @@ smtp: # Omit username/password if authentication is not needed. username: webkey password: SuperS3curePassword123 +# Configure the LMTP server +lmtp: + host: "::1" + port: 8024 # Every domain served by EasyWKS must be listed here domains: example.org: # Users send their requests to this address. It's up to # you to make sure that the mails sent their get handed # to EasyWKS. - submission_address: webkey@example.org + submission_address: webkey@example.com # If you want the PGP key for this domain to be # password-protected, or if you're supplying your own # password-protected key, set the passphrase here: @@ -135,7 +139,7 @@ There are generally two ways to get WKD working: ```unit file (systemd) [Unit] -Description=OpenPGP WKS for Human Beings +Description=OpenPGP WKS for Human Beings - HTTP Server [Service] Type=simple @@ -162,6 +166,10 @@ Note that the webserver built into EasyWKS validates that the `?l=` matches #### Postfix +You can either connect EasyWKS using a pipe transport or as a LMTP server. + +##### Pipe Transport + Add an entry in `/etc/postfix/master.cf`: ``` @@ -169,8 +177,45 @@ webkey unix - n n - - pipe flags=DRhu user=webkey argv=/path/to/easywks process ``` +Then tell postfix to forward mails to the submission addresses to said transport, e.g. in `/etc/postfix/transport`: + +``` +# The colon at the end is important, it lets postfix know +# that "webkey" is a transport, not a name +gpgwks@example.org webkey: +webkey@example.com webkey: +``` + Configure EasyWKS to send outgoing mails via SMTP. +##### LMTP Server + +Configure EasyWKS to run the LMTP server, e.g. using the following systemd unit: + +```unit file (systemd) +[Unit] +Description=OpenPGP WKS for Human Beings - LMTP Server + +[Service] +Type=simple +ExecStart=/path/to/easywks lmtpd +Restart=on-failure +User=webkey +Group=webkey +WorkingDirectory=/var/lib/easywks + +[Install] +WantedBy=multi-user.target +``` + +Also configure EasyWKS to send outgoing mails via SMTP. + +Then tell postfix to forward mails to the submission addresses to said transport, e.g. in `/etc/postfix/transport`: + +``` +gpgwks@example.org lmtp:localhost:10024 +webkey@example.com lmtp:localhost:10024 +``` [wkd]: https://wiki.gnupg.org/WKD [wks]: https://wiki.gnupg.org/WKS diff --git a/ROADMAP.md b/ROADMAP.md index 5e43ba1..62e2ff8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,4 @@ # EasyWKS Roadmap -- [ ] LMTP server mode, e.g. using the `smtpd` module. - [ ] Figure out whether file locking in the working directory is necessary to avoid races. - [ ] Testing, testing, testing! \ No newline at end of file diff --git a/easywks/config.py b/easywks/config.py index 261029e..34b9e63 100644 --- a/easywks/config.py +++ b/easywks/config.py @@ -36,6 +36,15 @@ def _validate_smtp_config(value): value['password'] = None +def _validate_lmtpd_config(value): + if not isinstance(value, dict): + return f'must be a map, got {type(value)}' + if not isinstance(value['host'], str): + return f'host must be a str, got {type(value["host"])}' + if not isinstance(value['port'], int): + return f'port must be a int, got {type(value["port"])}' + + def _validate_policy_flags(value): alphabet = string.ascii_lowercase + string.digits + '-._' if not isinstance(value, dict): @@ -128,4 +137,8 @@ Config = _GlobalConfig( 'tls': False, 'starttls': False }, validator=_validate_smtp_config), + lmtpd=_ConfigOption('lmtpd', dict, { + 'host': 'localhost', + 'port': 25, + }, validator=_validate_lmtpd_config), ) diff --git a/easywks/lmtpd.py b/easywks/lmtpd.py new file mode 100644 index 0000000..1e6e19f --- /dev/null +++ b/easywks/lmtpd.py @@ -0,0 +1,54 @@ + +import asyncio +import traceback + +from aiosmtpd.controller import Controller +from aiosmtpd.lmtp import LMTP + +from . import __version__ as version +from .config import Config +from .process import process_mail +from .types import EasyWksError + + +class LmtpMailServer: + + async def handle_MAIL(self, server, session, envelope, address, mail_options): + _, domain = address.split('@', 1) + if domain not in Config.domains: + return '550 Not accepting mails from this domain' + envelope.mail_from = address + return '250 OK' + + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + for domain in Config.domains: + if Config[domain].submission_address == address: + envelope.rcpt_tos.append(address) + return '250 OK' + return '550 Not responsible for this address' + + async def handle_DATA(self, server, session, envelope): + message = envelope.content + try: + process_mail(message) + except EasyWksError as e: + return f'550 {e}' + except BaseException: + tb = traceback.format_exc() + return f'550 Error during message processing: {tb}' + return '250 Message successfully handled' + + +class LmtpdController(Controller): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def factory(self): + return LMTP(self.handler, ident=f'EasyWKS {version}', **self.SMTP_kwargs) + + +def run_lmtpd(): + controller = LmtpdController(LmtpMailServer(), Config.lmtpd['host'], Config.lmtpd['port']) + controller.start() + asyncio.get_event_loop().run_forever() diff --git a/easywks/main.py b/easywks/main.py index cb7a07c..32b3743 100644 --- a/easywks/main.py +++ b/easywks/main.py @@ -3,6 +3,7 @@ from .config import Config from .files import init_working_directory, clean_stale_requests from .process import process_mail from .server import run_server +from .lmtpd import run_lmtpd import sys @@ -14,7 +15,7 @@ def parse_arguments(): ap.add_argument('--config', '-c', metavar='/path/to/config.yml', type=str, nargs=1, default='/etc/easywks.yaml') sp = ap.add_subparsers(description='EasyWKS understands the following commands:', required=True) - init = sp.add_parser('init', help='Initialize the EasyWKS working directory and generate the PGP Key' + init = sp.add_parser('init', help='Initialize the EasyWKS working directory and generate the PGP Key. ' 'Also called automatically by the other commands.') init.set_defaults(fn=None) @@ -22,12 +23,15 @@ def parse_arguments(): clean.set_defaults(fn=clean_stale_requests) process = sp.add_parser('process', help='Read an incoming mail from stdin and write the response to stdout. ' - 'Hook this up to your MTA.') + 'Hook this up to your MTA. Also see lmtpd.') process.set_defaults(fn=process_mail) server = sp.add_parser('webserver', help='Run a WKD web server. Put this behind a HTTPS-terminating reverse proxy.') server.set_defaults(fn=run_server) + server = sp.add_parser('lmtpd', help='Run a LMTP server to receive mails from your MTA. Also see process.') + server.set_defaults(fn=run_lmtpd) + return ap.parse_args(sys.argv[1:]) diff --git a/easywks/process.py b/easywks/process.py index 20ec04f..676bff7 100644 --- a/easywks/process.py +++ b/easywks/process.py @@ -6,8 +6,7 @@ from .mailing import get_mailing_method from .util import xloop_header from .config import Config from .files import read_pending_key, write_public_key, remove_pending_key, write_pending_key -from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest - +from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError from email.message import MIMEPart, Message from email.parser import BytesParser @@ -39,10 +38,10 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage: continue if p.is_encrypted: if pgp is not None: - raise ValueError('More than one encrypted message part') + raise EasyWksError('More than one encrypted message part') pgp = p if pgp is None: - raise ValueError('No encrypted message part') + raise EasyWksError('No encrypted message part') return pgp @@ -53,12 +52,12 @@ def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey: key, _ = PGPKey.from_blob(part.get_content()) if key.is_public: if pubkey: - raise ValueError('More than one key in message') + raise EasyWksError('More than one PGP public key in message') pubkey = key except PGPError: pass if not pubkey: - raise ValueError('No pubkey in message') + raise EasyWksError('No PGP public key pubkey in message') return pubkey @@ -68,7 +67,7 @@ def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str): pubkey = _get_pgp_publickey(leafs) sender_uid: PGPUID = pubkey.get_uid(sender) if sender_uid is None or sender_uid.email != sender: - raise ValueError(f'Key has no UID that matches {sender}') + raise EasyWksError(f'Key has no UID that matches {sender}') return SubmissionRequest(sender, submission, pubkey) @@ -92,7 +91,7 @@ def _find_confirmation_response(parts: List[MIMEPart]) -> str: response = c continue if not response: - raise ValueError('No confirmation response found in message') + raise EasyWksError('No confirmation response found in message') return response @@ -106,10 +105,12 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str): continue key, value = line.split(':', 1) rdict[key.strip()] = value.strip() - if 'sender' not in rdict or 'nonce' not in rdict or rdict.get('type', '') != 'confirmation-response': - raise ValueError('Message is not a valid confirmation response') + if rdict.get('type', '') != 'confirmation-response': + raise EasyWksError('Invalid confirmation response: "type" missing or not "confirmation-response"') + if 'sender' not in rdict or 'nonce' not in rdict: + raise EasyWksError('Invalid confirmation response: "sender" or "nonce" missing from confirmation response') if rdict['sender'] != sender: - raise ValueError('Confirmation sender does not match message sender') + raise EasyWksError('Confirmation sender does not match message sender') return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp) @@ -118,19 +119,22 @@ def process_mail(mail: bytes = None): mail = sys.stdin.read().encode() msg: Message = BytesParser(policy=default).parsebytes(mail) _, sender_mail = getaddresses([msg['from']])[0] - local, sender_domain = sender_mail.split('@') + try: + local, sender_domain = sender_mail.split('@', 1) + except ValueError: + raise EasyWksError('Sender mail is not a valid mail address') if sender_domain not in Config.domains: - raise KeyError(f'Domain {sender_domain} not supported') + raise EasyWksError(f'Domain {sender_domain} not supported') if msg.get('x-loop', '') == xloop_header(sender_domain) or 'auto-submitted' in msg: # Mail has somehow looped back to us, discard return submission_address: str = Config[sender_domain].submission_address rcpt = getaddresses(msg.get_all('to', []) + msg.get_all('cc', [])) if len(rcpt) != 1: - raise ValueError('Message has more than one recipients') + raise EasyWksError('Message has more than one recipients') _, rcpt_mail = rcpt[0] if rcpt_mail != submission_address: - raise ValueError(f'Message not addressed to submission address {submission_address} for domain {sender_domain}') + raise EasyWksError(f'Message not addressed to submission address {submission_address} for domain {sender_domain}') leafs = _get_mime_leafs(msg) pgp: PGPMessage = _get_pgp_message(leafs) decrypted = pgp_decrypt(sender_domain, pgp) diff --git a/easywks/types.py b/easywks/types.py index 51e0b94..ba76162 100644 --- a/easywks/types.py +++ b/easywks/types.py @@ -157,12 +157,12 @@ class ConfirmationResponse: def verify_signature(self, key: PGPKey) -> 'PublishResponse': uid: PGPUID = key.get_uid(self._submitter_addr) if uid is None or uid.email != self._submitter_addr: - raise ValueError(f'UID {self._submitter_addr} not found in PGP key') + raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key') verification: SignatureVerification = key.verify(self._msg) for verified, by, sig, subject in verification.good_signatures: if fingerprint(key) == fingerprint(by): return PublishResponse(self._submitter_addr, self._submission_addr, key) - raise ValueError('Signature could not be verified') + raise EasyWksError('PGP Signature could not be verified') class PublishResponse: @@ -229,3 +229,13 @@ Encrypt live everybody is. email['X-Loop'] = xloop_header(self.domain) email['Auto-Submitted'] = 'auto-replied' return email + + +class EasyWksError(BaseException): + + def __init__(self, msg): + super().__init__() + self._msg = msg + + def __str__(self) -> str: + return self._msg diff --git a/setup.py b/setup.py index e2b9a6b..4517a8e 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ setup( url='https://gitlab.com/s3lph/easywks', packages=find_packages(exclude=['*.test']), install_requires=[ + 'aiosmtpd', 'bottle', 'PyYAML', 'PGPy',