Add LMTP server
This commit is contained in:
parent
6520ba29c5
commit
254cc4b1f4
8 changed files with 153 additions and 23 deletions
51
README.md
51
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=<uid>` 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
|
||||
|
|
|
@ -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!
|
|
@ -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),
|
||||
)
|
||||
|
|
54
easywks/lmtpd.py
Normal file
54
easywks/lmtpd.py
Normal 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()
|
|
@ -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:])
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
1
setup.py
1
setup.py
|
@ -14,6 +14,7 @@ setup(
|
|||
url='https://gitlab.com/s3lph/easywks',
|
||||
packages=find_packages(exclude=['*.test']),
|
||||
install_requires=[
|
||||
'aiosmtpd',
|
||||
'bottle',
|
||||
'PyYAML',
|
||||
'PGPy',
|
||||
|
|
Loading…
Reference in a new issue