118 lines
5.5 KiB
Python
118 lines
5.5 KiB
Python
"""Create/renew LAN TLS certificates for Traefik. Run on Linux with OpenSSL.
|
|
|
|
An existing CA can be installed as tls/ca/root.crt and tls/ca/root.key before
|
|
the first run. The CA key is never mounted into the proxy or copied into images.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import tempfile
|
|
|
|
|
|
def openssl(*args, check=True):
|
|
return subprocess.run(["openssl", *map(str, args)], check=check, capture_output=True)
|
|
|
|
|
|
def identity(value):
|
|
try:
|
|
host = str(ipaddress.ip_address(value))
|
|
return host, "IP", "-verify_ip"
|
|
except ValueError:
|
|
host = value.encode("idna").decode("ascii").lower()
|
|
if len(host) > 253 or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in host.split(".")):
|
|
raise ValueError("Invalid hostname. Supply only a DNS name or IP address, without a port.")
|
|
return host, "DNS", "-verify_hostname"
|
|
|
|
|
|
def publish(path, content):
|
|
# Publish complete config/state files atomically. Traefik watches the directory.
|
|
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=".tls-", suffix=".tmp", delete=False) as stream:
|
|
temporary = Path(stream.name)
|
|
stream.write(content)
|
|
try:
|
|
temporary.replace(path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def renew(project, host, *, force=False):
|
|
import fcntl
|
|
host, san_type, verify_flag = identity(host)
|
|
os.umask(0o077)
|
|
ca = project / "tls" / "ca"
|
|
certificates = project / "tls" / "certs"
|
|
dynamic = project / "traefik" / "dynamic"
|
|
for directory in (ca, certificates, dynamic):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
with (project / "tls" / "renew.lock").open("a") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
root_cert, root_key = ca / "root.crt", ca / "root.key"
|
|
if root_cert.exists() != root_key.exists():
|
|
raise RuntimeError("Incomplete CA: both root.crt and root.key are required. Existing files were retained.")
|
|
if not root_cert.exists():
|
|
openssl("genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", root_key)
|
|
openssl("req", "-new", "-x509", "-sha256", "-key", root_key, "-out", root_cert,
|
|
"-days", "3650", "-subj", "/CN=CS-R Local Authority",
|
|
"-addext", "basicConstraints=critical,CA:TRUE",
|
|
"-addext", "keyUsage=critical,keyCertSign,cRLSign")
|
|
root_key.chmod(0o600)
|
|
# Refuse to issue a certificate beyond the lifetime of its trust anchor.
|
|
openssl("x509", "-in", root_cert, "-checkend", str(91 * 86400), "-noout")
|
|
fingerprint = hashlib.sha256(root_cert.read_bytes()).hexdigest()
|
|
state_path = certificates / "current.json"
|
|
state = json.loads(state_path.read_text()) if state_path.exists() else {}
|
|
serial = state.get("serial", "")
|
|
config = dynamic / "tls.yml"
|
|
if not force and state.get("host") == host and state.get("ca") == fingerprint and re.fullmatch(r"[0-9a-f]{32}", serial):
|
|
cert, key = certificates / f"{serial}.crt", certificates / f"{serial}.key"
|
|
if cert.exists() and key.exists() and config.exists():
|
|
valid = openssl("verify", "-CAfile", root_cert, "-purpose", "sslserver", verify_flag, host, cert, check=False)
|
|
lifetime = openssl("x509", "-in", cert, "-checkend", str(30 * 86400), "-noout", check=False)
|
|
if valid.returncode == lifetime.returncode == 0:
|
|
print("TLS certificate is valid for more than 30 days; no renewal needed.")
|
|
return
|
|
serial = secrets.token_hex(16)
|
|
cert, key = certificates / f"{serial}.crt", certificates / f"{serial}.key"
|
|
with tempfile.TemporaryDirectory(dir=certificates, prefix=".issue-") as temporary:
|
|
temp = Path(temporary)
|
|
extensions = temp / "extensions.cnf"
|
|
extensions.write_text(
|
|
"basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n"
|
|
f"extendedKeyUsage=serverAuth\nsubjectAltName={san_type}:{host}\n"
|
|
)
|
|
openssl("genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", key)
|
|
openssl("req", "-new", "-key", key, "-out", temp / "request.csr", "-subj", "/CN=CS-R Inventory")
|
|
openssl("x509", "-req", "-in", temp / "request.csr", "-CA", root_cert, "-CAkey", root_key,
|
|
"-set_serial", "0x" + serial, "-days", "90", "-sha256", "-extfile", extensions, "-out", cert)
|
|
openssl("verify", "-CAfile", root_cert, "-purpose", "sslserver", verify_flag, host, cert)
|
|
publish(config, f"""tls:
|
|
certificates:
|
|
- certFile: /certs/{serial}.crt
|
|
keyFile: /certs/{serial}.key
|
|
stores:
|
|
default:
|
|
defaultCertificate:
|
|
certFile: /certs/{serial}.crt
|
|
keyFile: /certs/{serial}.key
|
|
options:
|
|
default:
|
|
minVersion: VersionTLS12
|
|
""")
|
|
publish(state_path, json.dumps({"host": host, "ca": fingerprint, "serial": serial}))
|
|
print(f"TLS certificate issued for {host}; Traefik will reload it automatically.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--hostname", required=True)
|
|
parser.add_argument("--project", type=Path, default=Path(__file__).resolve().parent.parent)
|
|
parser.add_argument("--force", action="store_true")
|
|
arguments = parser.parse_args()
|
|
renew(arguments.project.resolve(), arguments.hostname, force=arguments.force)
|