#!/usr/bin/python3
"""Manage the router's PKI from the command line, with vpnrouter_pki.

status shows the root, the issuing CAs and the warnings; list the certificates the router issued. issue signs a CSR,
or makes a key and writes a PFX file whose password is read from the first line of standard input. ca csr prints the
CSR of an issuing CA that waits for the offline root, with the openssl extensions to sign it with, and ca import takes
the signed certificate. root export-key writes the root key encrypted with the passphrase read from standard input,
root remove-key removes it from the router, root import-key puts it back, root import-cert places an existing root's
certificate before the root exists, and root import-crl takes a root CRL signed offline. maintain signs the stale CRLs
again; vpn-router-pki.service runs it daily. A change that needs vpn-router-setup restarts it without waiting.

The answer is text for people on standard output, and a failure is a message on standard error. With --json the answer
is JSON on standard output, and a failure is {"error": ...} there too. The exit status says what failed: 2 the request
is invalid, 3 something it names does not exist, 4 the state does not allow it, 1 anything else. -h or --help shows the
commands and their options.

  vpn-router-ca [--json] status
  vpn-router-ca [--json] list [--ca NAME]
  vpn-router-ca [--json] issue --ca NAME --template NAME --csr FILE
  vpn-router-ca [--json] issue --ca NAME --template NAME --subject NAME [--san NAME ...] --pfx FILE   (password on stdin)
  vpn-router-ca [--json] revoke SERIAL [--reason REASON]
  vpn-router-ca [--json] maintain
  vpn-router-ca [--json] ca csr NAME
  vpn-router-ca [--json] ca import NAME FILE
  vpn-router-ca [--json] root import-cert FILE
  vpn-router-ca [--json] root export-key --out FILE   (passphrase on stdin)
  vpn-router-ca [--json] root remove-key [--yes]
  vpn-router-ca [--json] root import-key FILE   (passphrase on stdin, an empty line for none)
  vpn-router-ca [--json] root import-crl FILE
"""

import argparse
import base64
import json
import os
import pathlib
import subprocess
import sys

sys.path.insert(0, '/usr/lib/vpn-router')

import vpnrouter
import vpnrouter_pki
from vpnrouter_pki import Failure


class Parser(argparse.ArgumentParser):
    """An argument parser whose errors end with status 2."""

    def error(self, message):
        print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr)
        sys.exit(2)


def secret():
    """The first line of standard input."""
    return sys.stdin.readline().rstrip('\r\n')


def restart_setup():
    subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)


def show_status(document):
    root = document['root']
    if root is None:
        print('Root CA: none; vpn-router-setup creates it once a platform is chosen')
    else:
        print(f'Root CA: {root["subject"]}, expires {root["expires"]}, key {"on the router" if root["key"] else "offline"}'
              f', CRL until {root["crl_next_update"] or "none"}')
    for authority in document['authorities']:
        line = f'Issuing CA {authority["name"]}: {authority["common_name"]}, {authority["state"]}'
        if authority['expires']:
            line += f', expires {authority["expires"]}, CRL until {authority["crl_next_update"] or "none"}'
        line += f', templates {", ".join(authority["templates"])}'
        if authority['acme_url']:
            line += f', ACME {authority["acme_url"]}'
        print(line)
    for retired in document['retired']:
        print(f'Retired CA {retired["name"]}: {retired["retired"]}')
    for warning in document['warnings']:
        print(f'Warning: {warning}')


def run(options):
    """Do the command of the options; the answer as a document, and a text for people."""
    cfg = vpnrouter.load_config()
    if options.command == 'status':
        document = vpnrouter_pki.status(cfg)
        return document, None
    if options.command == 'list':
        found = [entry for entry in vpnrouter_pki.certificates() if options.ca in (None, entry['ca'])]
        text = '\n'.join(f'{entry["serial"]}  {entry["ca"]}  {entry["kind"]}  {entry["subject"]}  {entry["state"]}  '
                         f'{entry["expires"]}' for entry in found)
        return {'certificates': found}, text
    with vpnrouter_pki.locked():
        if options.command == 'issue':
            if options.csr:
                document = vpnrouter_pki.issue(cfg, options.ca, options.template,
                                               csr=pathlib.Path(options.csr).read_text(encoding='ascii'))
                return document, document['certificate'] + document['chain']
            if not options.subject or not options.pfx:
                raise Failure('give --csr, or --subject and --pfx', 2)
            document = vpnrouter_pki.issue(cfg, options.ca, options.template, subject=options.subject,
                                           sans=options.san or [], password=secret())
            out = pathlib.Path(options.pfx)
            with open(out, 'xb') as handle:
                handle.write(base64.b64decode(document['pfx']))
            return {'serial': document['serial'], 'pfx': str(out)}, f'Certificate {document["serial"]} written to {out}.'
        if options.command == 'revoke':
            document = vpnrouter_pki.revoke(cfg, options.serial, options.reason)
            if document['reissue']:
                restart_setup()
            return document, f'Certificate {document["serial"]} revoked' + \
                ('; vpn-router-setup issues a new one.' if document['reissue'] else '.')
        if options.command == 'maintain':
            due = vpnrouter_pki.maintain(cfg)
            if due:
                restart_setup()
            return {'renewal': due}, 'CRLs checked' + ('; vpn-router-setup renews the router certificates.' if due else '.')
        if options.command == 'ca' and options.action == 'csr':
            document = vpnrouter_pki.csr(cfg, options.name)
            return document, document['csr'] + '\n' + document['extensions']
        if options.command == 'ca':
            document = vpnrouter_pki.import_ca(cfg, options.name, pathlib.Path(options.file).read_text(encoding='ascii'))
            restart_setup()
            return document, f'Issuing CA {options.name} has its certificate, which expires {document["expires"]}.'
        if options.action == 'import-cert':
            document = vpnrouter_pki.import_root_cert(pathlib.Path(options.file).read_bytes())
            restart_setup()
            return document, f'Root CA {document["subject"]} placed, without its key.'
        if options.action == 'export-key':
            data = vpnrouter_pki.export_root_key(secret())
            out = pathlib.Path(options.out)
            with open(out, 'xb') as handle:
                handle.write(data)
            return {'exported': str(out)}, f'Root key written to {out}, encrypted.'
        if options.action == 'remove-key':
            if not options.yes:
                print('Remove the root key from the router? Keep an exported copy first. Type yes: ', end='',
                      file=sys.stderr, flush=True)
                if secret().strip() != 'yes':
                    return {'removed': False}, 'Nothing removed.'
            vpnrouter_pki.remove_root_key()
            return {'removed': True}, 'Root key removed; new issuing CAs now wait for an offline signature.'
        if options.action == 'import-key':
            vpnrouter_pki.import_root_key(pathlib.Path(options.file).read_bytes(), secret() or None)
            restart_setup()
            return {'imported': True}, 'Root key back on the router.'
        document = vpnrouter_pki.import_root_crl(pathlib.Path(options.file).read_bytes())
        return document, f'Root CRL taken, valid until {document["next_update"]}.'


def main(argv):
    parser = Parser(prog='vpn-router-ca', description="Manage the router's PKI.")
    parser.add_argument('--json', action='store_true', help='answer in JSON')
    commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
    commands.add_parser('status', help='show the root, the issuing CAs and the warnings')
    lister = commands.add_parser('list', help='list the certificates the router issued')
    lister.add_argument('--ca', help='only those of this issuing CA')
    issuer = commands.add_parser('issue', help='issue a certificate from a CSR, or with a new key into a PFX file')
    issuer.add_argument('--ca', required=True, help='the issuing CA')
    issuer.add_argument('--template', required=True, help='the certificate template')
    issuer.add_argument('--csr', help='the PEM CSR to sign')
    issuer.add_argument('--subject', help='the common name, without --csr')
    issuer.add_argument('--san', action='append', help='a subject alternative name, without --csr; repeat for more')
    issuer.add_argument('--pfx', help='the PFX file to write, without --csr; the password is read from standard input')
    revoker = commands.add_parser('revoke', help='revoke a certificate the router issued')
    revoker.add_argument('serial', help='the serial number, hexadecimal')
    revoker.add_argument('--reason', choices=vpnrouter_pki.REASONS, help='the reason, unspecified by default')
    commands.add_parser('maintain', help='sign the stale CRLs again')
    authority = commands.add_parser('ca', help='an issuing CA that waits for the offline root')
    actions = authority.add_subparsers(dest='action', required=True, parser_class=Parser)
    actions.add_parser('csr', help='print its CSR and the openssl extensions').add_argument('name')
    importer = actions.add_parser('import', help='take its certificate signed by the root')
    importer.add_argument('name')
    importer.add_argument('file')
    root = commands.add_parser('root', help='the root CA')
    root_actions = root.add_subparsers(dest='action', required=True, parser_class=Parser)
    root_actions.add_parser('import-cert', help="place an existing root's certificate").add_argument('file')
    exporter = root_actions.add_parser('export-key', help='write the root key encrypted with the passphrase from stdin')
    exporter.add_argument('--out', required=True, help='the file to write')
    remover = root_actions.add_parser('remove-key', help='remove the root key from the router')
    remover.add_argument('--yes', action='store_true', help='do not ask')
    root_actions.add_parser('import-key', help='put the root key back').add_argument('file')
    root_actions.add_parser('import-crl', help='take a root CRL signed offline').add_argument('file')
    options = parser.parse_args(argv)
    os.umask(0o077)

    try:
        document, text = run(options)
    except Failure as failure:
        code, message = failure.status, str(failure)
    except OSError as exc:
        code, message = 1, str(exc)
    except SystemExit as exc:
        code, message = 1, str(exc.code)
    else:
        if options.json:
            print(json.dumps(document, indent=2))
        elif text is None:
            show_status(document)
        elif text:
            print(text.rstrip('\n'))
        return 0
    if options.json:
        print(json.dumps({'error': message}, indent=2))
    else:
        print(f'vpn-router-ca: {message}', file=sys.stderr)
    return code


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
