213 lines
11 KiB
Python
Executable File
213 lines
11 KiB
Python
Executable File
#!/usr/bin/python3
|
|
"""Manage the router's PKI, which the router's directory holds, 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, 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 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 ldap
|
|
|
|
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 == '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)
|
|
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 ldap.LDAPError as exc:
|
|
code, message = 1, f'the directory, which holds the PKI, failed: {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:]))
|