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:
committed by
Brian Krabach
parent
abe6a97241
commit
33ea016f7e
+76
-2
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user