Skip to content

Commit

Permalink
Fix #22, Refs #21 - Add update processing for e-mail
Browse files Browse the repository at this point in the history
Currently, emails can be delivered to a local Python script, and
will then be parsed, including extraction of PGP keys.
  • Loading branch information
mxsasha committed Aug 2, 2018
1 parent 874c512 commit 376450a
Show file tree
Hide file tree
Showing 19 changed files with 1,209 additions and 43 deletions.
6 changes: 5 additions & 1 deletion irrd/conf/__init__.py
Expand Up @@ -8,12 +8,16 @@
'server.whois.interface': '::0',
'server.whois.port': 8043,
'server.whois.max_connections': 50,
'gnupg.homedir': os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), '../gnupg/'),
'email.from': 'example@example.com',
'email.footer': '',
'email.smtp': 'localhost',
}


def get_setting(setting_name: str) -> Any:
default = DEFAULT_SETTINGS.get(setting_name)
env_key = 'IRRD_' + setting_name.upper()
env_key = 'IRRD_' + setting_name.upper().replace('.', '_')
if env_key in os.environ:
return os.environ[env_key]
return default
Expand Down
2 changes: 1 addition & 1 deletion irrd/rpsl/parser.py
Expand Up @@ -351,7 +351,7 @@ def _update_attribute_value(self, attribute, new_values):
"""
if isinstance(new_values, str):
new_values = [new_values]
self.parsed_data["attribute"] = "\n".join(new_values)
self.parsed_data[attribute] = "\n".join(new_values)

self._object_data = list(filter(lambda a: a[0] != attribute, self._object_data))
insert_idx = 1
Expand Down
8 changes: 3 additions & 5 deletions irrd/rpsl/rpsl_objects.py
Expand Up @@ -3,6 +3,7 @@

import gnupg

from irrd.conf import get_setting
from .config import PASSWORD_HASHERS
from .fields import (RPSLTextField, RPSLIPv4PrefixField, RPSLIPv4PrefixesField, RPSLIPv6PrefixField,
RPSLIPv6PrefixesField, RPSLIPv4AddressRangeField, RPSLASNumberField, RPSLASBlockField,
Expand Down Expand Up @@ -217,7 +218,7 @@ def clean(self) -> bool:
if not super().clean():
return False # pragma: no cover

gpg = gnupg.GPG(gnupghome=self.gpg_dir())
gpg = gnupg.GPG(gnupghome=get_setting('gnupg.homedir'))
certif_data = self.parsed_data.get("certif", "").replace(",", "\n")
result = gpg.import_keys(certif_data)

Expand Down Expand Up @@ -252,13 +253,10 @@ def clean(self) -> bool:
# which key signed a message, which can then be stored and compared to key-cert's later.
# This method will probably be extracted to the update handler.
def verify(self, message: str) -> bool:
gpg = gnupg.GPG(gnupghome=self.gpg_dir())
gpg = gnupg.GPG(gnupghome=get_setting('gnupg.homedir'))
result = gpg.verify(message)
return result.valid and result.key_status is None and result.fingerprint == self.fingerprint

def gpg_dir(self) -> str: # pragma: no cover
return "gnupg"

@staticmethod
def format_fingerprint(fingerprint: str) -> str:
string_parts = []
Expand Down
27 changes: 10 additions & 17 deletions irrd/rpsl/tests/test_rpsl_objects.py
Expand Up @@ -8,29 +8,17 @@
KEY_CERT_SIGNED_MESSAGE_VALID, KEY_CERT_SIGNED_MESSAGE_INVALID,
KEY_CERT_SIGNED_MESSAGE_CORRUPT, KEY_CERT_SIGNED_MESSAGE_WRONG_KEY, TEMPLATE_ROUTE_OBJECT,
TEMPLATE_PERSON_OBJECT)

# noinspection PyUnresolvedReferences
from irrd.utils.test_utils import tmp_gpg_dir # noqa: F401

from ..parser import UnknownRPSLObjectClassException
from ..rpsl_objects import (RPSLAsBlock, RPSLAsSet, RPSLAutNum, RPSLDictionary, RPSLDomain, RPSLFilterSet, RPSLInetRtr,
RPSLInet6Num, RPSLInetnum, RPSLKeyCert, RPSLLimerick, RPSLMntner, RPSLPeeringSet,
RPSLPerson, RPSLRepository, RPSLRole, RPSLRoute, RPSLRouteSet, RPSLRoute6, RPSLRtrSet,
OBJECT_CLASS_MAPPING, rpsl_object_from_text)


@pytest.fixture()
def tmp_gpg_dir(tmpdir, monkeypatch):
"""
Fixture to use a temporary separate gpg dir, to prevent it using your
user's keyring.
NOTE: if the gpg homedir name is very long, this introduces a 5 second
delay in all gpg tests due to gpg incorrectly waiting to find a gpg-agent.
Default tmpdirs on Mac OS X are affected, to prevent this run pytest with:
--basetemp=.tmpdirs
"""
def gpg_dir(self):
return str(tmpdir) + "/gnupg"
monkeypatch.setattr(RPSLKeyCert, "gpg_dir", gpg_dir)


class TestRPSLParsingGeneric:
# Most malformed objects are tested without strict validation, as they should always fail.
def test_unknown_class(self):
Expand Down Expand Up @@ -245,7 +233,7 @@ def test_has_mapping(self):
obj = RPSLKeyCert()
assert OBJECT_CLASS_MAPPING[obj.rpsl_object_class] == obj.__class__

def test_parse_parse(self, tmp_gpg_dir):
def test_parse_parse(self):
rpsl_text = object_sample_mapping[RPSLKeyCert().rpsl_object_class]

# Mangle the fingerprint/owner/method lines to ensure the parser correctly re-generates them
Expand All @@ -257,7 +245,9 @@ def test_parse_parse(self, tmp_gpg_dir):
assert not obj.messages.errors()
assert obj.pk() == "PGPKEY-80F238C6"
assert obj.render_rpsl_text() == rpsl_text
assert obj.parsed_data['fingerpr'] == "8626 1D8D BEBD A4F5 4692 D64D A838 3BA7 80F2 38C6"

@pytest.mark.usefixtures("tmp_gpg_dir") # noqa: F811
def test_parse_incorrect_object_name(self, tmp_gpg_dir):
rpsl_text = object_sample_mapping[RPSLKeyCert().rpsl_object_class]
obj = rpsl_object_from_text(rpsl_text.replace("PGPKEY-80F238C6", "PGPKEY-80F23816"))
Expand All @@ -266,6 +256,7 @@ def test_parse_incorrect_object_name(self, tmp_gpg_dir):
assert len(errors) == 1, f"Unexpected multiple errors: {errors}"
assert "does not match key fingerprint" in errors[0]

@pytest.mark.usefixtures("tmp_gpg_dir") # noqa: F811
def test_parse_missing_key(self, tmp_gpg_dir):
rpsl_text = object_sample_mapping[RPSLKeyCert().rpsl_object_class]
obj = rpsl_object_from_text(rpsl_text.replace("certif:", "remarks:"), strict_validation=True)
Expand All @@ -275,6 +266,7 @@ def test_parse_missing_key(self, tmp_gpg_dir):
assert "Mandatory attribute 'certif' on object key-cert is missing" in errors[0]
assert "No valid data found" in errors[1]

@pytest.mark.usefixtures("tmp_gpg_dir") # noqa: F811
def test_verify(self, tmp_gpg_dir):
rpsl_text = object_sample_mapping[RPSLKeyCert().rpsl_object_class]
obj = rpsl_object_from_text(rpsl_text)
Expand Down Expand Up @@ -305,6 +297,7 @@ def test_parse(self):
assert obj.parsed_data["mnt-by"] == ['AS760-MNT', 'ACONET-LIR-MNT', 'ACONET2-LIR-MNT']
assert obj.render_rpsl_text() == rpsl_text

@pytest.mark.usefixtures("tmp_gpg_dir") # noqa: F811
def test_verify(self, tmp_gpg_dir):
rpsl_text = object_sample_mapping[RPSLMntner().rpsl_object_class]
# Unknown hashes should simply be ignored.
Expand Down
4 changes: 4 additions & 0 deletions irrd/scripts/rpsl_read.py
@@ -1,12 +1,16 @@
#!/usr/bin/env python
# flake8: noqa: E402
"""
This is a helper script to run RPSL data through the parser and, optionally,
insert it into the database.
"""
import argparse
import os
import sys
from typing import Set

sys.path.append(os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), '../'))

from irrd.db.api import DatabaseHandler
from irrd.rpsl.parser import UnknownRPSLObjectClassException
from irrd.rpsl.rpsl_objects import rpsl_object_from_text
Expand Down
17 changes: 17 additions & 0 deletions irrd/scripts/submit_email.py
@@ -0,0 +1,17 @@
#!/usr/bin/env python
# flake8: noqa: E402
import os
import sys

from irrd.updates.email import handle_email_update

sys.path.append(os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), '../'))


def main(data):
handler = handle_email_update(data)
print(handler.user_report())


if __name__ == "__main__":
main(sys.stdin.read())
4 changes: 4 additions & 0 deletions irrd/scripts/submit_update.py
@@ -1,6 +1,10 @@
#!/usr/bin/env python
# flake8: noqa: E402
import os
import sys

sys.path.append(os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), '../'))

from irrd.updates.handler import UpdateRequestHandler


Expand Down
13 changes: 13 additions & 0 deletions irrd/scripts/tests/test_submit_email.py
@@ -0,0 +1,13 @@
from unittest.mock import Mock

from ..submit_email import main


def test_submit_email(capsys, monkeypatch):
mock_update_handler = Mock()
monkeypatch.setattr("irrd.scripts.submit_email.handle_email_update", lambda data: mock_update_handler)
mock_update_handler.user_report = lambda: 'output'

main('test input')
captured = capsys.readouterr().out
assert captured == 'output\n'
111 changes: 111 additions & 0 deletions irrd/updates/email.py
@@ -0,0 +1,111 @@
# flake8: noqa: W293
import email
import logging
import socket
import textwrap
from email.mime.text import MIMEText
from smtplib import SMTP
from typing import Optional

from irrd import __version__
from irrd.conf import get_setting
from irrd.updates.handler import UpdateRequestHandler
from irrd.utils.pgp import validate_pgp_signature

logger = logging.getLogger(__name__)


class EmailUpdateParser:
"""
Parse a raw email.
"""
body: Optional[str] = None
pgp_fingerprint: Optional[str] = None
message_id: Optional[str] = None
message_from: Optional[str] = None
message_date: Optional[str] = None
message_subject: Optional[str] = None
_default_encoding = 'ascii'
_raw_body: Optional[str] = None
_pgp_signature: Optional[str] = None

def __init__(self, email_txt: str) -> None:
"""
Extract data from an MIME email message.
If present, the following fields will be filled:
- body: the processed message body (decoded, only PGP signed parts included if applicable)
- pgp_fingerprint: the hex-encoded fingerprint of the PGP key that signed body
- message_id/message_from/message_date/message_subject: the values of these headers in the message
"""
message: email.message.Message = email.message_from_string(email_txt)

if message.is_multipart():
for part in message.walk():
charset = part.get_content_charset()
if not charset:
charset = self._default_encoding
if part.get_content_type() == 'text/plain':
self.body = part.get_payload(decode=True).decode(str(charset), 'ignore') # type: ignore
self._raw_body = part.as_string()
if part.get_content_type() == 'application/pgp-signature':
self._pgp_signature = part.get_payload(decode=True).decode(str(charset), 'ignore').strip() # type: ignore
else:
charset = message.get_content_charset()
if not charset:
charset = self._default_encoding
self.body = message.get_payload(decode=True).decode(charset, 'ignore') # type: ignore
self._raw_body = message.as_string()

self.message_id = message['Message-ID'] # type: ignore
self.message_from = message['From'] # type: ignore
self.message_date = message['Date'] # type: ignore
self.message_subject = message['Subject'] # type: ignore

if not (self.body and self._raw_body):
return

new_body, self.pgp_fingerprint = validate_pgp_signature(self._raw_body, self._pgp_signature)
if new_body:
self.body = new_body


def handle_email_update(email_txt: str) -> Optional[UpdateRequestHandler]:
email = EmailUpdateParser(email_txt)
request_meta = {
'Message-ID': email.message_id,
'From': email.message_from,
'Date': email.message_date,
'Subject': email.message_subject,
}
if not email.body:
handler = None
subject = f'FAILED: {email.message_subject}'
reply_content = textwrap.dedent(f"""
Unfortunately, your message with ID {email.message_id}
could not be processed, as no text/plain part could be found.
Please try to resend your message as plain text email.
""")
else:
handler = UpdateRequestHandler(email.body, email.pgp_fingerprint, request_meta)
subject = f'{handler.status()}: {email.message_subject}'
reply_content = handler.user_report()

send_email(email.message_from, subject, reply_content)
return handler


def send_email(recipient, subject, body) -> None:
body += get_setting('email.footer')
hostname = socket.gethostname()
body += f'\n\nGenerated by IRRDv4 version {__version__} on {hostname}'

msg = MIMEText(body)
msg['Subject'] = subject
msg['From'] = get_setting('email.from')
msg['To'] = recipient

s = SMTP(get_setting('email.smtp'))
s.send_message(msg)
s.quit()
31 changes: 26 additions & 5 deletions irrd/updates/handler.py
@@ -1,7 +1,7 @@
import textwrap
from typing import List
from typing import List, Optional, Dict

from irrd.db.api import DatabaseHandler
from irrd.db.api import DatabaseHandler, RPSLDatabaseQuery
from .parser import parse_update_requests, UpdateRequest
from .parser_state import UpdateRequestStatus, UpdateRequestType
from .validators import ReferenceValidator, AuthValidator
Expand All @@ -15,13 +15,15 @@ class UpdateRequestHandler:
those part of the same update message, and checking authentication.
"""

def __init__(self, object_texts: str) -> None:
def __init__(self, object_texts: str, pgp_fingerprint: str=None, request_meta: Dict[str, Optional[str]]=None) -> None:
self.database_handler = DatabaseHandler()
self.request_meta = request_meta if request_meta else {}
self._pgp_key_id = self._resolve_pgp_key_id(pgp_fingerprint) if pgp_fingerprint else None
self._handle_object_texts(object_texts)

def _handle_object_texts(self, object_texts: str) -> None:
reference_validator = ReferenceValidator(self.database_handler)
auth_validator = AuthValidator(self.database_handler)
auth_validator = AuthValidator(self.database_handler, self._pgp_key_id)
results = parse_update_requests(object_texts, self.database_handler, auth_validator, reference_validator)

# When an object references another object, e.g. tech-c referring a person or mntner,
Expand Down Expand Up @@ -60,6 +62,22 @@ def _handle_object_texts(self, object_texts: str) -> None:
self.database_handler.commit()
self.results = results

def _resolve_pgp_key_id(self, pgp_fingerprint: str) -> Optional[str]:
"""
Find a PGP key ID for a given fingerprint.
This method looks for an actual matching object in the database,
and then returns the object's PK.
"""
clean_fingerprint = pgp_fingerprint.replace(' ', '')
key_id = "PGPKEY-" + clean_fingerprint[-8:]
query = RPSLDatabaseQuery().object_classes(['key-cert']).rpsl_pk(key_id)
results = list(self.database_handler.execute_query(query))

for result in results:
if result['parsed_data'].get('fingerpr', '').replace(' ', '') == clean_fingerprint:
return key_id
return None

def status(self) -> str:
"""Provide a simple SUCCESS/FAILED string based - former used if all objects were saved."""
if all([result.status == UpdateRequestStatus.SAVED for result in self.results]):
Expand All @@ -78,7 +96,10 @@ def user_report(self) -> str:
number_failed_modify = len([r for r in failed if r.request_type == UpdateRequestType.MODIFY])
number_failed_delete = len([r for r in failed if r.request_type == UpdateRequestType.DELETE])

user_report = textwrap.dedent(f"""
request_meta_str = '\n'.join([f"> {k}: {v}" for k, v in self.request_meta.items() if v])
if request_meta_str:
request_meta_str = "\n" + request_meta_str + "\n\n"
user_report = request_meta_str + textwrap.dedent(f"""
SUMMARY OF UPDATE:
Number of objects found: {len(self.results):3}
Expand Down
6 changes: 0 additions & 6 deletions irrd/updates/parser.py
Expand Up @@ -176,7 +176,6 @@ def parse_update_requests(requests_text: str,
database_handler: DatabaseHandler,
auth_validator: AuthValidator,
reference_validator: ReferenceValidator,
keycert_obj_pk: Optional[str] = None
) -> List[UpdateRequest]:
"""
Parse update requests, a text of RPSL objects along with metadata like
Expand All @@ -186,11 +185,7 @@ def parse_update_requests(requests_text: str,
:param database_handler: a DatabaseHandler instance
:param auth_validator: a AuthValidator instance, to resolve authentication requirements
:param reference_validator: a ReferenceValidator instance
:param keycert_obj_pk: the RPSL PK of a PGPKEY key-cert object, if the message was signed with this
:return: a list of UpdateRequest instances
NOTE: keycert_obj_pk is trusted without further verification.
User provided values must never be passed in without prior validation.
"""
results = []
passwords = []
Expand Down Expand Up @@ -227,5 +222,4 @@ def parse_update_requests(requests_text: str,
if auth_validator:
auth_validator.passwords = passwords
auth_validator.overrides = overrides
auth_validator.keycert_obj_pk = keycert_obj_pk
return results

0 comments on commit 376450a

Please sign in to comment.