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>
This commit is contained in:
Brian Krabach
2026-05-06 08:23:30 -07:00
committed by Brian Krabach
parent abe6a97241
commit 33ea016f7e
5 changed files with 666 additions and 5 deletions
+76 -2
View File
@@ -742,8 +742,13 @@ def setup_tls(method: str = "auto") -> None:
"""
from muxplex.settings import SETTINGS_PATH, load_settings, patch_settings # noqa: PLC0415
from muxplex.tls import ( # noqa: PLC0415
_default_hostnames,
_default_lan_ip,
_default_tailnet_name,
detect_mkcert,
detect_tailscale,
generate_leaf_signed_by_ca,
generate_local_ca,
generate_mkcert,
generate_self_signed,
generate_tailscale,
@@ -813,6 +818,46 @@ def setup_tls(method: str = "auto") -> None:
if result is None and method in ("auto", "selfsigned"):
result = generate_self_signed(cert_path, key_path)
# Step 3.5: Try local CA (explicit opt-in only — not part of "auto").
# Generates a persistent local CA in ~/.config/muxplex/ca/ and signs
# a short-lived leaf with it. Install the CA on each client to get
# browser-trusted HTTPS without Tailscale or a public domain.
if result is None and method == "ca":
ca_dir = config_dir / "ca"
ca_cert_path = ca_dir / "muxplex-ca.crt"
ca_key_path = ca_dir / "muxplex-ca.key"
ca_info = generate_local_ca(ca_cert_path, ca_key_path)
if ca_info["regenerated"]:
print(f" Generated local CA at {ca_cert_path}")
else:
print(f" Reusing existing local CA at {ca_cert_path}")
# Build the SAN list: defaults + tailnet name (if reachable) + LAN IP.
leaf_hostnames: list[str] = list(_default_hostnames())
tailnet_name = _default_tailnet_name()
if tailnet_name and tailnet_name not in leaf_hostnames:
leaf_hostnames.append(tailnet_name)
leaf_ips: list[str] = ["127.0.0.1", "::1"]
lan_ip = _default_lan_ip()
if lan_ip and lan_ip not in leaf_ips:
leaf_ips.append(lan_ip)
result = generate_leaf_signed_by_ca(
ca_cert_path,
ca_key_path,
cert_path,
key_path,
hostnames=leaf_hostnames,
ip_addresses=leaf_ips,
)
# Decorate the result with the CA path so the success block can
# surface install instructions.
if result:
result["ca_cert_path"] = str(ca_cert_path)
result["ca_regenerated"] = ca_info["regenerated"]
# Step 4: Final failure check
if result is None:
print(
@@ -851,6 +896,32 @@ def setup_tls(method: str = "auto") -> None:
print(" Note: Tailscale certificates expire after 90 days.")
print(" Run 'muxplex setup-tls' to renew.")
print()
elif method_used == "ca":
ca_cert_path_str = result.get("ca_cert_path", "")
print(f" Local CA: {ca_cert_path_str}")
print()
print(" Install the CA on each client to eliminate browser warnings.")
print(" The leaf rotates without re-trusting; the CA is what you trust.")
print()
print(" Windows (PowerShell, no admin needed):")
print(
" Import-Certificate -FilePath <path-to-ca.crt> "
"-CertStoreLocation Cert:\\CurrentUser\\Root"
)
print()
print(" macOS:")
print(
" sudo security add-trusted-cert -d -r trustRoot "
"-k /Library/Keychains/System.keychain <path-to-ca.crt>"
)
print()
print(" Linux (system-wide):")
print(" sudo cp <path-to-ca.crt> /usr/local/share/ca-certificates/")
print(" sudo update-ca-certificates")
print()
print(" Leaf cert rotates yearly — re-run 'muxplex setup-tls --method ca'")
print(" to generate a fresh leaf signed by the same CA (no client re-trust).")
print()
print(" Restart service to apply: muxplex service restart")
@@ -993,9 +1064,12 @@ def main() -> None:
)
setup_tls_parser.add_argument(
"--method",
choices=["auto", "tailscale", "mkcert", "selfsigned"],
choices=["auto", "tailscale", "mkcert", "selfsigned", "ca"],
default="auto",
help="Certificate generation method (default: auto)",
help="Certificate generation method (default: auto). 'ca' creates "
"a persistent local CA in ~/.config/muxplex/ca/ and signs a leaf "
"cert with it — install the CA on each client to eliminate browser "
"warnings without requiring Tailscale or a public domain.",
)
setup_tls_parser.add_argument(
"--status",
+336
View File
@@ -3,6 +3,10 @@ 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)
"""
@@ -17,6 +21,62 @@ def _default_hostnames() -> list[str]:
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,
@@ -119,6 +179,282 @@ def generate_self_signed(
}
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.