""" muxplex/tls.py — TLS certificate generation and inspection. Provides: generate_self_signed(cert_path, key_path, hostnames=None, days_valid=3650) generate_local_ca(ca_cert_path, ca_key_path, days_valid=3650) generate_leaf_signed_by_ca(ca_cert_path, ca_key_path, leaf_cert_path, leaf_key_path, hostnames, ip_addresses, days_valid=397) get_cert_info(cert_path) """ import ipaddress import socket from datetime import datetime, timezone from pathlib import Path def _default_hostnames() -> list[str]: hostname = socket.gethostname() return [hostname, f"{hostname}.local", "localhost"] def _default_lan_ip() -> str | None: """Detect the primary outbound IPv4 address. Returns the local IP that would be used to reach an external host (no packets are actually sent — we use a connected UDP socket to ask the kernel which interface would route the traffic). Returns None on failure (no network, all-loopback configuration, etc.). """ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: # 8.8.8.8:80 is a routing target; UDP connect doesn't transmit. s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] except OSError: return None finally: s.close() if ip in ("0.0.0.0", "127.0.0.1"): return None return ip def _default_tailnet_name() -> str | None: """Return this host's MagicDNS name (e.g. 'spark-1.tail8f3c4e.ts.net'), or None if Tailscale is not installed / not connected / has no DNSName. Best-effort and short-timeout — failure is silent and returns None. """ import json import shutil import subprocess if not shutil.which("tailscale"): return None try: result = subprocess.run( ["tailscale", "status", "--self", "--json"], timeout=5, capture_output=True, text=True, ) except (subprocess.TimeoutExpired, OSError): return None if result.returncode != 0: return None try: data = json.loads(result.stdout) except json.JSONDecodeError: return None self_info = data.get("Self") or {} dns_name = self_info.get("DNSName", "") or data.get("DNSName", "") if not dns_name: return None return dns_name.rstrip(".") def generate_self_signed( cert_path, key_path, hostnames: list[str] | None = None, days_valid: int = 3650, ) -> dict: """Generate a self-signed TLS certificate and private key. Args: cert_path: Destination path for the certificate PEM file. key_path: Destination path for the private key PEM file. hostnames: DNS names to include. Defaults to [hostname, hostname.local, localhost]. days_valid: Certificate validity period in days. Default 3650 (≈10 years). Returns: dict with keys: method, cert_path, key_path, hostnames, expires. """ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID if hostnames is None: hostnames = _default_hostnames() cert_path = Path(cert_path) key_path = Path(key_path) # Create parent directories if needed cert_path.parent.mkdir(parents=True, exist_ok=True) key_path.parent.mkdir(parents=True, exist_ok=True) # Generate RSA 2048-bit private key private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, ) # Build subject / issuer with CN = first hostname, O = muxplex subject = issuer = x509.Name( [ x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0]), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "muxplex"), ] ) # Build SAN extension: DNS names for all hostnames + loopback IPs san_entries: list[x509.GeneralName] = [x509.DNSName(h) for h in hostnames] san_entries.append(x509.IPAddress(ipaddress.IPv4Address("127.0.0.1"))) san_entries.append(x509.IPAddress(ipaddress.IPv6Address("::1"))) now = datetime.now(timezone.utc) # Build the certificate cert = ( x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(private_key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now) .not_valid_after( datetime.fromtimestamp( now.timestamp() + days_valid * 86400, tz=timezone.utc, ) ) .add_extension( x509.SubjectAlternativeName(san_entries), critical=False, ) .sign(private_key, hashes.SHA256()) ) try: expires = cert.not_valid_after_utc # type: ignore[attr-defined] except AttributeError: expires = cert.not_valid_after # type: ignore[attr-defined] # Write key PEM — create file with restricted permissions before writing key_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) key_path.touch(mode=0o600, exist_ok=True) key_path.write_bytes(key_pem) key_path.chmod(0o600) # Write cert PEM cert_pem = cert.public_bytes(serialization.Encoding.PEM) cert_path.write_bytes(cert_pem) return { "method": "selfsigned", "cert_path": str(cert_path), "key_path": str(key_path), "hostnames": hostnames, "expires": expires, } def generate_local_ca( ca_cert_path, ca_key_path, days_valid: int = 3650, common_name: str = "muxplex Local CA", ) -> dict: """Generate (or reuse) a persistent local CA for signing leaf certs. The CA is suitable for installation into OS / browser trust stores: BasicConstraints CA:TRUE (path_length=0), KeyUsage keyCertSign+cRLSign, SubjectKeyIdentifier present. Idempotent: if both ca_cert_path and ca_key_path already exist, this function does nothing and returns metadata for the existing CA. To regenerate, delete the files first. Args: ca_cert_path: Destination path for the CA certificate PEM file. ca_key_path: Destination path for the CA private key PEM file. days_valid: CA validity period in days. Default 3650 (~10 years). common_name: CN for the CA's subject/issuer name. Returns: dict with keys: ca_cert_path, ca_key_path, common_name, expires, regenerated (True if newly generated, False if reused). """ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID ca_cert_path = Path(ca_cert_path) ca_key_path = Path(ca_key_path) if ca_cert_path.exists() and ca_key_path.exists(): info = get_cert_info(ca_cert_path) return { "ca_cert_path": str(ca_cert_path), "ca_key_path": str(ca_key_path), "common_name": common_name, "expires": info["expires"] if info else None, "regenerated": False, } ca_cert_path.parent.mkdir(parents=True, exist_ok=True) ca_key_path.parent.mkdir(parents=True, exist_ok=True) # 4096-bit RSA for the long-lived CA ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) subject = issuer = x509.Name( [ x509.NameAttribute(NameOID.COMMON_NAME, common_name), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "muxplex"), ] ) now = datetime.now(timezone.utc) cert = ( x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(ca_key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now) .not_valid_after( datetime.fromtimestamp( now.timestamp() + days_valid * 86400, tz=timezone.utc ) ) .add_extension( x509.BasicConstraints(ca=True, path_length=0), critical=True, ) .add_extension( x509.KeyUsage( digital_signature=False, content_commitment=False, key_encipherment=False, data_encipherment=False, key_agreement=False, key_cert_sign=True, crl_sign=True, encipher_only=False, decipher_only=False, ), critical=True, ) .add_extension( x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False, ) .sign(ca_key, hashes.SHA256()) ) # Write key with restricted permissions (touch 0o600 BEFORE write to # avoid the world-readable window during file creation). key_pem = ca_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) ca_key_path.touch(mode=0o600, exist_ok=True) ca_key_path.write_bytes(key_pem) ca_key_path.chmod(0o600) cert_pem = cert.public_bytes(serialization.Encoding.PEM) ca_cert_path.write_bytes(cert_pem) try: expires = cert.not_valid_after_utc # type: ignore[attr-defined] except AttributeError: expires = cert.not_valid_after # type: ignore[attr-defined] return { "ca_cert_path": str(ca_cert_path), "ca_key_path": str(ca_key_path), "common_name": common_name, "expires": expires, "regenerated": True, } def generate_leaf_signed_by_ca( ca_cert_path, ca_key_path, leaf_cert_path, leaf_key_path, hostnames: list[str], ip_addresses: list[str] | None = None, days_valid: int = 397, ) -> dict: """Generate a leaf TLS certificate signed by a local CA. The leaf is suitable for serving HTTPS: KeyUsage digitalSignature + keyEncipherment, ExtendedKeyUsage serverAuth, SAN populated from hostnames + ip_addresses, AuthorityKeyIdentifier linked to the CA. Args: ca_cert_path: Path to the CA certificate PEM. ca_key_path: Path to the CA private key PEM. leaf_cert_path: Destination for the leaf certificate. leaf_key_path: Destination for the leaf private key. hostnames: DNS names to include in SAN. The first is also used as the leaf's CN. ip_addresses: Optional IPv4/IPv6 strings to include as IP SAN entries. Invalid entries are skipped silently. days_valid: Leaf validity in days. Default 397 — just under the CA/B Forum 398-day enforcement ceiling that Apple/Chrome also apply to privately-installed roots in many recent versions. Returns: dict with keys: method, cert_path, key_path, hostnames, expires. """ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID if not hostnames: raise ValueError("at least one hostname is required for leaf certificate") ca_cert_path = Path(ca_cert_path) ca_key_path = Path(ca_key_path) leaf_cert_path = Path(leaf_cert_path) leaf_key_path = Path(leaf_key_path) leaf_cert_path.parent.mkdir(parents=True, exist_ok=True) leaf_key_path.parent.mkdir(parents=True, exist_ok=True) ca_cert = x509.load_pem_x509_certificate(ca_cert_path.read_bytes()) ca_key = serialization.load_pem_private_key(ca_key_path.read_bytes(), password=None) leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) subject = x509.Name( [ x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0]), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "muxplex"), ] ) san_entries: list[x509.GeneralName] = [x509.DNSName(h) for h in hostnames] valid_ips: list[str] = [] if ip_addresses: for ip_str in ip_addresses: try: addr = ipaddress.ip_address(ip_str) except ValueError: continue san_entries.append(x509.IPAddress(addr)) valid_ips.append(ip_str) now = datetime.now(timezone.utc) cert = ( x509.CertificateBuilder() .subject_name(subject) .issuer_name(ca_cert.subject) .public_key(leaf_key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now) .not_valid_after( datetime.fromtimestamp( now.timestamp() + days_valid * 86400, tz=timezone.utc ) ) .add_extension( x509.SubjectAlternativeName(san_entries), critical=False, ) .add_extension( x509.BasicConstraints(ca=False, path_length=None), critical=True, ) .add_extension( x509.KeyUsage( digital_signature=True, content_commitment=False, key_encipherment=True, data_encipherment=False, key_agreement=False, key_cert_sign=False, crl_sign=False, encipher_only=False, decipher_only=False, ), critical=True, ) .add_extension( x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False, ) .add_extension( x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()), critical=False, ) .add_extension( x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key()), # type: ignore[arg-type] critical=False, ) .sign(ca_key, hashes.SHA256()) # type: ignore[arg-type] ) key_pem = leaf_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) leaf_key_path.touch(mode=0o600, exist_ok=True) leaf_key_path.write_bytes(key_pem) leaf_key_path.chmod(0o600) cert_pem = cert.public_bytes(serialization.Encoding.PEM) leaf_cert_path.write_bytes(cert_pem) try: expires = cert.not_valid_after_utc # type: ignore[attr-defined] except AttributeError: expires = cert.not_valid_after # type: ignore[attr-defined] # Combined display list: DNS names + stringified IPs (for the success # message in the CLI). display_hostnames = list(hostnames) + valid_ips return { "method": "ca", "cert_path": str(leaf_cert_path), "key_path": str(leaf_key_path), "hostnames": display_hostnames, "expires": expires, } def detect_tailscale() -> dict | None: """Probe for Tailscale and return connection info if available. Checks whether the Tailscale CLI is installed, verifies the node is connected, and confirms HTTPS certificate domains are enabled. Returns: dict with keys: hostname (str), ips (list[str]), cert_domains (list[str]) if Tailscale is installed, connected, and cert domains are configured. Returns None if any of these conditions are not met. """ import json import shutil import subprocess if not shutil.which("tailscale"): return None try: result = subprocess.run( ["tailscale", "status", "--self", "--json"], timeout=10, capture_output=True, text=True, ) except (subprocess.TimeoutExpired, OSError): return None if result.returncode != 0: return None try: data = json.loads(result.stdout) except json.JSONDecodeError: return None self_info = data.get("Self") or {} dns_name = self_info.get("DNSName", "") or data.get("DNSName", "") cert_domains = data.get("CertDomains") or [] ips = data.get("TailscaleIPs") or [] if not dns_name or not cert_domains: return None return { "hostname": dns_name.rstrip("."), "ips": ips, "cert_domains": cert_domains, } def generate_tailscale(cert_path, key_path, hostname: str) -> dict | None: """Obtain a Let's Encrypt certificate via Tailscale. Args: cert_path: Destination path for the certificate PEM file. key_path: Destination path for the private key PEM file. hostname: Tailscale hostname to request a certificate for. Returns: dict with keys: method, cert_path, key_path, hostnames, expires. Returns None on failure (non-zero exit, timeout, OS error, or missing files). """ import subprocess cert_path = Path(cert_path) key_path = Path(key_path) cert_path.parent.mkdir(parents=True, exist_ok=True) key_path.parent.mkdir(parents=True, exist_ok=True) try: result = subprocess.run( [ "tailscale", "cert", "--cert-file", str(cert_path), "--key-file", str(key_path), hostname, ], capture_output=True, text=True, timeout=30, ) except (subprocess.TimeoutExpired, OSError): return None if result.returncode != 0: return None if not cert_path.exists() or not key_path.exists(): return None key_path.chmod(0o600) info = get_cert_info(cert_path) if info is None: return None return { "method": "tailscale", "cert_path": str(cert_path), "key_path": str(key_path), "hostnames": info["hostnames"], "expires": info["expires"], } def detect_mkcert() -> bool: """Return True if mkcert is available on PATH, False otherwise.""" import shutil return shutil.which("mkcert") is not None def generate_mkcert( cert_path, key_path, extra_hostnames: list[str] | None = None, ) -> dict | None: """Generate a locally-trusted certificate via mkcert. Args: cert_path: Destination path for the certificate PEM file. key_path: Destination path for the private key PEM file. extra_hostnames: Additional hostnames to include in the certificate. Returns: dict with keys: method, cert_path, key_path, hostnames, expires. Returns None on failure (mkcert not found, non-zero exit, timeout, or missing files). """ import subprocess cert_path = Path(cert_path) key_path = Path(key_path) cert_path.parent.mkdir(parents=True, exist_ok=True) key_path.parent.mkdir(parents=True, exist_ok=True) # Step 1: install the local CA try: result = subprocess.run( ["mkcert", "-install"], capture_output=True, text=True, timeout=30, ) except (subprocess.TimeoutExpired, OSError): return None if result.returncode != 0: return None # Step 2: build deduplicated hostname list (preserving order) hostname = socket.gethostname() base_hostnames = [hostname, f"{hostname}.local", "localhost", "127.0.0.1", "::1"] if extra_hostnames: base_hostnames.extend(extra_hostnames) seen: set[str] = set() unique_hostnames: list[str] = [] for h in base_hostnames: if h not in seen: seen.add(h) unique_hostnames.append(h) # Step 3: generate the certificate try: result = subprocess.run( [ "mkcert", "-cert-file", str(cert_path), "-key-file", str(key_path), *unique_hostnames, ], capture_output=True, text=True, timeout=30, ) except (subprocess.TimeoutExpired, OSError): return None if result.returncode != 0: return None if not cert_path.exists() or not key_path.exists(): return None key_path.chmod(0o600) info = get_cert_info(cert_path) expires = info["expires"] if info else None return { "method": "mkcert", "cert_path": str(cert_path), "key_path": str(key_path), "hostnames": unique_hostnames, "expires": expires, } def get_cert_info(cert_path) -> dict | None: """Inspect a PEM certificate and return metadata. Args: cert_path: Path to the PEM certificate file. Returns: dict with expires, not_before, hostnames (DNS names + IPs from SANs), serial. Returns None if the file is missing or cannot be parsed. """ from cryptography import x509 from cryptography.x509.extensions import ExtensionNotFound cert_path = Path(cert_path) try: pem_data = cert_path.read_bytes() except (FileNotFoundError, PermissionError, OSError): return None try: cert = x509.load_pem_x509_certificate(pem_data) except Exception: return None # Extract hostnames from SAN extension hostnames: list[str] = [] try: san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) for entry in san.value: if isinstance(entry, x509.DNSName): hostnames.append(entry.value) elif isinstance(entry, x509.IPAddress): hostnames.append(str(entry.value)) except ExtensionNotFound: pass try: expires = cert.not_valid_after_utc # type: ignore[attr-defined] except AttributeError: expires = cert.not_valid_after # type: ignore[attr-defined] try: not_before = cert.not_valid_before_utc # type: ignore[attr-defined] except AttributeError: not_before = cert.not_valid_before # type: ignore[attr-defined] return { "expires": expires, "not_before": not_before, "hostnames": hostnames, "serial": cert.serial_number, }