├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.rst ├── acmagent ├── __init__.py ├── cli │ └── __init__.py ├── confirm.py └── request.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_cli.py ├── test_confirm.py └── test_request.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .idea/ 3 | ### Python template 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | ### VirtualEnv template 99 | # Virtualenv 100 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 101 | .Python 102 | [Bb]in 103 | [Ii]nclude 104 | [Ll]ib 105 | [Ll]ib64 106 | [Ll]ocal 107 | [Ss]cripts 108 | pyvenv.cfg 109 | .venv 110 | pip-selfcheck.json 111 | ### macOS template 112 | *.DS_Store 113 | .AppleDouble 114 | .LSOverride 115 | 116 | # Icon must end with two \r 117 | Icon 118 | 119 | 120 | # Thumbnails 121 | ._* 122 | 123 | # Files that might appear in the root of a volume 124 | .DocumentRevisions-V100 125 | .fseventsd 126 | .Spotlight-V100 127 | .TemporaryItems 128 | .Trashes 129 | .VolumeIcon.icns 130 | .com.apple.timemachine.donotpresent 131 | 132 | # Directories potentially created on remote AFP share 133 | .AppleDB 134 | .AppleDesktop 135 | Network Trash Folder 136 | Temporary Items 137 | .apdisk 138 | 139 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | - AWS_DEFAULT_REGION="ap-southeast-2" 4 | python: 5 | - "2.7" 6 | install: 7 | - "pip install coveralls" 8 | - "pip install -r requirements.txt" 9 | script: "coverage run --source acmagent setup.py test" 10 | after_success: 11 | - "coveralls" 12 | notifications: 13 | email: 14 | on_success: never 15 | on_failure: always -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017 Bernard Baltrusaitis Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/b-b3rn4rd/acmagent.svg?branch=master 2 | :target: https://travis-ci.org/b-b3rn4rd/acmagent 3 | 4 | .. image:: https://coveralls.io/repos/github/b-b3rn4rd/acmagent/badge.svg?branch=master 5 | :target: https://coveralls.io/github/b-b3rn4rd/acmagent?branch=master 6 | 7 | 8 | ====================================== 9 | ACMagent - automates ACM certificates 10 | ====================================== 11 | ACM agents provides functionality to request and confirm ACM certificates using the CLI interface 12 | 13 | Installation 14 | ############ 15 | 16 | :: 17 | 18 | $ pip install acmagent 19 | 20 | 21 | Configuration 22 | ############# 23 | In order to approve ACM certificates, create and configure acmagent IMAP credentials file. By default ``acmagent`` loads configuration ``.acmagent`` file from the user's home folder for example: `/home/john.doe/.acmagent`. However, you have an option to specify a custom path to the credentials file. 24 | 25 | :: 26 | 27 | # /home/john.doe/.acmagent 28 | 29 | username: username@example.com 30 | server: imap.example.com 31 | password: mysecretpassword 32 | 33 | Usage 34 | ##### 35 | 36 | Issuing ACM certificates 37 | ------------------------ 38 | 39 | The simplest option to request ACM certificate is to specify ``--domain-name`` and/or ``--validation-domain`` parameters. 40 | 41 | :: 42 | 43 | $ acmagent request-certificate --domain-name *.dev.example.com 44 | 12345678-1234-1234-1234-123456789012 45 | 46 | 47 | :: 48 | 49 | $ acmagent request-certificate --domain-name *.dev.example.com --validation-domain example.com 50 | 12345678-1234-1234-1234-123456789012 51 | 52 | 53 | Optionally, if you need to generate a certificate for multiple domain names you can provide the ``--alternative-names`` parameter to specify **space separated** alternative domain names. 54 | 55 | :: 56 | 57 | $ acmagent request-certificate --domain-name dev.example.com --validation-domain example.com --alternative-names www.dev.example.com ftp.dev.example.com 58 | 12345678-1234-1234-1234-123456789012 59 | 60 | ACMAgent offers an option to specify JSON input file instead of typing them at the command line using ``--cli-input-json`` parameter. 61 | 62 | - Generate CLI skeleton output 63 | 64 | :: 65 | 66 | $ acmagent request-certificate --generate-cli-skeleton &> certificate.json 67 | 68 | 69 | :: 70 | 71 | $ cat certificate.json 72 | { 73 | "DomainName": "", 74 | "SubjectAlternativeNames": [], 75 | "ValidationDomain": "" 76 | } 77 | 78 | 79 | - Modify generated skeleton file using your preferred method 80 | - Using ``--cli-input-json`` parameter specify path fo the ``certificate.json`` file 81 | 82 | :: 83 | 84 | $ acmagent request-certificate --cli-input-json file:./certificate.json 85 | 86 | 87 | **Output** 88 | 89 | The `request-certificate` outputs ACM certificate id, it's the last part of the ARN arn:aws:acm:us-east-1:123456789012:certificate/**12345678-1234-1234-1234-123456789012** you will need that id for a certificate approval process. 90 | 91 | Approving ACM certificates 92 | -------------------------- 93 | 94 | *Before approving ACM issued certificate, please ensure that the credentials file has been setup.* 95 | *For gmail and yahoo enable access for 'less secure apps' (https://support.google.com/accounts/answer/6010255?hl=en-GB&authuser=1)* 96 | 97 | confirm-certificate 98 | ^^^^^^^^^^^^^^^^^^^ 99 | 100 | :: 101 | 102 | $ acmagent confirm-certificate --help 103 | usage: acmagent confirm-certificate [-h] --certificate-id CERTIFICATE_ID 104 | [--wait WAIT] [--attempts ATTEMPTS] 105 | [--debug] [--credentials CREDENTIALS] 106 | optional arguments: 107 | -h, --help show this help message and exit 108 | --certificate-id CERTIFICATE_ID Certificate id 109 | --wait WAIT Timeout in seconds between querying IMAP server 110 | --attempts ATTEMPTS Number of attempts to query IMAP server 111 | --debug (boolean) Send logging to standard output 112 | --credentials CREDENTIALS Explicitly provide IMAP credentials file 113 | 114 | Examples 115 | ^^^^^^^^ 116 | Confirming a certificate using the default settings: 117 | 118 | :: 119 | 120 | $ acmagent confirm-certificate --certificate-id 12345678-1234-1234-1234-123456789012 121 | 122 | 123 | However, for most scenarios the recommended approach to specify custom values for ``--wait`` and ``--attempts`` parameters tailored for your IMAP server. 124 | 125 | :: 126 | 127 | $ acmagent confirm-certificate --wait 10 --attempts 6 --certificate-id 12345678-1234-1234-1234-123456789012 128 | 129 | 130 | In the situations when you can't use the default IMAP credentials file provide the ``--credentials`` parameter 131 | 132 | :: 133 | 134 | $ acmagent confirm-certificate --certificate-id 12345678-1234-1234-1234-123456789012 --credentials file:///var/lib/jenkins/.acmagent 135 | 136 | 137 | -------------------------------------------------------------------------------- /acmagent/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import yaml 4 | from logging.handlers import RotatingFileHandler 5 | 6 | 7 | VERSION = '1.0.2' 8 | 9 | 10 | def load_imap_credentials(file='.acmagent'): 11 | home = os.path.expanduser('~') 12 | filename = '{}/{}'.format(home, file) 13 | 14 | try: 15 | with open(filename, 'r') as ymlfile: 16 | return yaml.load(ymlfile) 17 | except IOError as e: 18 | raise MissingIMAPCredentailsException('IMAP credentials file: {} is not found'.format(filename)) 19 | except yaml.scanner.ScannerError as e: 20 | raise InvalidIMAPCredentailsFileException('IMAP credentials file: {} is not valid YAML'.format(filename)) 21 | 22 | 23 | def _create_log_filename(filename): 24 | if os.name == 'nt': 25 | logdir = os.path.expandvars(r'${SystemDrive}\cfn\log') 26 | if not os.path.exists(logdir): 27 | os.makedirs(logdir) 28 | return logdir + os.path.sep + filename 29 | 30 | return '/tmp/%s' % filename 31 | 32 | 33 | def add_stream_log_handler(logger): 34 | """ 35 | Add StreamHandler to the logger 36 | 37 | :param logger: existing logger 38 | :return: None 39 | """ 40 | handler = logging.StreamHandler() 41 | handler.setLevel(logging.DEBUG) 42 | handler.setFormatter(logging.Formatter( 43 | '%(asctime)s %(name)-12s %(levelname)-8s %(message)s')) 44 | logger.addHandler(handler) 45 | 46 | 47 | def add_file_log_handler(logger, name): 48 | """ 49 | Add RotatingFileHandler to the logger 50 | 51 | :param logger: existing logger 52 | :param name: filename 53 | :return: None 54 | """ 55 | handler = RotatingFileHandler( 56 | backupCount=3, maxBytes=1000000, filename=_create_log_filename('{}.log'.format(name)) 57 | ) 58 | handler.setLevel(logging.DEBUG) 59 | handler.setFormatter(logging.Formatter( 60 | '%(asctime)s %(name)-12s %(levelname)-8s %(message)s')) 61 | logger.addHandler(handler) 62 | 63 | 64 | def configure_logger(name): 65 | logger = logging.getLogger(name) 66 | logger.addHandler(logging.NullHandler()) 67 | logger.setLevel(logging.DEBUG) 68 | 69 | return logger 70 | 71 | 72 | class ACManagerException(Exception): 73 | """Basic ACManager exception""" 74 | 75 | 76 | class MissingIMAPCredentailsException(ACManagerException): 77 | """Missing IMAP credentails file""" 78 | 79 | 80 | class InvalidIMAPCredentailsFileException(ACManagerException): 81 | """IMAP credentails file is not valid YAML""" 82 | 83 | 84 | class MissingCertificateArgException(ACManagerException): 85 | """Missing required CLI argument exception""" 86 | 87 | 88 | class IMAPCredentialFileMissingPropertyException(ACManagerException): 89 | """IMAP credential file is missing required property""" 90 | 91 | 92 | class InvalidCertificateJsonFileException(ACManagerException): 93 | """Missing required CLI argument exception""" 94 | 95 | 96 | class SMTPConnectionFailedException(ACManagerException): 97 | """Raised when failed to establish connection with SMTP server""" 98 | 99 | 100 | class FailedToFetchEmailException(SMTPConnectionFailedException): 101 | """Raised when failed to fetch email""" 102 | 103 | 104 | class NoEmailsFoundException(ACManagerException): 105 | """Raised when emails are not found""" 106 | 107 | 108 | class EmailBodyUnknownContentType(ACManagerException): 109 | """Raised when certificate body is not in html format""" 110 | 111 | 112 | class EmailBodyConfirmLinkIsMissingException(ACManagerException): 113 | """Raised when certificate email is missing confirmation url""" 114 | 115 | 116 | class ConfirmPageIsMissingFormException(ACManagerException): 117 | """Raised when confirm page is missing the actual form""" 118 | 119 | UserHeaders = { 120 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36' 121 | } 122 | -------------------------------------------------------------------------------- /acmagent/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import os 3 | import sys 4 | import argparse 5 | import json 6 | import urllib2 7 | import time 8 | import yaml 9 | import acmagent 10 | import pkg_resources 11 | from acmagent import request 12 | from acmagent import confirm 13 | 14 | 15 | logger = acmagent.configure_logger('acmagent') 16 | 17 | class ParseJsonInput(argparse.Action): 18 | """ 19 | Parse json input file for the request-certificate command 20 | """ 21 | def __init__(self, option_strings, dest, nargs=None, **kwargs): 22 | if nargs is not None: 23 | raise ValueError("nargs not allowed") 24 | super(ParseJsonInput, self).__init__(option_strings, dest, **kwargs) 25 | 26 | def __call__(self, parser, namespace, value, option_string=None): 27 | try: 28 | logger.debug('Opening input json file'.format(value)) 29 | certificate_args = json.loads(urllib2.urlopen(value).read()) 30 | setattr(namespace, self.dest, certificate_args) 31 | except urllib2.URLError: 32 | logger.exception('Failed reading json input') 33 | parser.error('Specified file "{}" is not readable'.format(value)) 34 | except ValueError: 35 | if not value.startswith('file:'): 36 | logger.exception('Input json file is missing file scheme') 37 | parser.error('Specified file "{}" is missing file URL scheme'.format(value)) 38 | else: 39 | logger.exception('Input json file is not valid json') 40 | parser.error('Specified file "{}" is not valid json'.format(value)) 41 | 42 | 43 | class ParseIMAPCredentials(argparse.Action): 44 | """ 45 | Parse YAML IMAP credentials file for the confirm-certificate command 46 | """ 47 | def __init__(self, option_strings, dest, nargs=None, **kwargs): 48 | if nargs is not None: 49 | raise ValueError("nargs not allowed") 50 | super(ParseIMAPCredentials, self).__init__(option_strings, dest, **kwargs) 51 | 52 | def __call__(self, parser, namespace, value, option_string=None): 53 | try: 54 | logger.debug('Opening IMAP credentials file'.format(value)) 55 | imap_credentials = yaml.load(urllib2.urlopen(value).read()) 56 | setattr(namespace, self.dest, imap_credentials) 57 | except urllib2.URLError as e: 58 | logger.exception('Failed reading json input') 59 | parser.error('Specified file "{}" is not readable'.format(value)) 60 | except ValueError as e: 61 | if not value.startswith('file:'): 62 | logger.exception('Input json file is missing file scheme') 63 | parser.error('Specified file "{}" is missing file URL scheme'.format(value)) 64 | else: 65 | logger.exception('Input json file is not valid YAML') 66 | parser.error('Specified file "{}" is not valid YAML'.format(value)) 67 | 68 | 69 | def _confirm_cert(args, parser): 70 | """ 71 | Confirm ACM issued certificate 72 | 73 | :param args: cli arguments 74 | :return: None 75 | """ 76 | try: 77 | imap_credentials = args.credentials if args.credentials else acmagent.load_imap_credentials() 78 | with confirm.ConfirmCertificate(imap_credentials) as acm_certificate_confirm: 79 | attempts_left = args.attempts 80 | while attempts_left: 81 | logger.debug('Starting ACM request for {} certificate, attempts left: {}, pause: {} seconds'.format( 82 | args.certificate_id, attempts_left, args.wait)) 83 | attempts_left -= 1 84 | time.sleep(args.wait) 85 | try: 86 | success = acm_certificate_confirm.confirm_certificate(args.certificate_id) 87 | if success: 88 | parser.exit(0, 'Success: certificate has been confirmed\n') 89 | except acmagent.NoEmailsFoundException as e: 90 | if attempts_left: 91 | continue 92 | parser.error(str(e)) 93 | except acmagent.ACManagerException as e: 94 | parser.error(str(e)) 95 | except acmagent.ACManagerException as e: 96 | parser.error(str(e)) 97 | 98 | 99 | def _request_cert(args, parser): 100 | """ 101 | Send a request to the ACM to issue SSL certificate 102 | 103 | :param args: cli arguments 104 | :return: None 105 | """ 106 | if args.generate_cli_skeleton: 107 | json_file = request.Certificate.template() 108 | logger.debug('Generating json input file: {}'.format(json_file)) 109 | parser.exit(0, "{}\n".format(json_file)) 110 | 111 | if args.cli_input_json: 112 | try: 113 | certificate = request.Certificate.from_json_input(args.cli_input_json) 114 | acm_certificate = dict(certificate) 115 | except acmagent.InvalidCertificateJsonFileException as e: 116 | parser.error(str(e)) 117 | else: 118 | if not args.domain_name: 119 | parser.error('--domain-name is required') 120 | 121 | certificate = request.Certificate(args.__dict__) 122 | acm_certificate = dict(certificate) 123 | 124 | acm_certificate_request = request.RequestCertificate() 125 | 126 | try: 127 | logger.debug('Requesting certificate: {}'.format(json.dumps(acm_certificate))) 128 | response = acm_certificate_request.request_certificate(acm_certificate) 129 | except Exception as e: 130 | logger.exception('Boto3 exception') 131 | parser.error(str(e)) 132 | 133 | certificate_id = (response['CertificateArn'].split('/')[-1]) 134 | logger.debug('Success, {} certificate was issued, id: {}'.format(acm_certificate['DomainName'], certificate_id)) 135 | parser.exit(0, "{}\n".format(certificate_id)) 136 | 137 | 138 | def _setup_argparser(): 139 | """ 140 | Argparse factory 141 | 142 | :return: argparse.ArgumentParser 143 | """ 144 | parser = argparse.ArgumentParser(description='ACM agent') 145 | parser.add_argument( 146 | '-v', '--version', 147 | action='version', 148 | version=pkg_resources.get_distribution("acmagent").version, 149 | help='print acmagent version' 150 | ) 151 | 152 | subparsers = parser.add_subparsers( 153 | title='ACM agent - automates ACM certificates', 154 | description='ACM agents provides functionality to request and confirm ACM certificates using the CLI interface') 155 | 156 | request_cert_parser = subparsers.add_parser('request-certificate') 157 | request_cert_parser.set_defaults(func=_request_cert) 158 | request_cert_parser.add_argument('--domain-name', 159 | dest='domain_name', 160 | required=False, 161 | help='Fully qualified domain name (FQDN), such as www.example.com') 162 | request_cert_parser.add_argument('--validation-domain', 163 | dest='domain_validation_options', 164 | required=False, 165 | help='The domain name that you want ACM to use to send you emails to validate your ownership of the domain') 166 | request_cert_parser.add_argument('--alternative-names', 167 | default=[], 168 | dest='subject_alternative_names', 169 | required=False, 170 | nargs='+', 171 | help='Additional FQDNs to be included in the Subject Alternative Name extension of the ACM Certificate') 172 | request_cert_parser.add_argument('--generate-cli-skeleton', 173 | required=False, 174 | action='store_true', 175 | default=False, 176 | dest='generate_cli_skeleton', 177 | help='(boolean) Prints a sample input JSON to standard output') 178 | 179 | request_cert_parser.add_argument('--cli-input-json', 180 | required=False, 181 | action=ParseJsonInput, 182 | dest='cli_input_json', 183 | help='(boolean) Prints a sample input JSON to standard output') 184 | 185 | request_cert_parser.add_argument('--debug', 186 | required=False, 187 | action='store_true', 188 | default=False, 189 | help='(boolean) Send logging to standard output') 190 | 191 | confirm_cert_parser = subparsers.add_parser('confirm-certificate') 192 | confirm_cert_parser.set_defaults(func=_confirm_cert) 193 | confirm_cert_parser.add_argument('--certificate-id', 194 | dest='certificate_id', 195 | required=True, 196 | help='Certificate id') 197 | confirm_cert_parser.add_argument('--wait', 198 | dest='wait', 199 | default=5, 200 | type=int, 201 | required=False, 202 | help='Timeout in seconds between querying IMAP server') 203 | confirm_cert_parser.add_argument('--attempts', 204 | dest='attempts', 205 | type=int, 206 | default=1, 207 | required=False, 208 | help='Number of attempts to query IMAP server') 209 | 210 | confirm_cert_parser.add_argument('--debug', 211 | required=False, 212 | action='store_true', 213 | default=False, 214 | help='(boolean) Send logging to standard output') 215 | 216 | confirm_cert_parser.add_argument('--credentials', 217 | required=False, 218 | action=ParseIMAPCredentials, 219 | help='Explicitly provide IMAP credentials file') 220 | 221 | return parser 222 | 223 | 224 | def main(): 225 | is_debug = '--debug' in sys.argv[1:] 226 | 227 | if is_debug: 228 | acmagent.add_stream_log_handler(logger) 229 | else: 230 | acmagent.add_file_log_handler(logger, 'acmagent') 231 | 232 | parser = _setup_argparser() 233 | args = parser.parse_args() 234 | args.func(args, parser) 235 | 236 | if __name__ == "__main__": 237 | main() 238 | -------------------------------------------------------------------------------- /acmagent/confirm.py: -------------------------------------------------------------------------------- 1 | import imaplib 2 | import email 3 | from bs4 import BeautifulSoup 4 | import requests 5 | import logging 6 | import acmagent 7 | import json 8 | 9 | logger = logging.getLogger('acmagent') 10 | 11 | 12 | class ConfirmCertificate(object): 13 | """ 14 | Certificate confirmation class, tried to confirm certificate with given id by connecting to IMAP server 15 | """ 16 | APPROVAL_URL_ID = 'approval_url' 17 | APPROVAL_FORM_URL = 'https://certificates.amazon.com/approvals' 18 | EMAIL_FOLDER = 'Inbox' 19 | 20 | def __enter__(self): 21 | return self 22 | 23 | def __init__(self, imap_credentials): 24 | try: 25 | self._server = imap_credentials['server'] 26 | self._username = imap_credentials['username'] 27 | self._password = imap_credentials['password'] 28 | except TypeError as e: 29 | logger.exception('IMAP credentials file is not well formatted') 30 | raise acmagent.InvaliIMAPCredentailsFileException('IMAP credentials file is empty or not well formatted') 31 | except KeyError as e: 32 | logger.exception('IMAP credentials missing property') 33 | raise acmagent.IMAPCredentialFileMissingPropertyException( 34 | 'Missing IMAP property "{}", check the credentials file'.format(e.args[0])) 35 | 36 | self._connect_to_imap() 37 | 38 | def _connect_to_imap(self): 39 | try: 40 | logger.info('Establishing connection with {} server'.format(self._server)) 41 | self._mail = imaplib.IMAP4_SSL(self._server) 42 | self._mail.login(self._username, self._password) 43 | except Exception as e: 44 | logger.exception('Failed establish IMAP connection: {}'.format(e)) 45 | raise acmagent.SMTPConnectionFailedException('Can\'t login to the "{}" server'.format(self._server)) 46 | 47 | @staticmethod 48 | def _search_query(certificate_id): 49 | search_query = ( 50 | 'UNSEEN', 51 | 'FROM "Amazon Certificates"', 52 | 'BODY "Certificate identifier: {}"'.format(certificate_id) 53 | ) 54 | 55 | return "({})".format(" ".join(search_query)) 56 | 57 | def _call_confirm_url(self, url): 58 | logger.info('Sending GET: {}'.format(url)) 59 | response = requests.get(url, headers=acmagent.UserHeaders) 60 | 61 | try: 62 | confirm_body = BeautifulSoup(response.content, "html.parser") 63 | confirm_form = confirm_body.body.find('form').find_all('input') 64 | payload = {input.get('name'): input.get('value') for input in confirm_form} 65 | logger.debug('Found confirmation form: {}'.format(json.dumps(payload))) 66 | return self._call_confirm_form(payload) 67 | except AttributeError as e: 68 | logger.exception('Failed to extract confirmation form') 69 | raise acmagent.ConfirmPageIsMissingFormException('The certificate has been confirmed or the confirmation link: "{}" has expired'.format(url)) 70 | 71 | def _call_confirm_form(self, payload): 72 | logger.info('Sending POST: {} PAYLOAD: {}'.format(ConfirmCertificate.APPROVAL_FORM_URL, json.dumps(payload))) 73 | response = requests.post(ConfirmCertificate.APPROVAL_FORM_URL, headers=acmagent.UserHeaders, data=payload) 74 | 75 | if not response.ok: 76 | logger.exception('Failed to submit confirmation form') 77 | raise acmagent.ACManagerException('An unknown error has occurred while requesting url:"{}"'.format(ConfirmCertificate.APPROVAL_FORM_URL)) 78 | 79 | logger.info('Success! The certificate has been confirmed') 80 | return True 81 | 82 | def _fetch_message(self, message_id): 83 | type, response = self._mail.fetch(message_id,'(RFC822)') 84 | email_message = email.message_from_string(response[0][1]) 85 | logger.debug('Opening email: {}'.format(email_message['subject'])) 86 | self._mail.store(message_id, '+FLAGS', '\\Seen') 87 | logger.debug('Marking email: {} as read'.format(email_message['subject'])) 88 | email_body = '' 89 | if email_message.is_multipart(): 90 | for multipart in email_message.get_payload(): 91 | if 'text/html' == multipart.get_content_type(): 92 | email_body = multipart.get_payload(decode=True) 93 | break 94 | if not email_body: 95 | logger.exception('Email is missing HTML') 96 | raise acmagent.EmailBodyUnknownContentType('Email "{}" is not in the text/html Content-Type'.format(message_id)) 97 | 98 | try: 99 | logger.debug('Reading email: {} HTML body'.format(email_message['subject'])) 100 | html = BeautifulSoup(email_body, "html.parser") 101 | approval_url = html.body.find('a', attrs={'id': ConfirmCertificate.APPROVAL_URL_ID}).get('href') 102 | logger.debug('Found confirmation url: {}'.format(approval_url)) 103 | return self._call_confirm_url(approval_url) 104 | except AttributeError as e: 105 | logger.exception('Failed to parse email html') 106 | raise acmagent.EmailBodyConfirmLinkIsMissingException('Url with "id={}" is not found in the email'.format(ConfirmCertificate.APPROVAL_URL_ID)) 107 | 108 | def confirm_certificate(self, certificate_id): 109 | try: 110 | self._mail.select(ConfirmCertificate.EMAIL_FOLDER) 111 | imap_search = ConfirmCertificate._search_query(certificate_id) 112 | logger.debug('Scan {} folder with {} condition'.format(ConfirmCertificate.EMAIL_FOLDER, imap_search)) 113 | success, messages = self._mail.search(None, imap_search) 114 | if success == 'OK': 115 | message_ids = [message_id for message_id in messages[0].split(' ') if message_id] 116 | 117 | if not message_ids: 118 | logger.info('Have not found email for requested certificate') 119 | raise acmagent.NoEmailsFoundException('Failed to find email for certificate {} in {} folder'.format( 120 | certificate_id, ConfirmCertificate.EMAIL_FOLDER)) 121 | 122 | logger.debug('Found {} email(s)'.format(len(message_ids))) 123 | for message_id in message_ids: 124 | success = self._fetch_message(message_id) 125 | if success: 126 | return True 127 | else: 128 | logger.exception('Unknown error') 129 | raise acmagent.ACManagerException('An unknown error has occurred while reading emails, state={}'.format(success)) 130 | except imaplib.IMAP4.error as e: 131 | if str(e).startswith('FETCH'): 132 | raise acmagent.FailedToFetchEmailException('Failed to fetch emails') 133 | elif str(e).startswith('command EXAMINE illegal'): 134 | raise acmagent.SMTPConnectionFailedException('Can\'t establish connection with "{}" server'.format(self._server)) 135 | 136 | def __exit__(self, exc_type, exc_val, exc_tb): 137 | logger.info('Closing connection with {} server'.format(self._server)) 138 | self._mail.close() 139 | -------------------------------------------------------------------------------- /acmagent/request.py: -------------------------------------------------------------------------------- 1 | import json 2 | import acmagent 3 | import logging 4 | import botocore 5 | import botocore.session 6 | 7 | logger = logging.getLogger('acmagent') 8 | 9 | 10 | class Certificate(object): 11 | """ 12 | acmagent - maps passed cli arguments to the certificate object attributes 13 | """ 14 | def __init__(self, certificate_attrs): 15 | try: 16 | self.domain_name = certificate_attrs['domain_name'] 17 | self.subject_alternative_names = certificate_attrs['subject_alternative_names'] 18 | self.domain_validation_options = certificate_attrs['domain_validation_options'] 19 | except KeyError as e: 20 | mappings = Certificate._json_mappings() 21 | map = {'cli': map['cli'] for name, map in mappings.items() if map['attr'] == e.args[0]} 22 | logger.exception('Missing certificate attribute') 23 | raise acmagent.MissingCertificateArgException('{} is required'.format(map['cli'])) 24 | 25 | @classmethod 26 | def from_json_input(cls, cli_input_json): 27 | try: 28 | mappings = Certificate._json_mappings() 29 | certificate_attrs = {mappings[k]['attr']: v for k,v in cli_input_json.items()} 30 | return cls(certificate_attrs) 31 | except KeyError as e: 32 | logger.exception('Unknown certificate property') 33 | raise acmagent.InvalidCertificateJsonFileException('Unknown property {} in the specified json file'.format(e.args[0])) 34 | 35 | @property 36 | def domain_name(self): 37 | return self._domain_name 38 | 39 | @domain_name.setter 40 | def domain_name(self, domain_name): 41 | self._domain_name = domain_name 42 | 43 | @property 44 | def subject_alternative_names(self): 45 | return self._subject_alternative_names 46 | 47 | @subject_alternative_names.setter 48 | def subject_alternative_names(self, subject_alternative_names): 49 | self._subject_alternative_names = subject_alternative_names 50 | 51 | @property 52 | def domain_validation_options(self): 53 | return self._domain_validation_options 54 | 55 | @domain_validation_options.setter 56 | def domain_validation_options(self, domain_validation_options): 57 | acm_domain_validation_options = None 58 | 59 | if domain_validation_options: 60 | acm_domain_validation_options = [ 61 | {'DomainName': alternative_name, 'ValidationDomain': domain_validation_options} 62 | for alternative_name in self.subject_alternative_names 63 | ] 64 | acm_domain_validation_options.append({ 65 | 'DomainName': self.domain_name, 66 | 'ValidationDomain': domain_validation_options 67 | }) 68 | 69 | self._domain_validation_options = acm_domain_validation_options 70 | 71 | @staticmethod 72 | def _json_mappings(): 73 | return { 74 | 'DomainName': { 75 | 'attr': 'domain_name', 76 | 'default': '', 77 | 'cli': '--domain-name' 78 | }, 79 | 'ValidationDomain': { 80 | 'attr': 'domain_validation_options', 81 | 'default': '', 82 | 'cli': '--validation-domain' 83 | }, 84 | 'SubjectAlternativeNames': { 85 | 'attr': 'subject_alternative_names', 86 | 'default': [], 87 | 'cli': '--alternative-names' 88 | } 89 | } 90 | 91 | @staticmethod 92 | def template(): 93 | template = {k: v['default'] for k,v in Certificate._json_mappings().items()} 94 | return json.dumps(template, sort_keys=True, indent=4, separators=(',', ': ')) 95 | 96 | def __iter__(self): 97 | acm_certificate = { 98 | 'DomainName': self.domain_name 99 | } 100 | 101 | if self.domain_validation_options: 102 | acm_certificate['DomainValidationOptions'] = self.domain_validation_options 103 | 104 | if self.subject_alternative_names: 105 | acm_certificate['SubjectAlternativeNames'] = self.subject_alternative_names 106 | 107 | for k,v in acm_certificate.items(): 108 | yield (k, v) 109 | 110 | 111 | class RequestCertificate(object): 112 | """ 113 | Sends actual request to AWS 114 | """ 115 | def __init__(self): 116 | self._acm_client = self._setup_acm_client() 117 | 118 | def request_certificate(self, certificate): 119 | return self._acm_client.request_certificate(**certificate) 120 | 121 | def _setup_acm_client(self): 122 | session = botocore.session.get_session() 123 | return session.create_client('acm') 124 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | beautifulsoup4==4.5.3 3 | boto3==1.4.4 4 | botocore==1.5.34 5 | docutils==0.13.1 6 | funcsigs==1.0.2 7 | futures==3.0.5 8 | jmespath==0.9.2 9 | mock==2.0.0 10 | packaging==16.8 11 | pbr==2.0.0 12 | pyparsing==2.2.0 13 | python-dateutil==2.6.0 14 | PyYAML==5.1 15 | requests==2.20.0 16 | s3transfer==0.1.10 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | import acmagent 4 | from setuptools import setup, find_packages 5 | 6 | 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name='acmagent', 14 | version=acmagent.VERSION, 15 | description='ACM agent - automates ACM certificates', 16 | long_description=long_description, 17 | url='https://github.com/b-b3rn4rd/acmagent', 18 | author='Bernard Baltrusaitis', 19 | test_suite="tests", 20 | tests_require=['mock'], 21 | author_email='bernard@runawaylover.info', 22 | license='MIT', 23 | classifiers=[ 24 | 'Development Status :: 4 - Beta', 25 | 'Intended Audience :: Developers', 26 | 'Intended Audience :: System Administrators', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Programming Language :: Python :: 2.7', 29 | 'Programming Language :: Python :: 3', 30 | 'Environment :: Console' 31 | ], 32 | keywords='acm aws ssl certificates', 33 | packages=find_packages(exclude=['docs', 'tests']), 34 | install_requires=[ 35 | 'boto3>=1.4.4', 36 | 'botocore>=1.5.34,<2.0.0', 37 | 'beautifulsoup4>=4.5.3', 38 | 'PyYAML>=3.12', 39 | 'requests>=2.13.0', 40 | ], 41 | package_data={ 42 | 'acmagent': ['*.json'] 43 | }, 44 | entry_points={ 45 | 'console_scripts': [ 46 | 'acmagent = acmagent.cli:main' 47 | ] 48 | } 49 | ) 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b-b3rn4rd/acmagent/e71876ccc42ab4e376b52f30f77daf4f0cb0e841/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from acmagent import cli 3 | from mock import patch 4 | from mock import MagicMock 5 | import urllib2 6 | import argparse 7 | import json 8 | import yaml 9 | import acmagent 10 | from acmagent import confirm 11 | from acmagent import request 12 | 13 | 14 | def SystemExitStub(status=0, message=None): 15 | """ 16 | system exit stub 17 | """ 18 | 19 | exit(status) 20 | 21 | 22 | class NamespaceStub(object): 23 | """ 24 | argprase namespace stub 25 | """ 26 | 27 | def __init__(self, **kwargs): 28 | for k,v in kwargs.items(): 29 | setattr(self, k, v) 30 | 31 | 32 | class ResponseStub(object): 33 | """ 34 | urllib2 stub 35 | """ 36 | 37 | def __init__(self, filename): 38 | self.response = filename 39 | 40 | def read(self): 41 | return self.response 42 | 43 | 44 | class Urllib2ExceptionStub(object): 45 | """ 46 | urllib2 exception stub 47 | """ 48 | def __init__(self, filename): 49 | self.response = filename 50 | 51 | def read(self): 52 | raise urllib2.URLError(self.response) 53 | 54 | def __str__(self): 55 | return self.response 56 | 57 | 58 | class TestParseJsonInput(unittest.TestCase): 59 | def setUp(self): 60 | self.json_string = '{"name": "test"}' 61 | self.namespace_stub = NamespaceStub() 62 | 63 | @patch("urllib2.urlopen") 64 | def test_ParseJsonInput_object_sets_json_content_for_cli_input_json_arg(self, urllib2_mock): 65 | urllib2_mock.side_effect = ResponseStub 66 | parser_mock = MagicMock() 67 | cli_input = cli.ParseJsonInput([], 'cli_input_json') 68 | cli_input(parser_mock, self.namespace_stub, self.json_string, '--cli-input-json') 69 | 70 | self.assertDictEqual(json.loads(self.json_string), self.namespace_stub.cli_input_json) 71 | 72 | @patch("urllib2.urlopen") 73 | def test_file_is_not_readable_when_urllib2_URLError_exception_is_raised(self, urllib2_mock): 74 | urllib2_mock.side_effect = Urllib2ExceptionStub 75 | parser_mock = MagicMock() 76 | cli_input = cli.ParseJsonInput([], 'cli_input_json') 77 | cli_input(parser_mock, self.namespace_stub, 'file://input.json', '--cli-input-json') 78 | parser_mock.error.assert_called_once() 79 | called_args = parser_mock.error.call_args 80 | error_message = called_args[0][0] 81 | assert error_message.endswith('is not readable') 82 | 83 | @patch("urllib2.urlopen") 84 | def test_file_is_missing_protocol_when_Value_exception_is_raised(self, urllib2_mock): 85 | urllib2_mock.side_effect = ValueError 86 | parser_mock = MagicMock() 87 | cli_input = cli.ParseJsonInput([], 'cli_input_json') 88 | cli_input(parser_mock, self.namespace_stub, 'input.json', '--cli-input-json') 89 | parser_mock.error.assert_called_once() 90 | called_args = parser_mock.error.call_args 91 | error_message = called_args[0][0] 92 | assert error_message.endswith('missing file URL scheme') 93 | 94 | @patch("urllib2.urlopen") 95 | def test_file_is_not_valid_json__when_Value_exception_is_raised(self, urllib2_mock): 96 | urllib2_mock.side_effect = ValueError 97 | parser_mock = MagicMock() 98 | cli_input = cli.ParseJsonInput([], 'cli_input_json') 99 | cli_input(parser_mock, self.namespace_stub, 'file://input.json', '--cli-input-json') 100 | parser_mock.error.assert_called_once() 101 | called_args = parser_mock.error.call_args 102 | error_message = called_args[0][0] 103 | assert error_message.endswith('is not valid json') 104 | 105 | 106 | class TestParseIMAPCredentials(unittest.TestCase): 107 | def setUp(self): 108 | self.yaml_string = """ 109 | server: imap.example.com 110 | username: user@example.com 111 | password: mypassword 112 | """ 113 | self.namespace_stub = NamespaceStub() 114 | 115 | @patch("urllib2.urlopen") 116 | def test_ParseIMAPCredentials_sets_imap_credentials_from_yaml_to_credentials_arg(self, urllib2_mock): 117 | urllib2_mock.side_effect = ResponseStub 118 | parser_mock = MagicMock() 119 | cli_input = cli.ParseIMAPCredentials([], 'credentials') 120 | cli_input(parser_mock, self.namespace_stub, self.yaml_string, '--credentials') 121 | 122 | self.assertDictEqual(yaml.load(self.yaml_string), self.namespace_stub.credentials) 123 | 124 | @patch("urllib2.urlopen") 125 | def test_file_is_not_readable_when_urllib2_URLError_exception_is_raised(self, urllib2_mock): 126 | urllib2_mock.side_effect = Urllib2ExceptionStub 127 | parser_mock = MagicMock() 128 | cli_input = cli.ParseIMAPCredentials([], 'credentials') 129 | cli_input(parser_mock, self.namespace_stub, 'file://creds.yaml', '--credentials') 130 | 131 | parser_mock.error.assert_called_once() 132 | called_args = parser_mock.error.call_args 133 | error_message = called_args[0][0] 134 | assert error_message.endswith('is not readable') 135 | 136 | @patch("urllib2.urlopen") 137 | def test_file_is_missing_protocol_when_Value_exception_is_raised(self, urllib2_mock): 138 | urllib2_mock.side_effect = ValueError 139 | parser_mock = MagicMock() 140 | 141 | cli_input = cli.ParseIMAPCredentials([], 'credentials') 142 | cli_input(parser_mock, self.namespace_stub, 'creds.yaml', '--credentials') 143 | 144 | parser_mock.error.assert_called_once() 145 | called_args = parser_mock.error.call_args 146 | error_message = called_args[0][0] 147 | assert error_message.endswith('missing file URL scheme') 148 | 149 | @patch("urllib2.urlopen") 150 | def test_file_is_not_valid_json__when_Value_exception_is_raised(self, urllib2_mock): 151 | urllib2_mock.side_effect = ValueError 152 | parser_mock = MagicMock() 153 | 154 | cli_input = cli.ParseIMAPCredentials([], 'credentials') 155 | cli_input(parser_mock, self.namespace_stub, 'file://creds.yaml', '--credentials') 156 | 157 | parser_mock.error.assert_called_once() 158 | called_args = parser_mock.error.call_args 159 | error_message = called_args[0][0] 160 | assert error_message.endswith('is not valid YAML') 161 | 162 | 163 | class TestConfirmCert(unittest.TestCase): 164 | """ 165 | Tests for confirm_cert function 166 | """ 167 | 168 | def setUp(self): 169 | self.certificate_id = 'test' 170 | self.credentials = None 171 | self.wait = 5 172 | self.attempts=1 173 | self.server = 'imap.example.com' 174 | self.username = 'user@example.com' 175 | self.password = 'mypassword' 176 | 177 | @patch("acmagent.cli.confirm.ConfirmCertificate") 178 | @patch("acmagent.load_imap_credentials") 179 | @patch("time.sleep") 180 | def test_confirm_cert_exists_with_error_if_email_not_found(self, sleep_mock, load_imap_credentials_mock, confirm_certificate_mock): 181 | 182 | args = NamespaceStub(certificate_id=self.certificate_id, 183 | credentials=self.credentials, 184 | attempts=self.attempts, 185 | wait=self.wait) 186 | 187 | parser_mock = MagicMock() 188 | 189 | confirm_certificate_mock.return_value.__enter__.return_value.confirm_certificate.side_effect = acmagent.NoEmailsFoundException('exception') 190 | cli._confirm_cert(args, parser_mock) 191 | parser_mock.error.assert_called_once_with('exception') 192 | 193 | @patch("acmagent.cli.confirm.ConfirmCertificate") 194 | @patch("acmagent.load_imap_credentials") 195 | @patch("time.sleep") 196 | def test_confirm_cert_function_happy_path(self, sleep_mock, load_imap_credentials_mock, confirm_certificate_mock): 197 | 198 | args = NamespaceStub(certificate_id=self.certificate_id, 199 | credentials=self.credentials, 200 | attempts=self.attempts, 201 | wait=self.wait) 202 | 203 | parser_mock = MagicMock() 204 | 205 | load_imap_credentials_mock.return_value = { 206 | 'server': self.server, 207 | 'username': self.username, 208 | 'password': self.password, 209 | } 210 | 211 | confirm_certificate_mock.return_value.__enter__.return_value.confirm_certificate.return_value = True 212 | cli._confirm_cert(args, parser_mock) 213 | 214 | # sleep is called with wait param 215 | sleep_mock.assert_called_once_with(self.wait) 216 | 217 | # credentials are loaded from load_imap_credentials function 218 | load_imap_credentials_mock.assert_called_once() 219 | 220 | # exit code is 0 with success error message 221 | parser_mock.exit.assert_called_once_with(0, "Success: certificate has been confirmed\n") 222 | 223 | # __enter__ method is called 224 | confirm_certificate_mock.return_value.__enter__.assert_called_once() 225 | 226 | # confirm certificate method is called 227 | confirm_certificate_mock.return_value.__enter__.return_value.confirm_certificate.assert_called_once_with(self.certificate_id) 228 | 229 | # __exit__ method is called 230 | confirm_certificate_mock.return_value.__exit__.assert_called_once() 231 | 232 | 233 | class TestRequestCert(unittest.TestCase): 234 | """ 235 | Tests for the _request_cert function 236 | """ 237 | def setUp(self): 238 | self.domain_name = 'www.example.com' 239 | self.subject_alternative_names = 'ftp.example.com' 240 | self.domain_validation_options = 'example.com' 241 | self.generate_cli_skeleton = False 242 | self.cli_input_json = False 243 | self.certificate_arn = 'arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012' 244 | 245 | @patch("acmagent.cli.request.RequestCertificate") 246 | def test_request_cert_generates_skeleton_when_generate_cli_skeleton_is_set(self, request_certificate_mock): 247 | args = NamespaceStub(generate_cli_skeleton=True) 248 | parser_mock = MagicMock() 249 | parser_mock.exit.side_effect = SystemExitStub 250 | 251 | with self.assertRaises(SystemExit): 252 | cli._request_cert(args, parser_mock) 253 | 254 | parser_mock.exit.assert_called_once_with(0, request.Certificate.template() + "\n") 255 | 256 | @patch("acmagent.cli.request.RequestCertificate") 257 | def test_request_cert_happy_path(self, request_certificate_mock): 258 | args = NamespaceStub(domain_name=self.domain_name, 259 | subject_alternative_names=self.subject_alternative_names, 260 | domain_validation_options=self.domain_validation_options, 261 | generate_cli_skeleton=False, 262 | cli_input_json=False 263 | ) 264 | parser_mock = MagicMock() 265 | 266 | certificate = dict(request.Certificate(args.__dict__)) 267 | request_certificate_mock.return_value.request_certificate.return_value = { 268 | 'CertificateArn': self.certificate_arn 269 | } 270 | cli._request_cert(args, parser_mock) 271 | 272 | # request_certificate method was called with certificate 273 | certificate_id = self.certificate_arn.split('/')[-1] 274 | request_certificate_mock.return_value.request_certificate.assert_called_once_with(certificate) 275 | parser_mock.exit.assert_called_once_with(0, "{}\n".format(certificate_id)) 276 | 277 | 278 | class TestArguments(unittest.TestCase): 279 | @patch("acmagent.cli.argparse.ArgumentParser.exit") 280 | def test_version_argument_uses_setup_file_value(self, argparse_mock): 281 | parser = cli._setup_argparser() 282 | parser.parse_args(['--version']) 283 | argparse_mock.assert_any_call(message=acmagent.VERSION+'\n') 284 | 285 | 286 | if __name__ == '__main__': 287 | unittest.main() -------------------------------------------------------------------------------- /tests/test_confirm.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | import acmagent 4 | import imaplib 5 | import requests 6 | from email.mime.multipart import MIMEMultipart 7 | from email.mime.text import MIMEText 8 | from acmagent import confirm 9 | from mock import patch 10 | import mock 11 | 12 | 13 | class CertificateFailedApprovalFormStub(object): 14 | def __init__(self, url, headers, data): 15 | self.ok = False 16 | 17 | class CertificateApprovalFormStub(object): 18 | def __init__(self, url, headers, data): 19 | self.ok = True 20 | 21 | class CertificateExpiredApprovalPageStub(object): 22 | def __init__(self, url, headers): 23 | self.content = """\ 24 | 25 | 26 |

27 | 28 | 29 | """ 30 | 31 | class CertificateApprovalPageStub(object): 32 | def __init__(self, url, headers): 33 | self.content = """\ 34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 | """ 42 | 43 | 44 | class TestConfirmCertificate(unittest.TestCase): 45 | def setUp(self): 46 | self.server = 'imap.example.com' 47 | self.username = 'test@example.com' 48 | self.password = 'my_imap_password' 49 | self.certificate_id = '12345678-1234-1234-1234-123456789012' 50 | self.email_id = '1' 51 | self.approval_url = 'test.com' 52 | 53 | msg = MIMEMultipart('alternative') 54 | msg['Subject'] = "Link" 55 | msg['From'] = 'admin@example.com' 56 | msg['To'] = 'user@example.com' 57 | html = """\ 58 | 59 | 60 | 61 | 62 | 63 | 64 | """.format(self.approval_url) 65 | msg.attach(MIMEText(html, 'html')) 66 | 67 | self.email_body = str(msg) 68 | 69 | @patch("imaplib.IMAP4_SSL") 70 | def test_confirm_certificate_connects_to_imap_using_provided_credentials(self, imap_mock): 71 | confirm_certificate = confirm.ConfirmCertificate({ 72 | 'server': self.server, 73 | 'username': self.username, 74 | 'password': self.password 75 | }) 76 | confirm_certificate._mail.login.assert_called_once_with(self.username, self.password) 77 | 78 | @patch("acmagent.confirm.requests.get", side_effect=CertificateApprovalPageStub) 79 | @patch("acmagent.confirm.requests.post", side_effect=CertificateApprovalFormStub) 80 | @patch("imaplib.IMAP4_SSL") 81 | def test_confirm_certificate_happy_path(self, imap_mock, post_mock, get_mock): 82 | confirm_certificate = confirm.ConfirmCertificate({ 83 | 'server': self.server, 84 | 'username': self.username, 85 | 'password': self.password 86 | }) 87 | confirm_certificate._mail.search.return_value = ('OK', [self.email_id]) 88 | confirm_certificate._mail.fetch.return_value = ('OK', [['', self.email_body]]) 89 | 90 | # successful request returns True 91 | self.assertTrue(confirm_certificate.confirm_certificate(self.certificate_id)) 92 | 93 | # specific IMAP folder was selected 94 | confirm_certificate._mail.select.assert_called_once_with(confirm.ConfirmCertificate.EMAIL_FOLDER) 95 | 96 | # message were searched using expected search query 97 | confirm_certificate._mail.search.assert_called_once_with(None, confirm.ConfirmCertificate._search_query(self.certificate_id)) 98 | 99 | # message was fetched 100 | confirm_certificate._mail.fetch.assert_called_once_with(self.email_id, '(RFC822)') 101 | 102 | # message was marked as read 103 | confirm_certificate._mail.store.assert_called_once_with(self.email_id, '+FLAGS', '\\Seen') 104 | 105 | # confirm url was requested using GET 106 | get_mock.assert_called_once_with(self.approval_url, headers=acmagent.UserHeaders) 107 | 108 | # confirm form was requested using POST 109 | post_mock.assert_called_once_with(confirm.ConfirmCertificate.APPROVAL_FORM_URL, headers=acmagent.UserHeaders, data={ 110 | 'test_input': 'test_value' 111 | }) 112 | 113 | @patch("imaplib.IMAP4_SSL") 114 | def test_confirm_certificate_raises_exception_if_email_is_not_found(self, imap_mock): 115 | confirm_certificate = confirm.ConfirmCertificate({ 116 | 'server': self.server, 117 | 'username': self.username, 118 | 'password': self.password 119 | }) 120 | confirm_certificate._mail.search.return_value = ('OK', ['']) 121 | with self.assertRaises(acmagent.NoEmailsFoundException): 122 | confirm_certificate.confirm_certificate(self.certificate_id) \ 123 | 124 | @patch("imaplib.IMAP4_SSL") 125 | def test_confirm_certificate_raises_exception_if_email_is_not_html(self, imap_mock): 126 | confirm_certificate = confirm.ConfirmCertificate({ 127 | 'server': self.server, 128 | 'username': self.username, 129 | 'password': self.password 130 | }) 131 | 132 | confirm_certificate._mail.search.return_value = ('OK', [self.email_id]) 133 | confirm_certificate._mail.fetch.return_value = ('OK', [['', '']]) 134 | 135 | with self.assertRaises(acmagent.EmailBodyUnknownContentType): 136 | confirm_certificate.confirm_certificate(self.certificate_id) 137 | 138 | @patch("imaplib.IMAP4_SSL") 139 | def test_confirm_certificate_raises_exception_if_email_is_missing_confirmation_url(self, imap_mock): 140 | confirm_certificate = confirm.ConfirmCertificate({ 141 | 'server': self.server, 142 | 'username': self.username, 143 | 'password': self.password 144 | }) 145 | 146 | msg = MIMEMultipart('alternative') 147 | msg['Subject'] = "Link" 148 | msg['From'] = 'admin@example.com' 149 | msg['To'] = 'user@example.com' 150 | html = """\ 151 | 152 | 153 | 154 | 155 | """.format(self.approval_url) 156 | msg.attach(MIMEText(html, 'html')) 157 | 158 | email_with_missing_approval_url = str(msg) 159 | 160 | confirm_certificate._mail.search.return_value = ('OK', [self.email_id]) 161 | confirm_certificate._mail.fetch.return_value = ('OK', [['', email_with_missing_approval_url]]) 162 | 163 | with self.assertRaises(acmagent.EmailBodyConfirmLinkIsMissingException): 164 | confirm_certificate.confirm_certificate(self.certificate_id) 165 | 166 | @patch("acmagent.confirm.requests.get", side_effect=CertificateExpiredApprovalPageStub) 167 | @patch("imaplib.IMAP4_SSL") 168 | def test_confirm_certificate_raises_exception_if_confirmation_page_is_missing_form(self, imap_mock, get_mock): 169 | confirm_certificate = confirm.ConfirmCertificate({ 170 | 'server': self.server, 171 | 'username': self.username, 172 | 'password': self.password 173 | }) 174 | 175 | confirm_certificate._mail.search.return_value = ('OK', [self.email_id]) 176 | confirm_certificate._mail.fetch.return_value = ('OK', [['', self.email_body]]) 177 | 178 | with self.assertRaises(acmagent.ConfirmPageIsMissingFormException): 179 | confirm_certificate.confirm_certificate(self.certificate_id) 180 | 181 | 182 | @patch("acmagent.confirm.requests.get", side_effect=CertificateApprovalPageStub) 183 | @patch("acmagent.confirm.requests.post", side_effect=CertificateFailedApprovalFormStub) 184 | @patch("imaplib.IMAP4_SSL") 185 | def test_confirm_certificate_raises_exception_if_form_submission_failed(self, imap_mock, post_mock, get_mock): 186 | confirm_certificate = confirm.ConfirmCertificate({ 187 | 'server': self.server, 188 | 'username': self.username, 189 | 'password': self.password 190 | }) 191 | 192 | confirm_certificate._mail.search.return_value = ('OK', [self.email_id]) 193 | confirm_certificate._mail.fetch.return_value = ('OK', [['', self.email_body]]) 194 | 195 | with self.assertRaises(acmagent.ACManagerException): 196 | confirm_certificate.confirm_certificate(self.certificate_id) 197 | 198 | if __name__ == '__main__': 199 | unittest.main() -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | import acmagent 4 | import mock 5 | from botocore.stub import Stubber 6 | import botocore.session 7 | from acmagent import request 8 | 9 | 10 | class TestCertificate(unittest.TestCase): 11 | def setUp(self): 12 | self.domain_name = 'test.example.com' 13 | self.subject_alternative_names = ['dev.example.com'] 14 | self.subject_no_alternative_names = [] 15 | self.domain_validation_options = 'example.com' 16 | self.no_domain_validation_options = None 17 | 18 | self.expected_certificate_with_alternative_names = { 19 | 'DomainName': self.domain_name, 20 | 'SubjectAlternativeNames': self.subject_alternative_names, 21 | 'DomainValidationOptions': [ 22 | { 23 | 'DomainName': self.subject_alternative_names[0], 24 | 'ValidationDomain': self.domain_validation_options 25 | }, 26 | { 27 | 'DomainName': self.domain_name, 28 | 'ValidationDomain': self.domain_validation_options 29 | } 30 | ] 31 | } 32 | self.expected_certificate_wo_alternative_names = { 33 | 'DomainName': self.domain_name, 34 | 'DomainValidationOptions': [ 35 | { 36 | 'DomainName': self.domain_name, 37 | 'ValidationDomain': self.domain_validation_options 38 | } 39 | ] 40 | } 41 | 42 | self.expected_certificate_with_only_domain_name = { 43 | 'DomainName': self.domain_name 44 | } 45 | 46 | def test_certificate_transformation_with_domain_name_only(self): 47 | certificate = request.Certificate({ 48 | 'domain_name': self.domain_name, 49 | 'subject_alternative_names': self.subject_no_alternative_names, 50 | 'domain_validation_options': self.no_domain_validation_options 51 | }) 52 | 53 | certificate_actual_dict = dict(certificate) 54 | 55 | self.assertDictEqual(self.expected_certificate_with_only_domain_name, certificate_actual_dict) 56 | 57 | def test_certificate_transformation_with_alternative_names(self): 58 | certificate = request.Certificate({ 59 | 'domain_name': self.domain_name, 60 | 'subject_alternative_names': self.subject_alternative_names, 61 | 'domain_validation_options': self.domain_validation_options 62 | }) 63 | 64 | certificate_actual_dict = dict(certificate) 65 | 66 | self.assertDictEqual(self.expected_certificate_with_alternative_names, certificate_actual_dict) 67 | 68 | def test_certificate_transformation_wo_alternative_names(self): 69 | certificate = request.Certificate({ 70 | 'domain_name': self.domain_name, 71 | 'subject_alternative_names': self.subject_no_alternative_names, 72 | 'domain_validation_options': self.domain_validation_options 73 | }) 74 | 75 | certificate_actual_dict = dict(certificate) 76 | self.assertDictEqual(self.expected_certificate_wo_alternative_names, certificate_actual_dict) 77 | 78 | def test_certificate_transformation_using_json_with_alternative_names(self): 79 | json_input = { 80 | 'DomainName': self.domain_name, 81 | 'ValidationDomain': self.domain_validation_options, 82 | 'SubjectAlternativeNames': self.subject_alternative_names 83 | } 84 | 85 | certificate = request.Certificate.from_json_input(json_input) 86 | certificate_actual_dict = dict(certificate) 87 | self.assertDictEqual(self.expected_certificate_with_alternative_names, certificate_actual_dict) 88 | 89 | def test_certificate_transformation_using_json_wo_alternative_names(self): 90 | json_input = { 91 | 'DomainName': self.domain_name, 92 | 'ValidationDomain': self.domain_validation_options, 93 | 'SubjectAlternativeNames': self.subject_no_alternative_names 94 | } 95 | 96 | certificate = request.Certificate.from_json_input(json_input) 97 | certificate_actual_dict = dict(certificate) 98 | self.assertDictEqual(self.expected_certificate_wo_alternative_names, certificate_actual_dict) 99 | 100 | def test_certificate_transformation_using_json_triggers_exception_if_properties_are_missing(self): 101 | json_input = { 102 | 'DomainName': self.domain_name, 103 | 'ValidationDomain': self.domain_validation_options 104 | } 105 | 106 | with self.assertRaises(acmagent.MissingCertificateArgException) as context: 107 | request.Certificate.from_json_input(json_input) 108 | 109 | def test_certificate_transformation_using_json_triggers_exception_if_unkown_property_is_given(self): 110 | json_input = { 111 | 'DomainName': self.domain_name, 112 | 'ValidationDomain': self.domain_validation_options, 113 | 'blabla': False 114 | } 115 | 116 | with self.assertRaises(acmagent.InvalidCertificateJsonFileException) as context: 117 | request.Certificate.from_json_input(json_input) 118 | 119 | def test_certificate_template_method_returns_required_structure(self): 120 | string = request.Certificate.template() 121 | certificate_template = json.loads(string) 122 | certificate_template_expected = { 123 | 'DomainName': '', 124 | 'ValidationDomain': '', 125 | 'SubjectAlternativeNames': [] 126 | } 127 | self.assertDictEqual(certificate_template_expected, certificate_template) 128 | 129 | 130 | class TestRequestCertificate(unittest.TestCase): 131 | def test_certificate_is_valid_for_boto3_request_certificate_method(self): 132 | certificate = request.Certificate({ 133 | 'domain_name': 'www.example.com', 134 | 'domain_validation_options': 'example.com', 135 | 'subject_alternative_names': [] 136 | }) 137 | 138 | certificate_params = dict(certificate) 139 | 140 | certificate_acm_params = { 141 | 'DomainName': 'www.example.com', 142 | 'DomainValidationOptions': [{ 143 | 'DomainName': 'www.example.com', 144 | 'ValidationDomain': 'example.com' 145 | }] 146 | } 147 | 148 | request_certificate = request.RequestCertificate() 149 | 150 | response = { 151 | 'CertificateArn': 'arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012' 152 | } 153 | 154 | with Stubber(request_certificate._acm_client) as stubber: 155 | stubber.add_response('request_certificate', response, certificate_acm_params) 156 | acm_response = request_certificate.request_certificate(certificate_params) 157 | 158 | self.assertDictEqual(acm_response, response) 159 | 160 | 161 | if __name__ == '__main__': 162 | unittest.main() 163 | --------------------------------------------------------------------------------