Initialize clean CSR inventory repository
This commit is contained in:
117
scripts/renew_local_tls.py
Normal file
117
scripts/renew_local_tls.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user