New llm FastAPI service (src/llm/api.py + rag.py + web/chat.html): grounded streaming chat over indexed comments with cited sources. Own image, compose service, Traefik reef entry with git-sso, llm subdomain registered. Dashboard tile + README row. stack llm serve CLI.
281 lines
6.9 KiB
Python
281 lines
6.9 KiB
Python
"""Bootstrap TLS certificates for the homelab stack.
|
|
|
|
Generates a self-signed CA and wildcard certificate for *.$DOMAIN,
|
|
then distributes the CA cert to Docker's certs.d for registry trust and
|
|
regenerates the CoreDNS hosts file from HOST_IP.
|
|
|
|
Usage:
|
|
uv run python dev/scripts/bootstrap_certs.py # defaults
|
|
uv run python dev/scripts/bootstrap_certs.py --force # overwrite existing
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
CERT_DIR = REPO_ROOT / "infra" / "traefik" / "certs"
|
|
COREDNS_DIR = REPO_ROOT / "infra" / "coredns"
|
|
DOCKER_CERTS_DIR = CERT_DIR / "docker-certs.d"
|
|
|
|
CA_KEY = CERT_DIR / "ca.key"
|
|
CA_CRT = CERT_DIR / "ca.crt"
|
|
TLS_KEY = CERT_DIR / "homelab.key"
|
|
TLS_CRT = CERT_DIR / "homelab.crt"
|
|
|
|
CA_SUBJECT = "/CN=Homelab CA/O=fhirworx"
|
|
CA_DAYS = 3650
|
|
TLS_DAYS = 3650
|
|
|
|
from conf import cfg # noqa: E402
|
|
|
|
DOMAIN = os.environ.get("DOMAIN", cfg.platform.domain)
|
|
HOST_IP = os.environ.get("HOST_IP", cfg.platform.host_ip)
|
|
|
|
HOSTS_SUBDOMAINS = [
|
|
"",
|
|
"git",
|
|
"ci",
|
|
"docs",
|
|
"notebooks",
|
|
"api",
|
|
"s3",
|
|
"s3console",
|
|
"grafana",
|
|
"traefik",
|
|
"zotero",
|
|
"webdav",
|
|
"prometheus",
|
|
"tempo",
|
|
"loki",
|
|
"nessie",
|
|
"trino",
|
|
"polaris",
|
|
"llm",
|
|
]
|
|
|
|
|
|
def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
|
|
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs)
|
|
|
|
|
|
def generate_ca(*, force: bool = False) -> tuple[Path, Path]:
|
|
"""Generate a self-signed CA key and certificate."""
|
|
if CA_KEY.exists() and CA_CRT.exists() and not force:
|
|
return CA_KEY, CA_CRT
|
|
|
|
CERT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
_run(
|
|
[
|
|
"openssl",
|
|
"req",
|
|
"-x509",
|
|
"-new",
|
|
"-nodes",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-keyout",
|
|
str(CA_KEY),
|
|
"-out",
|
|
str(CA_CRT),
|
|
"-days",
|
|
str(CA_DAYS),
|
|
"-subj",
|
|
CA_SUBJECT,
|
|
]
|
|
)
|
|
CA_KEY.chmod(0o600)
|
|
return CA_KEY, CA_CRT
|
|
|
|
|
|
def generate_wildcard(
|
|
domain: str = DOMAIN, *, force: bool = False
|
|
) -> tuple[Path, Path]:
|
|
"""Generate a wildcard TLS cert signed by the CA."""
|
|
if TLS_KEY.exists() and TLS_CRT.exists() and not force:
|
|
return TLS_KEY, TLS_CRT
|
|
|
|
if not CA_KEY.exists() or not CA_CRT.exists():
|
|
raise FileNotFoundError("CA cert/key not found — run generate_ca first")
|
|
|
|
san = f"DNS:*.{domain},DNS:{domain}"
|
|
|
|
_run(
|
|
[
|
|
"openssl",
|
|
"req",
|
|
"-new",
|
|
"-nodes",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-keyout",
|
|
str(TLS_KEY),
|
|
"-out",
|
|
str(TLS_KEY.with_suffix(".csr")),
|
|
"-subj",
|
|
f"/CN=*.{domain}",
|
|
]
|
|
)
|
|
|
|
_run(
|
|
[
|
|
"openssl",
|
|
"x509",
|
|
"-req",
|
|
"-in",
|
|
str(TLS_KEY.with_suffix(".csr")),
|
|
"-CA",
|
|
str(CA_CRT),
|
|
"-CAkey",
|
|
str(CA_KEY),
|
|
"-CAcreateserial",
|
|
"-out",
|
|
str(TLS_CRT),
|
|
"-days",
|
|
str(TLS_DAYS),
|
|
"-extfile",
|
|
"/dev/stdin",
|
|
],
|
|
input=f"subjectAltName={san}\n",
|
|
)
|
|
|
|
TLS_KEY.chmod(0o600)
|
|
TLS_KEY.with_suffix(".csr").unlink(missing_ok=True)
|
|
CA_CRT.with_suffix(".srl").unlink(missing_ok=True)
|
|
return TLS_KEY, TLS_CRT
|
|
|
|
|
|
def distribute_ca(
|
|
registry: str = f"git.{DOMAIN}",
|
|
) -> Path:
|
|
"""Copy CA cert to Docker certs.d for registry trust."""
|
|
dest_dir = DOCKER_CERTS_DIR / registry
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
dest = dest_dir / "ca.crt"
|
|
shutil.copy2(CA_CRT, dest)
|
|
|
|
# Also install for rootless Docker daemon
|
|
user_certs = Path.home() / ".config" / "docker" / "certs.d" / registry
|
|
user_certs.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(CA_CRT, user_certs / "ca.crt")
|
|
|
|
return dest
|
|
|
|
|
|
def generate_coredns_hosts(
|
|
host_ip: str = HOST_IP,
|
|
domain: str = DOMAIN,
|
|
) -> Path:
|
|
"""Generate the CoreDNS hosts file from HOST_IP and DOMAIN."""
|
|
COREDNS_DIR.mkdir(parents=True, exist_ok=True)
|
|
hosts_path = COREDNS_DIR / "hosts"
|
|
lines = []
|
|
for sub in HOSTS_SUBDOMAINS:
|
|
fqdn = f"{sub}.{domain}" if sub else domain
|
|
lines.append(f"{host_ip} {fqdn}")
|
|
hosts_path.write_text("\n".join(lines) + "\n")
|
|
return hosts_path
|
|
|
|
|
|
def verify_ca() -> dict[str, str]:
|
|
"""Read CA cert metadata. Returns dict with subject, issuer, dates."""
|
|
result = _run(
|
|
[
|
|
"openssl",
|
|
"x509",
|
|
"-in",
|
|
str(CA_CRT),
|
|
"-noout",
|
|
"-subject",
|
|
"-issuer",
|
|
"-startdate",
|
|
"-enddate",
|
|
]
|
|
)
|
|
info = {}
|
|
for line in result.stdout.strip().splitlines():
|
|
key, _, val = line.partition("=")
|
|
info[key.strip()] = val.strip()
|
|
return info
|
|
|
|
|
|
def verify_wildcard(domain: str = DOMAIN) -> dict[str, str]:
|
|
"""Read wildcard cert metadata and verify CA signature."""
|
|
result = _run(
|
|
[
|
|
"openssl",
|
|
"x509",
|
|
"-in",
|
|
str(TLS_CRT),
|
|
"-noout",
|
|
"-subject",
|
|
"-issuer",
|
|
"-startdate",
|
|
"-enddate",
|
|
]
|
|
)
|
|
info = {}
|
|
for line in result.stdout.strip().splitlines():
|
|
key, _, val = line.partition("=")
|
|
info[key.strip()] = val.strip()
|
|
|
|
# Verify signature chain
|
|
_run(["openssl", "verify", "-CAfile", str(CA_CRT), str(TLS_CRT)])
|
|
info["verified"] = "true"
|
|
|
|
# Check SAN
|
|
san_result = _run(
|
|
[
|
|
"openssl",
|
|
"x509",
|
|
"-in",
|
|
str(TLS_CRT),
|
|
"-noout",
|
|
"-ext",
|
|
"subjectAltName",
|
|
]
|
|
)
|
|
info["san"] = san_result.stdout.strip()
|
|
return info
|
|
|
|
|
|
def bootstrap(*, force: bool = False) -> None:
|
|
"""Run the full bootstrap: CA → wildcard → distribute → hosts."""
|
|
print(f"Domain: {DOMAIN}")
|
|
print(f"Host IP: {HOST_IP}")
|
|
print()
|
|
|
|
print("1. Generating CA...")
|
|
ca_key, ca_crt = generate_ca(force=force)
|
|
print(f" {ca_key}")
|
|
print(f" {ca_crt}")
|
|
|
|
print("2. Generating wildcard cert...")
|
|
tls_key, tls_crt = generate_wildcard(force=force)
|
|
print(f" {tls_key}")
|
|
print(f" {tls_crt}")
|
|
|
|
print("3. Distributing CA to Docker certs.d...")
|
|
dest = distribute_ca()
|
|
print(f" {dest}")
|
|
|
|
print("4. Generating CoreDNS hosts...")
|
|
hosts = generate_coredns_hosts()
|
|
print(f" {hosts}")
|
|
|
|
print()
|
|
print("Done. Run dev/scripts/install_certs.sh to install in browser trust stores.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--force", action="store_true", help="overwrite existing certs")
|
|
args = parser.parse_args()
|
|
bootstrap(force=args.force)
|