Initialize clean CSR inventory repository

This commit is contained in:
DenisToxic
2026-09-29 08:43:05 +02:00
commit 059f386c60
107 changed files with 9639 additions and 0 deletions

117
scripts/renew_local_tls.py Normal file
View 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)