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
|
# 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
|
||||||
|
|
|
@ -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!
|
|
@ -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
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 .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:])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue