Compare commits

...

330 Commits

Author SHA1 Message Date
Emmanuel Garette 6f7ddb3a25 WIP: add OpenSSH plugin 2020-11-14 11:50:56 +01:00
Hossein Shafagh 95b24cbadc
Merge pull request #3251 from hosseinsh/release-0.8.0
preparing for release 0.8.0
2020-11-13 17:49:26 -08:00
Hossein Shafagh 50483c01da preparing for release 0.8.0 2020-11-13 17:37:01 -08:00
Hossein Shafagh 2d2ecdeee2
Merge pull request #3245 from hosseinsh/aws-pluging-S3-remove-acme-token
AWS plugin s3 adding remove acme token
2020-11-13 12:01:07 -08:00
Hossein Shafagh 02c7093b32
Merge branch 'master' into aws-pluging-S3-remove-acme-token 2020-11-11 16:48:00 -08:00
Hossein Shafagh 95854c6f68
Merge pull request #3167 from unic/feature/acme-http-challenge
Add support for acme http-01 challenge
2020-11-11 16:47:08 -08:00
Mathias Petermann 9fd3440cf6 Cleanup tests 2020-11-11 12:21:06 +01:00
Mathias Petermann 453826c59c Get rid of unnecessary current_app patches 2020-11-11 12:11:13 +01:00
Mathias Petermann 2b01bdb471 Refactor sftp plugin, to avoid duplicate code 2020-11-11 11:58:36 +01:00
Mathias Petermann 648565d3e9 Improve exception handling in lemur_sftp, Add Authentication failure test 2020-11-11 11:45:57 +01:00
Mathias Petermann e12ee1d89c Implement delete file and delete token tests 2020-11-11 11:23:55 +01:00
Mathias Petermann ae7a044b9c Add test for upload_acme_token 2020-11-11 11:13:09 +01:00
Mathias Petermann df11a03bde Implement sftp upload tests 2020-11-11 11:02:15 +01:00
Mathias Petermann 6e5aa4e979 Deduplicate chain/certificate extraction 2020-11-11 08:46:55 +01:00
Mathias Petermann 5cdd88e033 Remove unnecessary token from delete_acme_token 2020-11-11 08:34:40 +01:00
Mathias Petermann 7b1beb62b6 Add directory uri, to exception message 2020-11-11 08:05:59 +01:00
Mathias Petermann 7a7f05ec9e Fix comments in sftp delete_files 2020-11-11 08:05:37 +01:00
Hossein Shafagh 252f84cf21 adding also response to upload acme token, just for future use-cases 2020-11-10 17:46:00 -08:00
Hossein Shafagh ea77ef08aa testing for delete 2020-11-10 17:45:02 -08:00
Hossein Shafagh 8efa682858 add delete acme token 2020-11-10 17:43:35 -08:00
Mathias Petermann 31b5f3df86 Remove duplicate code for revoke_certificate 2020-11-10 18:18:45 +01:00
Mathias Petermann fba1fdcc34 Improve exception handling during http challenge 2020-11-10 18:06:19 +01:00
Mathias Petermann 9ebcdfc189 Check authorization state and skip already validated challenges 2020-11-10 17:10:43 +01:00
Mathias Petermann 6ffe7bc526 Check if challenges are already validated, and skip them if possible 2020-11-10 16:47:56 +01:00
Mathias Petermann 960b8e78e3 Implement cleanup_acme_token for http challenge 2020-11-10 16:22:25 +01:00
Mathias Petermann 99ca0ac78d Add context fix to tests, Add regex, Flake8 2020-11-10 15:32:04 +01:00
Mathias Petermann 4a181aff6e Merge branch 'master' into feature/acme-http-challenge 2020-11-10 15:20:47 +01:00
Hossein Shafagh f42d9539fc
Merge pull request #3237 from ExaneServerTeam/bugfix/group-lookup-fix-referral
Fix group lookup when AD DNS Referal is in lookup path
2020-11-09 14:38:12 -08:00
Hossein Shafagh 018f4a4b77
Merge branch 'master' into bugfix/group-lookup-fix-referral 2020-11-09 14:30:01 -08:00
dependabot-preview[bot] a7a5a8fb72
Merge pull request #3244 from Netflix/dependabot/pip/boto3-1.16.14 2020-11-09 22:29:14 +00:00
dependabot-preview[bot] 65d9ac6a0f
Bump boto3 from 1.16.9 to 1.16.14
Bumps [boto3](https://github.com/boto/boto3) from 1.16.9 to 1.16.14.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.16.9...1.16.14)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-09 22:22:52 +00:00
dependabot-preview[bot] 11386c6c7e
Merge pull request #3243 from Netflix/dependabot/pip/botocore-1.19.14 2020-11-09 22:20:20 +00:00
dependabot-preview[bot] 7ec2860f88
Bump botocore from 1.19.9 to 1.19.14
Bumps [botocore](https://github.com/boto/botocore) from 1.19.9 to 1.19.14.
- [Release notes](https://github.com/boto/botocore/releases)
- [Changelog](https://github.com/boto/botocore/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/botocore/compare/1.19.9...1.19.14)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-09 21:49:57 +00:00
dependabot-preview[bot] 76fb92d970
Merge pull request #3241 from Netflix/dependabot/pip/certifi-2020.11.8 2020-11-09 21:47:20 +00:00
dependabot-preview[bot] 4c6645ca04
Bump certifi from 2020.6.20 to 2020.11.8
Bumps [certifi](https://github.com/certifi/python-certifi) from 2020.6.20 to 2020.11.8.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2020.06.20...2020.11.08)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-09 21:40:24 +00:00
dependabot-preview[bot] 021f530810
Merge pull request #3240 from Netflix/dependabot/pip/faker-4.14.2 2020-11-09 21:37:39 +00:00
dependabot-preview[bot] a74b8aed15
Bump faker from 4.14.0 to 4.14.2
Bumps [faker](https://github.com/joke2k/faker) from 4.14.0 to 4.14.2.
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/joke2k/faker/compare/v4.14.0...v4.14.2)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-09 21:29:51 +00:00
dependabot-preview[bot] 307e4693c6
Merge pull request #3239 from Netflix/dependabot/pip/pytest-flask-1.1.0 2020-11-09 21:27:46 +00:00
dependabot-preview[bot] d3e8921731
Bump pytest-flask from 1.0.0 to 1.1.0
Bumps [pytest-flask](https://github.com/pytest-dev/pytest-flask) from 1.0.0 to 1.1.0.
- [Release notes](https://github.com/pytest-dev/pytest-flask/releases)
- [Changelog](https://github.com/pytest-dev/pytest-flask/blob/master/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-flask/compare/1.0.0...1.1.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-09 21:20:48 +00:00
Hossein Shafagh 40ef6d06d7
Merge pull request #3192 from hosseinsh/expanding-S3-plugin
Expanding the S3 plugin with acme-http01-token support
2020-11-09 13:18:38 -08:00
Frederic Brin 57208fe198 Fix group lookup when AD DNS Referal is in lookup path
Fix an issue when the DNS AD referal is in the path.
An Exception is raised, with the following stacktrace::

  Traceback (most recent call last):
    File "/www/lemur/lemur/auth/views.py", line 317, in post
      user = ldap_principal.authenticate()
    File "/www/lemur/lemur/auth/ldap.py", line 147, in authenticate
      self._bind()
    File "/www/lemur/lemur/auth/ldap.py", line 216, in _bind
      self.ldap_groups.append(values["cn"][0].decode("ascii"))
  TypeError: list indices must be integers or slices, not str

This is issue is trigerred by some extra rows that referrences
the DNS subtree::

   ['ldaps://DomainDnsZones.xxxx']

Limiting the extraction to the expected dicts fix this issue.
2020-11-09 09:40:28 +01:00
Hossein Shafagh 7c779d6283
regex 2020-11-06 22:41:48 -08:00
Hossein Shafagh 519411b309
regex 2020-11-06 22:40:55 -08:00
charhate 6fe855e824
Merge branch 'master' into expanding-S3-plugin 2020-11-05 12:12:45 -08:00
charhate cafc2c1d80
Merge pull request #3236 from charhate/ecc_changes
Version updates
2020-11-05 12:12:04 -08:00
sayali 320667935d flake8 version 3.8.4 2020-11-04 19:09:34 -08:00
charhate 894b74f523
Merge branch 'master' into expanding-S3-plugin 2020-11-04 18:39:23 -08:00
sayali 206d010c9a Version updates and making lint happy 2020-11-04 18:23:39 -08:00
charhate 92a555ba4b
Merge pull request #3233 from charhate/ecc_changes
Fixing build and test warnings
2020-11-04 18:16:35 -08:00
sayali 7d2ce61303 Updating comment for application context 2020-11-04 18:04:57 -08:00
charhate 8990209411
Merge branch 'master' into ecc_changes 2020-11-04 17:00:09 -08:00
Jasmine Schladen fb25d82eea
Merge pull request #3235 from jtschladen/stop-repeating-certs-in-security-emails
Stop repeating certs when sending expiration notifications to security team email
2020-11-04 11:09:04 -08:00
Jasmine Schladen 4d32adb3bf
Merge branch 'master' into stop-repeating-certs-in-security-emails 2020-11-04 10:58:20 -08:00
Jasmine Schladen 4cc0f6bb60 Stop repeating certs when sending expiration notifications to security team email 2020-11-04 10:53:27 -08:00
sayali ab014873d0 invalid escape sequence warning for not an escape char 2020-11-03 19:33:13 -08:00
sayali 003779a112 Mock fix for DeprecationWarning: callable is None 2020-11-03 19:27:41 -08:00
sayali dc7497e29d Fix Working outside of application context Test Failures in dev 2020-11-03 19:05:18 -08:00
Hossein Shafagh 2a61206fdf
Merge branch 'master' into expanding-S3-plugin 2020-11-03 17:25:06 -08:00
Mathias Petermann c71dbcb0a0 Fix duplicate tests 2020-11-03 09:48:25 +01:00
Mathias Petermann bc564b574d Merge branch 'master' into feature/acme-http-challenge 2020-11-03 09:36:37 +01:00
sayali 3d64aa8d11 Fixing DeprecationWarning: callable is None: another syntax 2020-11-02 18:58:38 -08:00
sayali 86b2cfbe4a invalid escape sequence \ 2020-11-02 18:45:38 -08:00
sayali b75bd56546 Check if ValueError assert works old way 2020-11-02 18:29:22 -08:00
sayali 6922d34825 invalid escape sequence \ 2020-11-02 18:16:15 -08:00
sayali 825a001a8b pass algorithm to jwt.decode() during login
api_jwt.py : pass "algorithms" argument when calling decode(). This argument will be mandatory in a future version
2020-11-02 17:37:04 -08:00
sayali d88da028b1 Replace binary with LargeBinary
https://flask-appbuilder.readthedocs.io/en/latest/_modules/sqlalchemy/sql/sqltypes.html
2020-11-02 17:37:04 -08:00
sayali d821024e35 Fixing DeprecationWarning: callable is None 2020-11-02 17:37:04 -08:00
sayali 2dac95c6fb Replacing PassiveDefault (deprecated) with DefaultClause 2020-11-02 17:37:04 -08:00
sayali 4ffced70f8 backref cannot be set for viewonly relationship
will be deprecated in SQLAlchemy 1.4, and will be disallowed in a future release
2020-11-02 17:37:04 -08:00
sayali 634339eac6 replacing imp (deprecated) with importlib 2020-11-02 17:37:04 -08:00
charhate 5569c9d8e1
Merge pull request #3232 from Netflix/hosseinsh-travis-ci-email
Disabling travis-ci emaill notification on success
2020-11-02 17:25:21 -08:00
Hossein Shafagh 4659df42d5
better formatting 2020-11-02 15:49:11 -08:00
Hossein Shafagh e9b0e2eca7
Merge branch 'master' into hosseinsh-travis-ci-email 2020-11-02 14:27:03 -08:00
dependabot-preview[bot] 9ad9f349ba
Merge pull request #3231 from Netflix/dependabot/pip/boto3-1.16.9 2020-11-02 19:28:40 +00:00
dependabot-preview[bot] fa620e539d
Bump boto3 from 1.16.5 to 1.16.9
Bumps [boto3](https://github.com/boto/boto3) from 1.16.5 to 1.16.9.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.16.5...1.16.9)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-02 19:21:05 +00:00
dependabot-preview[bot] c4e3998715
Merge pull request #3228 from Netflix/dependabot/pip/pytest-6.1.2 2020-11-02 19:18:14 +00:00
dependabot-preview[bot] fc2fce6c0b
Bump pytest from 6.1.1 to 6.1.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.1.1 to 6.1.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/6.1.1...6.1.2)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-02 19:10:18 +00:00
dependabot-preview[bot] 3f46b0b6d7
Merge pull request #3227 from Netflix/dependabot/pip/sphinx-3.3.0 2020-11-02 19:08:20 +00:00
dependabot-preview[bot] 2331638ed1
Bump sphinx from 3.2.1 to 3.3.0
Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/3.x/CHANGES)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v3.2.1...v3.3.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-02 19:01:19 +00:00
dependabot-preview[bot] 01f31772bb
Merge pull request #3229 from Netflix/dependabot/pip/pre-commit-2.8.2 2020-11-02 18:59:04 +00:00
dependabot-preview[bot] 771c272895
Bump pre-commit from 2.7.1 to 2.8.2
Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.7.1 to 2.8.2.
- [Release notes](https://github.com/pre-commit/pre-commit/releases)
- [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pre-commit/pre-commit/compare/v2.7.1...v2.8.2)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-02 18:51:46 +00:00
dependabot-preview[bot] 3fb0aae43a
Merge pull request #3226 from Netflix/dependabot/pip/cryptography-3.2.1 2020-11-02 18:49:55 +00:00
Hossein Shafagh a15e1831d0
turning off Travis-ci notification on success 2020-11-02 09:43:09 -08:00
dependabot-preview[bot] a4d2f79a9b
Bump cryptography from 3.2 to 3.2.1
Bumps [cryptography](https://github.com/pyca/cryptography) from 3.2 to 3.2.1.
- [Release notes](https://github.com/pyca/cryptography/releases)
- [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/3.2...3.2.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-11-02 13:34:06 +00:00
Hossein Shafagh 9c6856bcdd adjusting the tests to the better naming 2020-10-30 18:36:32 -07:00
Hossein Shafagh 7bca42776b better comments 2020-10-30 18:28:34 -07:00
Hossein Shafagh 3dfafa0021 making lint happy 2020-10-30 18:28:10 -07:00
Hossein Shafagh add0960579 more meaningful variable naming 2020-10-30 18:18:37 -07:00
Hossein Shafagh e1ff89eb2d better return arguments 2020-10-30 18:18:14 -07:00
Hossein Shafagh cc2aa5c1de cli for live testing 2020-10-30 18:17:34 -07:00
Hossein Shafagh ba8eb7a3f5 better logging and metrics 2020-10-30 18:17:02 -07:00
Hossein Shafagh c5769378cf
making lint happy 2020-10-30 15:21:22 -07:00
Hossein Shafagh f90041353c
Merge branch 'master' into expanding-S3-plugin 2020-10-30 15:19:26 -07:00
charhate 8eba97fd14
Merge pull request #3223 from charhate/ecc_changes
Small fixes
2020-10-30 11:06:02 -07:00
Hossein Shafagh d41daeb4af
Merge branch 'master' into ecc_changes 2020-10-30 10:55:23 -07:00
Chad S 507e3caee5
Merge pull request #3224 from Netflix/cname_01
Fixing floating comma in CNAME PR
2020-10-29 19:09:47 -07:00
Chad S cc05d21260
Merge branch 'master' into cname_01 2020-10-29 18:59:43 -07:00
csine-nflx a4178ca113 fixing floating comma in CNAME PR 2020-10-29 18:52:22 -07:00
charhate 69aa98c1c8
Merge branch 'master' into ecc_changes 2020-10-29 18:05:38 -07:00
sayali 03dfbf535d Consistent algo in UI and API
Removed '-' since UI displays only handful options
2020-10-29 17:59:31 -07:00
sayali aec24ae132 Missing commit in downgrade 2020-10-29 17:58:37 -07:00
sayali 4e44dd3d8f Check if authority options is JSON Array 2020-10-29 17:57:54 -07:00
charhate 4330a42dd3
Merge pull request #3196 from GnunuX/url_context_path
Do not add urlContextPath to relative path
2020-10-29 16:20:05 -07:00
Hossein Shafagh 725eee549d
Merge branch 'master' into url_context_path 2020-10-29 15:11:39 -07:00
Hossein Shafagh 497bd6a13c
Merge pull request #3209 from jtschladen/notification-plugin-field-fix
Fix plugin field on notification edit
2020-10-29 15:08:12 -07:00
Hossein Shafagh 945ec0895b
Merge branch 'master' into url_context_path 2020-10-29 15:06:50 -07:00
Hossein Shafagh 9aa2d2af76
Merge branch 'master' into notification-plugin-field-fix 2020-10-29 14:58:43 -07:00
Chad S 4bc5899e24
Merge pull request #3221 from Netflix/cname_01
Delegated CNAME DNS validation for ACME
2020-10-29 14:50:19 -07:00
csine-nflx ccecb26816 Merge branch 'cname_01' of github.com:Netflix/lemur into cname_01 2020-10-29 14:43:14 -07:00
csine-nflx ca465e3c9e updating debug string with target_domain 2020-10-29 14:42:51 -07:00
Jasmine Schladen a3a02a8077
Merge branch 'master' into notification-plugin-field-fix 2020-10-29 14:32:57 -07:00
Hossein Shafagh 2aec317127
Merge branch 'master' into cname_01 2020-10-29 14:32:23 -07:00
Jasmine Schladen 83d363fd9e
Merge pull request #3219 from jtschladen/certificates-for-notification-fix
Fix notification view to actually show associated certs
2020-10-29 14:29:55 -07:00
Jasmine Schladen 86207db93b
Merge branch 'master' into certificates-for-notification-fix 2020-10-29 14:21:25 -07:00
Hossein Shafagh 15a7921bf4
Merge branch 'master' into cname_01 2020-10-29 14:09:48 -07:00
Jasmine Schladen 84f8905cf1 Hide expired certs for notifications 2020-10-29 14:07:25 -07:00
Hossein Shafagh cca4670745
Merge pull request #3220 from jtschladen/ses-arn-override
Add ability to override SourceArn for SES
2020-10-29 14:06:05 -07:00
Chad S 14348a1f95
Merge branch 'master' into cname_01 2020-10-29 14:01:14 -07:00
Hossein Shafagh 28c6f8583a
Merge branch 'master' into ses-arn-override 2020-10-29 13:52:51 -07:00
csine-nflx a1f99c29c0 Merge branch 'cname_01' of github.com:Netflix/lemur into cname_01 2020-10-29 13:51:58 -07:00
Jasmine Schladen aa2e0aa2f9
Merge pull request #3222 from jtschladen/sns-date-subject
Add subject for SNS messages and correct date format
2020-10-29 13:51:26 -07:00
csine-nflx 2b91077d92 updating variables based on feedback 2020-10-29 13:51:22 -07:00
Jasmine Schladen 28686fcf5d Merge branch 'ses-arn-override' of github.com:jtschladen/lemur into ses-arn-override 2020-10-29 13:48:55 -07:00
Jasmine Schladen 45cc9528d2 Cleaner syntax for default region 2020-10-29 13:48:43 -07:00
Jasmine Schladen 78afc060ae Add subject for SNS messages and correct date format 2020-10-29 13:41:47 -07:00
Hossein Shafagh e967f2c676
Merge branch 'master' into ses-arn-override 2020-10-29 11:11:30 -07:00
Hossein Shafagh 2cea33cb11
Merge branch 'master' into expanding-S3-plugin 2020-10-29 11:09:00 -07:00
Chad S af348b1012
Merge branch 'master' into cname_01 2020-10-28 22:41:23 -07:00
csine-nflx 33a006bbeb fixing delete with optional validation 2020-10-28 22:24:37 -07:00
csine-nflx b47667b73e cname redirection working 2020-10-28 20:51:35 -07:00
Jasmine Schladen 3e492e6310 Add ability to override SES region 2020-10-28 17:09:54 -07:00
charhate ff83721720
Merge pull request #3211 from charhate/ecc_changes
Modify description during reissue
2020-10-28 17:08:03 -07:00
charhate bbfc65813d
Merge branch 'master' into ecc_changes 2020-10-28 17:00:45 -07:00
charhate 166dfa89ad
Merge pull request #3204 from GnunuX/log_update
do not create db_upgrade.log during migrations
2020-10-28 17:00:22 -07:00
charhate 6adf94d28f
Merge branch 'master' into log_update 2020-10-28 16:52:19 -07:00
charhate 43ebc5aac1
Merge pull request #3212 from hosseinsh/issuer-retry
Issuer retry
2020-10-28 16:51:58 -07:00
charhate 9fd61a37dc
Merge branch 'master' into log_update 2020-10-28 16:39:18 -07:00
Hossein Shafagh 576302fdd5
Merge branch 'master' into issuer-retry 2020-10-28 16:35:25 -07:00
Hossein Shafagh 54566ad4c3
Merge pull request #3218 from Netflix/hosseinsh-travis-contact-update
updating notification contact for travis-ci
2020-10-28 16:34:57 -07:00
Jasmine Schladen 5e696f36bf Add ability to override SourceArnn for SES 2020-10-28 16:34:31 -07:00
Jasmine Schladen acc95a4b66 Fix notification view to actually show associated certs 2020-10-28 16:12:27 -07:00
charhate c25782468b
Merge branch 'master' into log_update 2020-10-28 15:40:16 -07:00
Hossein Shafagh c0bf111cb9
updating notification contact for travis 2020-10-28 15:02:22 -07:00
Hossein Shafagh cc69b433ca
Merge branch 'master' into notification-plugin-field-fix 2020-10-28 14:58:58 -07:00
csine-nflx d27f2a53af Merge branch 'master' of github.com:Netflix/lemur into cname_01 2020-10-28 14:03:23 -07:00
charhate 95b647ee1d
Merge branch 'master' into ecc_changes 2020-10-28 13:54:14 -07:00
Hossein Shafagh 84d30b5d50
Merge branch 'master' into issuer-retry 2020-10-28 13:21:10 -07:00
Jasmine Schladen 31b9e2cd20
Merge pull request #3201 from jtschladen/sns
Support for SNS Expiration Notifications
2020-10-28 13:19:54 -07:00
Jasmine Schladen 13e8421c78
Merge branch 'master' into notification-plugin-field-fix 2020-10-28 08:50:46 -07:00
Jasmine Schladen 16ce7970d0
Merge branch 'master' into sns 2020-10-28 08:50:09 -07:00
Hossein Shafagh a9d3b7a676
Merge branch 'master' into issuer-retry 2020-10-28 08:48:29 -07:00
Hossein Shafagh adca20ade1
Merge pull request #3214 from hosseinsh/improving-cn-owner-search-endpooint
Improving cn/owner search endpoint
2020-10-28 08:39:10 -07:00
Mathias Petermann 23e1700fad flake8 2020-10-28 13:47:57 +01:00
Mathias Petermann b656e0d75a
Merge branch 'master' into feature/acme-http-challenge 2020-10-28 13:46:29 +01:00
Jasmine Schladen b7b7e9022f
Merge branch 'master' into sns 2020-10-27 17:41:29 -07:00
Jasmine Schladen 794e4d3855 Revert log to debug to be safe 2020-10-27 17:36:01 -07:00
Hossein Shafagh 61ef7f207d
Merge branch 'master' into improving-cn-owner-search-endpooint 2020-10-27 17:01:27 -07:00
Hossein Shafagh 44e4100a39
Merge pull request #3217 from hosseinsh/travis-dist-update
Travis dist update
2020-10-27 17:00:52 -07:00
Hossein Shafagh 2dd9ea3d01 Merge branch 'master' of github.com:Netflix/lemur into travis-dist-update
* 'master' of github.com:Netflix/lemur:
  Bump boto3 from 1.15.16 to 1.16.5
  Bump cryptography from 3.1.1 to 3.2
  Bump fakeredis from 1.4.3 to 1.4.4
2020-10-27 16:43:13 -07:00
Hossein Shafagh a1af7c89b1 we have been running on bionic since some time 2020-10-27 16:43:02 -07:00
Hossein Shafagh c6a8034890
language 2020-10-27 16:13:05 -07:00
Hossein Shafagh f77c262953 how did that happen 2020-10-27 14:44:18 -07:00
Hossein Shafagh 10aa02fd85 more compact design, thanks to Chad for the feedback 2020-10-27 14:42:51 -07:00
Hossein Shafagh 54c2245115 comments 2020-10-27 12:47:35 -07:00
Hossein Shafagh d59a558d58 adopting ilike and not relying on ==
reducing redundancy
2020-10-27 12:44:38 -07:00
Emmanuel Garette e9824a6808 change the log level to info if upgrade is successful 2020-10-27 20:38:18 +01:00
Emmanuel Garette 79647e3372 add reference to LOG_UPGRADE_FILE in toplevel comment 2020-10-27 20:38:18 +01:00
Hossein Shafagh 7f25e02589
Merge branch 'master' into expanding-S3-plugin 2020-10-27 12:02:14 -07:00
Hossein Shafagh 729a6e69f5
Merge branch 'master' into sns 2020-10-27 12:02:01 -07:00
Hossein Shafagh 46ec1798d3
Merge branch 'master' into log_update 2020-10-27 12:01:53 -07:00
Hossein Shafagh 437933c558
Merge branch 'master' into issuer-retry 2020-10-27 12:01:11 -07:00
Hossein Shafagh 259a8808f1
Merge branch 'master' into improving-cn-owner-search-endpooint 2020-10-27 12:00:45 -07:00
Hossein Shafagh 56061a7e3b
Merge branch 'master' into url_context_path 2020-10-27 12:00:00 -07:00
Hossein Shafagh 7a982a731a
Merge branch 'master' into ecc_changes 2020-10-27 11:58:47 -07:00
dependabot-preview[bot] dfdb26f994
Merge pull request #3216 from Netflix/dependabot/pip/boto3-1.16.5 2020-10-27 18:56:52 +00:00
Hossein Shafagh 97f66276ec
Merge branch 'master' into log_update 2020-10-27 10:53:20 -07:00
dependabot-preview[bot] 196a311084
Bump boto3 from 1.15.16 to 1.16.5
Bumps [boto3](https://github.com/boto/boto3) from 1.15.16 to 1.16.5.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.15.16...1.16.5)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-10-27 17:26:18 +00:00
dependabot-preview[bot] 3551437d9c
Merge pull request #3215 from Netflix/dependabot/pip/cryptography-3.2 2020-10-27 17:23:05 +00:00
Jasmine Schladen 20b8c2fd93 PR feedback 2020-10-27 08:56:43 -07:00
dependabot-preview[bot] 5c3758731c
Bump cryptography from 3.1.1 to 3.2
Bumps [cryptography](https://github.com/pyca/cryptography) from 3.1.1 to 3.2.
- [Release notes](https://github.com/pyca/cryptography/releases)
- [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/3.1.1...3.2)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-10-27 13:45:59 +00:00
Mathias Petermann ccf87986c0 Add store_account to AcmeDnsIssuer 2020-10-27 12:15:07 +01:00
Mathias Petermann 96fbcdaf70 Fix test_finalize_authorizations, dont reuse cleanup_dns_challenges in finalize_authorizations 2020-10-27 11:27:44 +01:00
Mathias Petermann 103e107668 Fix patches for test_create_certificate 2020-10-27 11:16:29 +01:00
Mathias Petermann 82bf8e2ac6 Remove unnecessary code from dnsChallenge, Fix patches in dns tests 2020-10-27 11:09:30 +01:00
Mathias Petermann 2d98e71977 Replace deprecated assertRaisesRegexp with assertRaisesRegex 2020-10-27 10:44:04 +01:00
Mathias Petermann 30c10b93f8 Fix patches for acme_handler tests 2020-10-27 10:37:30 +01:00
Mathias Petermann 3b20a47603 Fix patches for acme_http tests, apparently isinstance is considered evil in python 2020-10-27 10:37:30 +01:00
Mathias Petermann 4464c5890d Flake8 2020-10-27 10:37:30 +01:00
Mathias Petermann 812e1dee92 Refactor Acme plugin into AcmeChallenge objects, dns01 2020-10-27 10:37:27 +01:00
Mathias Petermann b91cebf245 Refactor Acme plugin into AcmeChallenge objects, http01 2020-10-27 10:36:06 +01:00
Mathias Petermann 6c1be02bfa Remove destination_list from AcmeHttpIssuer 2020-10-27 10:28:34 +01:00
Mathias Petermann ef0fce2661 Set timeout for finalize to 90s 2020-10-27 10:28:34 +01:00
Mathias Petermann 235653b558 Refactor destination selection for acme-http authorities, to load destinations dynamically 2020-10-27 10:28:34 +01:00
Mathias Petermann 81b078604c Implement revoke certificate for ACME 2020-10-27 10:28:34 +01:00
Mathias Petermann 215070b327 Fix create_certificate tests 2020-10-27 10:28:34 +01:00
Mathias Petermann 41ea59d7e3 Remove unneeded polling 2020-10-27 10:28:33 +01:00
Mathias Petermann d24fae0bac Fix permissions on acme token upload, dont append well-known automatically 2020-10-27 10:28:33 +01:00
Mathias Petermann 66cab6abd3 Make http-01 challenge work for SAN certificates 2020-10-27 10:28:33 +01:00
Mathias Petermann e3e5ef7d66 Refactor AcmeHandler, Move DNS stuff into AcmeDnsHandler 2020-10-27 10:28:33 +01:00
Mathias Petermann 76dcfbd528 Add more tests 2020-10-27 10:28:33 +01:00
Mathias Petermann d6719b729c Implement some test for AcmeHttpIssuerPlugin 2020-10-27 10:28:33 +01:00
Mathias Petermann b2de986652 Split tests into handler, and dns specifics 2020-10-27 10:28:30 +01:00
Mathias Petermann b93d271f31 Fix flake8 2020-10-27 10:25:31 +01:00
Mathias Petermann e06bdcf2a3 Implement create_certificate for HTTP-01 challenge 2020-10-27 10:25:31 +01:00
Mathias Petermann 3012995c76 Improve naming, make it possible to create directories recursively with SFTP 2020-10-27 10:25:31 +01:00
Mathias Petermann 348d8477dd Refactor destination plugin, to allow upload of ACME http-challenge tokens 2020-10-27 10:25:31 +01:00
Mathias Petermann d00dd9d295 Initial structure for ACME http challenge 2020-10-27 10:25:31 +01:00
Hossein Shafagh faceee0f77
Merge branch 'master' into expanding-S3-plugin 2020-10-26 19:42:56 -07:00
Hossein Shafagh 56a4200d2c
Merge branch 'master' into notification-plugin-field-fix 2020-10-26 19:42:39 -07:00
Hossein Shafagh 645b45401d
Merge branch 'master' into sns 2020-10-26 19:42:21 -07:00
Hossein Shafagh 3b258447db addressing Chad's feedbakc 2020-10-26 19:16:40 -07:00
Hossein Shafagh 2430507e55 Merge branch 'master' of github.com:Netflix/lemur into improving-cn-owner-search-endpooint
* 'master' of github.com:Netflix/lemur:
  Bump fakeredis from 1.4.3 to 1.4.4
2020-10-26 18:34:40 -07:00
Hossein Shafagh 1ef6139f9b ignore rotated certs, since there is a new cert that can be used 2020-10-26 18:34:21 -07:00
Hossein Shafagh 6a1b4b4857 ignore expired certs 2020-10-26 18:33:33 -07:00
Hossein Shafagh 709a9808aa better structure of the query and and removing ilike 2020-10-26 18:32:53 -07:00
Hossein Shafagh cb4f814478
Merge branch 'master' into issuer-retry 2020-10-26 18:01:12 -07:00
Hossein Shafagh 4fffb8ba5b
Merge branch 'master' into log_update 2020-10-26 18:01:01 -07:00
Hossein Shafagh a87bf0d50a
Merge branch 'master' into url_context_path 2020-10-26 17:59:57 -07:00
Hossein Shafagh 831e5619e1
Merge pull request #3198 from Netflix/dependabot/pip/fakeredis-1.4.4
Bump fakeredis from 1.4.3 to 1.4.4
2020-10-26 17:58:27 -07:00
Hossein Shafagh ab47db4cd4
Merge branch 'master' into log_update 2020-10-26 17:57:27 -07:00
sayali 392725ff30 Add description check in reissue unit test 2020-10-26 15:33:20 -07:00
csine-nflx 749aa772ba First change to get CNAME redirection working 2020-10-26 11:57:33 -07:00
Jasmine Schladen 2d94b19c32 Merge branch 'notification-plugin-field-fix' of github.com:jtschladen/lemur into notification-plugin-field-fix 2020-10-26 11:27:53 -07:00
Jasmine Schladen 3f765b51ef Fix sources and destinations, and allow actually updating the notification type 2020-10-26 11:27:40 -07:00
Hossein Shafagh 6723e3c80d now fixing the month to minute bug 2020-10-26 11:27:40 -07:00
Hossein Shafagh 3290d6634b fixing testing 2020-10-26 11:27:40 -07:00
Hossein Shafagh fa62023b2d fixing the time bug, sub-second to second, and month to minute! 2020-10-26 11:27:40 -07:00
dependabot-preview[bot] 37f05a89f2 Bump botocore from 1.18.16 to 1.18.18
Bumps [botocore](https://github.com/boto/botocore) from 1.18.16 to 1.18.18.
- [Release notes](https://github.com/boto/botocore/releases)
- [Changelog](https://github.com/boto/botocore/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/botocore/compare/1.18.16...1.18.18)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-10-26 11:27:40 -07:00
Emmanuel Garette d7478a5c5c use an alternative logger for the upgrade 2020-10-25 19:24:17 +01:00
Hossein Shafagh f6554a9a1e typo, fixing abstract class complaints 2020-10-23 18:03:55 -07:00
Hossein Shafagh 0e02abbb37 Entrust just looks into CSR for RSA/EC key type 2020-10-23 18:03:27 -07:00
Hossein Shafagh 9957120a7f adding missing import 2020-10-23 18:03:07 -07:00
Hossein Shafagh 7e573d6d51 fixing typo 2020-10-23 18:02:54 -07:00
Hossein Shafagh 6891077501 readability 2020-10-23 18:02:35 -07:00
Hossein Shafagh 75bc3a5b20 refactoring and adding retry 2020-10-23 18:02:05 -07:00
Hossein Shafagh d233490c8a simple retry 2020-10-23 18:01:14 -07:00
Hossein Shafagh 2c1e7b19a2 10x 10s delay might be too long for the load balancer request 2020-10-23 17:59:58 -07:00
Hossein Shafagh db0b245b6c
Merge branch 'master' into sns 2020-10-23 17:12:06 -07:00
sayali 2c22d42a57 Modify description during reissue
Include the certificate ID being reissued and mention that this is created by Lemur as part of reissue
2020-10-23 17:07:14 -07:00
Hossein Shafagh 0a05f99741
Merge branch 'master' into notification-plugin-field-fix 2020-10-23 16:39:01 -07:00
Hossein Shafagh d9bbf42480
Merge branch 'master' into dependabot/pip/fakeredis-1.4.4 2020-10-23 16:37:34 -07:00
dependabot-preview[bot] d58b32a19c
Merge pull request #3199 from Netflix/dependabot/pip/botocore-1.18.18 2020-10-23 22:56:13 +00:00
Hossein Shafagh 3d83db6f8f
Merge branch 'master' into expanding-S3-plugin 2020-10-23 14:13:30 -07:00
Hossein Shafagh 2b7cb0d44f
Merge branch 'master' into url_context_path 2020-10-23 14:13:25 -07:00
Hossein Shafagh fd16edb3e5
Merge branch 'master' into notification-plugin-field-fix 2020-10-23 14:13:21 -07:00
Hossein Shafagh 30915d30be
Merge branch 'master' into log_update 2020-10-23 14:13:17 -07:00
Hossein Shafagh 5b523bb8ed
Merge branch 'master' into dependabot/pip/fakeredis-1.4.4 2020-10-23 14:12:39 -07:00
Hossein Shafagh 584159c916
Merge branch 'master' into dependabot/pip/botocore-1.18.18 2020-10-23 14:12:35 -07:00
Hossein Shafagh 01bd357b1c
Merge branch 'master' into sns 2020-10-23 11:38:35 -07:00
Jasmine Schladen fd12d4848c Grammar fixes 2020-10-23 11:26:11 -07:00
Hossein Shafagh 582c7b0771
Merge pull request #3210 from hosseinsh/digicert-time-bug-fix
Digicert time bug fix
2020-10-23 10:48:18 -07:00
Hossein Shafagh 1495fb3595 now fixing the month to minute bug 2020-10-23 10:18:24 -07:00
Hossein Shafagh bc6fb02fc2 fixing testing 2020-10-23 10:16:38 -07:00
Hossein Shafagh e01863097b fixing the time bug, sub-second to second, and month to minute! 2020-10-23 10:16:23 -07:00
Jasmine Schladen a5cea4fb9a Skip revoked certs when looking for certs to notify 2020-10-23 09:42:03 -07:00
Jasmine Schladen 233f9768e8 Fix error handling 2020-10-23 09:35:46 -07:00
Hossein Shafagh 5ccc99bbfa
Merge branch 'master' into dependabot/pip/botocore-1.18.18 2020-10-23 09:08:47 -07:00
Jasmine Schladen 98962ae5f5
Merge branch 'master' into sns 2020-10-23 08:50:26 -07:00
Hossein Shafagh 41ac43013d
Merge branch 'master' into notification-plugin-field-fix 2020-10-23 08:43:29 -07:00
Hossein Shafagh 2ea39a51e3
Merge pull request #3208 from hosseinsh/improved-logging
Improved issuer logging
2020-10-23 08:43:11 -07:00
Hossein Shafagh 2b274f723a
Merge branch 'master' into improved-logging 2020-10-23 07:59:30 -07:00
Hossein Shafagh e87cf040f3
Merge pull request #3207 from hosseinsh/entrust-deactivate
Entrust deactivate test certificates
2020-10-23 07:59:15 -07:00
Jasmine Schladen 71df6b8560 Fix plugin field on notification edit 2020-10-22 18:15:26 -07:00
Hossein Shafagh 8610af8b83
more precise language 2020-10-22 17:54:46 -07:00
Hossein Shafagh 820106e333
Merge branch 'master' into expanding-S3-plugin 2020-10-22 17:35:20 -07:00
Hossein Shafagh 9ce0010bf1 handle_respone can also handle the no data response 2020-10-22 17:33:39 -07:00
Hossein Shafagh cf87e178c8 making lint happy 2020-10-22 17:33:02 -07:00
Hossein Shafagh 97f80b79dc adjusting digicert test to support seconds 2020-10-22 17:23:33 -07:00
Hossein Shafagh 9acd974b74 fixing the test to support seconds 2020-10-22 17:20:47 -07:00
Hossein Shafagh ae1e9d120b consistent messaging 2020-10-22 17:13:58 -07:00
Hossein Shafagh 2e7652962c refactoring of the error handling 2020-10-22 17:11:02 -07:00
Hossein Shafagh 1c96ea9ab1 better messaging of exceptions 2020-10-22 17:10:32 -07:00
Hossein Shafagh 02c040865d more meaningful message 2020-10-22 16:05:29 -07:00
Hossein Shafagh 8fa90a2ce5 digicert expects also seconds, though not yet honoring it 2020-10-22 16:01:09 -07:00
Hossein Shafagh c60645bec4 improved logging for all responses 2020-10-22 16:00:26 -07:00
Hossein Shafagh c2fe2b5e03 improved logging for all responses 2020-10-22 15:59:59 -07:00
Hossein Shafagh 03d1af16e7 better logging for exceptions around all plugins 2020-10-22 15:59:38 -07:00
Hossein Shafagh 3e1e17998e
Merge branch 'master' into url_context_path 2020-10-22 12:04:11 -07:00
Hossein Shafagh 2b876f22a5
Merge branch 'master' into log_update 2020-10-22 12:00:51 -07:00
Hossein Shafagh 2e7e3a82fa
Update cli.py
logging in exception
2020-10-22 11:57:54 -07:00
Hossein Shafagh c40ecd12cb improved naming 2020-10-22 10:58:16 -07:00
Hossein Shafagh 2cc03088cd creating a celery task 2020-10-21 19:53:08 -07:00
Hossein Shafagh a4dba0cb35 creating a cli to handle entrust deactivation 2020-10-21 19:52:51 -07:00
Hossein Shafagh 906b3b2337 better handling of status code 2020-10-21 19:52:25 -07:00
Hossein Shafagh 92eec5cc9c revocation should only check for not expired and not revoked certs 2020-10-21 18:52:55 -07:00
charhate 55f219e97a
Merge pull request #3206 from charhate/ecc_changes
Check if present - Organization, State, Country
2020-10-21 16:25:03 -07:00
Hossein Shafagh adf8f37718
Merge branch 'master' into log_update 2020-10-21 16:03:46 -07:00
sayali 43483cb1c7 Check if present - Organization, State, Country 2020-10-21 15:44:53 -07:00
charhate 2ccb7034b5
Merge pull request #3205 from charhate/ecc_changes
Check if OU and L is present in subject
2020-10-21 13:11:04 -07:00
Hossein Shafagh 0986a7a3ff
Merge branch 'master' into ecc_changes 2020-10-21 12:32:35 -07:00
sayali 757e190b60 Check if OU and L is present in subject
fixing index out of range
2020-10-21 12:11:41 -07:00
Emmanuel Garette 9374adaa46 do not create db_upgrade.log during migrations 2020-10-21 11:17:54 +02:00
charhate c1bf192bd8
Merge pull request #3203 from charhate/ecc_changes
Removing ECC 192 and 521 from UI
2020-10-20 18:14:08 -07:00
Hossein Shafagh 18fdd420a7
Merge branch 'master' into ecc_changes 2020-10-20 18:08:16 -07:00
sayali 4997165235 Removing ECC 192 and 521 from UI
not CAB supported. Keeping 521 for authority
2020-10-20 17:59:50 -07:00
charhate 59a97cde1d
Merge pull request #3202 from charhate/ecc_changes
Fix cert reissue when L/OU is not set
2020-10-20 17:23:31 -07:00
sayali 01dddd2a55 iterate over subject details 2020-10-20 17:17:28 -07:00
sayali 788703ce12 Fix cert reissue when L/OU is not set
get_certificate_primitives complains with None L/OU
2020-10-20 16:44:17 -07:00
Jasmine Schladen 1fc9cd2ff8
Merge branch 'master' into sns 2020-10-20 12:13:51 -07:00
Jasmine Schladen 4f552cb636 Code cleanup 2020-10-20 12:02:36 -07:00
Jasmine Schladen d6075ebc11 Merge 2020-10-20 11:48:54 -07:00
Hossein Shafagh 63ace016f9
Merge branch 'master' into url_context_path 2020-10-20 10:23:08 -07:00
dependabot-preview[bot] a3b90c1a6b
Bump botocore from 1.18.16 to 1.18.18
Bumps [botocore](https://github.com/boto/botocore) from 1.18.16 to 1.18.18.
- [Release notes](https://github.com/boto/botocore/releases)
- [Changelog](https://github.com/boto/botocore/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/botocore/compare/1.18.16...1.18.18)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-10-20 09:23:10 +00:00
dependabot-preview[bot] 58798fbc2e
Bump fakeredis from 1.4.3 to 1.4.4
Bumps [fakeredis](https://github.com/jamesls/fakeredis) from 1.4.3 to 1.4.4.
- [Release notes](https://github.com/jamesls/fakeredis/releases)
- [Commits](https://github.com/jamesls/fakeredis/compare/1.4.3...1.4.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-10-20 09:21:21 +00:00
charhate ea33fe9979
Merge pull request #3197 from charhate/ecc_changes
Show only few supported ECC algorithms on UI
2020-10-19 17:53:48 -07:00
Hossein Shafagh 5cf9ea4830
Merge branch 'master' into url_context_path 2020-10-19 17:50:43 -07:00
sayali 855baadfee Show only few supported ECC algorithms on UI 2020-10-19 17:42:52 -07:00
Jasmine Schladen 669a4273c2 Merge branch 'master' of github.com:jtschladen/lemur into sns 2020-10-19 16:29:33 -07:00
Jasmine Schladen ad07b41763
Merge pull request #3193 from jtschladen/notification-fixes
Miscellaneous notification fixes and tests
2020-10-19 16:17:57 -07:00
Jasmine Schladen b5f0fc5a19 Fix syntax error 2020-10-19 15:21:34 -07:00
Jasmine Schladen ecd4d6ebe3 Change string formatting pattern 2020-10-19 15:12:48 -07:00
Hossein Shafagh af3afe36e1
Merge branch 'master' into expanding-S3-plugin 2020-10-19 14:23:01 -07:00
Emmanuel Garette 591c8cf524 Do not add urlContextPath to relative path 2020-10-19 22:35:10 +02:00
Jasmine Schladen e90b08b363 Correct typo and enable Slack notification test 2020-10-16 17:08:44 -07:00
Jasmine Schladen 6a1889787d Correct log attributes 2020-10-16 16:30:21 -07:00
Jasmine Schladen 2c92fc6eb9 Merge branch 'notification-fixes' of github.com:jtschladen/lemur into notification-fixes 2020-10-16 16:22:28 -07:00
Jasmine Schladen 072b337f37 Restructure log messages 2020-10-16 16:21:43 -07:00
Jasmine Schladen fe5d75c7f8
Merge branch 'master' into notification-fixes 2020-10-16 15:20:42 -07:00
Jasmine Schladen 60bb0037f0 Miscellaneous notification fixes and tests 2020-10-16 15:13:12 -07:00
Hossein Shafagh dbdfa9eab8
Merge branch 'master' into expanding-S3-plugin 2020-10-16 11:35:38 -07:00
Jasmine Schladen a04cce6044 Initial implementation 2020-10-16 10:40:11 -07:00
Hossein Shafagh 503530e935 the test requires region param for sts 2020-10-16 10:32:10 -07:00
Hossein Shafagh 11ce540246 formatting 2020-10-16 10:31:19 -07:00
Hossein Shafagh 9c04a888d8 adjusting the S3 test 2020-10-16 09:52:04 -07:00
Hossein Shafagh 17e528b5dd adding testing for acme_upload method 2020-10-16 09:50:35 -07:00
Hossein Shafagh d705e3ae3b expanding the S3 destination plugin to support the acme token upload inteface 2020-10-16 09:49:56 -07:00
Hossein Shafagh 7d8eb1c61e improving test 2020-10-16 09:49:26 -07:00
Hossein Shafagh 6aad37e1f9 cleaning up code 2020-10-16 09:49:00 -07:00
Hossein Shafagh d73db59d23 revsering removing region 2020-10-16 09:48:47 -07:00
Hossein Shafagh bfe89e131e adding delete and put interfaces for the S3 plugin 2020-10-15 18:13:50 -07:00
81 changed files with 3366 additions and 1065 deletions

View File

@ -1,5 +1,5 @@
language: python language: python
dist: xenial dist: bionic
node_js: node_js:
- "6.2.0" - "6.2.0"
@ -47,4 +47,7 @@ after_success:
notifications: notifications:
email: email:
ccastrapel@netflix.com recipients:
- lemur@netflix.com
on_success: never
on_failure: always

View File

@ -1,6 +1,87 @@
Changelog Changelog
========= =========
0.8.0 - `2020-11-13`
~~~~~~~~~~~~~~
This release comes after more than two years and contains many interesting new features and improvements.
In addition to multiple new plugins, such as ACME-http01, ADCS, PowerDNS, UltraDNS, Entrust, SNS, many of Lemur's existing
flows have improved.
In the future, we plan to do frequent releases.
Summary of notable changes:
- AWS S3 plugin: added delete, get methods, and support for uploading/deleting acme tokens
- ACME plugin:
- revamp of the plugin
- support for http01 domain validation, via S3 and SFTP as destination for the acme token
- support for CNAME delegated domain validation
- store-acme-account-details
- PowerDNS plugin
- UltraDNS plugin
- ADCS plugin
- SNS plugin
- Entrust plugin
- Rotation:
- respecting keyType and extensions
- region-by-region rotation option
- default to auto-rotate when cert attached to endpoint
- default to 1y validity during rotation for multi-year browser-trusted certs
- Certificate: search_by_name, and important performance improvements
- UI
- reducing the EC curve options to the relevant ones
- edit option for notifications, destinations and sources
- showing 13 month validity as default
- option to hide certs expired since 3month
- faster Permalink (no search involved)
- commonName Auto Added as DNS in the UI
- improved search and cert lookup
- celery tasks instead of crone, for better logging and monitoring
- countless bugfixes
- group-lookup-fix-referral
- url_context_path
- duplicate notification
- digicert-time-bug-fix
- improved-csr-support
- fix-cryptography-intermediate-ca
- enhanced logging
- vault-k8s-auth
- cfssl-key-fix
- cert-sync-endpoint-find-by-hash
- nlb-naming-bug
- fix_vault_api_v2_append
- aid_openid_roles_provider_integration
- rewrite-java-keystore-use-pyjks
- vault_kv2
To see the full list of changes, you can run
$ git log --merges --first-parent master --pretty=format:"%h %<(10,trunc)%aN %C(white)%<(15)%ar%Creset %C(red bold)%<(15)%D%Creset %s" | grep -v "depend"
Special thanks to all who contributed to this release, notably:
- `peschmae <https://github.com/peschmae>`_
- `sirferl <https://github.com/sirferl>`_
- `lukasmrtvy <https://github.com/lukasmrtvy>`_
- `intgr <https://github.com/intgr>`_
- `kush-bavishi <https://github.com/kush-bavishi>`_
- `alwaysjolley <https://github.com/alwaysjolley>`_
- `jplana <https://github.com/jplana>`_
- `explody <https://github.com/explody>`_
- `titouanc <https://github.com/titouanc>`_
- `jramosf <https://github.com/jramosf>`_
Upgrading
---------
.. note:: This release will need a migration change. Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
0.7 - `2018-05-07` 0.7 - `2018-05-07`
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~

View File

@ -28,6 +28,13 @@ Basic Configuration
LOG_FILE = "/logs/lemur/lemur-test.log" LOG_FILE = "/logs/lemur/lemur-test.log"
.. data:: LOG_UPGRADE_FILE
:noindex:
::
LOG_UPGRADE_FILE = "/logs/lemur/db_upgrade.log"
.. data:: DEBUG .. data:: DEBUG
:noindex: :noindex:
@ -269,7 +276,7 @@ Certificates marked as inactive will **not** be notified of upcoming expiration.
silence the expiration. If a certificate is active and is expiring the above will be notified according to the `LEMUR_DEFAULT_EXPIRATION_NOTIFICATION_INTERVALS` or silence the expiration. If a certificate is active and is expiring the above will be notified according to the `LEMUR_DEFAULT_EXPIRATION_NOTIFICATION_INTERVALS` or
30, 15, 2 days before expiration if no intervals are set. 30, 15, 2 days before expiration if no intervals are set.
Lemur supports sending certification expiration notifications through SES and SMTP. Lemur supports sending certificate expiration notifications through SES and SMTP.
.. data:: LEMUR_EMAIL_SENDER .. data:: LEMUR_EMAIL_SENDER
@ -285,6 +292,25 @@ Lemur supports sending certification expiration notifications through SES and SM
you can send any mail. See: `Verifying Email Address in Amazon SES <http://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html>`_ you can send any mail. See: `Verifying Email Address in Amazon SES <http://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html>`_
.. data:: LEMUR_SES_SOURCE_ARN
:noindex:
Specifies an ARN to use as the SourceArn when sending emails via SES.
.. note::
This parameter is only required if you're using a sending authorization with SES.
See: `Using sending authorization with Amazon SES <https://docs.aws.amazon.com/ses/latest/DeveloperGuide/sending-authorization.html>`_
.. data:: LEMUR_SES_REGION
:noindex:
Specifies a region for sending emails via SES.
.. note::
This parameter defaults to us-east-1 and is only required if you wish to use a different region.
.. data:: LEMUR_EMAIL .. data:: LEMUR_EMAIL
:noindex: :noindex:
@ -664,6 +690,20 @@ If you are not using a metric provider you do not need to configure any of these
Plugin Specific Options Plugin Specific Options
----------------------- -----------------------
ACME Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. data:: ACME_DNS_PROVIDER_TYPES
:noindex:
Dictionary of ACME DNS Providers and their requirements.
.. data:: ACME_ENABLE_DELEGATED_CNAME
:noindex:
Enables delegated DNS domain validation using CNAMES. When enabled, Lemur will attempt to follow CNAME records to authoritative DNS servers when creating DNS-01 challenges.
Active Directory Certificate Services Plugin Active Directory Certificate Services Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -1436,7 +1476,7 @@ Slack
Adds support for slack notifications. Adds support for slack notifications.
AWS AWS (Source)
---- ----
:Authors: :Authors:
@ -1449,7 +1489,7 @@ AWS
Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment. Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment.
AWS AWS (Destination)
---- ----
:Authors: :Authors:
@ -1462,6 +1502,19 @@ AWS
Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment. Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment.
AWS (SNS Notification)
-----
:Authors:
Jasmine Schladen <jschladen@netflix.com>
:Type:
Notification
:Description:
Adds support for SNS notifications. SNS notifications (like other notification plugins) are currently only supported
for certificate expiration. Configuration requires a region, account number, and SNS topic name; these elements
are then combined to build the topic ARN. Lemur must have access to publish messages to the specified SNS topic.
Kubernetes Kubernetes
---------- ----------

View File

@ -215,18 +215,21 @@ Notification
------------ ------------
Lemur includes the ability to create Email notifications by **default**. These notifications Lemur includes the ability to create Email notifications by **default**. These notifications
currently come in the form of expiration notices. Lemur periodically checks certifications expiration dates and currently come in the form of expiration and rotation notices. Lemur periodically checks certificate expiration dates and
determines if a given certificate is eligible for notification. There are currently only two parameters used to determines if a given certificate is eligible for notification. There are currently only two parameters used to
determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number
of days the current date (UTC) is from that expiration date. of days the current date (UTC) is from that expiration date.
There are currently two objects that available for notification plugins the first is `NotficationPlugin`. This is the base object for Expiration notifications can also be configured for Slack or AWS SNS. Rotation notifications are not configurable.
any notification within Lemur. Currently the only support notification type is an certificate expiration notification. If you Notifications sent to a certificate owner and security team (`LEMUR_SECURITY_TEAM_EMAIL`) can currently only be sent via email.
There are currently two objects that are available for notification plugins. The first is `NotificationPlugin`, which is the base object for
any notification within Lemur. Currently the only supported notification type is a certificate expiration notification. If you
are trying to create a new notification type (audit, failed logins, etc.) this would be the object to base your plugin on. are trying to create a new notification type (audit, failed logins, etc.) this would be the object to base your plugin on.
You would also then need to build additional code to trigger the new notification type. You would also then need to build additional code to trigger the new notification type.
The second is `ExpirationNotificationPlugin`, this object inherits from `NotificationPlugin` object. The second is `ExpirationNotificationPlugin`, which inherits from the `NotificationPlugin` object.
You will most likely want to base your plugin on, if you want to add new channels for expiration notices (Slack, HipChat, Jira, etc.). It adds default options that are required by You will most likely want to base your plugin on this object if you want to add new channels for expiration notices (HipChat, Jira, etc.). It adds default options that are required by
all expiration notifications (interval, unit). This interface expects for the child to define the following function:: all expiration notifications (interval, unit). This interface expects for the child to define the following function::
def send(self, notification_type, message, targets, options, **kwargs): def send(self, notification_type, message, targets, options, **kwargs):

View File

@ -555,3 +555,122 @@ Using `python-jwt` converting an existing private key in PEM format is quite eas
{"body": {}, "uri": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/<ACCOUNT_NUMBER>"} {"body": {}, "uri": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/<ACCOUNT_NUMBER>"}
The URI can be retrieved from the ACME create account endpoint when creating a new account, using the existing key. The URI can be retrieved from the ACME create account endpoint when creating a new account, using the existing key.
OpenSSH
=======
OpenSSH (also known as OpenBSD Secure Shell) is a suite of secure networking utilities based on the Secure Shell (SSH) protocol, which provides a secure channel over an unsecured network in a clientserver architecture.
Using a PKI with OpenSSH means you can sign a key for a user and it can log into any server that trust the CA.
Using a CA avoids TOFU or synchronize a list of server public keys to `known_hosts` files.
This is useful when you're managing large number of machines or for an immutable infrastructure.
Add first OpenSSH authority
---------------------------
To start issuing OpenSSH, you need to create an OpenSSH authority. To do this, visit
Authorities -> Create. Set the applicable attributes:
- Name : OpenSSH
- Common Name: example.net
Then click "More Options" and change the plugin value to "OpenSSH".
Just click to "Create" button to add this authority.
.. note:: OpenSSH do not support sub CA feature.
Add a server certificate
-------------------------
Now visit Certificates -> Create to add a server certificate. Set the applicable attributes:
- Common Name: server.example.net
Then click "More Options" and set the Certificate Template to "Server Certificate".
This step is important, a certificat for a server and for a client is not exactly the same thing.
In this case "Common Name" and all Subject Alternate Names with type DNSName will be added in the certificate.
Finally click on "Create" button.
Add a client certificate
------------------------
Now visit Certificates -> Create to add a client certificate. Set the applicable attributes:
- Common Name: example.net
Then click "More Options" and set the Certificate Template to "Client Certificate".
In this case the name of the creator is used as principal (in this documentation we assume that this certificate is created by the user "lemur").
Finally click on "Create" button.
Configure OpenSSH server
------------------------
Connect to the server.example.net server to correctly configure the OpenSSH server with the CA created previously.
First of all add the CA chain, private and public certificates:
- Create file `/etc/ssh/ca.pub` and copy the "CHAIN" content of the *server certificate* (everything in one line).
- Create file `/etc/ssh/ssh_host_key` and copy "PRIVATE KEY" content.
- Create file `/etc/ssh/ssh_host_key.pub` and copy "PUBLIC CERTIFICATE" content (everything in one line).
Set the appropriate right:
.. code-block:: bash
chmod 600 /etc/ssh/ca.pub /etc/ssh/ssh_host_key
chmod 644 /etc/ssh/ssh_host_key.pub
chown root: /etc/ssh/ca.pub /etc/ssh/ssh_host_key /etc/ssh/ssh_host_key.pub
Then change OpenSSH server configuration to use these files. Edit `/etc/ssh/sshd_config` and add::
TrustedUserCAKeys /etc/ssh/ca.pub
HostKey /etc/ssh/ssh_host_key
HostCertificate /etc/ssh/ssh_host_key.pub
You can remove all other `HostKey` lines.
Finally restart OpenSSH.
.. note:: By default the server public certificate is sign for 2 weeks. You must update the `/etc/ssh/ssh_host_key.pub` file before this delay. You can use the config's parameter OPENSSH_VALID_INTERVAL_SERVER to change this behavor (unit is number of day).
Configure the OpenSSH client
----------------------------
Now you can configure the user's computer.
First of all add private and public certificates:
- Create file `~/.ssh/key` and copy "PRIVATE KEY" content.
- Create file `~/.ssh/key.pub` and copy "PUBLIC CERTIFICATE" content of the *client certicate* (everything in one line).
Set the appropriate right:
.. code-block:: bash
chmod 600 ~/.ssh/key.pub ~/.ssh/key
To avoid TOFU, edite the `~/.ssh/known_hosts` file and add a new line (all in one line):
- @cert-authority \*example.net
- the "CHAIN" content
Now you can connect to server with (here 'lemur' is the principal name and must exists on the server):
.. code-block:: bash
ssh lemur@server.example.net -i ~/.ssh/key
With this configuration you don't have any line like::
Warning: Permanently added 'server.example.net,192.168.0.1' (RSA) to the list of known hosts.
And you don't have to enter any password.
.. note:: By default the client public certificate is sign for 1 day. You must update the `.ssh/key.pub` everyday. You can use the config's parameter OPENSSH_VALID_INTERVAL_CLIENT to change this behavor (unit is number of day).

View File

@ -237,7 +237,7 @@ gulp.task('addUrlContextPath',['addUrlContextPath:revreplace'], function(){
.forEach(function(file){ .forEach(function(file){
return gulp.src(file) return gulp.src(file)
.pipe(gulpif(urlContextPathExists, replace('api/', argv.urlContextPath + '/api/'))) .pipe(gulpif(urlContextPathExists, replace('api/', argv.urlContextPath + '/api/')))
.pipe(gulpif(urlContextPathExists, replace('angular/', argv.urlContextPath + '/angular/'))) .pipe(gulpif(urlContextPathExists, replace('/angular/', '/' + argv.urlContextPath + '/angular/')))
.pipe(gulp.dest(function(file){ .pipe(gulp.dest(function(file){
return file.base; return file.base;
})) }))
@ -256,7 +256,6 @@ gulp.task('addUrlContextPath:revreplace', ['addUrlContextPath:revision'], functi
var manifest = gulp.src("lemur/static/dist/rev-manifest.json"); var manifest = gulp.src("lemur/static/dist/rev-manifest.json");
var urlContextPathExists = argv.urlContextPath ? true : false; var urlContextPathExists = argv.urlContextPath ? true : false;
return gulp.src( "lemur/static/dist/index.html") return gulp.src( "lemur/static/dist/index.html")
.pipe(gulpif(urlContextPathExists, revReplace({prefix: argv.urlContextPath + '/', manifest: manifest}, revReplace({manifest: manifest}))))
.pipe(gulp.dest('lemur/static/dist')); .pipe(gulp.dest('lemur/static/dist'));
}) })

View File

@ -15,7 +15,7 @@ __title__ = "lemur"
__summary__ = "Certificate management and orchestration service" __summary__ = "Certificate management and orchestration service"
__uri__ = "https://github.com/Netflix/lemur" __uri__ = "https://github.com/Netflix/lemur"
__version__ = "0.7.0" __version__ = "0.8.0"
__author__ = "The Lemur developers" __author__ = "The Lemur developers"
__email__ = "security@netflix.com" __email__ = "security@netflix.com"

View File

@ -1,12 +1,15 @@
import time import time
import json import json
import arrow
from flask_script import Manager from flask_script import Manager
from flask import current_app from flask import current_app
from lemur.extensions import sentry from lemur.extensions import sentry
from lemur.constants import SUCCESS_METRIC_STATUS from lemur.constants import SUCCESS_METRIC_STATUS
from lemur.plugins import plugins
from lemur.plugins.lemur_acme.plugin import AcmeHandler from lemur.plugins.lemur_acme.plugin import AcmeHandler
from lemur.plugins.lemur_aws import s3
manager = Manager( manager = Manager(
usage="Handles all ACME related tasks" usage="Handles all ACME related tasks"
@ -84,3 +87,105 @@ def dnstest(domain, token):
status = SUCCESS_METRIC_STATUS status = SUCCESS_METRIC_STATUS
print("[+] Done with ACME Tests.") print("[+] Done with ACME Tests.")
@manager.option(
"-t",
"--token",
dest="token",
default="date: " + arrow.utcnow().format("YYYY-MM-DDTHH-mm-ss"),
required=False,
help="Value of the Token",
)
@manager.option(
"-n",
"--token_name",
dest="token_name",
default="Token-" + arrow.utcnow().format("YYYY-MM-DDTHH-mm-ss"),
required=False,
help="path",
)
@manager.option(
"-p",
"--prefix",
dest="prefix",
default="test/",
required=False,
help="S3 bucket prefix",
)
@manager.option(
"-a",
"--account_number",
dest="account_number",
required=True,
help="AWS Account",
)
@manager.option(
"-b",
"--bucket_name",
dest="bucket_name",
required=True,
help="Bucket Name",
)
def upload_acme_token_s3(token, token_name, prefix, account_number, bucket_name):
"""
This method serves for testing the upload_acme_token to S3, fetching the token to verify it, and then deleting it.
It mainly serves for testing purposes.
:param token:
:param token_name:
:param prefix:
:param account_number:
:param bucket_name:
:return:
"""
additional_options = [
{
"name": "bucket",
"value": bucket_name,
"type": "str",
"required": True,
"validation": r"[0-9a-z.-]{3,63}",
"helpMessage": "Must be a valid S3 bucket name!",
},
{
"name": "accountNumber",
"type": "str",
"value": account_number,
"required": True,
"validation": r"[0-9]{12}",
"helpMessage": "A valid AWS account number with permission to access S3",
},
{
"name": "region",
"type": "str",
"default": "us-east-1",
"required": False,
"helpMessage": "Region bucket exists",
"available": ["us-east-1", "us-west-2", "eu-west-1"],
},
{
"name": "encrypt",
"type": "bool",
"value": False,
"required": False,
"helpMessage": "Enable server side encryption",
"default": True,
},
{
"name": "prefix",
"type": "str",
"value": prefix,
"required": False,
"helpMessage": "Must be a valid S3 object prefix!",
},
]
p = plugins.get("aws-s3")
p.upload_acme_token(token_name, token, additional_options)
if not prefix.endswith("/"):
prefix + "/"
token_res = s3.get(bucket_name, prefix + token_name, account_number=account_number)
assert(token_res == token)
s3.delete(bucket_name, prefix + token_name, account_number=account_number)

View File

@ -210,7 +210,8 @@ class LdapPrincipal:
self.ldap_groups = [] self.ldap_groups = []
for group in lgroups: for group in lgroups:
(dn, values) = group (dn, values) = group
self.ldap_groups.append(values["cn"][0].decode("ascii")) if type(values) == dict:
self.ldap_groups.append(values["cn"][0].decode("ascii"))
else: else:
lgroups = self.ldap_client.search_s( lgroups = self.ldap_client.search_s(
self.ldap_base_dn, ldap.SCOPE_SUBTREE, ldap_filter, self.ldap_attrs self.ldap_base_dn, ldap.SCOPE_SUBTREE, ldap_filter, self.ldap_attrs

View File

@ -101,7 +101,8 @@ def login_required(f):
return dict(message="Token is invalid"), 403 return dict(message="Token is invalid"), 403
try: try:
payload = jwt.decode(token, current_app.config["LEMUR_TOKEN_SECRET"]) header_data = fetch_token_header(token)
payload = jwt.decode(token, current_app.config["LEMUR_TOKEN_SECRET"], algorithms=[header_data["alg"]])
except jwt.DecodeError: except jwt.DecodeError:
return dict(message="Token is invalid"), 403 return dict(message="Token is invalid"), 403
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:

View File

@ -18,7 +18,7 @@ from sqlalchemy import (
func, func,
ForeignKey, ForeignKey,
DateTime, DateTime,
PassiveDefault, DefaultClause,
Boolean, Boolean,
) )
from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import JSON
@ -39,7 +39,7 @@ class Authority(db.Model):
plugin_name = Column(String(64)) plugin_name = Column(String(64))
description = Column(Text) description = Column(Text)
options = Column(JSON) options = Column(JSON)
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False) date_created = Column(DateTime, DefaultClause(func.now()), nullable=False)
roles = relationship( roles = relationship(
"Role", "Role",
secondary=roles_authorities, secondary=roles_authorities,
@ -93,9 +93,11 @@ class Authority(db.Model):
if not self.options: if not self.options:
return None return None
for option in json.loads(self.options): options_array = json.loads(self.options)
if "name" in option and option["name"] == 'cab_compliant': if isinstance(options_array, list):
return option["value"] for option in options_array:
if "name" in option and option["name"] == 'cab_compliant':
return option["value"]
return None return None

View File

@ -7,7 +7,7 @@
""" """
from flask import current_app from flask import current_app
from marshmallow import fields, validates_schema, pre_load from marshmallow import fields, validates_schema, pre_load, post_dump
from marshmallow import validate from marshmallow import validate
from marshmallow.exceptions import ValidationError from marshmallow.exceptions import ValidationError
@ -24,6 +24,7 @@ from lemur.common import validators, missing
from lemur.common.fields import ArrowDateTime from lemur.common.fields import ArrowDateTime
from lemur.constants import CERTIFICATE_KEY_TYPES from lemur.constants import CERTIFICATE_KEY_TYPES
from lemur.plugins.base import plugins
class AuthorityInputSchema(LemurInputSchema): class AuthorityInputSchema(LemurInputSchema):
@ -129,6 +130,12 @@ class AuthorityOutputSchema(LemurOutputSchema):
default_validity_days = fields.Integer() default_validity_days = fields.Integer()
authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema) authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema)
@post_dump
def handle_auth_certificate(self, cert):
# Plugins may need to modify the cert object before returning it to the user
plugin = plugins.get(cert['plugin']['slug'])
plugin.wrap_auth_certificate(cert['authority_certificate'])
class AuthorityNestedOutputSchema(LemurOutputSchema): class AuthorityNestedOutputSchema(LemurOutputSchema):
__envelope__ = False __envelope__ = False

View File

@ -735,3 +735,45 @@ def automatically_enable_autorotate():
}) })
cert.rotation = True cert.rotation = True
database.update(cert) database.update(cert)
@manager.command
def deactivate_entrust_certificates():
"""
Attempt to deactivate test certificates issued by Entrust
"""
log_data = {
"function": f"{__name__}.{sys._getframe().f_code.co_name}",
"message": "Deactivating Entrust certificates"
}
certificates = get_all_valid_certs(['entrust-issuer'])
entrust_plugin = plugins.get('entrust-issuer')
for cert in certificates:
try:
response = entrust_plugin.deactivate_certificate(cert)
if response == 200:
cert.status = "revoked"
else:
cert.status = "unknown"
log_data["valid"] = cert.status
log_data["certificate_name"] = cert.name
log_data["certificate_id"] = cert.id
metrics.send(
"certificate_deactivate",
"counter",
1,
metric_tags={"status": log_data["valid"],
"certificate_name": log_data["certificate_name"],
"certificate_id": log_data["certificate_id"]},
)
current_app.logger.info(log_data)
database.update(cert)
except Exception as e:
current_app.logger.info(log_data)
sentry.captureException()
current_app.logger.exception(e)

View File

@ -16,7 +16,7 @@ from sqlalchemy import (
Integer, Integer,
ForeignKey, ForeignKey,
String, String,
PassiveDefault, DefaultClause,
func, func,
Column, Column,
Text, Text,
@ -138,7 +138,7 @@ class Certificate(db.Model):
not_after = Column(ArrowType) not_after = Column(ArrowType)
not_after_ix = Index("ix_certificates_not_after", not_after.desc()) not_after_ix = Index("ix_certificates_not_after", not_after.desc())
date_created = Column(ArrowType, PassiveDefault(func.now()), nullable=False) date_created = Column(ArrowType, DefaultClause(func.now()), nullable=False)
signing_algorithm = Column(String(128)) signing_algorithm = Column(String(128))
status = Column(String(128)) status = Column(String(128))
@ -184,7 +184,6 @@ class Certificate(db.Model):
"PendingCertificate", "PendingCertificate",
secondary=pending_cert_replacement_associations, secondary=pending_cert_replacement_associations,
backref="pending_replace", backref="pending_replace",
viewonly=True,
) )
logs = relationship("Log", backref="certificate") logs = relationship("Log", backref="certificate")

View File

@ -38,6 +38,7 @@ from lemur.schemas import (
AssociatedRotationPolicySchema, AssociatedRotationPolicySchema,
) )
from lemur.users.schemas import UserNestedOutputSchema from lemur.users.schemas import UserNestedOutputSchema
from lemur.plugins.base import plugins
class CertificateSchema(LemurInputSchema): class CertificateSchema(LemurInputSchema):
@ -324,6 +325,8 @@ class CertificateOutputSchema(LemurOutputSchema):
notifications = fields.Nested(NotificationNestedOutputSchema, many=True) notifications = fields.Nested(NotificationNestedOutputSchema, many=True)
replaces = fields.Nested(CertificateNestedOutputSchema, many=True) replaces = fields.Nested(CertificateNestedOutputSchema, many=True)
authority = fields.Nested(AuthorityNestedOutputSchema) authority = fields.Nested(AuthorityNestedOutputSchema)
# if this certificate is an authority, the authority informations are in root_authority
root_authority = fields.Nested(AuthorityNestedOutputSchema)
dns_provider = fields.Nested(DnsProvidersNestedOutputSchema) dns_provider = fields.Nested(DnsProvidersNestedOutputSchema)
roles = fields.Nested(RoleNestedOutputSchema, many=True) roles = fields.Nested(RoleNestedOutputSchema, many=True)
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
@ -340,6 +343,8 @@ class CertificateOutputSchema(LemurOutputSchema):
@post_dump @post_dump
def handle_subject_details(self, data): def handle_subject_details(self, data):
subject_details = ["country", "state", "location", "organization", "organizational_unit"]
# Remove subject details if authority is CA/Browser Forum compliant. The code will use default set of values in that case. # Remove subject details if authority is CA/Browser Forum compliant. The code will use default set of values in that case.
# If CA/Browser Forum compliance of an authority is unknown (None), it is safe to fallback to default values. Thus below # If CA/Browser Forum compliance of an authority is unknown (None), it is safe to fallback to default values. Thus below
# condition checks for 'not False' ==> 'True or None' # condition checks for 'not False' ==> 'True or None'
@ -347,11 +352,23 @@ class CertificateOutputSchema(LemurOutputSchema):
is_cab_compliant = data.get("authority").get("isCabCompliant") is_cab_compliant = data.get("authority").get("isCabCompliant")
if is_cab_compliant is not False: if is_cab_compliant is not False:
data.pop("country", None) for field in subject_details:
data.pop("state", None) data.pop(field, None)
data.pop("location", None)
data.pop("organization", None) # Removing subject fields if None, else it complains in de-serialization
data.pop("organizational_unit", None) for field in subject_details:
if field in data and data[field] is None:
data.pop(field)
@post_dump
def handle_certificate(self, cert):
# Plugins may need to modify the cert object before returning it to the user
if cert['root_authority'] and cert['authority'] is None:
# this certificate is an authority
cert['authority'] = cert['root_authority']
del cert['root_authority']
plugin = plugins.get(cert['authority']['plugin']['slug'])
plugin.wrap_certificate(cert)
class CertificateShortOutputSchema(LemurOutputSchema): class CertificateShortOutputSchema(LemurOutputSchema):

View File

@ -6,11 +6,13 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import arrow import arrow
import re
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from flask import current_app from flask import current_app
from sqlalchemy import func, or_, not_, cast, Integer from sqlalchemy import func, or_, not_, cast, Integer
from sqlalchemy.sql.expression import false, true
from lemur import database from lemur import database
from lemur.authorities.models import Authority from lemur.authorities.models import Authority
@ -85,6 +87,16 @@ def get_by_attributes(conditions):
return database.find_all(query, Certificate, conditions).all() return database.find_all(query, Certificate, conditions).all()
def get_by_root_authority(id):
"""
Retrieves certificate by its root_authority's id.
:param id:
:return:
"""
return database.get(Certificate, id, field="root_authority_id")
def delete(cert_id): def delete(cert_id):
""" """
Delete's a certificate. Delete's a certificate.
@ -105,7 +117,7 @@ def get_all_certs():
def get_all_valid_certs(authority_plugin_name): def get_all_valid_certs(authority_plugin_name):
""" """
Retrieves all valid (not expired) certificates within Lemur, for the given authority plugin names Retrieves all valid (not expired & not revoked) certificates within Lemur, for the given authority plugin names
ignored if no authority_plugin_name provided. ignored if no authority_plugin_name provided.
Note that depending on the DB size retrieving all certificates might an expensive operation Note that depending on the DB size retrieving all certificates might an expensive operation
@ -116,11 +128,12 @@ def get_all_valid_certs(authority_plugin_name):
return ( return (
Certificate.query.outerjoin(Authority, Authority.id == Certificate.authority_id).filter( Certificate.query.outerjoin(Authority, Authority.id == Certificate.authority_id).filter(
Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter( Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter(
Authority.plugin_name.in_(authority_plugin_name)).all() Authority.plugin_name.in_(authority_plugin_name)).filter(Certificate.revoked.is_(False)).all()
) )
else: else:
return ( return (
Certificate.query.filter(Certificate.not_after > arrow.now().format("YYYY-MM-DD")).all() Certificate.query.filter(Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter(
Certificate.revoked.is_(False)).all()
) )
@ -148,7 +161,7 @@ def get_all_certs_attached_to_endpoint_without_autorotate():
""" """
return ( return (
Certificate.query.filter(Certificate.endpoints.any()) Certificate.query.filter(Certificate.endpoints.any())
.filter(Certificate.rotation == False) .filter(Certificate.rotation == false())
.filter(Certificate.not_after >= arrow.now()) .filter(Certificate.not_after >= arrow.now())
.filter(not_(Certificate.replaced.any())) .filter(not_(Certificate.replaced.any()))
.all() # noqa .all() # noqa
@ -203,9 +216,9 @@ def get_all_pending_reissue():
:return: :return:
""" """
return ( return (
Certificate.query.filter(Certificate.rotation == True) Certificate.query.filter(Certificate.rotation == true())
.filter(not_(Certificate.replaced.any())) .filter(not_(Certificate.replaced.any()))
.filter(Certificate.in_rotation_window == True) .filter(Certificate.in_rotation_window == true())
.all() .all()
) # noqa ) # noqa
@ -359,7 +372,12 @@ def create(**kwargs):
try: try:
cert_body, private_key, cert_chain, external_id, csr = mint(**kwargs) cert_body, private_key, cert_chain, external_id, csr = mint(**kwargs)
except Exception: except Exception:
current_app.logger.error("Exception minting certificate", exc_info=True) log_data = {
"message": "Exception minting certificate",
"issuer": kwargs["authority"].name,
"cn": kwargs["common_name"],
}
current_app.logger.error(log_data, exc_info=True)
sentry.captureException() sentry.captureException()
raise raise
kwargs["body"] = cert_body kwargs["body"] = cert_body
@ -518,7 +536,7 @@ def render(args):
) )
if current_app.config.get("ALLOW_CERT_DELETION", False): if current_app.config.get("ALLOW_CERT_DELETION", False):
query = query.filter(Certificate.deleted == False) # noqa query = query.filter(Certificate.deleted == false())
result = database.sort_and_page(query, Certificate, args) result = database.sort_and_page(query, Certificate, args)
return result return result
@ -554,20 +572,21 @@ def query_common_name(common_name, args):
:return: :return:
""" """
owner = args.pop("owner") owner = args.pop("owner")
if not owner:
owner = "%"
# only not expired certificates # only not expired certificates
current_time = arrow.utcnow() current_time = arrow.utcnow()
result = ( query = Certificate.query.filter(Certificate.not_after >= current_time.format("YYYY-MM-DD"))\
Certificate.query.filter(Certificate.cn.ilike(common_name)) .filter(not_(Certificate.revoked))\
.filter(Certificate.owner.ilike(owner)) .filter(not_(Certificate.replaced.any())) # ignore rotated certificates to avoid duplicates
.filter(Certificate.not_after >= current_time.format("YYYY-MM-DD"))
.all()
)
return result if owner:
query = query.filter(Certificate.owner.ilike(owner))
if common_name != "%":
# if common_name is a wildcard ('%'), no need to include it in the query
query = query.filter(Certificate.cn.ilike(common_name))
return query.all()
def create_csr(**csr_config): def create_csr(**csr_config):
@ -772,6 +791,19 @@ def reissue_certificate(certificate, replace=None, user=None):
if replace: if replace:
primitives["replaces"] = [certificate] primitives["replaces"] = [certificate]
# Modify description to include the certificate ID being reissued and mention that this is created by Lemur
# as part of reissue
reissue_message_prefix = "Reissued by Lemur for cert ID "
reissue_message = re.compile(f"{reissue_message_prefix}([0-9]+)")
if primitives["description"]:
match = reissue_message.search(primitives["description"])
if match:
primitives["description"] = primitives["description"].replace(match.group(1), str(certificate.id))
else:
primitives["description"] = f"{reissue_message_prefix}{certificate.id}, {primitives['description']}"
else:
primitives["description"] = f"{reissue_message_prefix}{certificate.id}"
new_cert = create(**primitives) new_cert = create(**primitives)
return new_cert return new_cert

View File

@ -82,4 +82,4 @@ def get_key_type_from_csr(data):
raise Exception("Unsupported key type") raise Exception("Unsupported key type")
except NotImplemented: except NotImplemented:
raise NotImplemented() raise NotImplementedError

View File

@ -675,6 +675,16 @@ class CertificatePrivateKey(AuthenticatedResource):
return dict(message="You are not authorized to view this key"), 403 return dict(message="You are not authorized to view this key"), 403
log_service.create(g.current_user, "key_view", certificate=cert) log_service.create(g.current_user, "key_view", certificate=cert)
# Plugins may need to modify the cert object before returning it to the user
if cert.root_authority:
# this certificate is an authority
plugin_name = cert.root_authority.plugin_name
else:
plugin_name = cert.authority.plugin_name
plugin = plugins.get(plugin_name)
plugin.wrap_private_key(cert)
response = make_response(jsonify(key=cert.private_key), 200) response = make_response(jsonify(key=cert.private_key), 200)
response.headers["cache-control"] = "private, max-age=0, no-cache, no-store" response.headers["cache-control"] = "private, max-age=0, no-cache, no-store"
response.headers["pragma"] = "no-cache" response.headers["pragma"] = "no-cache"
@ -1155,6 +1165,7 @@ class NotificationCertificatesList(AuthenticatedResource):
) )
parser.add_argument("creator", type=str, location="args") parser.add_argument("creator", type=str, location="args")
parser.add_argument("show", type=str, location="args") parser.add_argument("show", type=str, location="args")
parser.add_argument("showExpired", type=int, location="args")
args = parser.parse_args() args = parser.parse_args()
args["notification_id"] = notification_id args["notification_id"] = notification_id

View File

@ -759,7 +759,7 @@ def check_revoked():
log_data = { log_data = {
"function": function, "function": function,
"message": "check if any certificates are revoked revoked", "message": "check if any valid certificate is revoked",
"task_id": task_id, "task_id": task_id,
} }
@ -842,3 +842,39 @@ def enable_autorotate_for_certs_attached_to_endpoint():
cli_certificate.automatically_enable_autorotate() cli_certificate.automatically_enable_autorotate()
metrics.send(f"{function}.success", "counter", 1) metrics.send(f"{function}.success", "counter", 1)
return log_data return log_data
@celery.task(soft_time_limit=3600)
def deactivate_entrust_test_certificates():
"""
This celery task attempts to deactivate all not yet deactivated Entrust certificates, and should only run in TEST
:return:
"""
function = f"{__name__}.{sys._getframe().f_code.co_name}"
task_id = None
if celery.current_task:
task_id = celery.current_task.request.id
log_data = {
"function": function,
"message": "deactivate entrust certificates",
"task_id": task_id,
}
if task_id and is_task_active(function, task_id, None):
log_data["message"] = "Skipping task: Task is already active"
current_app.logger.debug(log_data)
return
current_app.logger.debug(log_data)
try:
cli_certificate.deactivate_entrust_certificates()
except SoftTimeLimitExceeded:
log_data["message"] = "Time limit exceeded."
current_app.logger.error(log_data)
sentry.captureException()
metrics.send("celery.timeout", "counter", 1, metric_tags={"function": function})
return
metrics.send(f"{function}.success", "counter", 1)
return log_data

View File

@ -95,9 +95,11 @@ def organization(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[ o = cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)
0 if not o:
].value.strip() return None
return o[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get organization! {0}".format(e)) current_app.logger.error("Unable to get organization! {0}".format(e))
@ -110,9 +112,11 @@ def organizational_unit(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATIONAL_UNIT_NAME)[ ou = cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATIONAL_UNIT_NAME)
0 if not ou:
].value.strip() return None
return ou[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get organizational unit! {0}".format(e)) current_app.logger.error("Unable to get organizational unit! {0}".format(e))
@ -125,9 +129,11 @@ def country(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_COUNTRY_NAME)[ c = cert.subject.get_attributes_for_oid(x509.OID_COUNTRY_NAME)
0 if not c:
].value.strip() return None
return c[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get country! {0}".format(e)) current_app.logger.error("Unable to get country! {0}".format(e))
@ -140,9 +146,11 @@ def state(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_STATE_OR_PROVINCE_NAME)[ s = cert.subject.get_attributes_for_oid(x509.OID_STATE_OR_PROVINCE_NAME)
0 if not s:
].value.strip() return None
return s[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get state! {0}".format(e)) current_app.logger.error("Unable to get state! {0}".format(e))
@ -155,9 +163,11 @@ def location(cert):
:return: :return:
""" """
try: try:
return cert.subject.get_attributes_for_oid(x509.OID_LOCALITY_NAME)[ loc = cert.subject.get_attributes_for_oid(x509.OID_LOCALITY_NAME)
0 if not loc:
].value.strip() return None
return loc[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException() sentry.captureException()
current_app.logger.error("Unable to get location! {0}".format(e)) current_app.logger.error("Unable to get location! {0}".format(e))

View File

@ -31,6 +31,9 @@ class DestinationOutputSchema(LemurOutputSchema):
def fill_object(self, data): def fill_object(self, data):
if data: if data:
data["plugin"]["pluginOptions"] = data["options"] data["plugin"]["pluginOptions"] = data["options"]
for option in data["plugin"]["pluginOptions"]:
if "export-plugin" in option["type"]:
option["value"]["pluginOptions"] = option["value"]["plugin_options"]
return data return data

View File

@ -41,12 +41,14 @@ def create(label, plugin_name, options, description=None):
return database.create(destination) return database.create(destination)
def update(destination_id, label, options, description): def update(destination_id, label, plugin_name, options, description):
""" """
Updates an existing destination. Updates an existing destination.
:param destination_id: Lemur assigned ID :param destination_id: Lemur assigned ID
:param label: Destination common name :param label: Destination common name
:param plugin_name:
:param options:
:param description: :param description:
:rtype : Destination :rtype : Destination
:return: :return:
@ -54,6 +56,11 @@ def update(destination_id, label, options, description):
destination = get(destination_id) destination = get(destination_id)
destination.label = label destination.label = label
destination.plugin_name = plugin_name
# remove any sub-plugin objects before try to save the json options
for option in options:
if "plugin" in option["type"]:
del option["value"]["plugin_object"]
destination.options = options destination.options = options
destination.description = description destination.description = description

View File

@ -338,6 +338,7 @@ class Destinations(AuthenticatedResource):
return service.update( return service.update(
destination_id, destination_id,
data["label"], data["label"],
data["plugin"]["slug"],
data["plugin"]["plugin_options"], data["plugin"]["plugin_options"],
data["description"], data["description"],
) )

View File

@ -3,9 +3,9 @@ from flask_script import Manager
import sys import sys
from lemur.constants import SUCCESS_METRIC_STATUS from lemur.constants import SUCCESS_METRIC_STATUS
from lemur.plugins.lemur_acme.acme_handlers import AcmeDnsHandler
from lemur.dns_providers.service import get_all_dns_providers, set_domains from lemur.dns_providers.service import get_all_dns_providers, set_domains
from lemur.extensions import metrics, sentry from lemur.extensions import metrics, sentry
from lemur.plugins.base import plugins
manager = Manager( manager = Manager(
usage="Iterates through all DNS providers and sets DNS zones in the database." usage="Iterates through all DNS providers and sets DNS zones in the database."
@ -19,7 +19,7 @@ def get_all_zones():
""" """
print("[+] Starting dns provider zone lookup and configuration.") print("[+] Starting dns provider zone lookup and configuration.")
dns_providers = get_all_dns_providers() dns_providers = get_all_dns_providers()
acme_plugin = plugins.get("acme-issuer") acme_dns_handler = AcmeDnsHandler()
function = f"{__name__}.{sys._getframe().f_code.co_name}" function = f"{__name__}.{sys._getframe().f_code.co_name}"
log_data = { log_data = {
@ -29,7 +29,7 @@ def get_all_zones():
for dns_provider in dns_providers: for dns_provider in dns_providers:
try: try:
zones = acme_plugin.get_all_zones(dns_provider) zones = acme_dns_handler.get_all_zones(dns_provider)
set_domains(dns_provider, zones) set_domains(dns_provider, zones)
except Exception as e: except Exception as e:
print("[+] Error with DNS Provider {}: {}".format(dns_provider.name, e)) print("[+] Error with DNS Provider {}: {}".format(dns_provider.name, e))

View File

@ -10,7 +10,7 @@
""" """
import os import os
import imp import importlib
import errno import errno
import pkg_resources import pkg_resources
import socket import socket
@ -73,8 +73,9 @@ def from_file(file_path, silent=False):
:param file_path: :param file_path:
:param silent: :param silent:
""" """
d = imp.new_module("config") module_spec = importlib.util.spec_from_file_location("config", file_path)
d.__file__ = file_path d = importlib.util.module_from_spec(module_spec)
try: try:
with open(file_path) as config_file: with open(file_path) as config_file:
exec( # nosec: config file safe exec( # nosec: config file safe

View File

@ -7,7 +7,7 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from sqlalchemy import Column, Integer, ForeignKey, PassiveDefault, func, Enum from sqlalchemy import Column, Integer, ForeignKey, DefaultClause, func, Enum
from sqlalchemy_utils.types.arrow import ArrowType from sqlalchemy_utils.types.arrow import ArrowType
@ -29,5 +29,5 @@ class Log(db.Model):
), ),
nullable=False, nullable=False,
) )
logged_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False) logged_at = Column(ArrowType(), DefaultClause(func.now()), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)

View File

@ -120,6 +120,7 @@ METRIC_PROVIDERS = []
LOG_LEVEL = "DEBUG" LOG_LEVEL = "DEBUG"
LOG_FILE = "lemur.log" LOG_FILE = "lemur.log"
LOG_UPGRADE_FILE = "db_upgrade.log"
# Database # Database

View File

@ -10,11 +10,21 @@ Create Date: 2018-08-03 12:56:44.565230
revision = "1db4f82bc780" revision = "1db4f82bc780"
down_revision = "3adfdd6598df" down_revision = "3adfdd6598df"
import logging
from alembic import op from alembic import op
log = logging.getLogger(__name__) from flask import current_app
from logging import Formatter, FileHandler, getLogger
log = getLogger(__name__)
handler = FileHandler(current_app.config.get("LOG_UPGRADE_FILE", "db_upgrade.log"))
handler.setFormatter(
Formatter(
"%(asctime)s %(levelname)s: %(message)s " "[in %(pathname)s:%(lineno)d]"
)
)
handler.setLevel(current_app.config.get("LOG_LEVEL", "DEBUG"))
log.setLevel(current_app.config.get("LOG_LEVEL", "DEBUG"))
log.addHandler(handler)
def upgrade(): def upgrade():

View File

@ -7,8 +7,9 @@ the rest of the keys, the certificate body is parsed to determine
the exact key_type information. the exact key_type information.
Each individual DB change is explicitly committed, and the respective Each individual DB change is explicitly committed, and the respective
log is added to a file named db_upgrade.log in the current working log is added to a file configured in LOG_UPGRADE_FILE or, by default,
directory. Any error encountered while parsing a certificate will to a file named db_upgrade.log in the current working directory.
Any error encountered while parsing a certificate will
also be logged along with the certificate ID. If faced with any issue also be logged along with the certificate ID. If faced with any issue
while running this upgrade, there is no harm in re-running the upgrade. while running this upgrade, there is no harm in re-running the upgrade.
Each run processes only rows for which key_type information is not yet Each run processes only rows for which key_type information is not yet
@ -31,15 +32,28 @@ down_revision = '434c29e40511'
from alembic import op from alembic import op
from sqlalchemy.sql import text from sqlalchemy.sql import text
from lemur.common import utils
import time import time
import datetime import datetime
from flask import current_app
log_file = open('db_upgrade.log', 'a') from logging import Formatter, FileHandler, getLogger
from lemur.common import utils
log = getLogger(__name__)
handler = FileHandler(current_app.config.get("LOG_UPGRADE_FILE", "db_upgrade.log"))
handler.setFormatter(
Formatter(
"%(asctime)s %(levelname)s: %(message)s " "[in %(pathname)s:%(lineno)d]"
)
)
handler.setLevel(current_app.config.get("LOG_LEVEL", "DEBUG"))
log.setLevel(current_app.config.get("LOG_LEVEL", "DEBUG"))
log.addHandler(handler)
def upgrade(): def upgrade():
log_file.write("\n*** Starting new run(%s) ***\n" % datetime.datetime.now()) log.info("\n*** Starting new run(%s) ***\n" % datetime.datetime.now())
start_time = time.time() start_time = time.time()
# Update RSA keys using the key length information # Update RSA keys using the key length information
@ -50,8 +64,7 @@ def upgrade():
# Process remaining certificates. Though below method does not make any assumptions, most of the remaining ones should be ECC certs. # Process remaining certificates. Though below method does not make any assumptions, most of the remaining ones should be ECC certs.
update_key_type() update_key_type()
log_file.write("--- Total %s seconds ---\n" % (time.time() - start_time)) log.info("--- Total %s seconds ---\n" % (time.time() - start_time))
log_file.close()
def downgrade(): def downgrade():
@ -61,6 +74,7 @@ def downgrade():
"update certificates set key_type=null where not_after > CURRENT_DATE - 32" "update certificates set key_type=null where not_after > CURRENT_DATE - 32"
) )
op.execute(stmt) op.execute(stmt)
commit()
""" """
@ -69,18 +83,18 @@ def downgrade():
def update_key_type_rsa(bits): def update_key_type_rsa(bits):
log_file.write("Processing certificate with key type RSA %s\n" % bits) log.info("Processing certificate with key type RSA %s\n" % bits)
stmt = text( stmt = text(
f"update certificates set key_type='RSA{bits}' where bits={bits} and not_after > CURRENT_DATE - 31 and key_type is null" f"update certificates set key_type='RSA{bits}' where bits={bits} and not_after > CURRENT_DATE - 31 and key_type is null"
) )
log_file.write("Query: %s\n" % stmt) log.info("Query: %s\n" % stmt)
start_time = time.time() start_time = time.time()
op.execute(stmt) op.execute(stmt)
commit() commit()
log_file.write("--- %s seconds ---\n" % (time.time() - start_time)) log.info("--- %s seconds ---\n" % (time.time() - start_time))
def update_key_type(): def update_key_type():
@ -95,9 +109,9 @@ def update_key_type():
try: try:
cert_key_type = utils.get_key_type_from_certificate(body) cert_key_type = utils.get_key_type_from_certificate(body)
except ValueError as e: except ValueError as e:
log_file.write("Error in processing certificate - ID: %s Error: %s \n" % (cert_id, str(e))) log.error("Error in processing certificate - ID: %s Error: %s \n" % (cert_id, str(e)))
else: else:
log_file.write("Processing certificate - ID: %s key_type: %s\n" % (cert_id, cert_key_type)) log.info("Processing certificate - ID: %s key_type: %s\n" % (cert_id, cert_key_type))
stmt = text( stmt = text(
"update certificates set key_type=:key_type where id=:id" "update certificates set key_type=:key_type where id=:id"
) )
@ -106,7 +120,7 @@ def update_key_type():
commit() commit()
log_file.write("--- %s seconds ---\n" % (time.time() - start_time)) log.info("--- %s seconds ---\n" % (time.time() - start_time))
def commit(): def commit():

View File

@ -8,6 +8,7 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import sys
from collections import defaultdict from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from itertools import groupby from itertools import groupby
@ -15,6 +16,7 @@ from itertools import groupby
import arrow import arrow
from flask import current_app from flask import current_app
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.sql.expression import false, true
from lemur import database from lemur import database
from lemur.certificates.models import Certificate from lemur.certificates.models import Certificate
@ -29,7 +31,7 @@ from lemur.plugins.utils import get_plugin_option
def get_certificates(exclude=None): def get_certificates(exclude=None):
""" """
Finds all certificates that are eligible for notifications. Finds all certificates that are eligible for expiration notifications.
:param exclude: :param exclude:
:return: :return:
""" """
@ -39,9 +41,10 @@ def get_certificates(exclude=None):
q = ( q = (
database.db.session.query(Certificate) database.db.session.query(Certificate)
.filter(Certificate.not_after <= max) .filter(Certificate.not_after <= max)
.filter(Certificate.notify == True) .filter(Certificate.notify == true())
.filter(Certificate.expired == False) .filter(Certificate.expired == false())
) # noqa .filter(Certificate.revoked == false())
)
exclude_conditions = [] exclude_conditions = []
if exclude: if exclude:
@ -61,7 +64,8 @@ def get_certificates(exclude=None):
def get_eligible_certificates(exclude=None): def get_eligible_certificates(exclude=None):
""" """
Finds all certificates that are eligible for certificate expiration. Finds all certificates that are eligible for certificate expiration notification.
Returns the set of all eligible certificates, grouped by owner, with a list of applicable notifications.
:param exclude: :param exclude:
:return: :return:
""" """
@ -86,21 +90,31 @@ def get_eligible_certificates(exclude=None):
return certificates return certificates
def send_notification(event_type, data, targets, notification): def send_plugin_notification(event_type, data, recipients, notification):
""" """
Executes the plugin and handles failure. Executes the plugin and handles failure.
:param event_type: :param event_type:
:param data: :param data:
:param targets: :param recipients:
:param notification: :param notification:
:return: :return:
""" """
function = f"{__name__}.{sys._getframe().f_code.co_name}"
log_data = {
"function": function,
"message": f"Sending expiration notification for to recipients {recipients}",
"notification_type": "expiration",
"certificate_targets": recipients,
}
status = FAILURE_METRIC_STATUS status = FAILURE_METRIC_STATUS
try: try:
notification.plugin.send(event_type, data, targets, notification.options) current_app.logger.debug(log_data)
notification.plugin.send(event_type, data, recipients, notification.options)
status = SUCCESS_METRIC_STATUS status = SUCCESS_METRIC_STATUS
except Exception as e: except Exception as e:
log_data["message"] = f"Unable to send {event_type} notification to recipients {recipients}"
current_app.logger.error(log_data, exc_info=True)
sentry.captureException() sentry.captureException()
metrics.send( metrics.send(
@ -124,11 +138,11 @@ def send_expiration_notifications(exclude):
# security team gets all # security team gets all
security_email = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL") security_email = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL")
security_data = []
for owner, notification_group in get_eligible_certificates(exclude=exclude).items(): for owner, notification_group in get_eligible_certificates(exclude=exclude).items():
for notification_label, certificates in notification_group.items(): for notification_label, certificates in notification_group.items():
notification_data = [] notification_data = []
security_data = []
notification = certificates[0][0] notification = certificates[0][0]
@ -140,36 +154,27 @@ def send_expiration_notifications(exclude):
notification_data.append(cert_data) notification_data.append(cert_data)
security_data.append(cert_data) security_data.append(cert_data)
if send_notification( if send_default_notification(
"expiration", notification_data, [owner], notification "expiration", notification_data, [owner], notification.options
): ):
success += 1 success += 1
else: else:
failure += 1 failure += 1
notification_recipient = get_plugin_option( recipients = notification.plugin.filter_recipients(notification.options, security_email + [owner])
"recipients", notification.options
)
if notification_recipient:
notification_recipient = notification_recipient.split(",")
# removing owner and security_email from notification_recipient
notification_recipient = [i for i in notification_recipient if i not in security_email and i != owner]
if ( if send_plugin_notification(
notification_recipient "expiration",
notification_data,
recipients,
notification,
): ):
if send_notification( success += 1
"expiration", else:
notification_data, failure += 1
notification_recipient,
notification,
):
success += 1
else:
failure += 1
if send_notification( if send_default_notification(
"expiration", security_data, security_email, notification "expiration", security_data, security_email, notification.options
): ):
success += 1 success += 1
else: else:
@ -178,107 +183,86 @@ def send_expiration_notifications(exclude):
return success, failure return success, failure
def send_rotation_notification(certificate, notification_plugin=None): def send_default_notification(notification_type, data, targets, notification_options=None):
""" """
Sends a report to certificate owners when their certificate has been Sends a report to the specified target via the default notification plugin. Applicable for any notification_type.
rotated. At present, "default" means email, as the other notification plugins do not support dynamically configured targets.
:param certificate: :param notification_type:
:param notification_plugin: :param data:
:param targets:
:param notification_options:
:return: :return:
""" """
function = f"{__name__}.{sys._getframe().f_code.co_name}"
log_data = {
"function": function,
"message": f"Sending notification for certificate data {data}",
"notification_type": notification_type,
}
status = FAILURE_METRIC_STATUS status = FAILURE_METRIC_STATUS
if not notification_plugin: notification_plugin = plugins.get(
notification_plugin = plugins.get( current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN") )
)
data = certificate_notification_output_schema.dump(certificate).data
try: try:
notification_plugin.send("rotation", data, [data["owner"]]) current_app.logger.debug(log_data)
# we need the notification.options here because the email templates utilize the interval/unit info
notification_plugin.send(notification_type, data, targets, notification_options)
status = SUCCESS_METRIC_STATUS status = SUCCESS_METRIC_STATUS
except Exception as e: except Exception as e:
current_app.logger.error( log_data["message"] = f"Unable to send {notification_type} notification for certificate data {data} " \
"Unable to send notification to {}.".format(data["owner"]), exc_info=True f"to target {targets}"
) current_app.logger.error(log_data, exc_info=True)
sentry.captureException() sentry.captureException()
metrics.send( metrics.send(
"notification", "notification",
"counter", "counter",
1, 1,
metric_tags={"status": status, "event_type": "rotation"}, metric_tags={"status": status, "event_type": notification_type},
) )
if status == SUCCESS_METRIC_STATUS: if status == SUCCESS_METRIC_STATUS:
return True return True
def send_rotation_notification(certificate):
data = certificate_notification_output_schema.dump(certificate).data
return send_default_notification("rotation", data, [data["owner"]])
def send_pending_failure_notification( def send_pending_failure_notification(
pending_cert, notify_owner=True, notify_security=True, notification_plugin=None pending_cert, notify_owner=True, notify_security=True
): ):
""" """
Sends a report to certificate owners when their pending certificate failed to be created. Sends a report to certificate owners when their pending certificate failed to be created.
:param pending_cert: :param pending_cert:
:param notification_plugin: :param notify_owner:
:param notify_security:
:return: :return:
""" """
status = FAILURE_METRIC_STATUS
if not notification_plugin:
notification_plugin = plugins.get(
current_app.config.get(
"LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification"
)
)
data = pending_certificate_output_schema.dump(pending_cert).data data = pending_certificate_output_schema.dump(pending_cert).data
data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL") data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL")
notify_owner_success = False
if notify_owner: if notify_owner:
try: notify_owner_success = send_default_notification("failed", data, [data["owner"]], pending_cert)
notification_plugin.send("failed", data, [data["owner"]], pending_cert)
status = SUCCESS_METRIC_STATUS
except Exception as e:
current_app.logger.error(
"Unable to send pending failure notification to {}.".format(
data["owner"]
),
exc_info=True,
)
sentry.captureException()
notify_security_success = False
if notify_security: if notify_security:
try: notify_security_success = send_default_notification("failed", data, data["security_email"], pending_cert)
notification_plugin.send(
"failed", data, data["security_email"], pending_cert
)
status = SUCCESS_METRIC_STATUS
except Exception as e:
current_app.logger.error(
"Unable to send pending failure notification to "
"{}.".format(data["security_email"]),
exc_info=True,
)
sentry.captureException()
metrics.send( return notify_owner_success or notify_security_success
"notification",
"counter",
1,
metric_tags={"status": status, "event_type": "rotation"},
)
if status == SUCCESS_METRIC_STATUS:
return True
def needs_notification(certificate): def needs_notification(certificate):
""" """
Determine if notifications for a given certificate should Determine if notifications for a given certificate should currently be sent.
currently be sent For each notification configured for the cert, verifies it is active, properly configured,
and that the configured expiration period is currently met.
:param certificate: :param certificate:
:return: :return:
@ -290,7 +274,7 @@ def needs_notification(certificate):
for notification in certificate.notifications: for notification in certificate.notifications:
if not notification.active or not notification.options: if not notification.active or not notification.options:
return continue
interval = get_plugin_option("interval", notification.options) interval = get_plugin_option("interval", notification.options)
unit = get_plugin_option("unit", notification.options) unit = get_plugin_option("unit", notification.options)
@ -306,9 +290,8 @@ def needs_notification(certificate):
else: else:
raise Exception( raise Exception(
"Invalid base unit for expiration interval: {0}".format(unit) f"Invalid base unit for expiration interval: {unit}"
) )
if days == interval: if days == interval:
notifications.append(notification) notifications.append(notification)
return notifications return notifications

View File

@ -43,7 +43,7 @@ def create_default_expiration_notifications(name, recipients, intervals=None):
"name": "recipients", "name": "recipients",
"type": "str", "type": "str",
"required": True, "required": True,
"validation": "^([\w+-.%]+@[\w-.]+\.[A-Za-z]{2,4},?)+$", "validation": r"^([\w+-.%]+@[\w-.]+\.[A-Za-z]{2,4},?)+$",
"helpMessage": "Comma delimited list of email addresses", "helpMessage": "Comma delimited list of email addresses",
"value": ",".join(recipients), "value": ",".join(recipients),
}, },
@ -63,7 +63,7 @@ def create_default_expiration_notifications(name, recipients, intervals=None):
"name": "interval", "name": "interval",
"type": "int", "type": "int",
"required": True, "required": True,
"validation": "^\d+$", "validation": r"^\d+$",
"helpMessage": "Number of days to be alert before expiration.", "helpMessage": "Number of days to be alert before expiration.",
"value": i, "value": i,
} }
@ -104,12 +104,13 @@ def create(label, plugin_name, options, description, certificates):
return database.create(notification) return database.create(notification)
def update(notification_id, label, options, description, active, certificates): def update(notification_id, label, plugin_name, options, description, active, certificates):
""" """
Updates an existing notification. Updates an existing notification.
:param notification_id: :param notification_id:
:param label: Notification label :param label: Notification label
:param plugin_name:
:param options: :param options:
:param description: :param description:
:param active: :param active:
@ -120,6 +121,7 @@ def update(notification_id, label, options, description, active, certificates):
notification = get(notification_id) notification = get(notification_id)
notification.label = label notification.label = label
notification.plugin_name = plugin_name
notification.options = options notification.options = options
notification.description = description notification.description = description
notification.active = active notification.active = active

View File

@ -340,6 +340,7 @@ class Notifications(AuthenticatedResource):
return service.update( return service.update(
notification_id, notification_id,
data["label"], data["label"],
data["plugin"]["slug"],
data["plugin"]["plugin_options"], data["plugin"]["plugin_options"],
data["description"], data["description"],
data["active"], data["active"],

View File

@ -9,7 +9,7 @@ from sqlalchemy import (
Integer, Integer,
ForeignKey, ForeignKey,
String, String,
PassiveDefault, DefaultClause,
func, func,
Column, Column,
Text, Text,
@ -76,14 +76,14 @@ class PendingCertificate(db.Model):
chain = Column(Text()) chain = Column(Text())
private_key = Column(Vault, nullable=True) private_key = Column(Vault, nullable=True)
date_created = Column(ArrowType, PassiveDefault(func.now()), nullable=False) date_created = Column(ArrowType, DefaultClause(func.now()), nullable=False)
dns_provider_id = Column( dns_provider_id = Column(
Integer, ForeignKey("dns_providers.id", ondelete="CASCADE") Integer, ForeignKey("dns_providers.id", ondelete="CASCADE")
) )
status = Column(Text(), nullable=True) status = Column(Text(), nullable=True)
last_updated = Column( last_updated = Column(
ArrowType, PassiveDefault(func.now()), onupdate=func.now(), nullable=False ArrowType, DefaultClause(func.now()), onupdate=func.now(), nullable=False
) )
rotation = Column(Boolean, default=False) rotation = Column(Boolean, default=False)

View File

@ -31,3 +31,12 @@ class IssuerPlugin(Plugin):
def cancel_ordered_certificate(self, pending_cert, **kwargs): def cancel_ordered_certificate(self, pending_cert, **kwargs):
raise NotImplementedError raise NotImplementedError
def wrap_certificate(self, cert):
pass
def wrap_auth_certificate(self, cert):
pass
def wrap_private_key(self, cert):
pass

View File

@ -20,6 +20,15 @@ class NotificationPlugin(Plugin):
def send(self, notification_type, message, targets, options, **kwargs): def send(self, notification_type, message, targets, options, **kwargs):
raise NotImplementedError raise NotImplementedError
def filter_recipients(self, options, excluded_recipients):
"""
Given a set of options (which should include configured recipient info), filters out recipients that
we do NOT want to notify.
For any notification types where recipients can't be dynamically modified, this returns an empty list.
"""
return []
class ExpirationNotificationPlugin(NotificationPlugin): class ExpirationNotificationPlugin(NotificationPlugin):
""" """
@ -33,7 +42,7 @@ class ExpirationNotificationPlugin(NotificationPlugin):
"name": "interval", "name": "interval",
"type": "int", "type": "int",
"required": True, "required": True,
"validation": "^\d+$", "validation": r"^\d+$",
"helpMessage": "Number of days to be alert before expiration.", "helpMessage": "Number of days to be alert before expiration.",
}, },
{ {
@ -50,5 +59,5 @@ class ExpirationNotificationPlugin(NotificationPlugin):
def options(self): def options(self):
return self.default_options + self.additional_options return self.default_options + self.additional_options
def send(self, notification_type, message, targets, options, **kwargs): def send(self, notification_type, message, excluded_targets, options, **kwargs):
raise NotImplementedError raise NotImplementedError

View File

@ -0,0 +1,521 @@
"""
.. module: lemur.plugins.lemur_acme.plugin
:platform: Unix
:synopsis: This module contains handlers for certain acme related tasks. It needed to be refactored to avoid circular imports
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
Snippets from https://raw.githubusercontent.com/alex/letsencrypt-aws/master/letsencrypt-aws.py
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Curtis Castrapel <ccastrapel@netflix.com>
.. moduleauthor:: Mathias Petermann <mathias.petermann@projektfokus.ch>
"""
import datetime
import json
import time
import OpenSSL.crypto
import josepy as jose
import dns.resolver
from acme import challenges, errors, messages
from acme.client import BackwardsCompatibleClientV2, ClientNetwork
from acme.errors import TimeoutError
from acme.messages import Error as AcmeError
from flask import current_app
from lemur.common.utils import generate_private_key
from lemur.dns_providers import service as dns_provider_service
from lemur.exceptions import InvalidAuthority, UnknownProvider, InvalidConfiguration
from lemur.extensions import metrics, sentry
from lemur.plugins.lemur_acme import cloudflare, dyn, route53, ultradns, powerdns
from lemur.authorities import service as authorities_service
from retrying import retry
class AuthorizationRecord(object):
def __init__(self, domain, target_domain, authz, dns_challenge, change_id):
self.domain = domain
self.target_domain = target_domain
self.authz = authz
self.dns_challenge = dns_challenge
self.change_id = change_id
class AcmeHandler(object):
def reuse_account(self, authority):
if not authority.options:
raise InvalidAuthority("Invalid authority. Options not set")
existing_key = False
existing_regr = False
for option in json.loads(authority.options):
if option["name"] == "acme_private_key" and option["value"]:
existing_key = True
if option["name"] == "acme_regr" and option["value"]:
existing_regr = True
if not existing_key and current_app.config.get("ACME_PRIVATE_KEY"):
existing_key = True
if not existing_regr and current_app.config.get("ACME_REGR"):
existing_regr = True
if existing_key and existing_regr:
return True
else:
return False
def strip_wildcard(self, host):
"""Removes the leading *. and returns Host and whether it was removed or not (True/False)"""
prefix = "*."
if host.startswith(prefix):
return host[len(prefix):], True
return host, False
def maybe_add_extension(self, host, dns_provider_options):
if dns_provider_options and dns_provider_options.get(
"acme_challenge_extension"
):
host = host + dns_provider_options.get("acme_challenge_extension")
return host
def request_certificate(self, acme_client, authorizations, order):
for authorization in authorizations:
for authz in authorization.authz:
authorization_resource, _ = acme_client.poll(authz)
deadline = datetime.datetime.now() + datetime.timedelta(seconds=360)
try:
orderr = acme_client.poll_and_finalize(order, deadline)
except (AcmeError, TimeoutError):
sentry.captureException(extra={"order_url": str(order.uri)})
metrics.send("request_certificate_error", "counter", 1, metric_tags={"uri": order.uri})
current_app.logger.error(
f"Unable to resolve Acme order: {order.uri}", exc_info=True
)
raise
except errors.ValidationError:
if order.fullchain_pem:
orderr = order
else:
raise
metrics.send("request_certificate_success", "counter", 1, metric_tags={"uri": order.uri})
current_app.logger.info(
f"Successfully resolved Acme order: {order.uri}", exc_info=True
)
pem_certificate, pem_certificate_chain = self.extract_cert_and_chain(orderr.fullchain_pem)
current_app.logger.debug(
"{0} {1}".format(type(pem_certificate), type(pem_certificate_chain))
)
return pem_certificate, pem_certificate_chain
def extract_cert_and_chain(self, fullchain_pem):
pem_certificate = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM,
OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, fullchain_pem
),
).decode()
if current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA", False) \
and datetime.datetime.now() < datetime.datetime.strptime(
current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA_EXPIRATION_DATE", "17/03/21"), '%d/%m/%y'):
pem_certificate_chain = current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA")
else:
pem_certificate_chain = fullchain_pem[len(pem_certificate):].lstrip()
return pem_certificate, pem_certificate_chain
@retry(stop_max_attempt_number=5, wait_fixed=5000)
def setup_acme_client(self, authority):
if not authority.options:
raise InvalidAuthority("Invalid authority. Options not set")
options = {}
for option in json.loads(authority.options):
options[option["name"]] = option.get("value")
email = options.get("email", current_app.config.get("ACME_EMAIL"))
tel = options.get("telephone", current_app.config.get("ACME_TEL"))
directory_url = options.get(
"acme_url", current_app.config.get("ACME_DIRECTORY_URL")
)
existing_key = options.get(
"acme_private_key", current_app.config.get("ACME_PRIVATE_KEY")
)
existing_regr = options.get("acme_regr", current_app.config.get("ACME_REGR"))
if existing_key and existing_regr:
current_app.logger.debug("Reusing existing ACME account")
# Reuse the same account for each certificate issuance
key = jose.JWK.json_loads(existing_key)
regr = messages.RegistrationResource.json_loads(existing_regr)
current_app.logger.debug(
"Connecting with directory at {0}".format(directory_url)
)
net = ClientNetwork(key, account=regr)
client = BackwardsCompatibleClientV2(net, key, directory_url)
return client, {}
else:
# Create an account for each certificate issuance
key = jose.JWKRSA(key=generate_private_key("RSA2048"))
current_app.logger.debug("Creating a new ACME account")
current_app.logger.debug(
"Connecting with directory at {0}".format(directory_url)
)
net = ClientNetwork(key, account=None, timeout=3600)
client = BackwardsCompatibleClientV2(net, key, directory_url)
registration = client.new_account_and_tos(
messages.NewRegistration.from_data(email=email)
)
# if store_account is checked, add the private_key and registration resources to the options
if options['store_account']:
new_options = json.loads(authority.options)
# the key returned by fields_to_partial_json is missing the key type, so we add it manually
key_dict = key.fields_to_partial_json()
key_dict["kty"] = "RSA"
acme_private_key = {
"name": "acme_private_key",
"value": json.dumps(key_dict)
}
new_options.append(acme_private_key)
acme_regr = {
"name": "acme_regr",
"value": json.dumps({"body": {}, "uri": registration.uri})
}
new_options.append(acme_regr)
authorities_service.update_options(authority.id, options=json.dumps(new_options))
current_app.logger.debug("Connected: {0}".format(registration.uri))
return client, registration
def get_domains(self, options):
"""
Fetches all domains currently requested
:param options:
:return:
"""
current_app.logger.debug("Fetching domains")
domains = [options["common_name"]]
if options.get("extensions"):
for dns_name in options["extensions"]["sub_alt_names"]["names"]:
if dns_name.value not in domains:
domains.append(dns_name.value)
current_app.logger.debug("Got these domains: {0}".format(domains))
return domains
def revoke_certificate(self, certificate):
if not self.reuse_account(certificate.authority):
raise InvalidConfiguration("There is no ACME account saved, unable to revoke the certificate.")
acme_client, _ = self.acme.setup_acme_client(certificate.authority)
fullchain_com = jose.ComparableX509(
OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, certificate.body))
try:
acme_client.revoke(fullchain_com, 0) # revocation reason = 0
except (errors.ConflictError, errors.ClientError, errors.Error) as e:
# Certificate already revoked.
current_app.logger.error("Certificate revocation failed with message: " + e.detail)
metrics.send("acme_revoke_certificate_failure", "counter", 1)
return False
current_app.logger.warning("Certificate succesfully revoked: " + certificate.name)
metrics.send("acme_revoke_certificate_success", "counter", 1)
return True
class AcmeDnsHandler(AcmeHandler):
def __init__(self):
self.dns_providers_for_domain = {}
try:
self.all_dns_providers = dns_provider_service.get_all_dns_providers()
except Exception as e:
metrics.send("AcmeHandler_init_error", "counter", 1)
sentry.captureException()
current_app.logger.error(f"Unable to fetch DNS Providers: {e}")
self.all_dns_providers = []
def get_all_zones(self, dns_provider):
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
return dns_provider_plugin.get_zones(account_number=account_number)
def get_dns_challenges(self, host, authorizations):
"""Get dns challenges for provided domain"""
domain_to_validate, is_wildcard = self.strip_wildcard(host)
dns_challenges = []
for authz in authorizations:
if not authz.body.identifier.value.lower() == domain_to_validate.lower():
continue
if is_wildcard and not authz.body.wildcard:
continue
if not is_wildcard and authz.body.wildcard:
continue
for combo in authz.body.challenges:
if isinstance(combo.chall, challenges.DNS01):
dns_challenges.append(combo)
return dns_challenges
def get_dns_provider(self, type):
provider_types = {
"cloudflare": cloudflare,
"dyn": dyn,
"route53": route53,
"ultradns": ultradns,
"powerdns": powerdns
}
provider = provider_types.get(type)
if not provider:
raise UnknownProvider("No such DNS provider: {}".format(type))
return provider
def start_dns_challenge(
self,
acme_client,
account_number,
domain,
target_domain,
dns_provider,
order,
dns_provider_options,
):
current_app.logger.debug(f"Starting DNS challenge for {domain} using target domain {target_domain}.")
change_ids = []
dns_challenges = self.get_dns_challenges(domain, order.authorizations)
host_to_validate, _ = self.strip_wildcard(target_domain)
host_to_validate = self.maybe_add_extension(host_to_validate, dns_provider_options)
if not dns_challenges:
sentry.captureException()
metrics.send("start_dns_challenge_error_no_dns_challenges", "counter", 1)
raise Exception("Unable to determine DNS challenges from authorizations")
for dns_challenge in dns_challenges:
# Only prepend '_acme-challenge' if not using CNAME redirection
if domain == target_domain:
host_to_validate = dns_challenge.validation_domain_name(host_to_validate)
change_id = dns_provider.create_txt_record(
host_to_validate,
dns_challenge.validation(acme_client.client.net.key),
account_number,
)
change_ids.append(change_id)
return AuthorizationRecord(
domain, target_domain, order.authorizations, dns_challenges, change_ids
)
def complete_dns_challenge(self, acme_client, authz_record):
current_app.logger.debug(
"Finalizing DNS challenge for {0}".format(
authz_record.authz[0].body.identifier.value
)
)
dns_providers = self.dns_providers_for_domain.get(authz_record.target_domain)
if not dns_providers:
metrics.send("complete_dns_challenge_error_no_dnsproviders", "counter", 1)
raise Exception(
"No DNS providers found for domain: {}".format(authz_record.target_domain)
)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
for change_id in authz_record.change_id:
try:
dns_provider_plugin.wait_for_dns_change(
change_id, account_number=account_number
)
except Exception:
metrics.send("complete_dns_challenge_error", "counter", 1)
sentry.captureException()
current_app.logger.debug(
f"Unable to resolve DNS challenge for change_id: {change_id}, account_id: "
f"{account_number}",
exc_info=True,
)
raise
for dns_challenge in authz_record.dns_challenge:
response = dns_challenge.response(acme_client.client.net.key)
verified = response.simple_verify(
dns_challenge.chall,
authz_record.target_domain,
acme_client.client.net.key.public_key(),
)
if not verified:
metrics.send("complete_dns_challenge_verification_error", "counter", 1)
raise ValueError("Failed verification")
time.sleep(5)
res = acme_client.answer_challenge(dns_challenge, response)
current_app.logger.debug(f"answer_challenge response: {res}")
def get_authorizations(self, acme_client, order, order_info):
authorizations = []
for domain in order_info.domains:
# If CNAME exists, set host to the target address
target_domain = domain
if current_app.config.get("ACME_ENABLE_DELEGATED_CNAME", False):
cname_result, _ = self.strip_wildcard(domain)
cname_result = challenges.DNS01().validation_domain_name(cname_result)
cname_result = self.get_cname(cname_result)
if cname_result:
target_domain = cname_result
self.autodetect_dns_providers(target_domain)
if not self.dns_providers_for_domain.get(target_domain):
metrics.send(
"get_authorizations_no_dns_provider_for_domain", "counter", 1
)
raise Exception("No DNS providers found for domain: {}".format(target_domain))
for dns_provider in self.dns_providers_for_domain[target_domain]:
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
authz_record = self.start_dns_challenge(
acme_client,
account_number,
domain,
target_domain,
dns_provider_plugin,
order,
dns_provider.options,
)
authorizations.append(authz_record)
return authorizations
def autodetect_dns_providers(self, domain):
"""
Get DNS providers associated with a domain when it has not been provided for certificate creation.
:param domain:
:return: dns_providers: List of DNS providers that have the correct zone.
"""
self.dns_providers_for_domain[domain] = []
match_length = 0
for dns_provider in self.all_dns_providers:
if not dns_provider.domains:
continue
for name in dns_provider.domains:
if name == domain or domain.endswith("." + name):
if len(name) > match_length:
self.dns_providers_for_domain[domain] = [dns_provider]
match_length = len(name)
elif len(name) == match_length:
self.dns_providers_for_domain[domain].append(dns_provider)
return self.dns_providers_for_domain
def finalize_authorizations(self, acme_client, authorizations):
for authz_record in authorizations:
self.complete_dns_challenge(acme_client, authz_record)
for authz_record in authorizations:
dns_challenges = authz_record.dns_challenge
for dns_challenge in dns_challenges:
dns_providers = self.dns_providers_for_domain.get(authz_record.target_domain)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_plugin = self.get_dns_provider(
dns_provider.provider_type
)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
host_to_validate, _ = self.strip_wildcard(authz_record.target_domain)
host_to_validate = self.maybe_add_extension(host_to_validate, dns_provider_options)
if authz_record.domain == authz_record.target_domain:
host_to_validate = challenges.DNS01().validation_domain_name(host_to_validate)
dns_provider_plugin.delete_txt_record(
authz_record.change_id,
account_number,
host_to_validate,
dns_challenge.validation(acme_client.client.net.key),
)
return authorizations
def cleanup_dns_challenges(self, acme_client, authorizations):
"""
Best effort attempt to delete DNS challenges that may not have been deleted previously. This is usually called
on an exception
:param acme_client:
:param account_number:
:param dns_provider:
:param authorizations:
:param dns_provider_options:
:return:
"""
for authz_record in authorizations:
dns_providers = self.dns_providers_for_domain.get(authz_record.target_domain)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_challenges = authz_record.dns_challenge
host_to_validate, _ = self.strip_wildcard(authz_record.target_domain)
host_to_validate = self.maybe_add_extension(
host_to_validate, dns_provider_options
)
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
for dns_challenge in dns_challenges:
if authz_record.domain == authz_record.target_domain:
host_to_validate = dns_challenge.validation_domain_name(host_to_validate)
try:
dns_provider_plugin.delete_txt_record(
authz_record.change_id,
account_number,
host_to_validate,
dns_challenge.validation(acme_client.client.net.key),
)
except Exception as e:
# If this fails, it's most likely because the record doesn't exist (It was already cleaned up)
# or we're not authorized to modify it.
metrics.send("cleanup_dns_challenges_error", "counter", 1)
sentry.captureException()
pass
def get_cname(self, domain):
"""
:param domain: Domain name to look up a CNAME for.
:return: First CNAME target or False if no CNAME record exists.
"""
try:
result = dns.resolver.query(domain, 'CNAME')
if len(result) > 0:
return str(result[0].target).rstrip('.')
except dns.exception.DNSException:
return False

View File

@ -0,0 +1,260 @@
"""
.. module: lemur.plugins.lemur_acme.plugin
:platform: Unix
:synopsis: This module contains the different challenge types for ACME implementations
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Mathias Petermann <mathias.petermann@projektfokus.ch>
"""
import datetime
import json
from acme import challenges
from acme.messages import errors, STATUS_VALID, ERROR_CODES
from flask import current_app
from lemur.authorizations import service as authorization_service
from lemur.exceptions import LemurException, InvalidConfiguration
from lemur.plugins.base import plugins
from lemur.destinations import service as destination_service
from lemur.plugins.lemur_acme.acme_handlers import AcmeHandler, AcmeDnsHandler
class AcmeChallengeMissmatchError(LemurException):
pass
class AcmeChallenge(object):
"""
This is the base class, all ACME challenges will need to extend, allowing for future extendability
"""
def create_certificate(self, csr, issuer_options):
"""
Create the new certificate, using the provided CSR and issuer_options.
Right now this is basically a copy of the create_certificate methods in the AcmeHandlers, but should be cleaned
and tried to make use of the deploy and cleanup methods
:param csr:
:param issuer_options:
:return:
"""
pass
def deploy(self, challenge, acme_client, validation_target):
"""
In here the challenge validation is fetched and deployed somewhere that it can be validated by the provider
:param self:
:param challenge: the challenge object, must match for the challenge implementation
:param acme_client: an already bootstrapped acme_client, to avoid passing all issuer_options and so on
:param validation_target: an identifier for the validation target, e.g. the name of a DNS provider
"""
raise NotImplementedError
def cleanup(self, challenge, acme_client, validation_target):
"""
Ideally the challenge should be cleaned up, after the validation is done
:param challenge: Needed to identify the challenge to be removed
:param acme_client: an already bootstrapped acme_client, to avoid passing all issuer_options and so on
:param validation_target: Needed to remove the validation
"""
raise NotImplementedError
class AcmeHttpChallenge(AcmeChallenge):
challengeType = challenges.HTTP01
def create_certificate(self, csr, issuer_options):
"""
Creates an ACME certificate using the HTTP-01 challenge.
:param csr:
:param issuer_options:
:return: :raise Exception:
"""
self.acme = AcmeHandler()
authority = issuer_options.get("authority")
acme_client, registration = self.acme.setup_acme_client(authority)
orderr = acme_client.new_order(csr)
chall = []
deployed_challenges = []
all_pre_validated = True
for authz in orderr.authorizations:
# Choosing challenge.
# check if authorizations is already in a valid state
if authz.body.status != STATUS_VALID:
all_pre_validated = False
# authz.body.challenges is a set of ChallengeBody objects.
for i in authz.body.challenges:
# Find the supported challenge.
if isinstance(i.chall, challenges.HTTP01):
chall.append(i)
else:
current_app.logger.info("{} already validated, skipping".format(authz.body.identifier.value))
if len(chall) == 0 and not all_pre_validated:
raise Exception('HTTP-01 challenge was not offered by the CA server at {}'.format(orderr.uri))
elif not all_pre_validated:
validation_target = None
for option in json.loads(issuer_options["authority"].options):
if option["name"] == "tokenDestination":
validation_target = option["value"]
if validation_target is None:
raise Exception('No token_destination configured for this authority. Cant complete HTTP-01 challenge')
for challenge in chall:
try:
response = self.deploy(challenge, acme_client, validation_target)
deployed_challenges.append(challenge.chall.path)
acme_client.answer_challenge(challenge, response)
except Exception as e:
current_app.logger.error(e)
raise Exception('Failure while trying to deploy token to configure destination. See logs for more information')
current_app.logger.info("Uploaded HTTP-01 challenge tokens, trying to poll and finalize the order")
try:
finalized_orderr = acme_client.poll_and_finalize(orderr,
datetime.datetime.now() + datetime.timedelta(seconds=90))
except errors.ValidationError as validationError:
for authz in validationError.failed_authzrs:
for chall in authz.body.challenges:
if chall.error:
current_app.logger.error(
"ValidationError occured of type {}, with message {}".format(chall.error.typ,
ERROR_CODES[chall.error.code]))
raise Exception('Validation error occured, can\'t complete challenges. See logs for more information.')
pem_certificate, pem_certificate_chain = self.acme.extract_cert_and_chain(finalized_orderr.fullchain_pem)
if len(deployed_challenges) != 0:
for token_path in deployed_challenges:
self.cleanup(token_path, validation_target)
# validation is a random string, we use it as external id, to make it possible to implement revoke_certificate
return pem_certificate, pem_certificate_chain, None
def deploy(self, challenge, acme_client, validation_target):
if not isinstance(challenge.chall, challenges.HTTP01):
raise AcmeChallengeMissmatchError(
'The provided challenge is not of type HTTP01, but instead of type {}'.format(
challenge.__class__.__name__))
destination = destination_service.get(validation_target)
if destination is None:
raise Exception(
'Couldn\'t find the destination with name {}. Cant complete HTTP01 challenge'.format(validation_target))
destination_plugin = plugins.get(destination.plugin_name)
response, validation = challenge.response_and_validation(acme_client.net.key)
destination_plugin.upload_acme_token(challenge.chall.path, validation, destination.options)
current_app.logger.info("Uploaded HTTP-01 challenge token.")
return response
def cleanup(self, token_path, validation_target):
destination = destination_service.get(validation_target)
if destination is None:
current_app.logger.info(
'Couldn\'t find the destination with name {}, won\'t cleanup the challenge'.format(validation_target))
destination_plugin = plugins.get(destination.plugin_name)
destination_plugin.delete_acme_token(token_path, destination.options)
current_app.logger.info("Cleaned up HTTP-01 challenge token.")
class AcmeDnsChallenge(AcmeChallenge):
challengeType = challenges.DNS01
def create_certificate(self, csr, issuer_options):
"""
Creates an ACME certificate.
:param csr:
:param issuer_options:
:return: :raise Exception:
"""
self.acme = AcmeDnsHandler()
authority = issuer_options.get("authority")
create_immediately = issuer_options.get("create_immediately", False)
acme_client, registration = self.acme.setup_acme_client(authority)
dns_provider = issuer_options.get("dns_provider", {})
if dns_provider:
dns_provider_options = dns_provider.options
credentials = json.loads(dns_provider.credentials)
current_app.logger.debug(
"Using DNS provider: {0}".format(dns_provider.provider_type)
)
dns_provider_plugin = __import__(
dns_provider.provider_type, globals(), locals(), [], 1
)
account_number = credentials.get("account_id")
provider_type = dns_provider.provider_type
if provider_type == "route53" and not account_number:
error = "Route53 DNS Provider {} does not have an account number configured.".format(
dns_provider.name
)
current_app.logger.error(error)
raise InvalidConfiguration(error)
else:
dns_provider = {}
dns_provider_options = None
account_number = None
provider_type = None
domains = self.acme.get_domains(issuer_options)
if not create_immediately:
# Create pending authorizations that we'll need to do the creation
dns_authorization = authorization_service.create(
account_number, domains, provider_type
)
# Return id of the DNS Authorization
return None, None, dns_authorization.id
authorizations = self.acme.get_authorizations(
acme_client,
account_number,
domains,
dns_provider_plugin,
dns_provider_options,
)
self.acme.finalize_authorizations(
acme_client,
account_number,
dns_provider_plugin,
authorizations,
dns_provider_options,
)
pem_certificate, pem_certificate_chain = self.acme.request_certificate(
acme_client, authorizations, csr
)
# TODO add external ID (if possible)
return pem_certificate, pem_certificate_chain, None
def deploy(self, challenge, acme_client, validation_target):
pass
def cleanup(self, authorizations, acme_client, validation_target):
"""
Best effort attempt to delete DNS challenges that may not have been deleted previously. This is usually called
on an exception
:param authorizations: all the authorizations to be cleaned up
:param acme_client: an already bootstrapped acme_client, to avoid passing all issuer_options and so on
:param validation_target: Unused right now
:return:
"""
acme = AcmeDnsHandler()
acme.cleanup_dns_challenges(acme_client, authorizations)

View File

@ -11,432 +11,27 @@
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com> .. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Curtis Castrapel <ccastrapel@netflix.com> .. moduleauthor:: Curtis Castrapel <ccastrapel@netflix.com>
""" """
import datetime
import json
import time
import OpenSSL.crypto from acme.errors import PollError, WildcardUnsupportedError
import josepy as jose
from acme import challenges, errors, messages
from acme.client import BackwardsCompatibleClientV2, ClientNetwork
from acme.errors import PollError, TimeoutError, WildcardUnsupportedError
from acme.messages import Error as AcmeError from acme.messages import Error as AcmeError
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from flask import current_app from flask import current_app
from lemur.authorizations import service as authorization_service from lemur.authorizations import service as authorization_service
from lemur.common.utils import generate_private_key
from lemur.dns_providers import service as dns_provider_service from lemur.dns_providers import service as dns_provider_service
from lemur.exceptions import InvalidAuthority, InvalidConfiguration, UnknownProvider from lemur.exceptions import InvalidConfiguration
from lemur.extensions import metrics, sentry from lemur.extensions import metrics, sentry
from lemur.plugins import lemur_acme as acme from lemur.plugins import lemur_acme as acme
from lemur.plugins.bases import IssuerPlugin from lemur.plugins.bases import IssuerPlugin
from lemur.plugins.lemur_acme import cloudflare, dyn, route53, ultradns, powerdns from lemur.plugins.lemur_acme.acme_handlers import AcmeHandler, AcmeDnsHandler
from lemur.authorities import service as authorities_service from lemur.plugins.lemur_acme.challenge_types import AcmeHttpChallenge, AcmeDnsChallenge
from retrying import retry
class AuthorizationRecord(object):
def __init__(self, host, authz, dns_challenge, change_id):
self.host = host
self.authz = authz
self.dns_challenge = dns_challenge
self.change_id = change_id
class AcmeHandler(object):
def __init__(self):
self.dns_providers_for_domain = {}
try:
self.all_dns_providers = dns_provider_service.get_all_dns_providers()
except Exception as e:
metrics.send("AcmeHandler_init_error", "counter", 1)
sentry.captureException()
current_app.logger.error(f"Unable to fetch DNS Providers: {e}")
self.all_dns_providers = []
def get_dns_challenges(self, host, authorizations):
"""Get dns challenges for provided domain"""
domain_to_validate, is_wildcard = self.strip_wildcard(host)
dns_challenges = []
for authz in authorizations:
if not authz.body.identifier.value.lower() == domain_to_validate.lower():
continue
if is_wildcard and not authz.body.wildcard:
continue
if not is_wildcard and authz.body.wildcard:
continue
for combo in authz.body.challenges:
if isinstance(combo.chall, challenges.DNS01):
dns_challenges.append(combo)
return dns_challenges
def strip_wildcard(self, host):
"""Removes the leading *. and returns Host and whether it was removed or not (True/False)"""
prefix = "*."
if host.startswith(prefix):
return host[len(prefix):], True
return host, False
def maybe_add_extension(self, host, dns_provider_options):
if dns_provider_options and dns_provider_options.get(
"acme_challenge_extension"
):
host = host + dns_provider_options.get("acme_challenge_extension")
return host
def start_dns_challenge(
self,
acme_client,
account_number,
host,
dns_provider,
order,
dns_provider_options,
):
current_app.logger.debug("Starting DNS challenge for {0}".format(host))
change_ids = []
dns_challenges = self.get_dns_challenges(host, order.authorizations)
host_to_validate, _ = self.strip_wildcard(host)
host_to_validate = self.maybe_add_extension(
host_to_validate, dns_provider_options
)
if not dns_challenges:
sentry.captureException()
metrics.send("start_dns_challenge_error_no_dns_challenges", "counter", 1)
raise Exception("Unable to determine DNS challenges from authorizations")
for dns_challenge in dns_challenges:
change_id = dns_provider.create_txt_record(
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key),
account_number,
)
change_ids.append(change_id)
return AuthorizationRecord(
host, order.authorizations, dns_challenges, change_ids
)
def complete_dns_challenge(self, acme_client, authz_record):
current_app.logger.debug(
"Finalizing DNS challenge for {0}".format(
authz_record.authz[0].body.identifier.value
)
)
dns_providers = self.dns_providers_for_domain.get(authz_record.host)
if not dns_providers:
metrics.send("complete_dns_challenge_error_no_dnsproviders", "counter", 1)
raise Exception(
"No DNS providers found for domain: {}".format(authz_record.host)
)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
for change_id in authz_record.change_id:
try:
dns_provider_plugin.wait_for_dns_change(
change_id, account_number=account_number
)
except Exception:
metrics.send("complete_dns_challenge_error", "counter", 1)
sentry.captureException()
current_app.logger.debug(
f"Unable to resolve DNS challenge for change_id: {change_id}, account_id: "
f"{account_number}",
exc_info=True,
)
raise
for dns_challenge in authz_record.dns_challenge:
response = dns_challenge.response(acme_client.client.net.key)
verified = response.simple_verify(
dns_challenge.chall,
authz_record.host,
acme_client.client.net.key.public_key(),
)
if not verified:
metrics.send("complete_dns_challenge_verification_error", "counter", 1)
raise ValueError("Failed verification")
time.sleep(5)
res = acme_client.answer_challenge(dns_challenge, response)
current_app.logger.debug(f"answer_challenge response: {res}")
def request_certificate(self, acme_client, authorizations, order):
for authorization in authorizations:
for authz in authorization.authz:
authorization_resource, _ = acme_client.poll(authz)
deadline = datetime.datetime.now() + datetime.timedelta(seconds=360)
try:
orderr = acme_client.poll_and_finalize(order, deadline)
except (AcmeError, TimeoutError):
sentry.captureException(extra={"order_url": str(order.uri)})
metrics.send("request_certificate_error", "counter", 1, metric_tags={"uri": order.uri})
current_app.logger.error(
f"Unable to resolve Acme order: {order.uri}", exc_info=True
)
raise
except errors.ValidationError:
if order.fullchain_pem:
orderr = order
else:
raise
metrics.send("request_certificate_success", "counter", 1, metric_tags={"uri": order.uri})
current_app.logger.info(
f"Successfully resolved Acme order: {order.uri}", exc_info=True
)
pem_certificate = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM,
OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, orderr.fullchain_pem
),
).decode()
if current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA", False) \
and datetime.datetime.now() < datetime.datetime.strptime(
current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA_EXPIRATION_DATE", "17/03/21"), '%d/%m/%y'):
pem_certificate_chain = current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA")
else:
pem_certificate_chain = orderr.fullchain_pem[
len(pem_certificate) : # noqa
].lstrip()
current_app.logger.debug(
"{0} {1}".format(type(pem_certificate), type(pem_certificate_chain))
)
return pem_certificate, pem_certificate_chain
@retry(stop_max_attempt_number=5, wait_fixed=5000)
def setup_acme_client(self, authority):
if not authority.options:
raise InvalidAuthority("Invalid authority. Options not set")
options = {}
for option in json.loads(authority.options):
options[option["name"]] = option.get("value")
email = options.get("email", current_app.config.get("ACME_EMAIL"))
tel = options.get("telephone", current_app.config.get("ACME_TEL"))
directory_url = options.get(
"acme_url", current_app.config.get("ACME_DIRECTORY_URL")
)
existing_key = options.get(
"acme_private_key", current_app.config.get("ACME_PRIVATE_KEY")
)
existing_regr = options.get("acme_regr", current_app.config.get("ACME_REGR"))
if existing_key and existing_regr:
current_app.logger.debug("Reusing existing ACME account")
# Reuse the same account for each certificate issuance
key = jose.JWK.json_loads(existing_key)
regr = messages.RegistrationResource.json_loads(existing_regr)
current_app.logger.debug(
"Connecting with directory at {0}".format(directory_url)
)
net = ClientNetwork(key, account=regr)
client = BackwardsCompatibleClientV2(net, key, directory_url)
return client, {}
else:
# Create an account for each certificate issuance
key = jose.JWKRSA(key=generate_private_key("RSA2048"))
current_app.logger.debug("Creating a new ACME account")
current_app.logger.debug(
"Connecting with directory at {0}".format(directory_url)
)
net = ClientNetwork(key, account=None, timeout=3600)
client = BackwardsCompatibleClientV2(net, key, directory_url)
registration = client.new_account_and_tos(
messages.NewRegistration.from_data(email=email)
)
# if store_account is checked, add the private_key and registration resources to the options
if options['store_account']:
new_options = json.loads(authority.options)
# the key returned by fields_to_partial_json is missing the key type, so we add it manually
key_dict = key.fields_to_partial_json()
key_dict["kty"] = "RSA"
acme_private_key = {
"name": "acme_private_key",
"value": json.dumps(key_dict)
}
new_options.append(acme_private_key)
acme_regr = {
"name": "acme_regr",
"value": json.dumps({"body": {}, "uri": registration.uri})
}
new_options.append(acme_regr)
authorities_service.update_options(authority.id, options=json.dumps(new_options))
current_app.logger.debug("Connected: {0}".format(registration.uri))
return client, registration
def get_domains(self, options):
"""
Fetches all domains currently requested
:param options:
:return:
"""
current_app.logger.debug("Fetching domains")
domains = [options["common_name"]]
if options.get("extensions"):
for dns_name in options["extensions"]["sub_alt_names"]["names"]:
if dns_name.value not in domains:
domains.append(dns_name.value)
current_app.logger.debug("Got these domains: {0}".format(domains))
return domains
def get_authorizations(self, acme_client, order, order_info):
authorizations = []
for domain in order_info.domains:
if not self.dns_providers_for_domain.get(domain):
metrics.send(
"get_authorizations_no_dns_provider_for_domain", "counter", 1
)
raise Exception("No DNS providers found for domain: {}".format(domain))
for dns_provider in self.dns_providers_for_domain[domain]:
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
authz_record = self.start_dns_challenge(
acme_client,
account_number,
domain,
dns_provider_plugin,
order,
dns_provider.options,
)
authorizations.append(authz_record)
return authorizations
def autodetect_dns_providers(self, domain):
"""
Get DNS providers associated with a domain when it has not been provided for certificate creation.
:param domain:
:return: dns_providers: List of DNS providers that have the correct zone.
"""
self.dns_providers_for_domain[domain] = []
match_length = 0
for dns_provider in self.all_dns_providers:
if not dns_provider.domains:
continue
for name in dns_provider.domains:
if name == domain or domain.endswith("." + name):
if len(name) > match_length:
self.dns_providers_for_domain[domain] = [dns_provider]
match_length = len(name)
elif len(name) == match_length:
self.dns_providers_for_domain[domain].append(dns_provider)
return self.dns_providers_for_domain
def finalize_authorizations(self, acme_client, authorizations):
for authz_record in authorizations:
self.complete_dns_challenge(acme_client, authz_record)
for authz_record in authorizations:
dns_challenges = authz_record.dns_challenge
for dns_challenge in dns_challenges:
dns_providers = self.dns_providers_for_domain.get(authz_record.host)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_plugin = self.get_dns_provider(
dns_provider.provider_type
)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
host_to_validate, _ = self.strip_wildcard(authz_record.host)
host_to_validate = self.maybe_add_extension(
host_to_validate, dns_provider_options
)
dns_provider_plugin.delete_txt_record(
authz_record.change_id,
account_number,
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key),
)
return authorizations
def cleanup_dns_challenges(self, acme_client, authorizations):
"""
Best effort attempt to delete DNS challenges that may not have been deleted previously. This is usually called
on an exception
:param acme_client:
:param account_number:
:param dns_provider:
:param authorizations:
:param dns_provider_options:
:return:
"""
for authz_record in authorizations:
dns_providers = self.dns_providers_for_domain.get(authz_record.host)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_challenges = authz_record.dns_challenge
host_to_validate, _ = self.strip_wildcard(authz_record.host)
host_to_validate = self.maybe_add_extension(
host_to_validate, dns_provider_options
)
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
for dns_challenge in dns_challenges:
try:
dns_provider_plugin.delete_txt_record(
authz_record.change_id,
account_number,
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key),
)
except Exception as e:
# If this fails, it's most likely because the record doesn't exist (It was already cleaned up)
# or we're not authorized to modify it.
metrics.send("cleanup_dns_challenges_error", "counter", 1)
sentry.captureException()
pass
def get_dns_provider(self, type):
provider_types = {
"cloudflare": cloudflare,
"dyn": dyn,
"route53": route53,
"ultradns": ultradns,
"powerdns": powerdns
}
provider = provider_types.get(type)
if not provider:
raise UnknownProvider("No such DNS provider: {}".format(type))
return provider
class ACMEIssuerPlugin(IssuerPlugin): class ACMEIssuerPlugin(IssuerPlugin):
title = "Acme" title = "Acme"
slug = "acme-issuer" slug = "acme-issuer"
description = ( description = (
"Enables the creation of certificates via ACME CAs (including Let's Encrypt)" "Enables the creation of certificates via ACME CAs (including Let's Encrypt), using the DNS-01 challenge"
) )
version = acme.VERSION version = acme.VERSION
@ -448,7 +43,7 @@ class ACMEIssuerPlugin(IssuerPlugin):
"name": "acme_url", "name": "acme_url",
"type": "str", "type": "str",
"required": True, "required": True,
"validation": "/^http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+$/", "validation": r"/^http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+$/",
"helpMessage": "Must be a valid web url starting with http[s]://", "helpMessage": "Must be a valid web url starting with http[s]://",
}, },
{ {
@ -461,7 +56,7 @@ class ACMEIssuerPlugin(IssuerPlugin):
"name": "email", "name": "email",
"type": "str", "type": "str",
"default": "", "default": "",
"validation": "/^?([-a-zA-Z0-9.`?{}]+@\w+\.\w+)$/", "validation": r"/^?([-a-zA-Z0-9.`?{}]+@\w+\.\w+)$/",
"helpMessage": "Email to use", "helpMessage": "Email to use",
}, },
{ {
@ -483,30 +78,8 @@ class ACMEIssuerPlugin(IssuerPlugin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(ACMEIssuerPlugin, self).__init__(*args, **kwargs) super(ACMEIssuerPlugin, self).__init__(*args, **kwargs)
def get_dns_provider(self, type):
self.acme = AcmeHandler()
provider_types = {
"cloudflare": cloudflare,
"dyn": dyn,
"route53": route53,
"ultradns": ultradns,
"powerdns": powerdns
}
provider = provider_types.get(type)
if not provider:
raise UnknownProvider("No such DNS provider: {}".format(type))
return provider
def get_all_zones(self, dns_provider):
self.acme = AcmeHandler()
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
return dns_provider_plugin.get_zones(account_number=account_number)
def get_ordered_certificate(self, pending_cert): def get_ordered_certificate(self, pending_cert):
self.acme = AcmeHandler() self.acme = AcmeDnsHandler()
acme_client, registration = self.acme.setup_acme_client(pending_cert.authority) acme_client, registration = self.acme.setup_acme_client(pending_cert.authority)
order_info = authorization_service.get(pending_cert.external_id) order_info = authorization_service.get(pending_cert.external_id)
if pending_cert.dns_provider_id: if pending_cert.dns_provider_id:
@ -552,7 +125,8 @@ class ACMEIssuerPlugin(IssuerPlugin):
return cert return cert
def get_ordered_certificates(self, pending_certs): def get_ordered_certificates(self, pending_certs):
self.acme = AcmeHandler() self.acme = AcmeDnsHandler()
self.acme_dns_challenge = AcmeDnsChallenge()
pending = [] pending = []
certs = [] certs = []
for pending_cert in pending_certs: for pending_cert in pending_certs:
@ -649,76 +223,22 @@ class ACMEIssuerPlugin(IssuerPlugin):
} }
) )
# Ensure DNS records get deleted # Ensure DNS records get deleted
self.acme.cleanup_dns_challenges( self.acme_dns_challenge.cleanup(
entry["acme_client"], entry["authorizations"] entry["authorizations"], entry["acme_client"]
) )
return certs return certs
def create_certificate(self, csr, issuer_options): def create_certificate(self, csr, issuer_options):
""" """
Creates an ACME certificate. Creates an ACME certificate using the DNS-01 challenge.
:param csr: :param csr:
:param issuer_options: :param issuer_options:
:return: :raise Exception: :return: :raise Exception:
""" """
self.acme = AcmeHandler() acme_dns_challenge = AcmeDnsChallenge()
authority = issuer_options.get("authority")
create_immediately = issuer_options.get("create_immediately", False)
acme_client, registration = self.acme.setup_acme_client(authority)
dns_provider = issuer_options.get("dns_provider", {})
if dns_provider: return acme_dns_challenge.create_certificate(csr, issuer_options)
dns_provider_options = dns_provider.options
credentials = json.loads(dns_provider.credentials)
current_app.logger.debug(
"Using DNS provider: {0}".format(dns_provider.provider_type)
)
dns_provider_plugin = __import__(
dns_provider.provider_type, globals(), locals(), [], 1
)
account_number = credentials.get("account_id")
provider_type = dns_provider.provider_type
if provider_type == "route53" and not account_number:
error = "Route53 DNS Provider {} does not have an account number configured.".format(
dns_provider.name
)
current_app.logger.error(error)
raise InvalidConfiguration(error)
else:
dns_provider = {}
dns_provider_options = None
account_number = None
provider_type = None
domains = self.acme.get_domains(issuer_options)
if not create_immediately:
# Create pending authorizations that we'll need to do the creation
dns_authorization = authorization_service.create(
account_number, domains, provider_type
)
# Return id of the DNS Authorization
return None, None, dns_authorization.id
authorizations = self.acme.get_authorizations(
acme_client,
account_number,
domains,
dns_provider_plugin,
dns_provider_options,
)
self.acme.finalize_authorizations(
acme_client,
account_number,
dns_provider_plugin,
authorizations,
dns_provider_options,
)
pem_certificate, pem_certificate_chain = self.acme.request_certificate(
acme_client, authorizations, csr
)
# TODO add external ID (if possible)
return pem_certificate, pem_certificate_chain, None
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):
@ -746,3 +266,108 @@ class ACMEIssuerPlugin(IssuerPlugin):
def cancel_ordered_certificate(self, pending_cert, **kwargs): def cancel_ordered_certificate(self, pending_cert, **kwargs):
# Needed to override issuer function. # Needed to override issuer function.
pass pass
def revoke_certificate(self, certificate, comments):
self.acme = AcmeDnsHandler()
return self.acme.revoke_certificate(certificate)
class ACMEHttpIssuerPlugin(IssuerPlugin):
title = "Acme HTTP-01"
slug = "acme-http-issuer"
description = (
"Enables the creation of certificates via ACME CAs (including Let's Encrypt), using the HTTP-01 challenge"
)
version = acme.VERSION
author = "Netflix"
author_url = "https://github.com/netflix/lemur.git"
options = [
{
"name": "acme_url",
"type": "str",
"required": True,
"validation": r"/^http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+$/",
"helpMessage": "Must be a valid web url starting with http[s]://",
},
{
"name": "telephone",
"type": "str",
"default": "",
"helpMessage": "Telephone to use",
},
{
"name": "email",
"type": "str",
"default": "",
"validation": r"/^?([-a-zA-Z0-9.`?{}]+@\w+\.\w+)$/",
"helpMessage": "Email to use",
},
{
"name": "certificate",
"type": "textarea",
"default": "",
"validation": "/^-----BEGIN CERTIFICATE-----/",
"helpMessage": "Certificate to use",
},
{
"name": "store_account",
"type": "bool",
"required": False,
"helpMessage": "Disable to create a new account for each ACME request",
"default": False,
},
{
"name": "tokenDestination",
"type": "destinationSelect",
"required": True,
"helpMessage": "The destination to use to deploy the token.",
},
]
def __init__(self, *args, **kwargs):
super(ACMEHttpIssuerPlugin, self).__init__(*args, **kwargs)
def create_certificate(self, csr, issuer_options):
"""
Creates an ACME certificate using the HTTP-01 challenge.
:param csr:
:param issuer_options:
:return: :raise Exception:
"""
acme_http_challenge = AcmeHttpChallenge()
return acme_http_challenge.create_certificate(csr, issuer_options)
@staticmethod
def create_authority(options):
"""
Creates an authority, this authority is then used by Lemur to allow a user
to specify which Certificate Authority they want to sign their certificate.
:param options:
:return:
"""
role = {"username": "", "password": "", "name": "acme"}
plugin_options = options.get("plugin", {}).get("plugin_options")
if not plugin_options:
error = "Invalid options for lemur_acme plugin: {}".format(options)
current_app.logger.error(error)
raise InvalidConfiguration(error)
# Define static acme_root based off configuration variable by default. However, if user has passed a
# certificate, use this certificate as the root.
acme_root = current_app.config.get("ACME_ROOT")
for option in plugin_options:
if option.get("name") == "certificate":
acme_root = option.get("value")
return acme_root, "", [role]
def cancel_ordered_certificate(self, pending_cert, **kwargs):
# Needed to override issuer function.
pass
def revoke_certificate(self, certificate, comments):
self.acme = AcmeHandler()
return self.acme.revoke_certificate(certificate)

View File

@ -3,16 +3,18 @@ from unittest.mock import patch, Mock
import josepy as jose import josepy as jose
from cryptography.x509 import DNSName from cryptography.x509 import DNSName
from flask import Flask
from lemur.plugins.lemur_acme import plugin from lemur.plugins.lemur_acme import plugin
from lemur.plugins.lemur_acme.acme_handlers import AuthorizationRecord
from lemur.common.utils import generate_private_key from lemur.common.utils import generate_private_key
from mock import MagicMock from mock import MagicMock
class TestAcme(unittest.TestCase): class TestAcmeDns(unittest.TestCase):
@patch("lemur.plugins.lemur_acme.plugin.dns_provider_service") @patch("lemur.plugins.lemur_acme.acme_handlers.dns_provider_service")
def setUp(self, mock_dns_provider_service): def setUp(self, mock_dns_provider_service):
self.ACMEIssuerPlugin = plugin.ACMEIssuerPlugin() self.ACMEIssuerPlugin = plugin.ACMEIssuerPlugin()
self.acme = plugin.AcmeHandler() self.acme = plugin.AcmeDnsHandler()
mock_dns_provider = Mock() mock_dns_provider = Mock()
mock_dns_provider.name = "cloudflare" mock_dns_provider.name = "cloudflare"
mock_dns_provider.credentials = "{}" mock_dns_provider.credentials = "{}"
@ -22,6 +24,16 @@ class TestAcme(unittest.TestCase):
"test.fakedomain.net": [mock_dns_provider], "test.fakedomain.net": [mock_dns_provider],
} }
# Creates a new Flask application for a test duration. In python 3.8, manual push of application context is
# needed to run tests in dev environment without getting error 'Working outside of application context'.
_app = Flask('lemur_test_acme')
self.ctx = _app.app_context()
assert self.ctx
self.ctx.push()
def tearDown(self):
self.ctx.pop()
@patch("lemur.plugins.lemur_acme.plugin.len", return_value=1) @patch("lemur.plugins.lemur_acme.plugin.len", return_value=1)
def test_get_dns_challenges(self, mock_len): def test_get_dns_challenges(self, mock_len):
assert mock_len assert mock_len
@ -39,36 +51,19 @@ class TestAcme(unittest.TestCase):
result = yield self.acme.get_dns_challenges(host, mock_authz) result = yield self.acme.get_dns_challenges(host, mock_authz)
self.assertEqual(result, mock_entry) self.assertEqual(result, mock_entry)
def test_strip_wildcard(self):
expected = ("example.com", False)
result = self.acme.strip_wildcard("example.com")
self.assertEqual(expected, result)
expected = ("example.com", True)
result = self.acme.strip_wildcard("*.example.com")
self.assertEqual(expected, result)
def test_authz_record(self):
a = plugin.AuthorizationRecord("host", "authz", "challenge", "id")
self.assertEqual(type(a), plugin.AuthorizationRecord)
@patch("acme.client.Client") @patch("acme.client.Client")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
@patch("lemur.plugins.lemur_acme.plugin.len", return_value=1) @patch("lemur.plugins.lemur_acme.plugin.len", return_value=1)
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.get_dns_challenges") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.get_dns_challenges")
def test_start_dns_challenge( def test_start_dns_challenge(
self, mock_get_dns_challenges, mock_len, mock_app, mock_acme self, mock_get_dns_challenges, mock_len, mock_acme
): ):
assert mock_len assert mock_len
mock_order = Mock() mock_order = Mock()
mock_app.logger.debug = Mock()
mock_authz = Mock() mock_authz = Mock()
mock_authz.body.resolved_combinations = [] mock_authz.body.resolved_combinations = []
mock_entry = MagicMock() mock_entry = MagicMock()
from acme import challenges
c = challenges.DNS01() mock_entry.chall = TestAcmeDns.test_complete_dns_challenge_fail
mock_entry.chall = TestAcme.test_complete_dns_challenge_fail
mock_authz.body.resolved_combinations.append(mock_entry) mock_authz.body.resolved_combinations.append(mock_entry)
mock_acme.request_domain_challenges = Mock(return_value=mock_authz) mock_acme.request_domain_challenges = Mock(return_value=mock_authz)
mock_dns_provider = Mock() mock_dns_provider = Mock()
@ -79,16 +74,15 @@ class TestAcme(unittest.TestCase):
iterator = iter(values) iterator = iter(values)
iterable.__iter__.return_value = iterator iterable.__iter__.return_value = iterator
result = self.acme.start_dns_challenge( result = self.acme.start_dns_challenge(
mock_acme, "accountid", "host", mock_dns_provider, mock_order, {} mock_acme, "accountid", "domain", "host", mock_dns_provider, mock_order, {}
) )
self.assertEqual(type(result), plugin.AuthorizationRecord) self.assertEqual(type(result), AuthorizationRecord)
@patch("acme.client.Client") @patch("acme.client.Client")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
@patch("lemur.plugins.lemur_acme.cloudflare.wait_for_dns_change") @patch("lemur.plugins.lemur_acme.cloudflare.wait_for_dns_change")
@patch("time.sleep") @patch("time.sleep")
def test_complete_dns_challenge_success( def test_complete_dns_challenge_success(
self, mock_sleep, mock_wait_for_dns_change, mock_current_app, mock_acme self, mock_sleep, mock_wait_for_dns_change, mock_acme
): ):
mock_dns_provider = Mock() mock_dns_provider = Mock()
mock_dns_provider.wait_for_dns_change = Mock(return_value=True) mock_dns_provider.wait_for_dns_change = Mock(return_value=True)
@ -97,7 +91,7 @@ class TestAcme(unittest.TestCase):
mock_authz.dns_challenge.response = Mock() mock_authz.dns_challenge.response = Mock()
mock_authz.dns_challenge.response.simple_verify = Mock(return_value=True) mock_authz.dns_challenge.response.simple_verify = Mock(return_value=True)
mock_authz.authz = [] mock_authz.authz = []
mock_authz.host = "www.test.com" mock_authz.target_domain = "www.test.com"
mock_authz_record = Mock() mock_authz_record = Mock()
mock_authz_record.body.identifier.value = "test" mock_authz_record.body.identifier.value = "test"
mock_authz.authz.append(mock_authz_record) mock_authz.authz.append(mock_authz_record)
@ -109,39 +103,38 @@ class TestAcme(unittest.TestCase):
self.acme.complete_dns_challenge(mock_acme, mock_authz) self.acme.complete_dns_challenge(mock_acme, mock_authz)
@patch("acme.client.Client") @patch("acme.client.Client")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
@patch("lemur.plugins.lemur_acme.cloudflare.wait_for_dns_change") @patch("lemur.plugins.lemur_acme.cloudflare.wait_for_dns_change")
def test_complete_dns_challenge_fail( def test_complete_dns_challenge_fail(
self, mock_wait_for_dns_change, mock_current_app, mock_acme self, mock_wait_for_dns_change, mock_acme
): ):
mock_dns_provider = Mock() mock_dns_provider = Mock()
mock_dns_provider.wait_for_dns_change = Mock(return_value=True) mock_dns_provider.wait_for_dns_change = Mock(return_value=True)
mock_dns_challenge = Mock()
response = Mock()
response.simple_verify = Mock(return_value=False)
mock_dns_challenge.response = Mock(return_value=response)
mock_authz = Mock() mock_authz = Mock()
mock_authz.dns_challenge.response = Mock() mock_authz.dns_challenge = []
mock_authz.dns_challenge.response.simple_verify = Mock(return_value=False) mock_authz.dns_challenge.append(mock_dns_challenge)
mock_authz.authz = []
mock_authz.host = "www.test.com" mock_authz.target_domain = "www.test.com"
mock_authz_record = Mock() mock_authz_record = Mock()
mock_authz_record.body.identifier.value = "test" mock_authz_record.body.identifier.value = "test"
mock_authz.authz = []
mock_authz.authz.append(mock_authz_record) mock_authz.authz.append(mock_authz_record)
mock_authz.change_id = [] mock_authz.change_id = []
mock_authz.change_id.append("123") mock_authz.change_id.append("123")
mock_authz.dns_challenge = [] with self.assertRaises(ValueError):
dns_challenge = Mock() self.acme.complete_dns_challenge(mock_acme, mock_authz)
mock_authz.dns_challenge.append(dns_challenge)
self.assertRaises(
ValueError, self.acme.complete_dns_challenge(mock_acme, mock_authz)
)
@patch("acme.client.Client") @patch("acme.client.Client")
@patch("OpenSSL.crypto", return_value="mock_cert") @patch("OpenSSL.crypto", return_value="mock_cert")
@patch("josepy.util.ComparableX509") @patch("josepy.util.ComparableX509")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.get_dns_challenges") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.get_dns_challenges")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
def test_request_certificate( def test_request_certificate(
self, self,
mock_current_app,
mock_get_dns_challenges, mock_get_dns_challenges,
mock_jose, mock_jose,
mock_crypto, mock_crypto,
@ -158,7 +151,6 @@ class TestAcme(unittest.TestCase):
mock_acme.fetch_chain = Mock(return_value="mock_chain") mock_acme.fetch_chain = Mock(return_value="mock_chain")
mock_crypto.dump_certificate = Mock(return_value=b"chain") mock_crypto.dump_certificate = Mock(return_value=b"chain")
mock_order = Mock() mock_order = Mock()
mock_current_app.config = {}
self.acme.request_certificate(mock_acme, [], mock_order) self.acme.request_certificate(mock_acme, [], mock_order)
def test_setup_acme_client_fail(self): def test_setup_acme_client_fail(self):
@ -167,10 +159,9 @@ class TestAcme(unittest.TestCase):
with self.assertRaises(Exception): with self.assertRaises(Exception):
self.acme.setup_acme_client(mock_authority) self.acme.setup_acme_client(mock_authority)
@patch("lemur.plugins.lemur_acme.plugin.jose.JWK.json_loads") @patch("lemur.plugins.lemur_acme.acme_handlers.jose.JWK.json_loads")
@patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2") @patch("lemur.plugins.lemur_acme.acme_handlers.BackwardsCompatibleClientV2")
@patch("lemur.plugins.lemur_acme.plugin.current_app") def test_setup_acme_client_success_load_account_from_authority(self, mock_acme, mock_key_json_load):
def test_setup_acme_client_success_load_account_from_authority(self, mock_current_app, mock_acme, mock_key_json_load):
mock_authority = Mock() mock_authority = Mock()
mock_authority.id = 2 mock_authority.id = 2
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \ mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
@ -179,7 +170,6 @@ class TestAcme(unittest.TestCase):
'{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]' '{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]'
mock_client = Mock() mock_client = Mock()
mock_acme.return_value = mock_client mock_acme.return_value = mock_client
mock_current_app.config = {}
mock_key_json_load.return_value = jose.JWKRSA(key=generate_private_key("RSA2048")) mock_key_json_load.return_value = jose.JWKRSA(key=generate_private_key("RSA2048"))
@ -189,11 +179,10 @@ class TestAcme(unittest.TestCase):
assert result_client assert result_client
assert not result_registration assert not result_registration
@patch("lemur.plugins.lemur_acme.plugin.jose.JWKRSA.fields_to_partial_json") @patch("lemur.plugins.lemur_acme.acme_handlers.jose.JWKRSA.fields_to_partial_json")
@patch("lemur.plugins.lemur_acme.plugin.authorities_service") @patch("lemur.plugins.lemur_acme.acme_handlers.authorities_service")
@patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2") @patch("lemur.plugins.lemur_acme.acme_handlers.BackwardsCompatibleClientV2")
@patch("lemur.plugins.lemur_acme.plugin.current_app") def test_setup_acme_client_success_store_new_account(self, mock_acme, mock_authorities_service,
def test_setup_acme_client_success_store_new_account(self, mock_current_app, mock_acme, mock_authorities_service,
mock_key_generation): mock_key_generation):
mock_authority = Mock() mock_authority = Mock()
mock_authority.id = 2 mock_authority.id = 2
@ -206,7 +195,6 @@ class TestAcme(unittest.TestCase):
mock_client.agree_to_tos = Mock(return_value=True) mock_client.agree_to_tos = Mock(return_value=True)
mock_client.new_account_and_tos.return_value = mock_registration mock_client.new_account_and_tos.return_value = mock_registration
mock_acme.return_value = mock_client mock_acme.return_value = mock_client
mock_current_app.config = {}
mock_key_generation.return_value = {"n": "PwIOkViO"} mock_key_generation.return_value = {"n": "PwIOkViO"}
@ -219,10 +207,9 @@ class TestAcme(unittest.TestCase):
'{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, ' '{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, '
'{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]') '{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]')
@patch("lemur.plugins.lemur_acme.plugin.authorities_service") @patch("lemur.plugins.lemur_acme.acme_handlers.authorities_service")
@patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2") @patch("lemur.plugins.lemur_acme.acme_handlers.BackwardsCompatibleClientV2")
@patch("lemur.plugins.lemur_acme.plugin.current_app") def test_setup_acme_client_success(self, mock_acme, mock_authorities_service):
def test_setup_acme_client_success(self, mock_current_app, mock_acme, mock_authorities_service):
mock_authority = Mock() mock_authority = Mock()
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \ mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
'{"name": "store_account", "value": false}]' '{"name": "store_account", "value": false}]'
@ -232,20 +219,17 @@ class TestAcme(unittest.TestCase):
mock_client.register = mock_registration mock_client.register = mock_registration
mock_client.agree_to_tos = Mock(return_value=True) mock_client.agree_to_tos = Mock(return_value=True)
mock_acme.return_value = mock_client mock_acme.return_value = mock_client
mock_current_app.config = {}
result_client, result_registration = self.acme.setup_acme_client(mock_authority) result_client, result_registration = self.acme.setup_acme_client(mock_authority)
mock_authorities_service.update_options.assert_not_called() mock_authorities_service.update_options.assert_not_called()
assert result_client assert result_client
assert result_registration assert result_registration
@patch('lemur.plugins.lemur_acme.plugin.current_app') def test_get_domains_single(self):
def test_get_domains_single(self, mock_current_app):
options = {"common_name": "test.netflix.net"} options = {"common_name": "test.netflix.net"}
result = self.acme.get_domains(options) result = self.acme.get_domains(options)
self.assertEqual(result, [options["common_name"]]) self.assertEqual(result, [options["common_name"]])
@patch("lemur.plugins.lemur_acme.plugin.current_app") def test_get_domains_multiple(self):
def test_get_domains_multiple(self, mock_current_app):
options = { options = {
"common_name": "test.netflix.net", "common_name": "test.netflix.net",
"extensions": { "extensions": {
@ -257,8 +241,7 @@ class TestAcme(unittest.TestCase):
result, [options["common_name"], "test2.netflix.net", "test3.netflix.net"] result, [options["common_name"], "test2.netflix.net", "test3.netflix.net"]
) )
@patch("lemur.plugins.lemur_acme.plugin.current_app") def test_get_domains_san(self):
def test_get_domains_san(self, mock_current_app):
options = { options = {
"common_name": "test.netflix.net", "common_name": "test.netflix.net",
"extensions": { "extensions": {
@ -270,10 +253,62 @@ class TestAcme(unittest.TestCase):
result, [options["common_name"], "test2.netflix.net"] result, [options["common_name"], "test2.netflix.net"]
) )
@patch( def test_create_authority(self):
"lemur.plugins.lemur_acme.plugin.AcmeHandler.start_dns_challenge", options = {
return_value="test", "plugin": {"plugin_options": [{"name": "certificate", "value": "123"}]}
) }
acme_root, b, role = self.ACMEIssuerPlugin.create_authority(options)
self.assertEqual(acme_root, "123")
self.assertEqual(b, "")
self.assertEqual(role, [{"username": "", "password": "", "name": "acme"}])
@patch("lemur.plugins.lemur_acme.acme_handlers.dns_provider_service")
def test_get_dns_provider(self, mock_dns_provider_service):
provider = plugin.AcmeDnsHandler()
route53 = provider.get_dns_provider("route53")
assert route53
cloudflare = provider.get_dns_provider("cloudflare")
assert cloudflare
dyn = provider.get_dns_provider("dyn")
assert dyn
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client")
@patch("lemur.plugins.lemur_acme.acme_handlers.dns_provider_service")
@patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.get_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.finalize_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.request_certificate")
@patch("lemur.plugins.lemur_acme.challenge_types.authorization_service")
def test_create_certificate(
self,
mock_authorization_service,
mock_request_certificate,
mock_finalize_authorizations,
mock_get_authorizations,
mock_dns_provider_service,
mock_acme,
):
provider = plugin.ACMEIssuerPlugin()
mock_authority = Mock()
mock_client = Mock()
mock_acme.return_value = (mock_client, "")
mock_dns_provider = Mock()
mock_dns_provider.credentials = '{"account_id": 1}'
mock_dns_provider.provider_type = "route53"
mock_dns_provider_service.get.return_value = mock_dns_provider
issuer_options = {
"authority": mock_authority,
"dns_provider": mock_dns_provider,
"common_name": "test.netflix.net",
}
csr = "123"
mock_request_certificate.return_value = ("pem_certificate", "chain")
result = provider.create_certificate(csr, issuer_options)
assert result
@patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.start_dns_challenge", return_value="test")
def test_get_authorizations(self, mock_start_dns_challenge): def test_get_authorizations(self, mock_start_dns_challenge):
mock_order = Mock() mock_order = Mock()
mock_order.body.identifiers = [] mock_order.body.identifiers = []
@ -288,7 +323,7 @@ class TestAcme(unittest.TestCase):
self.assertEqual(result, ["test"]) self.assertEqual(result, ["test"])
@patch( @patch(
"lemur.plugins.lemur_acme.plugin.AcmeHandler.complete_dns_challenge", "lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.complete_dns_challenge",
return_value="test", return_value="test",
) )
def test_finalize_authorizations(self, mock_complete_dns_challenge): def test_finalize_authorizations(self, mock_complete_dns_challenge):
@ -306,51 +341,21 @@ class TestAcme(unittest.TestCase):
result = self.acme.finalize_authorizations(mock_acme_client, mock_authz) result = self.acme.finalize_authorizations(mock_acme_client, mock_authz)
self.assertEqual(result, mock_authz) self.assertEqual(result, mock_authz)
@patch("lemur.plugins.lemur_acme.plugin.current_app")
def test_create_authority(self, mock_current_app):
mock_current_app.config = Mock()
options = {
"plugin": {"plugin_options": [{"name": "certificate", "value": "123"}]}
}
acme_root, b, role = self.ACMEIssuerPlugin.create_authority(options)
self.assertEqual(acme_root, "123")
self.assertEqual(b, "")
self.assertEqual(role, [{"username": "", "password": "", "name": "acme"}])
@patch("lemur.plugins.lemur_acme.plugin.current_app")
@patch("lemur.plugins.lemur_acme.dyn.current_app")
@patch("lemur.plugins.lemur_acme.cloudflare.current_app")
@patch("lemur.plugins.lemur_acme.plugin.dns_provider_service")
def test_get_dns_provider(
self,
mock_dns_provider_service,
mock_current_app_cloudflare,
mock_current_app_dyn,
mock_current_app,
):
provider = plugin.ACMEIssuerPlugin()
route53 = provider.get_dns_provider("route53")
assert route53
cloudflare = provider.get_dns_provider("cloudflare")
assert cloudflare
dyn = provider.get_dns_provider("dyn")
assert dyn
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client") @patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
@patch("lemur.plugins.lemur_acme.plugin.authorization_service") @patch("lemur.plugins.lemur_acme.plugin.authorization_service")
@patch("lemur.plugins.lemur_acme.acme_handlers.dns_provider_service")
@patch("lemur.plugins.lemur_acme.plugin.dns_provider_service") @patch("lemur.plugins.lemur_acme.plugin.dns_provider_service")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.get_authorizations") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.get_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.finalize_authorizations") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.finalize_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.request_certificate")
def test_get_ordered_certificate( def test_get_ordered_certificate(
self, self,
mock_request_certificate, mock_request_certificate,
mock_finalize_authorizations, mock_finalize_authorizations,
mock_get_authorizations, mock_get_authorizations,
mock_dns_provider_service_p,
mock_dns_provider_service, mock_dns_provider_service,
mock_authorization_service, mock_authorization_service,
mock_current_app,
mock_acme, mock_acme,
): ):
mock_client = Mock() mock_client = Mock()
@ -368,20 +373,20 @@ class TestAcme(unittest.TestCase):
) )
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client") @patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
@patch("lemur.plugins.lemur_acme.plugin.authorization_service") @patch("lemur.plugins.lemur_acme.plugin.authorization_service")
@patch("lemur.plugins.lemur_acme.acme_handlers.dns_provider_service")
@patch("lemur.plugins.lemur_acme.plugin.dns_provider_service") @patch("lemur.plugins.lemur_acme.plugin.dns_provider_service")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.get_authorizations") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.get_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.finalize_authorizations") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.finalize_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate") @patch("lemur.plugins.lemur_acme.plugin.AcmeDnsHandler.request_certificate")
def test_get_ordered_certificates( def test_get_ordered_certificates(
self, self,
mock_request_certificate, mock_request_certificate,
mock_finalize_authorizations, mock_finalize_authorizations,
mock_get_authorizations, mock_get_authorizations,
mock_dns_provider_service, mock_dns_provider_service,
mock_dns_provider_service_p,
mock_authorization_service, mock_authorization_service,
mock_current_app,
mock_acme, mock_acme,
): ):
mock_client = Mock() mock_client = Mock()
@ -406,41 +411,3 @@ class TestAcme(unittest.TestCase):
result[1]["cert"], result[1]["cert"],
{"body": "pem_certificate", "chain": "chain", "external_id": "2"}, {"body": "pem_certificate", "chain": "chain", "external_id": "2"},
) )
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client")
@patch("lemur.plugins.lemur_acme.plugin.dns_provider_service")
@patch("lemur.plugins.lemur_acme.plugin.current_app")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.get_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.finalize_authorizations")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate")
@patch("lemur.plugins.lemur_acme.plugin.authorization_service")
def test_create_certificate(
self,
mock_authorization_service,
mock_request_certificate,
mock_finalize_authorizations,
mock_get_authorizations,
mock_current_app,
mock_dns_provider_service,
mock_acme,
):
provider = plugin.ACMEIssuerPlugin()
mock_authority = Mock()
mock_client = Mock()
mock_acme.return_value = (mock_client, "")
mock_dns_provider = Mock()
mock_dns_provider.credentials = '{"account_id": 1}'
mock_dns_provider.provider_type = "route53"
mock_dns_provider_service.get.return_value = mock_dns_provider
issuer_options = {
"authority": mock_authority,
"dns_provider": mock_dns_provider,
"common_name": "test.netflix.net",
}
csr = "123"
mock_request_certificate.return_value = ("pem_certificate", "chain")
result = provider.create_certificate(csr, issuer_options)
assert result

View File

@ -0,0 +1,112 @@
import unittest
from unittest.mock import patch, Mock
from flask import Flask
from cryptography.x509 import DNSName
from lemur.plugins.lemur_acme import acme_handlers
class TestAcmeHandler(unittest.TestCase):
def setUp(self):
self.acme = acme_handlers.AcmeHandler()
# Creates a new Flask application for a test duration. In python 3.8, manual push of application context is
# needed to run tests in dev environment without getting error 'Working outside of application context'.
_app = Flask('lemur_test_acme')
self.ctx = _app.app_context()
assert self.ctx
self.ctx.push()
def tearDown(self):
self.ctx.pop()
def test_strip_wildcard(self):
expected = ("example.com", False)
result = self.acme.strip_wildcard("example.com")
self.assertEqual(expected, result)
expected = ("example.com", True)
result = self.acme.strip_wildcard("*.example.com")
self.assertEqual(expected, result)
def test_authz_record(self):
a = acme_handlers.AuthorizationRecord("domain", "host", "authz", "challenge", "id")
self.assertEqual(type(a), acme_handlers.AuthorizationRecord)
def test_setup_acme_client_fail(self):
mock_authority = Mock()
mock_authority.options = []
with self.assertRaises(Exception):
self.acme.setup_acme_client(mock_authority)
def test_reuse_account_not_defined(self):
mock_authority = Mock()
mock_authority.options = []
with self.assertRaises(Exception):
self.acme.reuse_account(mock_authority)
def test_reuse_account_from_authority(self):
mock_authority = Mock()
mock_authority.options = '[{"name": "acme_private_key", "value": "PRIVATE_KEY"}, {"name": "acme_regr", "value": "ACME_REGR"}]'
self.assertTrue(self.acme.reuse_account(mock_authority))
@patch("lemur.plugins.lemur_acme.acme_handlers.current_app")
def test_reuse_account_from_config(self, mock_current_app):
mock_authority = Mock()
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}]'
mock_current_app.config = {"ACME_PRIVATE_KEY": "PRIVATE_KEY", "ACME_REGR": "ACME_REGR"}
self.assertTrue(self.acme.reuse_account(mock_authority))
def test_reuse_account_no_configuration(self):
mock_authority = Mock()
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}]'
self.assertFalse(self.acme.reuse_account(mock_authority))
@patch("lemur.plugins.lemur_acme.acme_handlers.authorities_service")
@patch("lemur.plugins.lemur_acme.acme_handlers.BackwardsCompatibleClientV2")
def test_setup_acme_client_success(self, mock_acme, mock_authorities_service):
mock_authority = Mock()
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
'{"name": "store_account", "value": false}]'
mock_client = Mock()
mock_registration = Mock()
mock_registration.uri = "http://test.com"
mock_client.register = mock_registration
mock_client.agree_to_tos = Mock(return_value=True)
mock_acme.return_value = mock_client
result_client, result_registration = self.acme.setup_acme_client(mock_authority)
mock_authorities_service.update_options.assert_not_called()
assert result_client
assert result_registration
def test_get_domains_single(self):
options = {"common_name": "test.netflix.net"}
result = self.acme.get_domains(options)
self.assertEqual(result, [options["common_name"]])
def test_get_domains_multiple(self):
options = {
"common_name": "test.netflix.net",
"extensions": {
"sub_alt_names": {"names": [DNSName("test2.netflix.net"), DNSName("test3.netflix.net")]}
},
}
result = self.acme.get_domains(options)
self.assertEqual(
result, [options["common_name"], "test2.netflix.net", "test3.netflix.net"]
)
def test_get_domains_san(self):
options = {
"common_name": "test.netflix.net",
"extensions": {
"sub_alt_names": {"names": [DNSName("test.netflix.net"), DNSName("test2.netflix.net")]}
},
}
result = self.acme.get_domains(options)
self.assertEqual(
result, [options["common_name"], "test2.netflix.net"]
)

View File

@ -0,0 +1,171 @@
import unittest
from unittest.mock import patch, Mock
from flask import Flask
from acme import challenges
from lemur.plugins.lemur_acme import plugin
class TestAcmeHttp(unittest.TestCase):
def setUp(self):
self.ACMEHttpIssuerPlugin = plugin.ACMEHttpIssuerPlugin()
self.acme = plugin.AcmeHandler()
# Creates a new Flask application for a test duration. In python 3.8, manual push of application context is
# needed to run tests in dev environment without getting error 'Working outside of application context'.
_app = Flask('lemur_test_acme')
self.ctx = _app.app_context()
assert self.ctx
self.ctx.push()
def tearDown(self):
self.ctx.pop()
def test_create_authority(self):
options = {
"plugin": {"plugin_options": [{"name": "certificate", "value": "123"}]}
}
acme_root, b, role = self.ACMEHttpIssuerPlugin.create_authority(options)
self.assertEqual(acme_root, "123")
self.assertEqual(b, "")
self.assertEqual(role, [{"username": "", "password": "", "name": "acme"}])
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client")
@patch("lemur.plugins.base.manager.PluginManager.get")
@patch("lemur.plugins.lemur_acme.challenge_types.destination_service")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate")
@patch("lemur.plugins.lemur_acme.plugin.authorization_service")
def test_create_certificate(
self,
mock_authorization_service,
mock_request_certificate,
mock_destination_service,
mock_plugin_manager_get,
mock_acme,
):
provider = plugin.ACMEHttpIssuerPlugin()
mock_authority = Mock()
mock_authority.options = '[{"name": "tokenDestination", "value": "mock-sftp-destination"}]'
mock_order_resource = Mock()
mock_order_resource.authorizations = [Mock()]
mock_order_resource.authorizations[0].body.challenges = [Mock()]
mock_order_resource.authorizations[0].body.challenges[0].response_and_validation.return_value = (Mock(), "Anything-goes")
mock_order_resource.authorizations[0].body.challenges[0].chall = challenges.HTTP01(
token=b'\x0f\x1c\xbe#od\xd1\x9c\xa6j\\\xa4\r\xed\xe5\xbf0pz\xeaxnl)\xea[i\xbc\x95\x08\x96\x1f')
mock_client = Mock()
mock_client.new_order.return_value = mock_order_resource
mock_client.answer_challenge.return_value = True
mock_finalized_order = Mock()
mock_finalized_order.fullchain_pem = "-----BEGIN CERTIFICATE-----\nMIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw\nGjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2\nMDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0\n8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym\noLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0\nZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN\nxDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56\ndhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9\nAgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw\nHQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0\nBggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu\nb3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu\nY3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq\nhkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF\nUGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9\nAFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp\nDQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7\nIkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf\nzWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI\nPTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w\nSVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em\n2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0\nWzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt\nn5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw\nGjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2\nMDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0\n8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym\noLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0\nZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN\nxDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56\ndhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9\nAgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw\nHQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0\nBggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu\nb3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu\nY3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq\nhkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF\nUGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9\nAFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp\nDQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7\nIkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf\nzWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI\nPTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w\nSVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em\n2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0\nWzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt\nn5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU=\n-----END CERTIFICATE-----\n"
mock_client.poll_and_finalize.return_value = mock_finalized_order
mock_acme.return_value = (mock_client, "")
mock_destination = Mock()
mock_destination.label = "mock-sftp-destination"
mock_destination.plugin_name = "SFTPDestinationPlugin"
mock_destination_service.get.return_value = mock_destination
mock_destination_plugin = Mock()
mock_destination_plugin.upload_acme_token.return_value = True
mock_plugin_manager_get.return_value = mock_destination_plugin
issuer_options = {
"authority": mock_authority,
"tokenDestination": "mock-sftp-destination",
"common_name": "test.netflix.net",
}
csr = "123"
mock_request_certificate.return_value = ("pem_certificate", "chain")
pem_certificate, pem_certificate_chain, _ = provider.create_certificate(csr, issuer_options)
self.assertEqual(pem_certificate, "-----BEGIN CERTIFICATE-----\nMIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw\nGjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2\nMDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0\n8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym\noLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0\nZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN\nxDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56\ndhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9\nAgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw\nHQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0\nBggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu\nb3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu\nY3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq\nhkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF\nUGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9\nAFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp\nDQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7\nIkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf\nzWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI\nPTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w\nSVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em\n2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0\nWzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt\nn5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU=\n-----END CERTIFICATE-----\n")
self.assertEqual(pem_certificate_chain,
"-----BEGIN CERTIFICATE-----\nMIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw\nGjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2\nMDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0\n8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym\noLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0\nZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN\nxDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56\ndhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9\nAgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw\nHQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0\nBggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu\nb3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu\nY3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq\nhkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF\nUGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9\nAFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp\nDQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7\nIkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf\nzWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI\nPTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w\nSVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em\n2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0\nWzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt\nn5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU=\n-----END CERTIFICATE-----\n")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client")
@patch("lemur.plugins.base.manager.PluginManager.get")
@patch("lemur.plugins.lemur_acme.challenge_types.destination_service")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate")
@patch("lemur.plugins.lemur_acme.plugin.authorization_service")
def test_create_certificate_missing_destination_token(
self,
mock_authorization_service,
mock_request_certificate,
mock_destination_service,
mock_plugin_manager_get,
mock_acme,
):
provider = plugin.ACMEHttpIssuerPlugin()
mock_authority = Mock()
mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}]'
mock_order_resource = Mock()
mock_order_resource.authorizations = [Mock()]
mock_order_resource.authorizations[0].body.challenges = [Mock()]
mock_order_resource.authorizations[0].body.challenges[0].chall = challenges.HTTP01(
token=b'\x0f\x1c\xbe#od\xd1\x9c\xa6j\\\xa4\r\xed\xe5\xbf0pz\xeaxnl)\xea[i\xbc\x95\x08\x96\x1f')
mock_client = Mock()
mock_client.new_order.return_value = mock_order_resource
mock_acme.return_value = (mock_client, "")
mock_destination = Mock()
mock_destination.label = "mock-sftp-destination"
mock_destination.plugin_name = "SFTPDestinationPlugin"
mock_destination_service.get_by_label.return_value = mock_destination
mock_destination_plugin = Mock()
mock_destination_plugin.upload_acme_token.return_value = True
mock_plugin_manager_get.return_value = mock_destination_plugin
issuer_options = {
"authority": mock_authority,
"tokenDestination": "mock-sftp-destination",
"common_name": "test.netflix.net",
}
csr = "123"
mock_request_certificate.return_value = ("pem_certificate", "chain")
with self.assertRaisesRegex(Exception, "No token_destination configured"):
provider.create_certificate(csr, issuer_options)
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client")
@patch("lemur.plugins.base.manager.PluginManager.get")
@patch("lemur.plugins.lemur_acme.challenge_types.destination_service")
@patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate")
@patch("lemur.plugins.lemur_acme.plugin.authorization_service")
def test_create_certificate_missing_http_challenge(
self,
mock_authorization_service,
mock_request_certificate,
mock_destination_service,
mock_plugin_manager_get,
mock_acme,
):
provider = plugin.ACMEHttpIssuerPlugin()
mock_authority = Mock()
mock_authority.options = '[{"name": "tokenDestination", "value": "mock-sftp-destination"}]'
mock_order_resource = Mock()
mock_order_resource.authorizations = [Mock()]
mock_order_resource.authorizations[0].body.challenges = [Mock()]
mock_order_resource.authorizations[0].body.challenges[0].chall = challenges.DNS01(
token=b'\x0f\x1c\xbe#od\xd1\x9c\xa6j\\\xa4\r\xed\xe5\xbf0pz\xeaxnl)\xea[i\xbc\x95\x08\x96\x1f')
mock_client = Mock()
mock_client.new_order.return_value = mock_order_resource
mock_acme.return_value = (mock_client, "")
issuer_options = {
"authority": mock_authority,
"tokenDestination": "mock-sftp-destination",
"common_name": "test.netflix.net",
}
csr = "123"
mock_request_certificate.return_value = ("pem_certificate", "chain")
with self.assertRaisesRegex(Exception, "HTTP-01 challenge was not offered"):
provider.create_certificate(csr, issuer_options)

View File

@ -1,5 +1,7 @@
import unittest import unittest
from unittest.mock import patch, Mock from unittest.mock import patch, Mock
from flask import Flask
from lemur.plugins.lemur_acme import plugin, powerdns from lemur.plugins.lemur_acme import plugin, powerdns
@ -17,6 +19,16 @@ class TestPowerdns(unittest.TestCase):
"test.fakedomain.net": [mock_dns_provider], "test.fakedomain.net": [mock_dns_provider],
} }
# Creates a new Flask application for a test duration. In python 3.8, manual push of application context is
# needed to run tests in dev environment without getting error 'Working outside of application context'.
_app = Flask('lemur_test_acme')
self.ctx = _app.app_context()
assert self.ctx
self.ctx.push()
def tearDown(self):
self.ctx.pop()
@patch("lemur.plugins.lemur_acme.powerdns.current_app") @patch("lemur.plugins.lemur_acme.powerdns.current_app")
def test_get_zones(self, mock_current_app): def test_get_zones(self, mock_current_app):
account_number = "1234567890" account_number = "1234567890"

View File

@ -1,6 +1,7 @@
import unittest import unittest
from unittest.mock import patch, Mock from unittest.mock import patch, Mock
from flask import Flask
from lemur.plugins.lemur_acme import plugin, ultradns from lemur.plugins.lemur_acme import plugin, ultradns
from requests.models import Response from requests.models import Response
@ -19,6 +20,16 @@ class TestUltradns(unittest.TestCase):
"test.fakedomain.net": [mock_dns_provider], "test.fakedomain.net": [mock_dns_provider],
} }
# Creates a new Flask application for a test duration. In python 3.8, manual push of application context is
# needed to run tests in dev environment without getting error 'Working outside of application context'.
_app = Flask('lemur_test_acme')
self.ctx = _app.app_context()
assert self.ctx
self.ctx.push()
def tearDown(self):
self.ctx.pop()
@patch("lemur.plugins.lemur_acme.ultradns.requests") @patch("lemur.plugins.lemur_acme.ultradns.requests")
@patch("lemur.plugins.lemur_acme.ultradns.current_app") @patch("lemur.plugins.lemur_acme.ultradns.current_app")
def test_ultradns_get_token(self, mock_current_app, mock_requests): def test_ultradns_get_token(self, mock_current_app, mock_requests):

View File

@ -32,13 +32,15 @@
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com> .. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Harm Weites <harm@weites.com> .. moduleauthor:: Harm Weites <harm@weites.com>
""" """
import sys
from acme.errors import ClientError from acme.errors import ClientError
from flask import current_app from flask import current_app
from lemur.extensions import sentry, metrics
from lemur.plugins import lemur_aws as aws from lemur.extensions import sentry, metrics
from lemur.plugins import lemur_aws as aws, ExpirationNotificationPlugin
from lemur.plugins.bases import DestinationPlugin, ExportDestinationPlugin, SourcePlugin from lemur.plugins.bases import DestinationPlugin, ExportDestinationPlugin, SourcePlugin
from lemur.plugins.lemur_aws import iam, s3, elb, ec2 from lemur.plugins.lemur_aws import iam, s3, elb, ec2, sns
def get_region_from_dns(dns): def get_region_from_dns(dns):
@ -406,3 +408,120 @@ class S3DestinationPlugin(ExportDestinationPlugin):
self.get_option("encrypt", options), self.get_option("encrypt", options),
account_number=self.get_option("accountNumber", options), account_number=self.get_option("accountNumber", options),
) )
def upload_acme_token(self, token_path, token, options, **kwargs):
"""
This is called from the acme http challenge
:param self:
:param token_path:
:param token:
:param options:
:param kwargs:
:return:
"""
current_app.logger.debug("S3 destination plugin is started to upload HTTP-01 challenge")
function = f"{__name__}.{sys._getframe().f_code.co_name}"
account_number = self.get_option("accountNumber", options)
bucket_name = self.get_option("bucket", options)
prefix = self.get_option("prefix", options)
region = self.get_option("region", options)
filename = token_path.split("/")[-1]
if not prefix.endswith("/"):
prefix + "/"
response = s3.put(bucket_name=bucket_name,
region_name=region,
prefix=prefix + filename,
data=token,
encrypt=False,
account_number=account_number)
res = "Success" if response else "Failure"
log_data = {
"function": function,
"message": "upload acme token challenge",
"result": res,
"bucket_name": bucket_name,
"filename": filename
}
current_app.logger.info(log_data)
metrics.send(f"{function}", "counter", 1, metric_tags={"result": res,
"bucket_name": bucket_name,
"filename": filename})
return response
def delete_acme_token(self, token_path, options, **kwargs):
current_app.logger.debug("S3 destination plugin is started to delete HTTP-01 challenge")
function = f"{__name__}.{sys._getframe().f_code.co_name}"
account_number = self.get_option("accountNumber", options)
bucket_name = self.get_option("bucket", options)
prefix = self.get_option("prefix", options)
filename = token_path.split("/")[-1]
response = s3.delete(bucket_name=bucket_name,
prefixed_object_name=prefix + filename,
account_number=account_number)
res = "Success" if response else "Failure"
log_data = {
"function": function,
"message": "delete acme token challenge",
"result": res,
"bucket_name": bucket_name,
"filename": filename
}
current_app.logger.info(log_data)
metrics.send(f"{function}", "counter", 1, metric_tags={"result": res,
"bucket_name": bucket_name,
"filename": filename})
return response
class SNSNotificationPlugin(ExpirationNotificationPlugin):
title = "AWS SNS"
slug = "aws-sns"
description = "Sends notifications to AWS SNS"
version = aws.VERSION
author = "Jasmine Schladen <jschladen@netflix.com>"
author_url = "https://github.com/Netflix/lemur"
additional_options = [
{
"name": "accountNumber",
"type": "str",
"required": True,
"validation": "[0-9]{12}",
"helpMessage": "A valid AWS account number with permission to access the SNS topic",
},
{
"name": "region",
"type": "str",
"required": True,
"validation": "[0-9a-z\\-]{1,25}",
"helpMessage": "Region in which the SNS topic is located, e.g. \"us-east-1\"",
},
{
"name": "topicName",
"type": "str",
"required": True,
# base topic name is 1-256 characters (alphanumeric plus underscore and hyphen)
"validation": "^[a-zA-Z0-9_\\-]{1,256}$",
"helpMessage": "The name of the topic to use for expiration notifications",
}
]
def send(self, notification_type, message, excluded_targets, options, **kwargs):
"""
While we receive a `targets` parameter here, it is unused, as the SNS topic is pre-configured in the
plugin configuration, and can't reasonably be changed dynamically.
"""
topic_arn = f"arn:aws:sns:{self.get_option('region', options)}:" \
f"{self.get_option('accountNumber', options)}:" \
f"{self.get_option('topicName', options)}"
current_app.logger.info(f"Publishing {notification_type} notification to topic {topic_arn}")
sns.publish(topic_arn, message, notification_type, region_name=self.get_option("region", options))

View File

@ -6,12 +6,15 @@
:license: Apache, see LICENSE for more details. :license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from botocore.exceptions import ClientError
from flask import current_app from flask import current_app
from lemur.extensions import sentry
from .sts import sts_client from .sts import sts_client
@sts_client("s3", service_type="resource") @sts_client("s3", service_type="resource")
def put(bucket_name, region, prefix, data, encrypt, **kwargs): def put(bucket_name, region_name, prefix, data, encrypt, **kwargs):
""" """
Use STS to write to an S3 bucket Use STS to write to an S3 bucket
""" """
@ -32,4 +35,41 @@ def put(bucket_name, region, prefix, data, encrypt, **kwargs):
ServerSideEncryption="AES256", ServerSideEncryption="AES256",
) )
else: else:
bucket.put_object(Key=prefix, Body=data, ACL="bucket-owner-full-control") try:
bucket.put_object(Key=prefix, Body=data, ACL="bucket-owner-full-control")
return True
except ClientError:
sentry.captureException()
return False
@sts_client("s3", service_type="client")
def delete(bucket_name, prefixed_object_name, **kwargs):
"""
Use STS to delete an object
"""
try:
response = kwargs["client"].delete_object(Bucket=bucket_name, Key=prefixed_object_name)
current_app.logger.debug(f"Delete data from S3."
f"Bucket: {bucket_name},"
f"Prefix: {prefixed_object_name},"
f"Status_code: {response}")
return response['ResponseMetadata']['HTTPStatusCode'] < 300
except ClientError:
sentry.captureException()
return False
@sts_client("s3", service_type="client")
def get(bucket_name, prefixed_object_name, **kwargs):
"""
Use STS to get an object
"""
try:
response = kwargs["client"].get_object(Bucket=bucket_name, Key=prefixed_object_name)
current_app.logger.debug(f"Get data from S3. Bucket: {bucket_name},"
f"object_name: {prefixed_object_name}")
return response['Body'].read().decode("utf-8")
except ClientError:
sentry.captureException()
return None

View File

@ -0,0 +1,58 @@
"""
.. module: lemur.plugins.lemur_aws.sts
:platform: Unix
:copyright: (c) 2020 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Jasmine Schladen <jschladen@netflix.com>
"""
import json
import arrow
import boto3
from flask import current_app
def publish(topic_arn, certificates, notification_type, **kwargs):
sns_client = boto3.client("sns", **kwargs)
message_ids = {}
subject = "Lemur: {0} Notification".format(notification_type.capitalize())
for certificate in certificates:
message_ids[certificate["name"]] = publish_single(sns_client, topic_arn, certificate, notification_type, subject)
return message_ids
def publish_single(sns_client, topic_arn, certificate, notification_type, subject):
response = sns_client.publish(
TopicArn=topic_arn,
Message=format_message(certificate, notification_type),
Subject=subject,
)
response_code = response["ResponseMetadata"]["HTTPStatusCode"]
if response_code != 200:
raise Exception(f"Failed to publish {notification_type} notification to SNS topic {topic_arn}. "
f"SNS response: {response_code} {response}")
current_app.logger.info(f"AWS SNS message published to topic [{topic_arn}] with message ID {response['MessageId']}")
current_app.logger.debug(f"AWS SNS message published to topic [{topic_arn}]: [{response}]")
return response["MessageId"]
def create_certificate_url(name):
return "https://{hostname}/#/certificates/{name}".format(
hostname=current_app.config.get("LEMUR_HOSTNAME"), name=name
)
def format_message(certificate, notification_type):
json_message = {
"notification_type": notification_type,
"certificate_name": certificate["name"],
"expires": arrow.get(certificate["validityEnd"]).format("YYYY-MM-DDTHH:mm:ss"), # 2047-12-31T22:00:00
"endpoints_detected": len(certificate["endpoints"]),
"owner": certificate["owner"],
"details": create_certificate_url(certificate["name"])
}
return json.dumps(json_message)

View File

@ -1,5 +1,88 @@
import boto3
from moto import mock_sts, mock_s3
def test_get_certificates(app): def test_get_certificates(app):
from lemur.plugins.base import plugins from lemur.plugins.base import plugins
p = plugins.get("aws-s3") p = plugins.get("aws-s3")
assert p assert p
@mock_sts()
@mock_s3()
def test_upload_acme_token(app):
from lemur.plugins.base import plugins
from lemur.plugins.lemur_aws.s3 import get
bucket = "public-bucket"
account = "123456789012"
prefix = "some-path/more-path/"
token_content = "Challenge"
token_name = "TOKEN"
token_path = ".well-known/acme-challenge/" + token_name
additional_options = [
{
"name": "bucket",
"value": bucket,
"type": "str",
"required": True,
"validation": r"[0-9a-z.-]{3,63}",
"helpMessage": "Must be a valid S3 bucket name!",
},
{
"name": "accountNumber",
"type": "str",
"value": account,
"required": True,
"validation": r"[0-9]{12}",
"helpMessage": "A valid AWS account number with permission to access S3",
},
{
"name": "region",
"type": "str",
"default": "us-east-1",
"required": False,
"helpMessage": "Region bucket exists",
"available": ["us-east-1", "us-west-2", "eu-west-1"],
},
{
"name": "encrypt",
"type": "bool",
"value": False,
"required": False,
"helpMessage": "Enable server side encryption",
"default": True,
},
{
"name": "prefix",
"type": "str",
"value": prefix,
"required": False,
"helpMessage": "Must be a valid S3 object prefix!",
},
]
s3_client = boto3.client('s3')
s3_client.create_bucket(Bucket=bucket)
p = plugins.get("aws-s3")
response = p.upload_acme_token(token_path=token_path,
token_content=token_content,
token=token_content,
options=additional_options)
assert response
response = get(bucket_name=bucket,
prefixed_object_name=prefix + token_name,
encrypt=False,
account_number=account)
# put data, and getting the same data
assert (response == token_content)
response = p.delete_acme_token(token_path=token_path,
options=additional_options,
account_number=account)
assert response

View File

@ -0,0 +1,41 @@
import boto3
from moto import mock_sts, mock_s3
@mock_sts()
@mock_s3()
def test_put_delete_s3_object(app):
from lemur.plugins.lemur_aws.s3 import put, delete, get
bucket = "public-bucket"
region = "us-east-1"
account = "123456789012"
path = "some-path/foo"
data = "dummy data"
s3_client = boto3.client('s3')
s3_client.create_bucket(Bucket=bucket)
put(bucket_name=bucket,
region_name=region,
prefix=path,
data=data,
encrypt=False,
account_number=account,
region=region)
response = get(bucket_name=bucket, prefixed_object_name=path, account_number=account)
# put data, and getting the same data
assert (response == data)
response = get(bucket_name="wrong-bucket", prefixed_object_name=path, account_number=account)
# attempting to get thccle wrong data
assert (response is None)
delete(bucket_name=bucket, prefixed_object_name=path, account_number=account)
response = get(bucket_name=bucket, prefixed_object_name=path, account_number=account)
# delete data, and getting the same data
assert (response is None)

View File

@ -0,0 +1,123 @@
import json
from datetime import timedelta
import arrow
import boto3
from moto import mock_sns, mock_sqs, mock_ses
from lemur.certificates.schemas import certificate_notification_output_schema
from lemur.plugins.lemur_aws.sns import format_message
from lemur.plugins.lemur_aws.sns import publish
from lemur.tests.factories import NotificationFactory, CertificateFactory
from lemur.tests.test_messaging import verify_sender_email
@mock_sns()
def test_format(certificate, endpoint):
data = [certificate_notification_output_schema.dump(certificate).data]
for certificate in data:
expected_message = {
"notification_type": "expiration",
"certificate_name": certificate["name"],
"expires": arrow.get(certificate["validityEnd"]).format("YYYY-MM-DDTHH:mm:ss"),
"endpoints_detected": 0,
"owner": certificate["owner"],
"details": "https://lemur.example.com/#/certificates/{name}".format(name=certificate["name"])
}
assert expected_message == json.loads(format_message(certificate, "expiration"))
@mock_sns()
@mock_sqs()
def create_and_subscribe_to_topic():
sns_client = boto3.client("sns", region_name="us-east-1")
topic_arn = sns_client.create_topic(Name='lemursnstest')["TopicArn"]
sqs_client = boto3.client("sqs", region_name="us-east-1")
queue = sqs_client.create_queue(QueueName="lemursnstestqueue")
queue_url = queue["QueueUrl"]
queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url)["Attributes"]["QueueArn"]
sns_client.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn)
return [topic_arn, sqs_client, queue_url]
@mock_sns()
@mock_sqs()
def test_publish(certificate, endpoint):
data = [certificate_notification_output_schema.dump(certificate).data]
topic_arn, sqs_client, queue_url = create_and_subscribe_to_topic()
message_ids = publish(topic_arn, data, "expiration", region_name="us-east-1")
assert len(message_ids) == len(data)
received_messages = sqs_client.receive_message(QueueUrl=queue_url)["Messages"]
for certificate in data:
expected_message_id = message_ids[certificate["name"]]
actual_message = next(
(m for m in received_messages if json.loads(m["Body"])["MessageId"] == expected_message_id), None)
actual_json = json.loads(actual_message["Body"])
assert actual_json["Message"] == format_message(certificate, "expiration")
assert actual_json["Subject"] == "Lemur: Expiration Notification"
def get_options():
return [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
{"name": "region", "value": "us-east-1"},
{"name": "accountNumber", "value": "123456789012"},
{"name": "topicName", "value": "lemursnstest"},
]
@mock_sns()
@mock_sqs()
@mock_ses() # because email notifications are also sent
def test_send_expiration_notification():
from lemur.notifications.messaging import send_expiration_notifications
verify_sender_email() # emails are sent to owner and security; SNS only used for configured notification
topic_arn, sqs_client, queue_url = create_and_subscribe_to_topic()
notification = NotificationFactory(plugin_name="aws-sns")
notification.options = get_options()
now = arrow.utcnow()
in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future
certificate = CertificateFactory()
certificate.not_after = in_ten_days
certificate.notifications.append(notification)
assert send_expiration_notifications([]) == (3, 0) # owner, SNS, and security
received_messages = sqs_client.receive_message(QueueUrl=queue_url)["Messages"]
assert len(received_messages) == 1
expected_message = format_message(certificate_notification_output_schema.dump(certificate).data, "expiration")
actual_message = json.loads(received_messages[0]["Body"])["Message"]
assert actual_message == expected_message
# Currently disabled as the SNS plugin doesn't support this type of notification
# def test_send_rotation_notification(endpoint, source_plugin):
# from lemur.notifications.messaging import send_rotation_notification
# from lemur.deployment.service import rotate_certificate
#
# notification = NotificationFactory(plugin_name="aws-sns")
# notification.options = get_options()
#
# new_certificate = CertificateFactory()
# rotate_certificate(endpoint, new_certificate)
# assert endpoint.certificate == new_certificate
#
# assert send_rotation_notification(new_certificate)
# Currently disabled as the SNS plugin doesn't support this type of notification
# def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin):
# from lemur.notifications.messaging import send_pending_failure_notification
#
# assert send_pending_failure_notification(pending_certificate)

View File

@ -37,7 +37,13 @@ def log_status_code(r, *args, **kwargs):
:param kwargs: :param kwargs:
:return: :return:
""" """
log_data = {
"reason": (r.reason if r.reason else ""),
"status_code": r.status_code,
"url": (r.url if r.url else ""),
}
metrics.send("digicert_status_code_{}".format(r.status_code), "counter", 1) metrics.send("digicert_status_code_{}".format(r.status_code), "counter", 1)
current_app.logger.info(log_data)
def signature_hash(signing_algorithm): def signature_hash(signing_algorithm):
@ -171,7 +177,7 @@ def map_cis_fields(options, csr):
"csr": csr, "csr": csr,
"signature_hash": signature_hash(options.get("signing_algorithm")), "signature_hash": signature_hash(options.get("signing_algorithm")),
"validity": { "validity": {
"valid_to": validity_end.format("YYYY-MM-DDTHH:MM") + "Z" "valid_to": validity_end.format("YYYY-MM-DDTHH:mm:ss") + "Z"
}, },
"organization": { "organization": {
"name": options["organization"], "name": options["organization"],
@ -204,7 +210,7 @@ def handle_response(response):
:return: :return:
""" """
if response.status_code > 399: if response.status_code > 399:
raise Exception(response.json()["errors"][0]["message"]) raise Exception("DigiCert rejected request with the error:" + response.json()["errors"][0]["message"])
return response.json() return response.json()
@ -215,13 +221,20 @@ def handle_cis_response(response):
:param response: :param response:
:return: :return:
""" """
if response.status_code > 399: if response.status_code == 404:
raise Exception(response.text) raise Exception("DigiCert: order not in issued state")
elif response.status_code == 406:
raise Exception("DigiCert: wrong header request format")
elif response.status_code > 399:
raise Exception("DigiCert rejected request with the error:" + response.text)
return response.json() if response.url.endswith("download"):
return response.content
else:
return response.json()
@retry(stop_max_attempt_number=10, wait_fixed=10000) @retry(stop_max_attempt_number=10, wait_fixed=1000)
def get_certificate_id(session, base_url, order_id): def get_certificate_id(session, base_url, order_id):
"""Retrieve certificate order id from Digicert API.""" """Retrieve certificate order id from Digicert API."""
order_url = "{0}/services/v2/order/certificate/{1}".format(base_url, order_id) order_url = "{0}/services/v2/order/certificate/{1}".format(base_url, order_id)
@ -232,17 +245,15 @@ def get_certificate_id(session, base_url, order_id):
return response_data["certificate"]["id"] return response_data["certificate"]["id"]
@retry(stop_max_attempt_number=10, wait_fixed=10000) @retry(stop_max_attempt_number=10, wait_fixed=1000)
def get_cis_certificate(session, base_url, order_id): def get_cis_certificate(session, base_url, order_id):
"""Retrieve certificate order id from Digicert API, including the chain""" """Retrieve certificate order id from Digicert API, including the chain"""
certificate_url = "{0}/platform/cis/certificate/{1}/download".format(base_url, order_id) certificate_url = "{0}/platform/cis/certificate/{1}/download".format(base_url, order_id)
session.headers.update({"Accept": "application/x-pkcs7-certificates"}) session.headers.update({"Accept": "application/x-pkcs7-certificates"})
response = session.get(certificate_url) response = session.get(certificate_url)
response_content = handle_cis_response(response)
if response.status_code == 404: cert_chain_pem = convert_pkcs7_bytes_to_pem(response_content)
raise Exception("Order not in issued state.")
cert_chain_pem = convert_pkcs7_bytes_to_pem(response.content)
if len(cert_chain_pem) < 3: if len(cert_chain_pem) < 3:
raise Exception("Missing the certificate chain") raise Exception("Missing the certificate chain")
return cert_chain_pem return cert_chain_pem

View File

@ -123,7 +123,7 @@ def test_map_cis_fields_with_validity_years(mock_current_app, authority):
"signature_hash": "sha256", "signature_hash": "sha256",
"organization": {"name": "Example, Inc."}, "organization": {"name": "Example, Inc."},
"validity": { "validity": {
"valid_to": arrow.get(2018, 11, 3).format("YYYY-MM-DDTHH:MM") + "Z" "valid_to": arrow.get(2018, 11, 3).format("YYYY-MM-DDTHH:mm:ss") + "Z"
}, },
"profile_name": None, "profile_name": None,
} }
@ -159,7 +159,7 @@ def test_map_cis_fields_with_validity_end_and_start(mock_current_app, app, autho
"signature_hash": "sha256", "signature_hash": "sha256",
"organization": {"name": "Example, Inc."}, "organization": {"name": "Example, Inc."},
"validity": { "validity": {
"valid_to": arrow.get(2017, 5, 7).format("YYYY-MM-DDTHH:MM") + "Z" "valid_to": arrow.get(2017, 5, 7).format("YYYY-MM-DDTHH:mm:ss") + "Z"
}, },
"profile_name": None, "profile_name": None,
} }

View File

@ -17,16 +17,19 @@ from lemur.plugins.bases import ExpirationNotificationPlugin
from lemur.plugins import lemur_email as email from lemur.plugins import lemur_email as email
from lemur.plugins.lemur_email.templates.config import env from lemur.plugins.lemur_email.templates.config import env
from lemur.plugins.utils import get_plugin_option
def render_html(template_name, message): def render_html(template_name, options, certificates):
""" """
Renders the html for our email notification. Renders the html for our email notification.
:param template_name: :param template_name:
:param message: :param options:
:param certificates:
:return: :return:
""" """
message = {"options": options, "certificates": certificates}
template = env.get_template("{}.html".format(template_name)) template = env.get_template("{}.html".format(template_name))
return template.render( return template.render(
dict(message=message, hostname=current_app.config.get("LEMUR_HOSTNAME")) dict(message=message, hostname=current_app.config.get("LEMUR_HOSTNAME"))
@ -35,7 +38,7 @@ def render_html(template_name, message):
def send_via_smtp(subject, body, targets): def send_via_smtp(subject, body, targets):
""" """
Attempts to deliver email notification via SES service. Attempts to deliver email notification via SMTP.
:param subject: :param subject:
:param body: :param body:
@ -52,21 +55,26 @@ def send_via_smtp(subject, body, targets):
def send_via_ses(subject, body, targets): def send_via_ses(subject, body, targets):
""" """
Attempts to deliver email notification via SMTP. Attempts to deliver email notification via SES service.
:param subject: :param subject:
:param body: :param body:
:param targets: :param targets:
:return: :return:
""" """
client = boto3.client("ses", region_name="us-east-1") ses_region = current_app.config.get("LEMUR_SES_REGION", "us-east-1")
client.send_email( client = boto3.client("ses", region_name=ses_region)
Source=current_app.config.get("LEMUR_EMAIL"), source_arn = current_app.config.get("LEMUR_SES_SOURCE_ARN")
Destination={"ToAddresses": targets}, args = {
Message={ "Source": current_app.config.get("LEMUR_EMAIL"),
"Destination": {"ToAddresses": targets},
"Message": {
"Subject": {"Data": subject, "Charset": "UTF-8"}, "Subject": {"Data": subject, "Charset": "UTF-8"},
"Body": {"Html": {"Data": body, "Charset": "UTF-8"}}, "Body": {"Html": {"Data": body, "Charset": "UTF-8"}},
}, },
) }
if source_arn:
args["SourceArn"] = source_arn
client.send_email(**args)
class EmailNotificationPlugin(ExpirationNotificationPlugin): class EmailNotificationPlugin(ExpirationNotificationPlugin):
@ -83,7 +91,7 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
"name": "recipients", "name": "recipients",
"type": "str", "type": "str",
"required": True, "required": True,
"validation": "^([\w+-.%]+@[\w-.]+\.[A-Za-z]{2,4},?)+$", "validation": r"^([\w+-.%]+@[\w-.]+\.[A-Za-z]{2,4},?)+$",
"helpMessage": "Comma delimited list of email addresses", "helpMessage": "Comma delimited list of email addresses",
} }
] ]
@ -100,8 +108,7 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
subject = "Lemur: {0} Notification".format(notification_type.capitalize()) subject = "Lemur: {0} Notification".format(notification_type.capitalize())
data = {"options": options, "certificates": message} body = render_html(notification_type, options, message)
body = render_html(notification_type, data)
s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower() s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower()
@ -110,3 +117,13 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
elif s_type == "smtp": elif s_type == "smtp":
send_via_smtp(subject, body, targets) send_via_smtp(subject, body, targets)
@staticmethod
def filter_recipients(options, excluded_recipients, **kwargs):
notification_recipients = get_plugin_option("recipients", options)
if notification_recipients:
notification_recipients = notification_recipients.split(",")
# removing owner and security_email from notification_recipient
notification_recipients = [i for i in notification_recipients if i not in excluded_recipients]
return notification_recipients

View File

@ -83,12 +83,12 @@
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>
<td style="line-height:1.2"> <td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.name }}</span> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.certificates.name }}</span>
<br> <br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272"> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
<br>{{ certificate.owner }} <br>{{ message.certificates.owner }}
<br>{{ certificate.validityEnd | time }} <br>{{ message.certificates.validityEnd | time }}
<a href="https://{{ hostname }}/#/certificates/{{ certificate.name }}" target="_blank">Details</a> <a href="https://{{ hostname }}/#/certificates/{{ message.certificates.name }}" target="_blank">Details</a>
</span> </span>
</td> </td>
</tr> </tr>
@ -110,12 +110,12 @@
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>
<td style="line-height:1.2"> <td style="line-height:1.2">
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.replacedBy[0].name }}</span> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.certificates.name }}</span>
<br> <br>
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272"> <span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
<br>{{ certificate.replacedBy[0].owner }} <br>{{ message.certificates.owner }}
<br>{{ certificate.replacedBy[0].validityEnd | time }} <br>{{ message.certificates.validityEnd | time }}
<a href="https://{{ hostname }}/#/certificates/{{ certificate.replacedBy[0].name }}" target="_blank">Details</a> <a href="https://{{ hostname }}/#/certificates/{{ message.certificates.name }}" target="_blank">Details</a>
</span> </span>
</td> </td>
</tr> </tr>
@ -133,7 +133,7 @@
<table border="0" cellspacing="0" cellpadding="0" <table border="0" cellspacing="0" cellpadding="0"
style="margin-top:48px;margin-bottom:48px"> style="margin-top:48px;margin-bottom:48px">
<tbody> <tbody>
{% for endpoint in certificate.endpoints %} {% for endpoint in message.certificates.endpoints %}
<tr valign="middle"> <tr valign="middle">
<td width="32px"></td> <td width="32px"></td>
<td width="16px"></td> <td width="16px"></td>

View File

@ -1,36 +1,90 @@
import os import os
from lemur.plugins.lemur_email.templates.config import env from datetime import timedelta
import arrow
from moto import mock_ses
from lemur.certificates.schemas import certificate_notification_output_schema
from lemur.plugins.lemur_email.plugin import render_html
from lemur.tests.factories import CertificateFactory from lemur.tests.factories import CertificateFactory
from lemur.tests.test_messaging import verify_sender_email
dir_path = os.path.dirname(os.path.realpath(__file__)) dir_path = os.path.dirname(os.path.realpath(__file__))
def test_render(certificate, endpoint): def get_options():
from lemur.certificates.schemas import certificate_notification_output_schema return [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
{"name": "recipients", "value": "person1@example.com,person2@example.com"},
]
def test_render_expiration(certificate, endpoint):
new_cert = CertificateFactory() new_cert = CertificateFactory()
new_cert.replaces.append(certificate) new_cert.replaces.append(certificate)
data = { assert render_html("expiration", get_options(), [certificate_notification_output_schema.dump(certificate).data])
"certificates": [certificate_notification_output_schema.dump(certificate).data],
"options": [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
],
}
template = env.get_template("{}.html".format("expiration"))
body = template.render(dict(message=data, hostname="lemur.test.example.com"))
template = env.get_template("{}.html".format("rotation"))
def test_render_rotation(certificate, endpoint):
certificate.endpoints.append(endpoint) certificate.endpoints.append(endpoint)
body = template.render( assert render_html("rotation", get_options(), certificate_notification_output_schema.dump(certificate).data)
dict(
certificate=certificate_notification_output_schema.dump(certificate).data,
hostname="lemur.test.example.com", def test_render_rotation_failure(pending_certificate):
) assert render_html("failed", get_options(), certificate_notification_output_schema.dump(pending_certificate).data)
)
@mock_ses
def test_send_expiration_notification():
from lemur.notifications.messaging import send_expiration_notifications
from lemur.tests.factories import CertificateFactory
from lemur.tests.factories import NotificationFactory
now = arrow.utcnow()
in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future
certificate = CertificateFactory()
notification = NotificationFactory(plugin_name="email-notification")
certificate.not_after = in_ten_days
certificate.notifications.append(notification)
certificate.notifications[0].options = get_options()
verify_sender_email()
assert send_expiration_notifications([]) == (3, 0) # owner, recipients (only counted as 1), and security
@mock_ses
def test_send_rotation_notification(endpoint, source_plugin):
from lemur.notifications.messaging import send_rotation_notification
from lemur.deployment.service import rotate_certificate
new_certificate = CertificateFactory()
rotate_certificate(endpoint, new_certificate)
assert endpoint.certificate == new_certificate
verify_sender_email()
assert send_rotation_notification(new_certificate)
@mock_ses
def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin):
from lemur.notifications.messaging import send_pending_failure_notification
verify_sender_email()
assert send_pending_failure_notification(pending_certificate)
def test_filter_recipients(certificate, endpoint):
from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin
options = [{"name": "recipients", "value": "security@example.com,bob@example.com,joe@example.com"}]
assert EmailNotificationPlugin.filter_recipients(options, []) == ["security@example.com", "bob@example.com",
"joe@example.com"]
assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com"]) == ["bob@example.com",
"joe@example.com"]
assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com", "bob@example.com",
"joe@example.com"]) == []

View File

@ -1,9 +1,9 @@
import arrow import arrow
import requests import requests
import json import json
import sys import sys
from flask import current_app from flask import current_app
from retrying import retry
from lemur.plugins import lemur_entrust as entrust from lemur.plugins import lemur_entrust as entrust
from lemur.plugins.bases import IssuerPlugin, SourcePlugin from lemur.plugins.bases import IssuerPlugin, SourcePlugin
@ -20,7 +20,13 @@ def log_status_code(r, *args, **kwargs):
:param kwargs: :param kwargs:
:return: :return:
""" """
log_data = {
"reason": (r.reason if r.reason else ""),
"status_code": r.status_code,
"url": (r.url if r.url else ""),
}
metrics.send(f"entrust_status_code_{r.status_code}", "counter", 1) metrics.send(f"entrust_status_code_{r.status_code}", "counter", 1)
current_app.logger.info(log_data)
def determine_end_date(end_date): def determine_end_date(end_date):
@ -72,7 +78,6 @@ def process_options(options):
"eku": "SERVER_AND_CLIENT_AUTH", "eku": "SERVER_AND_CLIENT_AUTH",
"certType": product_type, "certType": product_type,
"certExpiryDate": validity_end, "certExpiryDate": validity_end,
# "keyType": "RSA", Entrust complaining about this parameter
"tracking": tracking_data "tracking": tracking_data
} }
return data return data
@ -81,7 +86,7 @@ def process_options(options):
def handle_response(my_response): def handle_response(my_response):
""" """
Helper function for parsing responses from the Entrust API. Helper function for parsing responses from the Entrust API.
:param content: :param my_response:
:return: :raise Exception: :return: :raise Exception:
""" """
msg = { msg = {
@ -94,22 +99,47 @@ def handle_response(my_response):
} }
try: try:
d = json.loads(my_response.content) data = json.loads(my_response.content)
except ValueError: except ValueError:
# catch an empty jason object here # catch an empty jason object here
d = {'response': 'No detailed message'} data = {'response': 'No detailed message'}
s = my_response.status_code status_code = my_response.status_code
if s > 399: if status_code > 399:
raise Exception(f"ENTRUST error: {msg.get(s, s)}\n{d['errors']}") raise Exception(f"ENTRUST error: {msg.get(status_code, status_code)}\n{data['errors']}")
log_data = { log_data = {
"function": f"{__name__}.{sys._getframe().f_code.co_name}", "function": f"{__name__}.{sys._getframe().f_code.co_name}",
"message": "Response", "message": "Response",
"status": s, "status": status_code,
"response": d "response": data
} }
current_app.logger.info(log_data) current_app.logger.info(log_data)
return d if data == {'response': 'No detailed message'}:
# status if no data
return status_code
else:
# return data from the response
return data
@retry(stop_max_attempt_number=3, wait_fixed=5000)
def order_and_download_certificate(session, url, data):
"""
Helper function to place a certificacte order and download it
:param session:
:param url: Entrust endpoint url
:param data: CSR, and the required order details, such as validity length
:return: the cert chain
:raise Exception:
"""
try:
response = session.post(url, json=data, timeout=(15, 40))
except requests.exceptions.Timeout:
raise Exception("Timeout for POST")
except requests.exceptions.RequestException as e:
raise Exception(f"Error for POST {e}")
return handle_response(response)
class EntrustIssuerPlugin(IssuerPlugin): class EntrustIssuerPlugin(IssuerPlugin):
@ -167,14 +197,8 @@ class EntrustIssuerPlugin(IssuerPlugin):
data = process_options(issuer_options) data = process_options(issuer_options)
data["csr"] = csr data["csr"] = csr
try: response_dict = order_and_download_certificate(self.session, url, data)
response = self.session.post(url, json=data, timeout=(15, 40))
except requests.exceptions.Timeout:
raise Exception("Timeout for POST")
except requests.exceptions.RequestException as e:
raise Exception(f"Error for POST {e}")
response_dict = handle_response(response)
external_id = response_dict['trackingId'] external_id = response_dict['trackingId']
cert = response_dict['endEntityCert'] cert = response_dict['endEntityCert']
if len(response_dict['chainCerts']) < 2: if len(response_dict['chainCerts']) < 2:
@ -189,6 +213,7 @@ class EntrustIssuerPlugin(IssuerPlugin):
return cert, chain, external_id return cert, chain, external_id
@retry(stop_max_attempt_number=3, wait_fixed=1000)
def revoke_certificate(self, certificate, comments): def revoke_certificate(self, certificate, comments):
"""Revoke an Entrust certificate.""" """Revoke an Entrust certificate."""
base_url = current_app.config.get("ENTRUST_URL") base_url = current_app.config.get("ENTRUST_URL")
@ -205,6 +230,7 @@ class EntrustIssuerPlugin(IssuerPlugin):
metrics.send("entrust_revoke_certificate", "counter", 1) metrics.send("entrust_revoke_certificate", "counter", 1)
return handle_response(response) return handle_response(response)
@retry(stop_max_attempt_number=3, wait_fixed=1000)
def deactivate_certificate(self, certificate): def deactivate_certificate(self, certificate):
"""Deactivates an Entrust certificate.""" """Deactivates an Entrust certificate."""
base_url = current_app.config.get("ENTRUST_URL") base_url = current_app.config.get("ENTRUST_URL")
@ -233,7 +259,7 @@ class EntrustIssuerPlugin(IssuerPlugin):
def get_ordered_certificate(self, order_id): def get_ordered_certificate(self, order_id):
raise NotImplementedError("Not implemented\n", self, order_id) raise NotImplementedError("Not implemented\n", self, order_id)
def canceled_ordered_certificate(self, pending_cert, **kwargs): def cancel_ordered_certificate(self, pending_cert, **kwargs):
raise NotImplementedError("Not implemented\n", self, pending_cert, **kwargs) raise NotImplementedError("Not implemented\n", self, pending_cert, **kwargs)

View File

@ -0,0 +1,4 @@
try:
VERSION = __import__("pkg_resources").get_distribution(__name__).version
except Exception as e:
VERSION = "unknown"

View File

@ -0,0 +1,152 @@
"""
.. module: lemur.plugins.lemur_openssh.plugin
:platform: Unix
:copyright: (c) 2020 by Emmanuel Garette, see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Emmanuel Garette <gnunux@gnunux.info>
"""
import subprocess
from os import unlink
from flask import current_app
from cryptography.hazmat.primitives import serialization
from datetime import datetime, timedelta
from lemur.utils import mktempfile
from lemur.plugins import lemur_openssh as openssh
from lemur.common.utils import parse_private_key, parse_certificate
from lemur.plugins.lemur_cryptography.plugin import CryptographyIssuerPlugin
from lemur.certificates.service import get_by_root_authority
def run_process(command):
"""
Runs a given command with pOpen and wraps some
error handling around it.
:param command:
:return:
"""
p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
current_app.logger.debug(" ".join(command))
stdout, stderr = p.communicate()
if p.returncode != 0:
current_app.logger.error(stderr.decode())
raise Exception(stderr.decode())
def split_cert(body):
"""
To display certificate in Lemur website, we have to split
certificate in several line
:param body: certificate
:retur: splitted certificate
"""
length = 65
return '\n'.join([body[i:i + length] for i in range(0, len(body), length)])
def sign_certificate(common_name, public_key, authority_private_key, user, extensions, not_before, not_after):
with mktempfile() as issuer_tmp:
cmd = ['ssh-keygen', '-s', issuer_tmp]
with open(issuer_tmp, 'w') as i:
i.writelines(authority_private_key)
if 'extendedKeyUsage' in extensions and extensions['extendedKeyUsage'].get('useClientAuthentication'):
valid_interval = current_app.config.get("OPENSSH_VALID_INTERVAL_CLIENT", 1) # 1 day by default
cmd.extend(['-I', user['username'] + ' user key',
'-n', user['username']])
else:
valid_interval = current_app.config.get("OPENSSH_VALID_INTERVAL_SERVER", 14) # 2 weeks by default
domains = {common_name}
for name in extensions['subAltNames']['names']:
if name['nameType'] == 'DNSName':
domains.add(name['value'])
cmd.extend(['-I', common_name + ' host key',
'-n', ','.join(domains),
'-h'])
# something like 20201024
ssh_not_before = datetime.fromisoformat(not_before).strftime("%Y%m%d")
cert_not_after = datetime.fromisoformat(not_after).strftime("%Y%m%d")
ssh_not_after = (datetime.now() + timedelta(days=valid_interval)).strftime("%Y%m%d")
ssh_not_after = min(ssh_not_after, cert_not_after)
cmd.extend(['-V', ssh_not_before + ':' + ssh_not_after])
with mktempfile() as cert_tmp:
with open(cert_tmp, 'w') as f:
f.write(public_key)
cmd.append(cert_tmp)
run_process(cmd)
pub = cert_tmp + '-cert.pub'
with open(pub, 'r') as p:
body = split_cert(p.read())
unlink(pub)
return body
class OpenSSHIssuerPlugin(CryptographyIssuerPlugin):
"""This issuer plugins is base in Cryptography plugin
Certificates and authorities are x509 certificates created by Cryptography plugin.
Those certificates are converted to OpenSSH format when people get them.
"""
title = "OpenSSH"
slug = "openssh-issuer"
description = "Enables the creation and signing OpenSSH keys"
version = openssh.VERSION
author = "Emmanuel Garette"
author_url = "http://gnunux.info"
def create_authority(self, options):
# OpenSSH do not support parent's authoriy
if options.get("parent"):
raise Exception('cannot create authority with a parent for OpenSSH plugin')
# create a x509 certificat
cert_pem, private_key, chain_cert_pem, roles = super().create_authority(options)
return cert_pem, private_key, chain_cert_pem, roles
def wrap_certificate(self, cert):
# get public_key in OpenSSH format
public_key = parse_certificate(cert['body']).public_key().public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
).decode()
public_key += ' ' + cert['user']['email']
# sign it with authority private key
authority = get_by_root_authority(cert['authority']['id'])
authority_private_key = authority.private_key
cert['body'] = sign_certificate(
cert['common_name'],
public_key,
authority_private_key,
cert['user'],
cert['extensions'],
cert['not_before'],
cert['not_after']
)
# convert chain in OpenSSH format
if cert['chain']:
chain_cert = {'body': cert['chain'], 'cn': authority.cn}
self.wrap_auth_certificate(chain_cert)
cert['chain'] = chain_cert['body']
# OpenSSH do not support csr
cert['csr'] = None
@staticmethod
def wrap_auth_certificate(auth_cert):
# convert chain in OpenSSH format
chain_key = parse_certificate(auth_cert['body']).public_key().public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
).decode()
chain_key += ' root@' + auth_cert['cn']
auth_cert['body'] = split_cert(chain_key)
@staticmethod
def wrap_private_key(cert):
# convert private_key in OpenSSH format
cert.private_key = parse_private_key(cert.private_key).private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=serialization.NoEncryption(),
)

View File

@ -16,8 +16,10 @@
.. moduleauthor:: Dmitry Zykov https://github.com/DmitryZykov .. moduleauthor:: Dmitry Zykov https://github.com/DmitryZykov
""" """
from os import path
import paramiko import paramiko
from paramiko.ssh_exception import AuthenticationException, NoValidConnectionsError
from flask import current_app from flask import current_app
from lemur.plugins import lemur_sftp from lemur.plugins import lemur_sftp
@ -47,7 +49,7 @@ class SFTPDestinationPlugin(DestinationPlugin):
"type": "int", "type": "int",
"required": True, "required": True,
"helpMessage": "The SFTP port, default is 22.", "helpMessage": "The SFTP port, default is 22.",
"validation": "^(6553[0-5]|655[0-2][0-9]\d|65[0-4](\d){2}|6[0-4](\d){3}|[1-5](\d){4}|[1-9](\d){0,3})", "validation": r"^(6553[0-5]|655[0-2][0-9]\d|65[0-4](\d){2}|6[0-4](\d){3}|[1-5](\d){4}|[1-9](\d){0,3})",
"default": "22", "default": "22",
}, },
{ {
@ -95,33 +97,15 @@ class SFTPDestinationPlugin(DestinationPlugin):
}, },
] ]
def upload(self, name, body, private_key, cert_chain, options, **kwargs): def open_sftp_connection(self, options):
current_app.logger.debug("SFTP destination plugin is started")
cn = common_name(parse_certificate(body))
host = self.get_option("host", options) host = self.get_option("host", options)
port = self.get_option("port", options) port = self.get_option("port", options)
user = self.get_option("user", options) user = self.get_option("user", options)
password = self.get_option("password", options) password = self.get_option("password", options)
ssh_priv_key = self.get_option("privateKeyPath", options) ssh_priv_key = self.get_option("privateKeyPath", options)
ssh_priv_key_pass = self.get_option("privateKeyPass", options) ssh_priv_key_pass = self.get_option("privateKeyPass", options)
dst_path = self.get_option("destinationPath", options)
export_format = self.get_option("exportFormat", options)
# prepare files for upload # delete files
files = {cn + ".key": private_key, cn + ".pem": body}
if cert_chain:
if export_format == "NGINX":
# assemble body + chain in the single file
files[cn + ".pem"] += "\n" + cert_chain
elif export_format == "Apache":
# store chain in the separate file
files[cn + ".ca.bundle.pem"] = cert_chain
# upload files
try: try:
current_app.logger.debug( current_app.logger.debug(
"Connecting to {0}@{1}:{2}".format(user, host, port) "Connecting to {0}@{1}:{2}".format(user, host, port)
@ -145,50 +129,170 @@ class SFTPDestinationPlugin(DestinationPlugin):
current_app.logger.error( current_app.logger.error(
"No password or private key provided. Can't proceed" "No password or private key provided. Can't proceed"
) )
raise paramiko.ssh_exception.AuthenticationException raise AuthenticationException
# open the sftp session inside the ssh connection # open the sftp session inside the ssh connection
sftp = ssh.open_sftp() return ssh.open_sftp(), ssh
# make sure that the destination path exist except AuthenticationException as e:
try: current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
current_app.logger.debug("Creating {0}".format(dst_path)) raise AuthenticationException("Couldn't connect to {0}, due to an Authentication exception.")
sftp.mkdir(dst_path) except NoValidConnectionsError as e:
except IOError: current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
current_app.logger.debug("{0} already exist, resuming".format(dst_path)) raise NoValidConnectionsError("Couldn't connect to {0}, possible timeout or invalid hostname")
try:
dst_path_cn = dst_path + "/" + cn
current_app.logger.debug("Creating {0}".format(dst_path_cn))
sftp.mkdir(dst_path_cn)
except IOError:
current_app.logger.debug(
"{0} already exist, resuming".format(dst_path_cn)
)
# upload certificate files to the sftp destination # this is called when using this as a default destination plugin
for filename, data in files.items(): def upload(self, name, body, private_key, cert_chain, options, **kwargs):
current_app.logger.debug("SFTP destination plugin is started")
cn = common_name(parse_certificate(body))
dst_path = self.get_option("destinationPath", options)
dst_path_cn = dst_path + "/" + cn
export_format = self.get_option("exportFormat", options)
# prepare files for upload
files = {cn + ".key": private_key, cn + ".pem": body}
if cert_chain:
if export_format == "NGINX":
# assemble body + chain in the single file
files[cn + ".pem"] += "\n" + cert_chain
elif export_format == "Apache":
# store chain in the separate file
files[cn + ".ca.bundle.pem"] = cert_chain
self.upload_file(dst_path_cn, files, options)
# this is called from the acme http challenge
def upload_acme_token(self, token_path, token, options, **kwargs):
current_app.logger.debug("SFTP destination plugin is started for HTTP-01 challenge")
dst_path = self.get_option("destinationPath", options)
_, filename = path.split(token_path)
# prepare files for upload
files = {filename: token}
self.upload_file(dst_path, files, options)
# this is called from the acme http challenge
def delete_acme_token(self, token_path, options, **kwargs):
dst_path = self.get_option("destinationPath", options)
_, filename = path.split(token_path)
# prepare files for upload
files = {filename: None}
self.delete_file(dst_path, files, options)
# here the file is deleted
def delete_file(self, dst_path, files, options):
try:
# open the ssh and sftp sessions
sftp, ssh = self.open_sftp_connection(options)
# delete files
for filename, _ in files.items():
current_app.logger.debug( current_app.logger.debug(
"Uploading {0} to {1}".format(filename, dst_path_cn) "Deleting {0} from {1}".format(filename, dst_path)
) )
try: try:
with sftp.open(dst_path_cn + "/" + filename, "w") as f: sftp.remove(path.join(dst_path, filename))
f.write(data) except PermissionError as permerror:
except (PermissionError) as permerror:
if permerror.errno == 13: if permerror.errno == 13:
current_app.logger.debug( current_app.logger.debug(
"Uploading {0} to {1} returned Permission Denied Error, making file writable and retrying".format(filename, dst_path_cn) "Deleting {0} from {1} returned Permission Denied Error, making file writable and retrying".format(
filename, dst_path)
) )
sftp.chmod(dst_path_cn + "/" + filename, 0o600) sftp.chmod(path.join(dst_path, filename), 0o600)
with sftp.open(dst_path_cn + "/" + filename, "w") as f: sftp.remove(path.join(dst_path, filename))
f.write(data)
# read only for owner, -r--------
sftp.chmod(dst_path_cn + "/" + filename, 0o400)
ssh.close() ssh.close()
except (AuthenticationException, NoValidConnectionsError) as e:
raise e
except Exception as e: except Exception as e:
current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e)) current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
try: try:
ssh.close() ssh.close()
except BaseException: except BaseException:
pass pass
# here the file is uploaded for real, this helps to keep this class DRY
def upload_file(self, dst_path, files, options):
try:
# open the ssh and sftp sessions
sftp, ssh = self.open_sftp_connection(options)
# split the path into it's segments, so we can create it recursively
allparts = []
path_copy = dst_path
while True:
parts = path.split(path_copy)
if parts[0] == path_copy: # sentinel for absolute paths
allparts.insert(0, parts[0])
break
elif parts[1] == path_copy: # sentinel for relative paths
allparts.insert(0, parts[1])
break
else:
path_copy = parts[0]
allparts.insert(0, parts[1])
# make sure that the destination path exists, recursively
remote_path = allparts[0]
for part in allparts:
try:
if part != "/" and part != "":
remote_path = path.join(remote_path, part)
sftp.stat(remote_path)
except IOError:
current_app.logger.debug("{0} doesn't exist, trying to create it".format(remote_path))
try:
sftp.mkdir(remote_path)
except IOError as ioerror:
current_app.logger.debug(
"Couldn't create {0}, error message: {1}".format(remote_path, ioerror))
# upload certificate files to the sftp destination
for filename, data in files.items():
current_app.logger.debug(
"Uploading {0} to {1}".format(filename, dst_path)
)
try:
with sftp.open(path.join(dst_path, filename), "w") as f:
f.write(data)
except PermissionError as permerror:
if permerror.errno == 13:
current_app.logger.debug(
"Uploading {0} to {1} returned Permission Denied Error, making file writable and retrying".format(
filename, dst_path)
)
sftp.chmod(path.join(dst_path, filename), 0o600)
with sftp.open(path.join(dst_path, filename), "w") as f:
f.write(data)
# most likely the upload user isn't the webuser, -rw-r--r--
sftp.chmod(path.join(dst_path, filename), 0o644)
ssh.close()
except (AuthenticationException, NoValidConnectionsError) as e:
raise e
except Exception as e:
current_app.logger.error("ERROR in {0}: {1}".format(e.__class__, e))
try:
ssh.close()
except BaseException:
pass
message = ''
if hasattr(e, 'errors'):
for _, error in e.errors.items():
message = error.strerror
raise Exception(
'Couldn\'t upload file to {}, error message: {}'.format(self.get_option("host", options), message))

View File

@ -0,0 +1,144 @@
import unittest
from unittest.mock import patch, Mock, MagicMock, mock_open
from flask import Flask
from lemur.plugins.lemur_sftp import plugin
from paramiko.ssh_exception import AuthenticationException
class TestSftp(unittest.TestCase):
def setUp(self):
self.sftp_destination = plugin.SFTPDestinationPlugin()
# Creates a new Flask application for a test duration. In python 3.8, manual push of application context is
# needed to run tests in dev environment without getting error 'Working outside of application context'.
_app = Flask('lemur_test_sftp')
self.ctx = _app.app_context()
assert self.ctx
self.ctx.push()
def tearDown(self):
self.ctx.pop()
def test_failing_ssh_connection(self):
dst_path = '/var/non-existent'
files = {'first-file': 'data'}
options = [{'name': 'host', 'value': 'non-existent'}, {'name': 'port', 'value': '22'},
{'name': 'user', 'value': 'test_acme'}]
with self.assertRaises(AuthenticationException):
self.sftp_destination.upload_file(dst_path, files, options)
@patch("lemur.plugins.lemur_sftp.plugin.paramiko")
def test_upload_file_single_with_password(self, mock_paramiko):
dst_path = '/var/non-existent'
files = {'first-file': 'data'}
options = [{'name': 'host', 'value': 'non-existent'}, {'name': 'port', 'value': '22'},
{'name': 'user', 'value': 'test_acme'}, {'name': 'password', 'value': 'test_password'}]
mock_sftp = Mock()
mock_sftp.open = mock_open()
mock_ssh = mock_paramiko.SSHClient.return_value
mock_ssh.connect = MagicMock()
mock_ssh.open_sftp.return_value = mock_sftp
self.sftp_destination.upload_file(dst_path, files, options)
mock_sftp.open.assert_called_once_with('/var/non-existent/first-file', 'w')
handle = mock_sftp.open()
handle.write.assert_called_once_with('data')
mock_ssh.close.assert_called_once()
mock_ssh.connect.assert_called_with('non-existent', username='test_acme', port='22',
password='test_password')
@patch("lemur.plugins.lemur_sftp.plugin.paramiko")
def test_upload_file_multiple_with_key(self, mock_paramiko):
dst_path = '/var/non-existent'
files = {'first-file': 'data', 'second-file': 'data2'}
options = [{'name': 'host', 'value': 'non-existent'}, {'name': 'port', 'value': '22'},
{'name': 'user', 'value': 'test_acme'}, {'name': 'privateKeyPath', 'value': '/var/id_rsa'},
{'name': 'privateKeyPass', 'value': 'ssh-key-password'}]
mock_sftp = Mock()
mock_sftp.open = mock_open()
mock_paramiko.RSAKey.from_private_key_file.return_value = 'ssh-rsa test-key'
mock_ssh = mock_paramiko.SSHClient.return_value
mock_ssh.connect = MagicMock()
mock_ssh.open_sftp.return_value = mock_sftp
self.sftp_destination.upload_file(dst_path, files, options)
mock_sftp.open.assert_called_with('/var/non-existent/second-file', 'w')
handle = mock_sftp.open()
handle.write.assert_called_with('data2')
mock_ssh.close.assert_called_once()
mock_paramiko.RSAKey.from_private_key_file.assert_called_with('/var/id_rsa', 'ssh-key-password')
mock_ssh.connect.assert_called_with('non-existent', username='test_acme', port='22',
pkey='ssh-rsa test-key')
@patch("lemur.plugins.lemur_sftp.plugin.paramiko")
def test_upload_acme_token(self, mock_paramiko):
token_path = './well-known/acme-challenge/some-token-path'
token = 'token-data'
options = [{'name': 'host', 'value': 'non-existent'}, {'name': 'port', 'value': '22'},
{'name': 'user', 'value': 'test_acme'}, {'name': 'password', 'value': 'test_password'},
{'name': 'destinationPath', 'value': '/var/destination-path'}]
mock_sftp = Mock()
mock_sftp.open = mock_open()
mock_ssh = mock_paramiko.SSHClient.return_value
mock_ssh.connect = MagicMock()
mock_ssh.open_sftp.return_value = mock_sftp
self.sftp_destination.upload_acme_token(token_path, token, options)
mock_sftp.open.assert_called_once_with('/var/destination-path/some-token-path', 'w')
handle = mock_sftp.open()
handle.write.assert_called_once_with('token-data')
mock_ssh.close.assert_called_once()
mock_ssh.connect.assert_called_with('non-existent', username='test_acme', port='22',
password='test_password')
@patch("lemur.plugins.lemur_sftp.plugin.paramiko")
def test_delete_file_with_password(self, mock_paramiko):
dst_path = '/var/non-existent'
files = {'first-file': None}
options = [{'name': 'host', 'value': 'non-existent'}, {'name': 'port', 'value': '22'},
{'name': 'user', 'value': 'test_acme'}, {'name': 'password', 'value': 'test_password'}]
mock_sftp = Mock()
mock_ssh = mock_paramiko.SSHClient.return_value
mock_ssh.connect = MagicMock()
mock_ssh.open_sftp.return_value = mock_sftp
self.sftp_destination.delete_file(dst_path, files, options)
mock_sftp.remove.assert_called_once_with('/var/non-existent/first-file')
mock_ssh.close.assert_called_once()
mock_ssh.connect.assert_called_with('non-existent', username='test_acme', port='22',
password='test_password')
@patch("lemur.plugins.lemur_sftp.plugin.paramiko")
def test_delete_acme_token(self, mock_paramiko):
token_path = './well-known/acme-challenge/some-token-path'
options = [{'name': 'host', 'value': 'non-existent'}, {'name': 'port', 'value': '22'},
{'name': 'user', 'value': 'test_acme'}, {'name': 'password', 'value': 'test_password'},
{'name': 'destinationPath', 'value': '/var/destination-path'}]
mock_sftp = Mock()
mock_ssh = mock_paramiko.SSHClient.return_value
mock_ssh.connect = MagicMock()
mock_ssh.open_sftp.return_value = mock_sftp
self.sftp_destination.delete_acme_token(token_path, options)
mock_sftp.remove.assert_called_once_with('/var/destination-path/some-token-path')
mock_ssh.close.assert_called_once()
mock_ssh.connect.assert_called_with('non-existent', username='test_acme', port='22',
password='test_password')

View File

@ -58,26 +58,19 @@ def create_rotation_attachments(certificate):
"title": certificate["name"], "title": certificate["name"],
"title_link": create_certificate_url(certificate["name"]), "title_link": create_certificate_url(certificate["name"]),
"fields": [ "fields": [
{"title": "Owner", "value": certificate["owner"], "short": True},
{ {
{"title": "Owner", "value": certificate["owner"], "short": True}, "title": "Expires",
{ "value": arrow.get(certificate["validityEnd"]).format(
"title": "Expires", "dddd, MMMM D, YYYY"
"value": arrow.get(certificate["validityEnd"]).format( ),
"dddd, MMMM D, YYYY" "short": True,
), },
"short": True, {
}, "title": "Endpoints Rotated",
{ "value": len(certificate["endpoints"]),
"title": "Replaced By", "short": True,
"value": len(certificate["replaced"][0]["name"]), },
"short": True,
},
{
"title": "Endpoints Rotated",
"value": len(certificate["endpoints"]),
"short": True,
},
}
], ],
} }
@ -96,7 +89,7 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
"name": "webhook", "name": "webhook",
"type": "str", "type": "str",
"required": True, "required": True,
"validation": "^https:\/\/hooks\.slack\.com\/services\/.+$", "validation": r"^https:\/\/hooks\.slack\.com\/services\/.+$",
"helpMessage": "The url Slack told you to use for this integration", "helpMessage": "The url Slack told you to use for this integration",
}, },
{ {
@ -119,6 +112,9 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
""" """
A typical check can be performed using the notify command: A typical check can be performed using the notify command:
`lemur notify` `lemur notify`
While we receive a `targets` parameter here, it is unused, as Slack webhooks do not allow
dynamic re-targeting of messages. The webhook itself specifies a channel.
""" """
attachments = None attachments = None
if notification_type == "expiration": if notification_type == "expiration":
@ -131,7 +127,7 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
raise Exception("Unable to create message attachments") raise Exception("Unable to create message attachments")
body = { body = {
"text": "Lemur {0} Notification".format(notification_type.capitalize()), "text": f"Lemur {notification_type.capitalize()} Notification",
"attachments": attachments, "attachments": attachments,
"channel": self.get_option("recipients", options), "channel": self.get_option("recipients", options),
"username": self.get_option("username", options), "username": self.get_option("username", options),
@ -140,8 +136,8 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
r = requests.post(self.get_option("webhook", options), json.dumps(body)) r = requests.post(self.get_option("webhook", options), json.dumps(body))
if r.status_code not in [200]: if r.status_code not in [200]:
raise Exception("Failed to send message") raise Exception(f"Failed to send message. Slack response: {r.status_code} {body}")
current_app.logger.error( current_app.logger.info(
"Slack response: {0} Message Body: {1}".format(r.status_code, body) f"Slack response: {r.status_code} Message Body: {body}"
) )

View File

@ -1,3 +1,12 @@
from datetime import timedelta
import arrow
from moto import mock_ses
from lemur.tests.factories import NotificationFactory, CertificateFactory
from lemur.tests.test_messaging import verify_sender_email
def test_formatting(certificate): def test_formatting(certificate):
from lemur.plugins.lemur_slack.plugin import create_expiration_attachments from lemur.plugins.lemur_slack.plugin import create_expiration_attachments
from lemur.certificates.schemas import certificate_notification_output_schema from lemur.certificates.schemas import certificate_notification_output_schema
@ -21,3 +30,52 @@ def test_formatting(certificate):
} }
assert attachment == create_expiration_attachments(data)[0] assert attachment == create_expiration_attachments(data)[0]
def get_options():
return [
{"name": "interval", "value": 10},
{"name": "unit", "value": "days"},
{"name": "webhook", "value": "https://slack.com/api/api.test"},
]
@mock_ses() # because email notifications are also sent
def test_send_expiration_notification():
from lemur.notifications.messaging import send_expiration_notifications
verify_sender_email() # emails are sent to owner and security; Slack only used for configured notification
notification = NotificationFactory(plugin_name="slack-notification")
notification.options = get_options()
now = arrow.utcnow()
in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future
certificate = CertificateFactory()
certificate.not_after = in_ten_days
certificate.notifications.append(notification)
assert send_expiration_notifications([]) == (3, 0) # owner, Slack, and security
# Currently disabled as the Slack plugin doesn't support this type of notification
# def test_send_rotation_notification(endpoint, source_plugin):
# from lemur.notifications.messaging import send_rotation_notification
# from lemur.deployment.service import rotate_certificate
#
# notification = NotificationFactory(plugin_name="slack-notification")
# notification.options = get_options()
#
# new_certificate = CertificateFactory()
# rotate_certificate(endpoint, new_certificate)
# assert endpoint.certificate == new_certificate
#
# assert send_rotation_notification(new_certificate, notification_plugin=notification.plugin)
# Currently disabled as the Slack plugin doesn't support this type of notification
# def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin):
# from lemur.notifications.messaging import send_pending_failure_notification
#
# assert send_pending_failure_notification(pending_certificate, notification_plugin=plugins.get("slack-notification"))

View File

@ -264,13 +264,14 @@ def create(label, plugin_name, options, description=None):
return database.create(source) return database.create(source)
def update(source_id, label, options, description): def update(source_id, label, plugin_name, options, description):
""" """
Updates an existing source. Updates an existing source.
:param source_id: Lemur assigned ID :param source_id: Lemur assigned ID
:param label: Source common name :param label: Source common name
:param options: :param options:
:param plugin_name:
:param description: :param description:
:rtype : Source :rtype : Source
:return: :return:
@ -278,6 +279,7 @@ def update(source_id, label, options, description):
source = get(source_id) source = get(source_id)
source.label = label source.label = label
source.plugin_name = plugin_name
source.options = options source.options = options
source.description = description source.description = description

View File

@ -284,6 +284,7 @@ class Sources(AuthenticatedResource):
return service.update( return service.update(
source_id, source_id,
data["label"], data["label"],
data["plugin"]["slug"],
data["plugin"]["plugin_options"], data["plugin"]["plugin_options"],
data["description"], data["description"],
) )

View File

@ -34,7 +34,7 @@ angular.module('lemur')
}; };
}) })
.controller('AuthorityCreateController', function ($scope, $uibModalInstance, AuthorityService, AuthorityApi, LemurRestangular, RoleService, PluginService, WizardHandler, toaster) { .controller('AuthorityCreateController', function ($scope, $uibModalInstance, AuthorityService, AuthorityApi, LemurRestangular, RoleService, PluginService, WizardHandler, toaster, DestinationService) {
$scope.authority = LemurRestangular.restangularizeElement(null, {}, 'authorities'); $scope.authority = LemurRestangular.restangularizeElement(null, {}, 'authorities');
// set the defaults // set the defaults
AuthorityService.getDefaults($scope.authority).then(function () { AuthorityService.getDefaults($scope.authority).then(function () {
@ -52,6 +52,12 @@ angular.module('lemur')
}); });
}); });
$scope.getDestinations = function() {
return DestinationService.findDestinationsByName('').then(function(destinations) {
$scope.destinations = destinations;
});
};
$scope.getAuthoritiesByName = function (value) { $scope.getAuthoritiesByName = function (value) {
return AuthorityService.findAuthorityByName(value).then(function (authorities) { return AuthorityService.findAuthorityByName(value).then(function (authorities) {
$scope.authorities = authorities; $scope.authorities = authorities;

View File

@ -20,8 +20,10 @@
Key Type Key Type
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" ng-model="authority.keyType" ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME192V1', 'ECCPRIME256V1', 'ECCSECP192R1', 'ECCSECP224R1', 'ECCSECP256R1', 'ECCSECP384R1', 'ECCSECP521R1', 'ECCSECP256K1', <select class="form-control" ng-model="authority.keyType"
'ECCSECT163K1', 'ECCSECT233K1', 'ECCSECT283K1', 'ECCSECT409K1', 'ECCSECT571K1', 'ECCSECT163R2', 'ECCSECT233R1', 'ECCSECT283R1', 'ECCSECT409R1', 'ECCSECT571R2']" ng-init="authority.keyType = 'RSA2048'"></select> ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME256V1', 'ECCSECP384R1', 'ECCSECP521R1']"
ng-init="authority.keyType = 'RSA2048'">
</select>
</div> </div>
</div> </div>
<div ng-show="authority.sensitivity == 'high'" class="form-group"> <div ng-show="authority.sensitivity == 'high'" class="form-group">
@ -64,11 +66,28 @@
<div class="col-sm-10"> <div class="col-sm-10">
<input name="sub" ng-if="item.type == 'int'" type="number" ng-pattern="item.validation?item.validation:'^[0-9]+$'" <input name="sub" ng-if="item.type == 'int'" type="number" ng-pattern="item.validation?item.validation:'^[0-9]+$'"
class="form-control" ng-model="item.value"/> class="form-control" ng-model="item.value"/>
<select name="sub" ng-if="item.type == 'select'" class="form-control" ng-options="i for i in item.available" <select name="sub" ng-if="item.type == 'select'" class="form-control" ng-options="i for i in item.available"
ng-model="item.value"></select> ng-model="item.value"></select>
<!-- DestSelect options -->
<ui-select class="input-md" ng-model="item.value" theme="bootstrap" title="choose a destination" ng-if="item.type == 'destinationSelect'">
<ui-select-match placeholder="select an destination...">{{$select.selected.label}}</ui-select-match>
<ui-select-choices class="form-control"
refresh="getDestinations()"
refresh-delay="300"
repeat="destination.id as destination in destinations | filter: $select.search">
<div ng-bind-html="destination.label | highlight: $select.search"></div>
<small>
<span ng-bind-html="''+destination.description | highlight: $select.search"></span>
</small>
</ui-select-choices>
</ui-select>
<input name="sub" ng-if="item.type == 'bool'" class="form-control" type="checkbox" ng-model="item.value"> <input name="sub" ng-if="item.type == 'bool'" class="form-control" type="checkbox" ng-model="item.value">
<input name="sub" ng-if="item.type == 'str'" type="text" class="form-control" ng-model="item.value"/> <input name="sub" ng-if="item.type == 'str'" type="text" class="form-control" ng-model="item.value"/>
<textarea name="sub" ng-if="item.type == 'textarea'" class="form-control" ng-model="item.value"></textarea> <textarea name="sub" ng-if="item.type == 'textarea'" class="form-control" ng-model="item.value"></textarea>
<div ng-if="item.type == 'export-plugin'"> <div ng-if="item.type == 'export-plugin'">
<form name="exportForm" class="form-horizontal" role="form" novalidate> <form name="exportForm" class="form-horizontal" role="form" novalidate>
<select class="form-control" ng-model="item.value" <select class="form-control" ng-model="item.value"

View File

@ -32,10 +32,7 @@
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" ng-model="certificate.keyType" <select class="form-control" ng-model="certificate.keyType"
ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME192V1', 'ECCPRIME256V1', 'ECCSECP192R1', ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME256V1', 'ECCSECP384R1']"
'ECCSECP224R1', 'ECCSECP256R1', 'ECCSECP384R1', 'ECCSECP521R1', 'ECCSECP256K1',
'ECCSECT163K1', 'ECCSECT233K1', 'ECCSECT283K1', 'ECCSECT409K1', 'ECCSECT571K1',
'ECCSECT163R2', 'ECCSECT233R1', 'ECCSECT283R1', 'ECCSECT409R1', 'ECCSECT571R2']"
ng-init="certificate.keyType = 'RSA2048'"></select> ng-init="certificate.keyType = 'RSA2048'"></select>
</div> </div>
</div> </div>

View File

@ -52,19 +52,19 @@ angular.module('lemur')
if (plugin.slug === $scope.destination.plugin.slug) { if (plugin.slug === $scope.destination.plugin.slug) {
plugin.pluginOptions = $scope.destination.plugin.pluginOptions; plugin.pluginOptions = $scope.destination.plugin.pluginOptions;
$scope.destination.plugin = plugin; $scope.destination.plugin = plugin;
_.each($scope.destination.plugin.pluginOptions, function (option) { PluginService.getByType('export').then(function (plugins) {
if (option.type === 'export-plugin') { $scope.exportPlugins = plugins;
PluginService.getByType('export').then(function (plugins) {
$scope.exportPlugins = plugins;
_.each($scope.destination.plugin.pluginOptions, function (option) {
if (option.type === 'export-plugin') {
_.each($scope.exportPlugins, function (plugin) { _.each($scope.exportPlugins, function (plugin) {
if (plugin.slug === option.value.slug) { if (plugin.slug === option.value.slug) {
plugin.pluginOptions = option.value.pluginOptions; plugin.pluginOptions = option.value.pluginOptions;
option.value = plugin; option.value = plugin;
} }
}); });
}); }
} });
}); });
} }
}); });

View File

@ -42,8 +42,8 @@ angular.module('lemur')
PluginService.getByType('notification').then(function (plugins) { PluginService.getByType('notification').then(function (plugins) {
$scope.plugins = plugins; $scope.plugins = plugins;
_.each($scope.plugins, function (plugin) { _.each($scope.plugins, function (plugin) {
if (plugin.slug === $scope.notification.pluginName) { if (plugin.slug === $scope.notification.plugin.slug) {
plugin.pluginOptions = $scope.notification.notificationOptions; plugin.pluginOptions = $scope.notification.plugin.pluginOptions;
$scope.notification.plugin = plugin; $scope.notification.plugin = plugin;
} }
}); });
@ -51,16 +51,6 @@ angular.module('lemur')
NotificationService.getCertificates(notification); NotificationService.getCertificates(notification);
}); });
PluginService.getByType('notification').then(function (plugins) {
$scope.plugins = plugins;
_.each($scope.plugins, function (plugin) {
if (plugin.slug === $scope.notification.pluginName) {
plugin.pluginOptions = $scope.notification.notificationOptions;
$scope.notification.plugin = plugin;
}
});
});
$scope.save = function (notification) { $scope.save = function (notification) {
NotificationService.update(notification).then( NotificationService.update(notification).then(
function () { function () {

View File

@ -27,7 +27,7 @@ angular.module('lemur')
}; };
NotificationService.getCertificates = function (notification) { NotificationService.getCertificates = function (notification) {
notification.getList('certificates').then(function (certificates) { notification.getList('certificates', {showExpired: 0}).then(function (certificates) {
notification.certificates = certificates; notification.certificates = certificates;
}); });
}; };
@ -40,7 +40,7 @@ angular.module('lemur')
NotificationService.loadMoreCertificates = function (notification, page) { NotificationService.loadMoreCertificates = function (notification, page) {
notification.getList('certificates', {page: page}).then(function (certificates) { notification.getList('certificates', {page: page, showExpired: 0}).then(function (certificates) {
_.each(certificates, function (certificate) { _.each(certificates, function (certificate) {
notification.roles.push(certificate); notification.roles.push(certificate);
}); });

View File

@ -41,22 +41,14 @@ angular.module('lemur')
PluginService.getByType('source').then(function (plugins) { PluginService.getByType('source').then(function (plugins) {
$scope.plugins = plugins; $scope.plugins = plugins;
_.each($scope.plugins, function (plugin) { _.each($scope.plugins, function (plugin) {
if (plugin.slug === $scope.source.pluginName) { if (plugin.slug === $scope.source.plugin.slug) {
plugin.pluginOptions = $scope.source.plugin.pluginOptions;
$scope.source.plugin = plugin; $scope.source.plugin = plugin;
} }
}); });
}); });
}); });
PluginService.getByType('source').then(function (plugins) {
$scope.plugins = plugins;
_.each($scope.plugins, function (plugin) {
if (plugin.slug === $scope.source.pluginName) {
$scope.source.plugin = plugin;
}
});
});
$scope.save = function (source) { $scope.save = function (source) {
SourceService.update(source).then( SourceService.update(source).then(
function () { function () {

View File

@ -46,7 +46,7 @@ LEMUR_ALLOWED_DOMAINS = [
# Lemur currently only supports SES for sending email, this address # Lemur currently only supports SES for sending email, this address
# needs to be verified # needs to be verified
LEMUR_EMAIL = "" LEMUR_EMAIL = "lemur@example.com"
LEMUR_SECURITY_TEAM_EMAIL = ["security@example.com"] LEMUR_SECURITY_TEAM_EMAIL = ["security@example.com"]
LEMUR_HOSTNAME = "lemur.example.com" LEMUR_HOSTNAME = "lemur.example.com"

View File

@ -802,6 +802,7 @@ def test_reissue_certificate(
assert new_cert.organization != certificate.organization assert new_cert.organization != certificate.organization
# Check for default value since authority does not have cab_compliant option set # Check for default value since authority does not have cab_compliant option set
assert new_cert.organization == LEMUR_DEFAULT_ORGANIZATION assert new_cert.organization == LEMUR_DEFAULT_ORGANIZATION
assert new_cert.description.startswith(f"Reissued by Lemur for cert ID {certificate.id}")
# update cab_compliant option to false for crypto_authority to maintain subject details # update cab_compliant option to false for crypto_authority to maintain subject details
update_options(crypto_authority.id, '[{"name": "cab_compliant","value":false}]') update_options(crypto_authority.id, '[{"name": "cab_compliant","value":false}]')

View File

@ -13,7 +13,7 @@ class TestDNSProvider(unittest.TestCase):
self.assertFalse(dnsutil.is_valid_domain('example-of-over-63-character-domain-label-length-limit-123456789.com')) self.assertFalse(dnsutil.is_valid_domain('example-of-over-63-character-domain-label-length-limit-123456789.com'))
self.assertTrue(dnsutil.is_valid_domain('_acme-chall.example.com')) self.assertTrue(dnsutil.is_valid_domain('_acme-chall.example.com'))
self.assertFalse(dnsutil.is_valid_domain('e/xample.com')) self.assertFalse(dnsutil.is_valid_domain('e/xample.com'))
self.assertFalse(dnsutil.is_valid_domain('exam\ple.com')) self.assertFalse(dnsutil.is_valid_domain('exam\\ple.com'))
self.assertFalse(dnsutil.is_valid_domain('<example.com')) self.assertFalse(dnsutil.is_valid_domain('<example.com'))
self.assertFalse(dnsutil.is_valid_domain('*.example.com')) self.assertFalse(dnsutil.is_valid_domain('*.example.com'))
self.assertFalse(dnsutil.is_valid_domain('-example.io')) self.assertFalse(dnsutil.is_valid_domain('-example.io'))

View File

@ -1,11 +1,18 @@
from datetime import timedelta
import arrow
import boto3
import pytest import pytest
from freezegun import freeze_time from freezegun import freeze_time
from datetime import timedelta
import arrow
from moto import mock_ses from moto import mock_ses
@mock_ses
def verify_sender_email():
ses_client = boto3.client("ses", region_name="us-east-1")
ses_client.verify_email_identity(EmailAddress="lemur@example.com")
def test_needs_notification(app, certificate, notification): def test_needs_notification(app, certificate, notification):
from lemur.notifications.messaging import needs_notification from lemur.notifications.messaging import needs_notification
@ -78,6 +85,7 @@ def test_get_eligible_certificates(app, certificate, notification):
@mock_ses @mock_ses
def test_send_expiration_notification(certificate, notification, notification_plugin): def test_send_expiration_notification(certificate, notification, notification_plugin):
from lemur.notifications.messaging import send_expiration_notifications from lemur.notifications.messaging import send_expiration_notifications
verify_sender_email()
certificate.notifications.append(notification) certificate.notifications.append(notification)
certificate.notifications[0].options = [ certificate.notifications[0].options = [
@ -87,7 +95,9 @@ def test_send_expiration_notification(certificate, notification, notification_pl
delta = certificate.not_after - timedelta(days=10) delta = certificate.not_after - timedelta(days=10)
with freeze_time(delta.datetime): with freeze_time(delta.datetime):
assert send_expiration_notifications([]) == (2, 0) # this will only send owner and security emails (no additional recipients),
# but it executes 3 successful send attempts
assert send_expiration_notifications([]) == (3, 0)
@mock_ses @mock_ses
@ -104,5 +114,14 @@ def test_send_expiration_notification_with_no_notifications(
@mock_ses @mock_ses
def test_send_rotation_notification(notification_plugin, certificate): def test_send_rotation_notification(notification_plugin, certificate):
from lemur.notifications.messaging import send_rotation_notification from lemur.notifications.messaging import send_rotation_notification
verify_sender_email()
send_rotation_notification(certificate, notification_plugin=notification_plugin) assert send_rotation_notification(certificate)
@mock_ses
def test_send_pending_failure_notification(notification_plugin, async_issuer_plugin, pending_certificate):
from lemur.notifications.messaging import send_pending_failure_notification
verify_sender_email()
assert send_pending_failure_notification(pending_certificate)

View File

@ -81,7 +81,7 @@ class Vault(types.TypeDecorator):
""" """
# required by SQLAlchemy. defines the underlying column type # required by SQLAlchemy. defines the underlying column type
impl = types.Binary impl = types.LargeBinary
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
""" """

View File

@ -1,6 +1,6 @@
# Run `make up-reqs` to update pinned dependencies in requirement text files # Run `make up-reqs` to update pinned dependencies in requirement text files
flake8==3.5.0 # flake8 3.6.0 is giving erroneous "W605 invalid escape sequence" errors. flake8==3.8.4 # flake8 latest version
pre-commit pre-commit
invoke invoke
twine twine

View File

@ -6,16 +6,16 @@
# #
appdirs==1.4.3 # via virtualenv appdirs==1.4.3 # via virtualenv
bleach==3.1.4 # via readme-renderer bleach==3.1.4 # via readme-renderer
certifi==2020.6.20 # via requests certifi==2020.11.8 # via requests
cffi==1.14.0 # via cryptography cffi==1.14.0 # via cryptography
cfgv==3.1.0 # via pre-commit cfgv==3.1.0 # via pre-commit
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
colorama==0.4.3 # via twine colorama==0.4.3 # via twine
cryptography==3.1.1 # via secretstorage cryptography==3.2.1 # via secretstorage
distlib==0.3.0 # via virtualenv distlib==0.3.0 # via virtualenv
docutils==0.16 # via readme-renderer docutils==0.16 # via readme-renderer
filelock==3.0.12 # via virtualenv filelock==3.0.12 # via virtualenv
flake8==3.5.0 # via -r requirements-dev.in flake8==3.8.4 # via -r requirements-dev.in
identify==1.4.14 # via pre-commit identify==1.4.14 # via pre-commit
idna==2.9 # via requests idna==2.9 # via requests
invoke==1.4.1 # via -r requirements-dev.in invoke==1.4.1 # via -r requirements-dev.in
@ -24,10 +24,10 @@ keyring==21.2.0 # via twine
mccabe==0.6.1 # via flake8 mccabe==0.6.1 # via flake8
nodeenv==1.5.0 # via -r requirements-dev.in, pre-commit nodeenv==1.5.0 # via -r requirements-dev.in, pre-commit
pkginfo==1.5.0.1 # via twine pkginfo==1.5.0.1 # via twine
pre-commit==2.7.1 # via -r requirements-dev.in pre-commit==2.8.2 # via -r requirements-dev.in
pycodestyle==2.3.1 # via flake8 pycodestyle==2.6.0 # via flake8
pycparser==2.20 # via cffi pycparser==2.20 # via cffi
pyflakes==1.6.0 # via flake8 pyflakes==2.2.0 # via flake8
pygments==2.6.1 # via readme-renderer pygments==2.6.1 # via readme-renderer
pyyaml==5.3.1 # via -r requirements-dev.in, pre-commit pyyaml==5.3.1 # via -r requirements-dev.in, pre-commit
readme-renderer==25.0 # via twine readme-renderer==25.0 # via twine

View File

@ -17,16 +17,16 @@ bcrypt==3.1.7 # via -r requirements.txt, flask-bcrypt, paramiko
beautifulsoup4==4.9.1 # via -r requirements.txt, cloudflare beautifulsoup4==4.9.1 # via -r requirements.txt, cloudflare
billiard==3.6.3.0 # via -r requirements.txt, celery billiard==3.6.3.0 # via -r requirements.txt, celery
blinker==1.4 # via -r requirements.txt, flask-mail, flask-principal, raven blinker==1.4 # via -r requirements.txt, flask-mail, flask-principal, raven
boto3==1.15.16 # via -r requirements.txt boto3==1.16.14 # via -r requirements.txt
botocore==1.18.16 # via -r requirements.txt, boto3, s3transfer botocore==1.19.14 # via -r requirements.txt, boto3, s3transfer
celery[redis]==4.4.2 # via -r requirements.txt celery[redis]==4.4.2 # via -r requirements.txt
certifi==2020.6.20 # via -r requirements.txt, requests certifi==2020.11.8 # via -r requirements.txt, requests
certsrv==2.1.1 # via -r requirements.txt certsrv==2.1.1 # via -r requirements.txt
cffi==1.14.0 # via -r requirements.txt, bcrypt, cryptography, pynacl cffi==1.14.0 # via -r requirements.txt, bcrypt, cryptography, pynacl
chardet==3.0.4 # via -r requirements.txt, requests chardet==3.0.4 # via -r requirements.txt, requests
click==7.1.1 # via -r requirements.txt, flask click==7.1.2 # via -r requirements.txt, flask
cloudflare==2.8.13 # via -r requirements.txt cloudflare==2.8.13 # via -r requirements.txt
cryptography==3.1.1 # via -r requirements.txt, acme, josepy, paramiko, pyopenssl, requests cryptography==3.2.1 # via -r requirements.txt, acme, josepy, paramiko, pyopenssl, requests
dnspython3==1.15.0 # via -r requirements.txt dnspython3==1.15.0 # via -r requirements.txt
dnspython==1.15.0 # via -r requirements.txt, dnspython3 dnspython==1.15.0 # via -r requirements.txt, dnspython3
docutils==0.15.2 # via sphinx docutils==0.15.2 # via sphinx
@ -92,7 +92,7 @@ six==1.15.0 # via -r requirements.txt, acme, bcrypt, cryptography,
snowballstemmer==2.0.0 # via sphinx snowballstemmer==2.0.0 # via sphinx
soupsieve==2.0.1 # via -r requirements.txt, beautifulsoup4 soupsieve==2.0.1 # via -r requirements.txt, beautifulsoup4
sphinx-rtd-theme==0.5.0 # via -r requirements-docs.in sphinx-rtd-theme==0.5.0 # via -r requirements-docs.in
sphinx==3.2.1 # via -r requirements-docs.in, sphinx-rtd-theme, sphinxcontrib-httpdomain sphinx==3.3.0 # via -r requirements-docs.in, sphinx-rtd-theme, sphinxcontrib-httpdomain
sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-applehelp==1.0.2 # via sphinx
sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx
sphinxcontrib-htmlhelp==1.0.3 # via sphinx sphinxcontrib-htmlhelp==1.0.3 # via sphinx

View File

@ -10,22 +10,22 @@ aws-sam-translator==1.22.0 # via cfn-lint
aws-xray-sdk==2.5.0 # via moto aws-xray-sdk==2.5.0 # via moto
bandit==1.6.2 # via -r requirements-tests.in bandit==1.6.2 # via -r requirements-tests.in
black==20.8b1 # via -r requirements-tests.in black==20.8b1 # via -r requirements-tests.in
boto3==1.15.16 # via aws-sam-translator, moto boto3==1.16.14 # via aws-sam-translator, moto
boto==2.49.0 # via moto boto==2.49.0 # via moto
botocore==1.18.16 # via aws-xray-sdk, boto3, moto, s3transfer botocore==1.19.14 # via aws-xray-sdk, boto3, moto, s3transfer
certifi==2020.6.20 # via requests certifi==2020.11.8 # via requests
cffi==1.14.0 # via cryptography cffi==1.14.0 # via cryptography
cfn-lint==0.29.5 # via moto cfn-lint==0.29.5 # via moto
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
click==7.1.2 # via black, flask click==7.1.2 # via black, flask
coverage==5.3 # via -r requirements-tests.in coverage==5.3 # via -r requirements-tests.in
cryptography==3.1.1 # via moto, python-jose, sshpubkeys cryptography==3.2.1 # via moto, python-jose, sshpubkeys
decorator==4.4.2 # via networkx decorator==4.4.2 # via networkx
docker==4.2.0 # via moto docker==4.2.0 # via moto
ecdsa==0.14.1 # via moto, python-jose, sshpubkeys ecdsa==0.14.1 # via moto, python-jose, sshpubkeys
factory-boy==3.1.0 # via -r requirements-tests.in factory-boy==3.1.0 # via -r requirements-tests.in
faker==4.14.0 # via -r requirements-tests.in, factory-boy faker==4.14.2 # via -r requirements-tests.in, factory-boy
fakeredis==1.4.3 # via -r requirements-tests.in fakeredis==1.4.4 # via -r requirements-tests.in
flask==1.1.2 # via pytest-flask flask==1.1.2 # via pytest-flask
freezegun==1.0.0 # via -r requirements-tests.in freezegun==1.0.0 # via -r requirements-tests.in
future==0.18.2 # via aws-xray-sdk future==0.18.2 # via aws-xray-sdk
@ -59,9 +59,9 @@ pycparser==2.20 # via cffi
pyflakes==2.2.0 # via -r requirements-tests.in pyflakes==2.2.0 # via -r requirements-tests.in
pyparsing==2.4.7 # via packaging pyparsing==2.4.7 # via packaging
pyrsistent==0.16.0 # via jsonschema pyrsistent==0.16.0 # via jsonschema
pytest-flask==1.0.0 # via -r requirements-tests.in pytest-flask==1.1.0 # via -r requirements-tests.in
pytest-mock==3.3.1 # via -r requirements-tests.in pytest-mock==3.3.1 # via -r requirements-tests.in
pytest==6.1.1 # via -r requirements-tests.in, pytest-flask, pytest-mock pytest==6.1.2 # via -r requirements-tests.in, pytest-flask, pytest-mock
python-dateutil==2.8.1 # via botocore, faker, freezegun, moto python-dateutil==2.8.1 # via botocore, faker, freezegun, moto
python-jose[cryptography]==3.1.0 # via moto python-jose[cryptography]==3.1.0 # via moto
pytz==2019.3 # via moto pytz==2019.3 # via moto

View File

@ -15,16 +15,16 @@ bcrypt==3.1.7 # via flask-bcrypt, paramiko
beautifulsoup4==4.9.1 # via cloudflare beautifulsoup4==4.9.1 # via cloudflare
billiard==3.6.3.0 # via celery billiard==3.6.3.0 # via celery
blinker==1.4 # via flask-mail, flask-principal, raven blinker==1.4 # via flask-mail, flask-principal, raven
boto3==1.15.16 # via -r requirements.in boto3==1.16.14 # via -r requirements.in
botocore==1.18.16 # via -r requirements.in, boto3, s3transfer botocore==1.19.14 # via -r requirements.in, boto3, s3transfer
celery[redis]==4.4.2 # via -r requirements.in celery[redis]==4.4.2 # via -r requirements.in
certifi==2020.6.20 # via -r requirements.in, requests certifi==2020.11.8 # via -r requirements.in, requests
certsrv==2.1.1 # via -r requirements.in certsrv==2.1.1 # via -r requirements.in
cffi==1.14.0 # via bcrypt, cryptography, pynacl cffi==1.14.0 # via bcrypt, cryptography, pynacl
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
click==7.1.1 # via flask click==7.1.2 # via flask
cloudflare==2.8.13 # via -r requirements.in cloudflare==2.8.13 # via -r requirements.in
cryptography==3.1.1 # via -r requirements.in, acme, josepy, paramiko, pyopenssl, requests cryptography==3.2.1 # via -r requirements.in, acme, josepy, paramiko, pyopenssl, requests
dnspython3==1.15.0 # via -r requirements.in dnspython3==1.15.0 # via -r requirements.in
dnspython==1.15.0 # via dnspython3 dnspython==1.15.0 # via dnspython3
dyn==1.8.1 # via -r requirements.in dyn==1.8.1 # via -r requirements.in

View File

@ -132,9 +132,11 @@ setup(
'lemur.plugins': [ 'lemur.plugins': [
'verisign_issuer = lemur.plugins.lemur_verisign.plugin:VerisignIssuerPlugin', 'verisign_issuer = lemur.plugins.lemur_verisign.plugin:VerisignIssuerPlugin',
'acme_issuer = lemur.plugins.lemur_acme.plugin:ACMEIssuerPlugin', 'acme_issuer = lemur.plugins.lemur_acme.plugin:ACMEIssuerPlugin',
'acme_http_issuer = lemur.plugins.lemur_acme.plugin:ACMEHttpIssuerPlugin',
'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin', 'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin',
'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin', 'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin',
'aws_s3 = lemur.plugins.lemur_aws.plugin:S3DestinationPlugin', 'aws_s3 = lemur.plugins.lemur_aws.plugin:S3DestinationPlugin',
'aws_sns = lemur.plugins.lemur_aws.plugin:SNSNotificationPlugin',
'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin', 'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin',
'slack_notification = lemur.plugins.lemur_slack.plugin:SlackNotificationPlugin', 'slack_notification = lemur.plugins.lemur_slack.plugin:SlackNotificationPlugin',
'java_truststore_export = lemur.plugins.lemur_jks.plugin:JavaTruststoreExportPlugin', 'java_truststore_export = lemur.plugins.lemur_jks.plugin:JavaTruststoreExportPlugin',
@ -155,7 +157,8 @@ setup(
'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin', 'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin',
'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin', 'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin',
'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin', 'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin',
'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin' 'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin',
'openssh_issuer = lemur.plugins.lemur_openssh.plugin:OpenSSHIssuerPlugin',
], ],
}, },
classifiers=[ classifiers=[