Files
muxplex/muxplex/tls.py
T
Brian Krabach 33ea016f7e feat(tls): add --method ca for persistent local CA-signed leaves
Adds a new TLS certificate method ('ca') that generates a persistent local
Certificate Authority and signs leaf certificates against it. This allows
users to install the CA once on client devices and have browser-trusted
HTTPS for plain LAN names (my-host, 192.168.1.5, my-host.local) without
requiring external services like Tailscale.

Key improvements over existing --method selfsigned:
- Persistent CA: the root cert is stored separately and never rotates,
  so leaf certificate renewal doesn't require re-trusting on clients
- Auto-detected SANs: includes LAN IP, tailnet name (if applicable),
  hostname, and localhost variants
- Per-platform install guide: comprehensive docs/TRUSTING_THE_LOCAL_CA.md
  with Windows PowerShell, macOS/Linux, iOS, and Android install steps

Solves PWA installation issues on Windows machines with corporate IT
policy blocking Tailscale: PWAs now persist in standalone mode when the
local CA is trusted in the Windows user cert store.

Changes:
- muxplex/tls.py: added _default_lan_ip(), _default_tailnet_name(),
  generate_local_ca(), generate_leaf_signed_by_ca()
- muxplex/cli.py: added 'ca' to --method choices, wired setup_tls()
  to generate CA + leaf with auto-detected SAN
- docs/TRUSTING_THE_LOCAL_CA.md: comprehensive per-platform install guide
- README.md: added --method ca documentation and cross-links
- CHANGELOG.md: documented v0.5.0 features

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-06 12:37:34 -07:00

717 lines
22 KiB
Python

"""
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,
}