Add LMTP server

This commit is contained in:
s3lph 2021-09-26 17:11:05 +02:00
parent 6520ba29c5
commit 254cc4b1f4
8 changed files with 153 additions and 23 deletions

View file

@ -90,7 +90,7 @@ mailing_method: smtp
# Configure smtp client options # Configure smtp client options
smtp: smtp:
# Connect to this SMTP server to send a mail. # Connect to this SMTP server to send a mail.
hostname: localhost host: localhost
port: 25 port: 25
# if tls=True, starttls is ignored # if tls=True, starttls is ignored
tls: false tls: false
@ -98,13 +98,17 @@ smtp:
# Omit username/password if authentication is not needed. # Omit username/password if authentication is not needed.
username: webkey username: webkey
password: SuperS3curePassword123 password: SuperS3curePassword123
# Configure the LMTP server
lmtp:
host: "::1"
port: 8024
# Every domain served by EasyWKS must be listed here # Every domain served by EasyWKS must be listed here
domains: domains:
example.org: example.org:
# Users send their requests to this address. It's up to # Users send their requests to this address. It's up to
# you to make sure that the mails sent their get handed # you to make sure that the mails sent their get handed
# to EasyWKS. # to EasyWKS.
submission_address: webkey@example.org submission_address: webkey@example.com
# If you want the PGP key for this domain to be # If you want the PGP key for this domain to be
# password-protected, or if you're supplying your own # password-protected, or if you're supplying your own
# password-protected key, set the passphrase here: # password-protected key, set the passphrase here:
@ -135,7 +139,7 @@ There are generally two ways to get WKD working:
```unit file (systemd) ```unit file (systemd)
[Unit] [Unit]
Description=OpenPGP WKS for Human Beings Description=OpenPGP WKS for Human Beings - HTTP Server
[Service] [Service]
Type=simple Type=simple
@ -162,6 +166,10 @@ Note that the webserver built into EasyWKS validates that the `?l=<uid>` matches
#### Postfix #### 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`: 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 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. 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 [wkd]: https://wiki.gnupg.org/WKD
[wks]: https://wiki.gnupg.org/WKS [wks]: https://wiki.gnupg.org/WKS

View file

@ -1,5 +1,4 @@
# EasyWKS Roadmap # 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. - [ ] Figure out whether file locking in the working directory is necessary to avoid races.
- [ ] Testing, testing, testing! - [ ] Testing, testing, testing!

View file

@ -36,6 +36,15 @@ def _validate_smtp_config(value):
value['password'] = None 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): def _validate_policy_flags(value):
alphabet = string.ascii_lowercase + string.digits + '-._' alphabet = string.ascii_lowercase + string.digits + '-._'
if not isinstance(value, dict): if not isinstance(value, dict):
@ -128,4 +137,8 @@ Config = _GlobalConfig(
'tls': False, 'tls': False,
'starttls': False 'starttls': False
}, validator=_validate_smtp_config), }, validator=_validate_smtp_config),
lmtpd=_ConfigOption('lmtpd', dict, {
'host': 'localhost',
'port': 25,
}, validator=_validate_lmtpd_config),
) )

54
easywks/lmtpd.py Normal file
View file

@ -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()

View file

@ -3,6 +3,7 @@ from .config import Config
from .files import init_working_directory, clean_stale_requests from .files import init_working_directory, clean_stale_requests
from .process import process_mail from .process import process_mail
from .server import run_server from .server import run_server
from .lmtpd import run_lmtpd
import sys 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') 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) 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.') 'Also called automatically by the other commands.')
init.set_defaults(fn=None) init.set_defaults(fn=None)
@ -22,12 +23,15 @@ def parse_arguments():
clean.set_defaults(fn=clean_stale_requests) 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. ' 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) 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 = 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.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:]) return ap.parse_args(sys.argv[1:])

View file

@ -6,8 +6,7 @@ from .mailing import get_mailing_method
from .util import xloop_header from .util import xloop_header
from .config import Config from .config import Config
from .files import read_pending_key, write_public_key, remove_pending_key, write_pending_key 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.message import MIMEPart, Message
from email.parser import BytesParser from email.parser import BytesParser
@ -39,10 +38,10 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
continue continue
if p.is_encrypted: if p.is_encrypted:
if pgp is not None: if pgp is not None:
raise ValueError('More than one encrypted message part') raise EasyWksError('More than one encrypted message part')
pgp = p pgp = p
if pgp is None: if pgp is None:
raise ValueError('No encrypted message part') raise EasyWksError('No encrypted message part')
return pgp return pgp
@ -53,12 +52,12 @@ def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
key, _ = PGPKey.from_blob(part.get_content()) key, _ = PGPKey.from_blob(part.get_content())
if key.is_public: if key.is_public:
if pubkey: if pubkey:
raise ValueError('More than one key in message') raise EasyWksError('More than one PGP public key in message')
pubkey = key pubkey = key
except PGPError: except PGPError:
pass pass
if not pubkey: if not pubkey:
raise ValueError('No pubkey in message') raise EasyWksError('No PGP public key pubkey in message')
return pubkey return pubkey
@ -68,7 +67,7 @@ def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str):
pubkey = _get_pgp_publickey(leafs) pubkey = _get_pgp_publickey(leafs)
sender_uid: PGPUID = pubkey.get_uid(sender) sender_uid: PGPUID = pubkey.get_uid(sender)
if sender_uid is None or sender_uid.email != 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) return SubmissionRequest(sender, submission, pubkey)
@ -92,7 +91,7 @@ def _find_confirmation_response(parts: List[MIMEPart]) -> str:
response = c response = c
continue continue
if not response: if not response:
raise ValueError('No confirmation response found in message') raise EasyWksError('No confirmation response found in message')
return response return response
@ -106,10 +105,12 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
continue continue
key, value = line.split(':', 1) key, value = line.split(':', 1)
rdict[key.strip()] = value.strip() rdict[key.strip()] = value.strip()
if 'sender' not in rdict or 'nonce' not in rdict or rdict.get('type', '') != 'confirmation-response': if rdict.get('type', '') != 'confirmation-response':
raise ValueError('Message is not a valid 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: 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) return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
@ -118,19 +119,22 @@ def process_mail(mail: bytes = None):
mail = sys.stdin.read().encode() mail = sys.stdin.read().encode()
msg: Message = BytesParser(policy=default).parsebytes(mail) msg: Message = BytesParser(policy=default).parsebytes(mail)
_, sender_mail = getaddresses([msg['from']])[0] _, 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: 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: if msg.get('x-loop', '') == xloop_header(sender_domain) or 'auto-submitted' in msg:
# Mail has somehow looped back to us, discard # Mail has somehow looped back to us, discard
return return
submission_address: str = Config[sender_domain].submission_address submission_address: str = Config[sender_domain].submission_address
rcpt = getaddresses(msg.get_all('to', []) + msg.get_all('cc', [])) rcpt = getaddresses(msg.get_all('to', []) + msg.get_all('cc', []))
if len(rcpt) != 1: if len(rcpt) != 1:
raise ValueError('Message has more than one recipients') raise EasyWksError('Message has more than one recipients')
_, rcpt_mail = rcpt[0] _, rcpt_mail = rcpt[0]
if rcpt_mail != submission_address: 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) leafs = _get_mime_leafs(msg)
pgp: PGPMessage = _get_pgp_message(leafs) pgp: PGPMessage = _get_pgp_message(leafs)
decrypted = pgp_decrypt(sender_domain, pgp) decrypted = pgp_decrypt(sender_domain, pgp)

View file

@ -157,12 +157,12 @@ class ConfirmationResponse:
def verify_signature(self, key: PGPKey) -> 'PublishResponse': def verify_signature(self, key: PGPKey) -> 'PublishResponse':
uid: PGPUID = key.get_uid(self._submitter_addr) uid: PGPUID = key.get_uid(self._submitter_addr)
if uid is None or uid.email != 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) verification: SignatureVerification = key.verify(self._msg)
for verified, by, sig, subject in verification.good_signatures: for verified, by, sig, subject in verification.good_signatures:
if fingerprint(key) == fingerprint(by): if fingerprint(key) == fingerprint(by):
return PublishResponse(self._submitter_addr, self._submission_addr, key) 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: class PublishResponse:
@ -229,3 +229,13 @@ Encrypt live everybody is.
email['X-Loop'] = xloop_header(self.domain) email['X-Loop'] = xloop_header(self.domain)
email['Auto-Submitted'] = 'auto-replied' email['Auto-Submitted'] = 'auto-replied'
return email return email
class EasyWksError(BaseException):
def __init__(self, msg):
super().__init__()
self._msg = msg
def __str__(self) -> str:
return self._msg

View file

@ -14,6 +14,7 @@ setup(
url='https://gitlab.com/s3lph/easywks', url='https://gitlab.com/s3lph/easywks',
packages=find_packages(exclude=['*.test']), packages=find_packages(exclude=['*.test']),
install_requires=[ install_requires=[
'aiosmtpd',
'bottle', 'bottle',
'PyYAML', 'PyYAML',
'PGPy', 'PGPy',