-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Chris Pollitt
committed
Nov 29, 2020
1 parent
8153057
commit 35d54ad
Showing
21 changed files
with
6,818 additions
and
0 deletions.
There are no files selected for viewing
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
#!/home/whatwelo/bin/python2 | ||
import sys | ||
sys.path.append('/home/whatwelo/user_python') | ||
import argparse, subprocess, json, os, base64, binascii, time, hashlib, re, copy, textwrap, logging | ||
from myurllib import urlopen, dnsinit | ||
|
||
# init | ||
base_domain=os.environ["ACME_BASE_DOMAIN"] # cpanel | ||
cpanel=os.environ["ACME_CPANEL"] # cpanel | ||
host = os.environ["ACME_HOST"] # myurllib dns spoofing | ||
ip = os.environ["ACME_IP"] # myurllib dns spoofing | ||
dns_map = { | ||
host: ip | ||
} | ||
dnsinit(dns_map) | ||
|
||
CA = "https://acme-v01.api.letsencrypt.org" | ||
|
||
LOGGER = logging.getLogger(__name__) | ||
LOGGER.addHandler(logging.StreamHandler(sys.stderr)) | ||
LOGGER.setLevel(logging.INFO) | ||
|
||
def get_crt(account_key, csr): | ||
# helper function base64 encode for jose spec | ||
def _b64(b): | ||
return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") | ||
|
||
# parse account key to get public key | ||
LOGGER.info("Parsing account key...") | ||
proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"], | ||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
out, err = proc.communicate() | ||
if proc.returncode != 0: | ||
raise IOError("OpenSSL Error: {0}".format(err)) | ||
pub_hex, pub_exp = re.search( | ||
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", | ||
out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() | ||
pub_exp = "{0:x}".format(int(pub_exp)) | ||
pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp | ||
header = { | ||
"alg": "RS256", | ||
"jwk": { | ||
"e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), | ||
"kty": "RSA", | ||
"n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), | ||
}, | ||
} | ||
accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) | ||
thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) | ||
|
||
# helper function make signed requests | ||
def _send_signed_request(url, payload): | ||
payload64 = _b64(json.dumps(payload).encode('utf8')) | ||
protected = copy.deepcopy(header) | ||
protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce'] | ||
protected64 = _b64(json.dumps(protected).encode('utf8')) | ||
proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key], | ||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8')) | ||
if proc.returncode != 0: | ||
raise IOError("OpenSSL Error: {0}".format(err)) | ||
data = json.dumps({ | ||
"header": header, "protected": protected64, | ||
"payload": payload64, "signature": _b64(out), | ||
}) | ||
try: | ||
resp = urlopen(url, data.encode('utf8')) | ||
return resp.getcode(), resp.read() | ||
except IOError as e: | ||
return getattr(e, "code", None), getattr(e, "read", e.__str__)() | ||
|
||
# find domains | ||
LOGGER.info("Parsing CSR...") | ||
proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"], | ||
stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
out, err = proc.communicate() | ||
if proc.returncode != 0: | ||
raise IOError("Error loading {0}: {1}".format(csr, err)) | ||
domains = set([]) | ||
common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8')) | ||
if common_name is not None: | ||
domains.add(common_name.group(1)) | ||
subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) | ||
if subject_alt_names is not None: | ||
for san in subject_alt_names.group(1).split(", "): | ||
if san.startswith("DNS:"): | ||
domains.add(san[4:]) | ||
|
||
# get the certificate domains and expiration | ||
LOGGER.info("Registering account...") | ||
code, result = _send_signed_request(CA + "/acme/new-reg", { | ||
"resource": "new-reg", | ||
"agreement": "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf", | ||
}) | ||
if code == 201: | ||
LOGGER.info("Registered!") | ||
elif code == 409: | ||
LOGGER.info("Already registered!") | ||
else: | ||
raise ValueError("Error registering: {0} {1}".format(code, result)) | ||
|
||
# verify each domain | ||
for domain in domains: | ||
LOGGER.info("Verifying {0}...".format(domain)) | ||
subdomains = domain.split(".") | ||
subdomain = subdomains[0] if len(subdomains)>2 else "www" | ||
proc = subprocess.Popen(["get_subdomains.php", cpanel, base_domain, subdomain], | ||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
out, err = proc.communicate() | ||
if proc.returncode != 0: | ||
raise IOError("get_subdomains Error: {0}".format(err)) | ||
domain_info = json.loads(out) | ||
domain_dir = domain_info['documentroot'] | ||
acme_dir = domain_dir + '/.well-known/acme-challenge' | ||
# get new challenge | ||
code, result = _send_signed_request(CA + "/acme/new-authz", { | ||
"resource": "new-authz", | ||
"identifier": {"type": "dns", "value": domain}, | ||
}) | ||
if code != 201: | ||
raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) | ||
|
||
# make the challenge file | ||
challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0] | ||
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) | ||
keyauthorization = "{0}.{1}".format(token, thumbprint) | ||
if not os.path.isdir(acme_dir): | ||
os.makedirs(acme_dir) | ||
wellknown_path = os.path.join(acme_dir, token) | ||
with open(wellknown_path, "w") as wellknown_file: | ||
wellknown_file.write(keyauthorization) | ||
|
||
# check that the file is in place | ||
wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) | ||
try: | ||
resp = urlopen(wellknown_url) | ||
resp_data = resp.read().decode('utf8').strip() | ||
assert resp_data == keyauthorization | ||
except Exception as e: | ||
os.remove(wellknown_path) | ||
raise ValueError("Wrote file to {0}, but couldn't download {1} because {2}".format( | ||
wellknown_path, wellknown_url, e)) | ||
|
||
# notify challenge are met | ||
code, result = _send_signed_request(challenge['uri'], { | ||
"resource": "challenge", | ||
"keyAuthorization": keyauthorization, | ||
}) | ||
if code != 202: | ||
raise ValueError("Error triggering challenge: {0} {1}".format(code, result)) | ||
|
||
# wait for challenge to be verified | ||
while True: | ||
try: | ||
resp = urlopen(challenge['uri']) | ||
challenge_status = json.loads(resp.read().decode('utf8')) | ||
except IOError as e: | ||
raise ValueError("Error checking challenge: {0} {1}".format( | ||
e.code, json.loads(e.read().decode('utf8')))) | ||
if challenge_status['status'] == "pending": | ||
time.sleep(2) | ||
elif challenge_status['status'] == "valid": | ||
LOGGER.info("{0} verified!".format(domain)) | ||
os.remove(wellknown_path) | ||
break | ||
else: | ||
raise ValueError("{0} challenge did not pass: {1}".format( | ||
domain, challenge_status)) | ||
|
||
# get the new certificate | ||
LOGGER.info("Signing certificate...") | ||
proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"], | ||
stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
csr_der, err = proc.communicate() | ||
code, result = _send_signed_request(CA + "/acme/new-cert", { | ||
"resource": "new-cert", | ||
"csr": _b64(csr_der), | ||
}) | ||
if code != 201: | ||
raise ValueError("Error signing certificate: {0} {1}".format(code, result)) | ||
|
||
# return signed certificate! | ||
LOGGER.info("Certificate signed!") | ||
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( | ||
"\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64))) | ||
|
||
def main(argv): | ||
LOGGER.info("Working...") | ||
parser = argparse.ArgumentParser( | ||
formatter_class=argparse.RawDescriptionHelpFormatter, | ||
description=textwrap.dedent("""\ | ||
This script automates the process of getting a signed TLS certificate from | ||
Let's Encrypt using the ACME protocol. It will need to be run on your server | ||
and have access to your private account key, so PLEASE READ THROUGH IT! It's | ||
only ~200 lines, so it won't take long. | ||
===Example Usage=== | ||
python acme_tiny.py --account-key ./account.key --csr ./domain.csr > signed.crt | ||
=================== | ||
===Example Crontab Renewal (once per month)=== | ||
0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr > /path/to/signed.crt 2>> /var/log/acme_tiny.log | ||
============================================== | ||
""") | ||
) | ||
parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") | ||
parser.add_argument("--csr", required=True, help="path to your certificate signing request") | ||
parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") | ||
|
||
args = parser.parse_args(argv) | ||
LOGGER.setLevel(args.quiet or LOGGER.level) | ||
signed_crt = get_crt(args.account_key, args.csr) | ||
sys.stdout.write(signed_crt) | ||
|
||
if __name__ == "__main__": # pragma: no cover | ||
main(sys.argv[1:]) |
Oops, something went wrong.