Created
June 10, 2021 00:28
-
-
Save withzombies/fb6ba5927faabc366c89cc66966fff32 to your computer and use it in GitHub Desktop.
Script to download the latest lets encrypt certificate and key from DNSimple and apply them to your heroku endpoints
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Copyright 2021 Trail of Bits | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
http://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
""" | |
import os | |
import sys | |
import requests | |
from urllib.parse import ( | |
urlencode, unquote, urlparse, parse_qsl, ParseResult | |
) | |
import tempfile | |
DNSIMPLE_API_TOKEN = os.environ['DNSIMPLE_API_TOKEN'] | |
HEROKU_API_TOKEN = os.environ['HEROKU_API_TOKEN'] | |
DOMAIN="your-domain.here" | |
apps = ("your-app-1", "your-app-2") | |
def main(): | |
certs, key = fetch_dnsimple_certificates() | |
for app in apps: | |
print(f"[-] {app}") | |
update_heroku_certificates(app, certs, key) | |
def fetch_dnsimple_certificates(): | |
headers = { | |
"Accept" : "application/json", | |
"Authorization": f"Bearer {DNSIMPLE_API_TOKEN}" | |
} | |
# Get my account ID | |
r = requests.get("https://api.dnsimple.com/v2/whoami", headers=headers) | |
data = r.json()["data"] | |
account = data["account"] | |
account_email = account["email"] | |
account_id = account["id"] | |
print(f"{account_email=}\n{account_id=}") | |
# Get a list of certificates (grab the one with the furthest into the future expiration time) | |
certificates = get_all_pages(f"https://api.dnsimple.com/v2/{account_id}/domains/{DOMAIN}/certificates?sort=expiration:desc", headers=headers) | |
certificates = sorted(certificates, key=lambda x: x['expires_at'], reverse=True) | |
certificate_id = [x for x in certificates if x['state'] == 'issued'][0]['id'] | |
print(f"{certificate_id=}") | |
# Download the public part and certificate chain | |
r = requests.get(f"https://api.dnsimple.com/v2/{account_id}/domains/{DOMAIN}/certificates/{certificate_id}/download", headers=headers) | |
certificate = r.json()['data'] | |
certificate_data = certificate['server'] + '\n' + '\n'.join(certificate['chain']) | |
print("[+] Got certificate chain") | |
# Download the private part of the certificate | |
r = requests.get(f"https://api.dnsimple.com/v2/{account_id}/domains/{DOMAIN}/certificates/{certificate_id}/private_key", headers=headers) | |
private_key = r.json()['data']['private_key'] | |
print("[+] Got private key") | |
return (certificate_data, private_key) | |
def update_heroku_certificates(app_name, certs, keys): | |
headers = { | |
"Accept" : "application/vnd.heroku+json; version=3", | |
"Authorization": f"Bearer {HEROKU_API_TOKEN}" | |
} | |
# Get app data | |
r = requests.get("https://api.heroku.com/apps", headers=headers) | |
json = r.json() | |
app = [x for x in json if x['name'] == app_name][0] | |
app_id = app['id'] | |
print(f"[{app_name}] {app_id=}") | |
# List SNI SSL Endpoints for app | |
r = requests.get(f"https://api.heroku.com/apps/{app_id}/sni-endpoints", headers=headers) | |
json = r.json() | |
sni_ids = [x['id'] for x in json] | |
print(f"[{app_name}] {sni_ids=}") | |
# Update certificate and key for all SNI endpoints | |
data = { | |
'certificate_chain' : certs, | |
'private_key' : keys | |
} | |
for sni_id in sni_ids: | |
r = requests.patch(f"https://api.heroku.com/apps/{app_id}/sni-endpoints/{sni_id}", headers=headers, data=data) | |
assert(r.status_code == 202) | |
print(f"[{app_name}] New certificate and key for {sni_id} accepted") | |
def add_url_params(url, params): | |
""" Add GET params to provided URL being aware of existing. | |
:param url: string of target URL | |
:param params: dict containing requested params to be added | |
:return: string with updated URL | |
>> url = 'http://stackoverflow.com/test?answers=true' | |
>> new_params = {'answers': False, 'data': ['some','values']} | |
>> add_url_params(url, new_params) | |
'http://stackoverflow.com/test?data=some&data=values&answers=false' | |
""" | |
# Unquoting URL first so we don't loose existing args | |
url = unquote(url) | |
# Extracting url info | |
parsed_url = urlparse(url) | |
# Extracting URL arguments from parsed URL | |
get_args = parsed_url.query | |
# Converting URL arguments to dict | |
parsed_get_args = dict(parse_qsl(get_args)) | |
# Merging URL arguments dict with new params | |
parsed_get_args.update(params) | |
# Bool and Dict values should be converted to json-friendly values | |
# you may throw this part away if you don't like it :) | |
parsed_get_args.update( | |
{k: dumps(v) for k, v in parsed_get_args.items() | |
if isinstance(v, (bool, dict))} | |
) | |
# Converting URL argument to proper query string | |
encoded_get_args = urlencode(parsed_get_args, doseq=True) | |
# Creating new parsed result object based on provided with new | |
# URL arguments. Same thing happens inside of urlparse. | |
new_url = ParseResult( | |
parsed_url.scheme, parsed_url.netloc, parsed_url.path, | |
parsed_url.params, encoded_get_args, parsed_url.fragment | |
).geturl() | |
return new_url | |
def get_all_pages(url, headers): | |
items = [] | |
page = 1 | |
per_page = 100 | |
while True: | |
request_url = add_url_params(url, {'page': page, 'per_page': per_page }) | |
r = requests.get(url, headers=headers) | |
json = r.json() | |
items += json['data'] | |
pagination = json['pagination'] | |
if pagination['total_pages'] == page: | |
break | |
page += 1 | |
return items | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment