From ac4e393720121a75f88fa6fcba22e28f66b18838 Mon Sep 17 00:00:00 2001 From: Luke Tidd Date: Thu, 4 Aug 2022 19:09:03 -0400 Subject: [PATCH] logging, moved code to functions, support for plex PKCS#12, like jellyfin --- update_cert.py | 349 +++++++++++++++++++++++++++++++------------------ 1 file changed, 219 insertions(+), 130 deletions(-) diff --git a/update_cert.py b/update_cert.py index 5ae0e1b..9e9bace 100755 --- a/update_cert.py +++ b/update_cert.py @@ -2,13 +2,13 @@ """Automation for cert renewal. assumptions: - * router is danknasty - * generate key in /root/.ssh/id_autofirewall on server - * firewall has config from `authorized_keys` file + * firewall has access configured for specified key * firewall sshd config contains: `AcceptEnv ssl_service state` * firewall has `ssl-update.sh` copied to /usr/local/bin and chmod +x """ +import datetime +import logging import getpass import os import pathlib @@ -18,241 +18,330 @@ import subprocess import sys import time -services = ['git', 'plex', 'jellyfin', 'photoprism'] +supported_services = ['git', 'plex', 'jellyfin', 'photoprism', 'nextcloud'] + +restart_delay = { + 'plex': 10 +} + +pfx_key_path = { + 'plex': '/data/plex/certs/certificate.pfx', + 'jellyfin': '/etc/letsencrypt/live/jellyfin.drheck.dev/jellyfin.pfx', +} users = { 'jellyfin': 'jellyfin', 'git': 'gitea', 'plex': 'plex', 'photoprism': 'photoprism', + 'nextcloud': 'nextcloud', } -service_systemd = { - 'jellyfin': 'jellyfin', +systemd_services = { 'git': 'gitea', 'plex': 'plexmediaserver', - 'photoprism': 'photoprism', } cert_files = ['privkey1.pem', 'fullchain1.pem', 'chain1.pem', 'cert1.pem'] -def rmdir(directory): +router = 'danknasty' +router_user = 'luke131' +router_key = '/root/.ssh/id_autofirewall' +server_user = 'luke' + + +def firewall_mod(state, service, decrypt_pp): + os.environ['state'] = 'HTTP_UP' + os.environ['ssl_service'] = service + cmd = ['/usr/bin/ssh', '-i', router_key, '-o', + 'SendEnv=state', '-o', 'SendEnv=ssl_service', '-l', router_user, + router, 'doas', '-n', '/usr/local/bin/ssl-update.sh'] + log.info(f'env for fw: state: {os.environ["state"]}') + log.info(f'env for fw: ssl_service: {service}') + log.info(f'cmd to connect to firewall: "{" ".join(cmd)}"') + p = pexpect.spawnu(' '.join(cmd)) + res = p.expect([f"Enter passphrase for key '{router_key}':", + pexpect.TIMEOUT, pexpect.EOF]) + if res > 0: + sys.exit('Couldnt send decryption key to ssh.') + p.sendline(decrypt_pp) + res = p.expect(['success', pexpect.TIMEOUT, pexpect.EOF]) + if res > 0: + sys.exit(f'Failed. error: {p.before}') + state_print = state.split('_')[1].lower() + log.info(f'Turned {state_print} HTTP for {service}') + + +def get_cert_dates(url, port=443): + cmd = ( + f'printf "" | /usr/bin/openssl s_client -servername {url} -connect ' + f'{url}:{port} | openssl x509 -noout -dates') + + ps = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + bstdout, bstderr = ps.communicate() + exit_code = ps.returncode + stdout = bstdout.decode('UTF-8').splitlines() + stderr = bstderr.decode('UTF-8') + if exit_code > 0: + sys.exit(f'Error checking state of SSL cert at {url}:{port}: {stderr}') + start_str = '' + finish_str = '' + for line in stdout: + if 'notBefore' in line: + start_str = line.split('=')[1] + if 'notAfter' in line: + finish_str = line.split('=')[1] + + ts = datetime.datetime.now() + start = datetime.datetime.strptime(start_str, '%b %d %H:%M:%S %Y %Z') + finish = datetime.datetime.strptime(finish_str, '%b %d %H:%M:%S %Y %Z') + if ts > start and ts < finish: + print('Cert is valid') + else: + print('Cert is not valid') + print(f'start: {start}\nnow: {ts}\nfinish: {finish}') + + +def recurse_rmdir(directory): directory = pathlib.Path(directory) for item in directory.iterdir(): if item.is_dir(): - rmdir(item) + recurse_rmdir(item) else: item.unlink() directory.rmdir() def jellyfin(): - cmd = ['/usr/bin/su', '-l', 'luke', '/usr/bin/pass', 'show', 'ssl/jellyfin'] + log.info('custom function for jellyfin actions') + pfx_gen('jellyfin') + + +def plex(): + log.info('custom function for plex actions') + pfx_gen('plex') + + +def restart(service): + if service in restart_delay: + wait = restart_delay[service] + else: + wait = 5 + + try: + systemd_service = systemd_services[service] + except KeyError: + systemd_service = service + log.info(f'going to restart service: {service}') + cmd = ['/usr/bin/systemctl', 'restart', systemd_service] + log.info(f'cmd to restart service: "{" ".join(cmd)}"') + p = subprocess.run(cmd, capture_output=True) + log.info(f'sleeping {wait} seconds') + time.sleep(wait) + + cmd = ['/usr/bin/systemctl', 'is-active', '--quiet', systemd_service] + log.info(f'cmd to check service status: "{" ".join(cmd)}"') + p = subprocess.run(cmd) + if p.returncode != 0: + log.error(f'{service} failed to restart:') + cmd = ['/usr/bin/systemctl', 'status', systemd_service] + log.info(f'cmd to show status of service: "{" ".join(cmd)}"') + p = subprocess.run(cmd, capture_output=True) + stderr = p.stdout.decode('UTF-8') + sys.stderr.write(p.stdout) + sys.exit(1) + log.error(f'{service} has restarted OK') + + +def pfx_gen(service): + log.info(f'generate pfx file for {service}') + cmd = ['/usr/bin/su', '-l', server_user, + '/usr/bin/pass', 'show', f'ssl/{service}'] + log.info(f'cmd to get password to encrypt private key: "{" ".join(cmd)}"') p = subprocess.run(cmd, capture_output=True) export_pw = p.stdout.decode('UTF-8').strip() if not export_pw: - sys.exit('Couldnt get ssl export password for jellyfin') - cmd = ['/usr/bin/openssl', 'pkcs12', '-export', '-out', - '/etc/letsencrypt/live/jellyfin.drheck.dev/jellyfin.pfx', - '-inkey', '/etc/letsencrypt/live/jellyfin.drheck.dev/privkey.pem', - '-in', '/etc/letsencrypt/live/jellyfin.drheck.dev/cert.pem', - '-certfile', '/etc/letsencrypt/live/jellyfin.drheck.dev/chain.pem'] + sys.exit(f'Couldnt get ssl export password for {service}') + try: + pkp = pfx_key_path[service] + except KeyError: + sys.exit(f'{service} has no defined private key path.') + + cmd = ['/usr/bin/openssl', 'pkcs12', '-export', '-out', pkp, + '-inkey', f'/etc/letsencrypt/live/{service}.drheck.dev/privkey.pem', + '-in', f'/etc/letsencrypt/live/{service}.drheck.dev/cert.pem', + '-certfile', f'/etc/letsencrypt/live/{service}.drheck.dev/chain.pem'] + log.info(f'cmd to encrypt private key: "{" ".join(cmd)}"') p = pexpect.spawnu(' '.join(cmd)) res = p.expect(['Enter Export Password:', pexpect.EOF, pexpect.TIMEOUT]) if res > 0: sys.exit('Failed to run openssl to generate ' - f'pkcs12 keys for jellyfin: {p.before}') + f'pkcs12 keys for {service}: {p.before}') p.sendline(export_pw) + log.info(f'send password to encrypt private key') res = p.expect(['Verifying - Enter Export Password:', pexpect.EOF, pexpect.TIMEOUT]) if res > 0: sys.exit('Failed to run openssl to generate ' - f'pkcs12 keys for jellyfin: {p.before}') + f'pkcs12 keys for {service}: {p.before}') p.sendline(export_pw) + log.info(f'send password to encrypt private key...again') res = p.expect([pexpect.EOF, pexpect.TIMEOUT]) if res > 0: sys.exit(f'Failed to run openssl to generate ' - 'pkcs12 keys for jellyfin: {p.before}') + 'pkcs12 keys for {service}: {p.before}') + log.info(f'this did not explicitly fail') def main(args): + logging.basicConfig(level=os.environ.get("LOGLEVEL", "WARNING")) - # Get SSH decryption password - cmd = ['/usr/bin/su', '-l', 'luke', '/usr/bin/pass', 'show', 'ssh/autofirewall'] + log.info(f'program start: {sys.argv}') + if len(args) != 1: + sys.exit(f'Give a service to renew: {", ".join(supported_services)} ') + service = args[0] + if service not in supported_services: + sys.exit(f'Give a service to renew: {", ".join(supported_services)} ') + + uid = os.getuid() + if uid != 0: + sys.exit('Run as root') + + log.info('Get SSH decryption pw') + cmd = ['/usr/bin/su', '-l', server_user, + '/usr/bin/pass', 'show', 'ssh/autofirewall'] + log.info(f'cmd: "{" ".join(cmd)}"') p = subprocess.run(cmd, capture_output=True) decrypt_pp = p.stdout.decode('UTF-8').strip() if not decrypt_pp: - sys.exit('Couldnt get decryption passpharase') + sys.exit('Could not get decryption passpharase') + log.info('Got SSH decryption pw') - # Start Certbot, get data to send to service - challenge_path = pathlib.Path('/usr/share/nginx/html/' - '.well-known/acme-challenge/') + challenge_path = pathlib.Path( + '/usr/share/nginx/html/.well-known/acme-challenge/') if challenge_path.is_dir(): - rmdir(challenge_path) + recurse_rmdir(challenge_path) + log.info('Challenge path deleted') challenge_path.mkdir() + log.info('Challenge path created') challenge_path.chmod(0o755) - if len(args) != 1: - sys.exit(f'Give a service to renew: {", ".join(services)} ') - service = args[0] - if service not in services: - sys.exit(f'Give a service to renew: {", ".join(services)} ') + log.info('Challenge path chmodded') fqdn = f'{service}.drheck.dev' - cmd = ['/usr/bin/certbot', 'certonly', '--manual', '-d', fqdn] + log.info(f'fqdn: {fqdn}') + # Dry run: + cmd = ['/usr/bin/certbot', '--dry-run', 'certonly', '--manual', '-d', fqdn] + # Real run: + # cmd = ['/usr/bin/certbot', 'certonly', '--manual', '-d', fqdn] + log.info(f'certbot cmd: "{" ".join(cmd)}"') cb = pexpect.spawnu(' '.join(cmd)) - res = cb.expect(['Create a file containing just this data:\r\n\r\n([^\r]+)\r', - pexpect.TIMEOUT, pexpect.EOF], timeout=20) - if res > 0: + res = cb.expect( + ['Create a file containing just this data:\r\n\r\n([^\r]+)\r', + ('You have an existing certificate that has exactly the ' + "same domains or certificate name you requested and isn't " + 'close to expiry'), pexpect.TIMEOUT, pexpect.EOF], + timeout=20) + if res > 1: sys.exit('Timed out') + if res == 1: + log.info('Current cert is not yet expired') + res = cb.expect_exact(['cancel):', pexpect.TIMEOUT, pexpect.EOF]) + if res > 0: + sys.exit('Timed out in setup with existing cert') + cb.sendline('2') + res = cb.expect( + ['Create a file containing just this data:\r\n\r\n([^\r]+)\r', + pexpect.TIMEOUT, pexpect.EOF], timeout=20) + if res > 1: + sys.exit('Timed out') + data = cb.match.group(1) + log.info(f'secret data: {data}') + log.info('the data string and location for the shared secret are known') long_match = ('And make it available on your web server at this URL:' '\r\n\r\nhttp://%s/.well-known/acme-challenge/([^\r]+)\r') res = cb.expect([long_match % (fqdn,), pexpect.TIMEOUT, pexpect.EOF]) if res > 0: sys.exit('Timed out') filename = cb.match.group(1) + log.info(f'filename of secret: {filename}') res = cb.expect(['Press Enter to Continue', pexpect.EOF], timeout=0) -# put data in acme-challenge file data_file = challenge_path / pathlib.Path(filename) try: with open(data_file, 'w') as f: f.write(data) except: sys.exit(f'Failed to write {data_file}') + log.info('created secret file with secret data') data_file.chmod(0o644) + log.info('and chmodded') -# put symlink in nginx enabled sites symlink_name = pathlib.Path(f'{service}-le') + log.info(f'nginx symlink: {symlink_name}') nx_conf = pathlib.Path('/etc/nginx') avail_path = nx_conf / pathlib.Path('sites-available') enabled_path = nx_conf / pathlib.Path('sites-enabled') service_available_file = avail_path / symlink_name + log.info(f'nginx service avail symlink: {service_available_file}') service_enabled_symlink = enabled_path / symlink_name + log.info(f'nginx service enabled symlink: {service_enabled_symlink}') if not service_enabled_symlink.is_symlink(): service_enabled_symlink.symlink_to(service_available_file) + log.info('created symlink to enable service') -# open port 80 to ${service}.drheck.dev - os.environ['state'] = 'HTTP_UP' - os.environ['ssl_service'] = service - cmd = ['/usr/bin/ssh', '-i', '/root/.ssh/id_autofirewall', '-o', - 'SendEnv=state', '-o', 'SendEnv=ssl_service', '-l', 'luke131', - 'danknasty', 'doas', '-n', '/usr/local/bin/ssl-update.sh'] - print(f'cmd: {cmd}') - p = pexpect.spawnu(' '.join(cmd)) - p.logfile = sys.stderr - res = p.expect(["Enter passphrase for key '/root/.ssh/id_autofirewall':", - pexpect.TIMEOUT, pexpect.EOF]) - if res > 0: - sys.exit('Couldnt send decryption key to ssh.') - p.sendline(decrypt_pp) - res = p.expect(['success', pexpect.TIMEOUT, pexpect.EOF]) - if res > 0: - sys.exit(f'Failed. error: {p.before}') + log.info(f'open port 80 to {service}') + firewall_mod('HTTP_UP', service, decrypt_pp) + restart('nginx') - print(f'Turned up HTTP for {service}') - -# restart nginx - cmd = ['/usr/bin/systemctl', 'restart', 'nginx'] - p = subprocess.run(cmd, capture_output=True) - time.sleep(5) - -# get nginx status - cmd = ['/usr/bin/systemctl', 'status', 'nginx'] - p = subprocess.run(cmd, capture_output=True) - stdout = p.stdout.decode('UTF-8').split('\n') - success = False - for line in stdout: - if 'Active: active (running)' in line: - success = True - if not success: - sys.exit('nginx did not restart properly') - print('nginx restarted properly') - -# continue with certbot cb.sendline() + log.info(f'sent to certbot to continue process') res = cb.expect([pexpect.EOF]) - print(cb.before) + log.info(f'cerbot completed. final output: {cb.before}') -# close port 80 to ${service}.drheck.dev - os.environ['state'] = 'HTTP_DOWN' - os.environ['ssl_service'] = service - cmd = ['/usr/bin/ssh', '-i', '/root/.ssh/id_autofirewall', '-o', - 'SendEnv=state', '-o', 'SendEnv=ssl_service', '-l', 'luke131', - 'danknasty', 'doas', '-n', '/usr/local/bin/ssl-update.sh'] - print(f'cmd: {cmd}') - p = pexpect.spawnu(' '.join(cmd)) - p.logfile = sys.stderr - res = p.expect(["Enter passphrase for key '/root/.ssh/id_autofirewall':", - pexpect.TIMEOUT, pexpect.EOF]) - if res > 0: - sys.exit('Couldnt send decryption key to ssh.') - p.sendline(decrypt_pp) - res = p.expect(['success', pexpect.TIMEOUT, pexpect.EOF]) - if res > 0: - sys.exit(f'Failed. error: {p.before}') + if 'failed' in cb.before: + sys.exit('Something went wrong') - print(f'Turned down HTTP for {service}') + log.info(f'open port 80 to {service}') + firewall_mod('HTTP_DOWN', service, decrypt_pp) -# remove symlink in nginx enabled sites service_enabled_symlink.unlink() + log.info('removed symlink in nginx to disable HTTP') -# restart nginx - cmd = ['/usr/bin/systemctl', 'restart', 'nginx'] - p = subprocess.run(cmd, capture_output=True) - time.sleep(5) + restart('nginx') -# get nginx status - cmd = ['/usr/bin/systemctl', 'status', 'nginx'] - p = subprocess.run(cmd, capture_output=True) - stdout = p.stdout.decode('UTF-8').split('\n') - success = False - for line in stdout: - if 'Active: active (running)' in line: - success = True - if not success: - sys.exit('nginx did not restart properly') - print('nginx restarted properly') - -# chmod key_path = pathlib.Path('/etc/letsencrypt') live = key_path / pathlib.Path(f'live/{service}.drheck.dev') archive = key_path / pathlib.Path(f'archive/{service}.drheck.dev') + log.info(f'live keypath: {live}') + log.info(f'archive keypath: {archive}') user = users[service] + log.info(f'service {service} has user {user}') uid = pwd.getpwnam(user).pw_uid gid = pwd.getpwnam(user).pw_gid + log.info(f'uid: {uid} gid: {gid}') os.chown(live, uid, gid) + log.info(f'live keypath chmodded') os.chown(archive, uid, gid) + log.info(f'archive keypath chmodded') for cert_file in cert_files: os.chown(archive / pathlib.Path(cert_file), uid, gid) + log.info(f'{cert_file} chowned to service user') + log.info(f'chmodded new keys from certbot') -# service specific work if service in globals(): + log.info(f'{service} has a service specific function to run') eval(f'{service}()') - print(f'{service} specific work complete.') + log.info(f'{service} specific work complete.') - # restart $service - systemd = service_systemd[service] - cmd = ['/usr/bin/systemctl', 'restart', systemd] - p = subprocess.run(cmd) - time.sleep(10) - -# get $service status - cmd = ['/usr/bin/systemctl', 'status', systemd] - p = subprocess.run(cmd, capture_output=True) - stdout = p.stdout.decode('UTF-8').split('\n') - success = False - for line in stdout: - if 'Active: active (running)' in line: - success = True - if not success: - sys.exit(f'{service} did not restart properly') - print(f'{service} restarted properly') + restart(service) +log = logging.getLogger(__name__) if __name__ == '__main__': main(sys.argv[1:])