feature: the router has a two-level CA with an optional offline root, issuing CAs for P2S, site-to-site and general use, templates, published certificates and CRLs and an ACME server, site-to-site connections authenticate with certificates, and setup installs Caddy from Ubuntu or Caddy's repository.
Release / release (push) Successful in 13s
Release / release (push) Successful in 13s
This commit is contained in:
1 parent
fe45272f7c
commit
8d3469d10a
37 files changed
+2499
-921
No files matched your search
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/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:]))
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/python3
|
||||
"""Install Caddy, the router's HTTPS proxy, from the source https.caddy_source names.
|
||||
|
||||
status shows the configured source, the source in effect and the installed and candidate versions of Caddy. apply
|
||||
installs Caddy when it is missing: from the Ubuntu archive, or with upstream from Caddy's own repository, which it
|
||||
configures first (vpnrouter.CADDY_APT_SOURCE, with the key in vpnrouter.CADDY_KEYRING). When upstream is configured
|
||||
and the repository is not, it configures the repository and replaces the installed Caddy with upstream's. It then
|
||||
restarts vpn-router-setup without waiting, which serves with the new Caddy; vpn-router-caddy.service runs it for
|
||||
vpn-router-setup. use-upstream sets https.caddy_source to upstream and applies it, printing each step.
|
||||
|
||||
Once the repository is configured Caddy stays upstream's: apt keeps the higher upstream version, and ubuntu has no
|
||||
effect. A failure is a message on standard error and the exit status 1. -h or --help shows the commands.
|
||||
|
||||
vpn-router-caddy [--json] status
|
||||
vpn-router-caddy apply
|
||||
vpn-router-caddy use-upstream
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import vpnrouter
|
||||
|
||||
#: apt-get, waiting up to ten minutes for dpkg's lock, which a package installation that runs setup holds.
|
||||
APT = ['apt-get', '-o', 'DPkg::Lock::Timeout=600']
|
||||
|
||||
#: The options of apt-get install: no questions, and the configuration files of the installed Caddy kept.
|
||||
INSTALL = ['-y', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confold', 'install']
|
||||
|
||||
#: Seconds the key of Caddy's repository may take to download.
|
||||
KEY_TIMEOUT = 60
|
||||
|
||||
|
||||
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 step(message):
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def installed():
|
||||
"""The installed version of caddy, or None when it is not installed."""
|
||||
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}\t${Version}', 'caddy'], capture_output=True, text=True)
|
||||
status, _, version = result.stdout.partition('\t')
|
||||
return version if result.returncode == 0 and status == 'install ok installed' else None
|
||||
|
||||
|
||||
def candidate():
|
||||
"""The version of caddy apt would install, or None when it knows none."""
|
||||
result = subprocess.run(['apt-cache', 'policy', 'caddy'], capture_output=True, text=True)
|
||||
for line in result.stdout.splitlines():
|
||||
if line.strip().startswith('Candidate:'):
|
||||
value = line.split(':', 1)[1].strip()
|
||||
return None if value == '(none)' else value
|
||||
return None
|
||||
|
||||
|
||||
def effective():
|
||||
"""The source Caddy comes from: upstream once its repository is configured, ubuntu otherwise."""
|
||||
return 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'
|
||||
|
||||
|
||||
def command(args):
|
||||
"""Run a command with its output shown; SystemExit when it fails."""
|
||||
step(f'$ {" ".join(args)}')
|
||||
result = subprocess.run(args, env=dict(os.environ, DEBIAN_FRONTEND='noninteractive'))
|
||||
if result.returncode != 0:
|
||||
sys.exit(f'vpn-router-caddy: {args[0]} failed with status {result.returncode}')
|
||||
|
||||
|
||||
def apply():
|
||||
"""Install Caddy, or replace it with upstream's, as https.caddy_source asks (see the module)."""
|
||||
source = vpnrouter.load_config()['https']['caddy_source']
|
||||
repository = source == 'upstream' and not vpnrouter.CADDY_APT_SOURCE.is_file()
|
||||
if installed() and not repository:
|
||||
if source == 'ubuntu' and effective() == 'upstream':
|
||||
step("https.caddy_source is ubuntu, but Caddy's repository is configured, so Caddy stays upstream's.")
|
||||
else:
|
||||
step(f'Caddy {installed()} is installed from the {source} source.')
|
||||
return
|
||||
if repository:
|
||||
step(f"Downloading the key of Caddy's repository from {vpnrouter.CADDY_KEY_URL}")
|
||||
with urllib.request.urlopen(vpnrouter.CADDY_KEY_URL, timeout=KEY_TIMEOUT) as response:
|
||||
key = response.read()
|
||||
if not key.lstrip().startswith(b'-----BEGIN PGP PUBLIC KEY BLOCK-----'):
|
||||
sys.exit(f'vpn-router-caddy: {vpnrouter.CADDY_KEY_URL} did not answer with an OpenPGP public key')
|
||||
vpnrouter.CADDY_KEYRING.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
vpnrouter.CADDY_KEYRING.write_bytes(key)
|
||||
os.chmod(vpnrouter.CADDY_KEYRING, 0o644)
|
||||
vpnrouter.CADDY_APT_SOURCE.write_text(
|
||||
f'Types: deb\nURIs: {vpnrouter.CADDY_REPOSITORY}\nSuites: any-version\nComponents: main\n'
|
||||
f'Signed-By: {vpnrouter.CADDY_KEYRING}\n', encoding='utf-8')
|
||||
os.chmod(vpnrouter.CADDY_APT_SOURCE, 0o644)
|
||||
step(f"Caddy's repository is configured in {vpnrouter.CADDY_APT_SOURCE}.")
|
||||
command([*APT, 'update'])
|
||||
command([*APT, *INSTALL, 'caddy'])
|
||||
step(f'Caddy {installed()} is installed from the {effective()} source.')
|
||||
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True)
|
||||
step('vpn-router-setup restarted.')
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = Parser(prog='vpn-router-caddy', description='Install Caddy from the source https.caddy_source names.')
|
||||
parser.add_argument('--json', action='store_true', help='answer status in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser)
|
||||
commands.add_parser('status', help='show the sources and the versions of Caddy')
|
||||
commands.add_parser('apply', help='install Caddy, or replace it with upstream\'s, as https.caddy_source asks')
|
||||
commands.add_parser('use-upstream', help="switch to Caddy's own repository and replace Caddy with upstream's")
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if options.command == 'status':
|
||||
document = {'configured': vpnrouter.load_config()['https']['caddy_source'], 'source': effective(),
|
||||
'installed': installed(), 'candidate': candidate()}
|
||||
if options.json:
|
||||
print(json.dumps(document, indent=2))
|
||||
else:
|
||||
print(f'Configured source: {document["configured"]}\nSource in effect: {document["source"]}\n'
|
||||
f'Installed: {document["installed"] or "no"}\nCandidate: {document["candidate"] or "none"}')
|
||||
return 0
|
||||
if options.command == 'use-upstream':
|
||||
with vpnrouter.config_lock():
|
||||
data = vpnrouter.read_settings()
|
||||
current = data.get('https')
|
||||
data['https'] = {**(current if isinstance(current, dict) else {}), 'caddy_source': 'upstream'}
|
||||
vpnrouter.validate(vpnrouter.with_defaults(data))
|
||||
vpnrouter.save_config(data)
|
||||
step('https.caddy_source is upstream.')
|
||||
apply()
|
||||
except vpnrouter.ConfigError as exc:
|
||||
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(f'vpn-router-caddy: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in new issue
Block a user