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

This commit is contained in:
slawek committed 2026-10-25 22:05:39 +01:00
1 parent fe45272f7c
commit 8d3469d10a
37 files changed
+2499 -921

No files matched your search

+214
View File
@@ -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:]))
+151
View File
@@ -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:]))