diff --git a/CHANGELOG.md b/CHANGELOG.md index 475da0c..fdb57bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.5.0 (2026-05-06) + +### Features +- **`muxplex setup-tls --method ca`** — generate a persistent local Certificate Authority and sign a 13-month leaf TLS certificate with it. Install the CA once on each client device to get browser-trusted HTTPS for plain LAN names (`my-host`, `192.168.1.5`) without requiring Tailscale on every client and without buying a public domain. The CA persists across regenerations, so leaf rotation does **not** require re-trusting on clients. The leaf SAN auto-discovers the host's primary outbound LAN IPv4 address and the Tailscale MagicDNS name (when Tailscale is connected), in addition to the existing ``, `.local`, `localhost`, `127.0.0.1`, and `::1` entries. The CA cert has proper `BasicConstraints CA:TRUE pathlen:0` and `KeyUsage keyCertSign+cRLSign` extensions, so OS / browser trust stores accept it cleanly as a Root. +- **PWA install reliability** — the `ca` method specifically addresses the symptom where an installed PWA with a self-signed-cert origin gets kicked back into a regular browser tab on relaunch. With the CA installed in the OS trust store, the PWA shell stays in standalone mode across reopens. +- **New documentation** — [`docs/TRUSTING_THE_LOCAL_CA.md`](docs/TRUSTING_THE_LOCAL_CA.md) walks through CA install on Windows (PowerShell, no admin), macOS (`security` CLI), Linux (`update-ca-certificates` / `update-ca-trust`), iOS (Profile + Trust Settings), Android, and Firefox (separate trust store). + +### API +- **`muxplex.tls.generate_local_ca(ca_cert_path, ca_key_path, days_valid=3650)`** — idempotent CA generator. Reuses the existing CA if both files exist; generates a new one otherwise. Returns metadata including a `regenerated` boolean. +- **`muxplex.tls.generate_leaf_signed_by_ca(ca_cert_path, ca_key_path, leaf_cert_path, leaf_key_path, hostnames, ip_addresses=None, days_valid=397)`** — generates a leaf TLS cert signed by an existing local CA. Builds proper `KeyUsage`, `ExtendedKeyUsage serverAuth`, `SubjectKeyIdentifier`, and `AuthorityKeyIdentifier` extensions, plus `SubjectAlternativeName` from the supplied DNS + IP lists. +- **`muxplex.tls._default_lan_ip()`** — returns the primary outbound IPv4 address (no actual packets sent; uses a connected UDP socket to ask the kernel which interface would route external traffic). Returns `None` on failure. +- **`muxplex.tls._default_tailnet_name()`** — returns the host's MagicDNS name from `tailscale status --self --json`, or `None` if Tailscale is unavailable / disconnected. Best-effort with a 5-second timeout. + ## v0.3.5 (2026-04-14) ### Bug Fixes diff --git a/README.md b/README.md index 1490f49..1d3c5cb 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,11 @@ ### HTTPS / TLS - `muxplex setup-tls` — auto-detect and set up TLS certificates -- **Tailscale** — real Let's Encrypt certs via `tailscale cert` (recommended) -- **mkcert** — locally-trusted certs, zero browser warnings +- **Tailscale** — real Let's Encrypt certs via `tailscale cert` (recommended when every client has Tailscale) +- **mkcert** — locally-trusted certs, zero browser warnings (when mkcert is installed on each client) +- **Local CA** — persistent root CA + signed leaf for browser-trusted HTTPS on plain LAN names (`spark-1`, `192.168.1.5`) without Tailscale or a public domain; install the CA once per client → see [Trusting the local CA](docs/TRUSTING_THE_LOCAL_CA.md) - **Self-signed** — fallback for immediate HTTPS (browser shows warning) -- Required for browser clipboard API on non-localhost +- Required for browser clipboard API on non-localhost, and for stable PWA install (browsers refuse to keep installed PWAs in standalone mode against an untrusted origin) --- @@ -204,6 +205,7 @@ muxplex setup-tls muxplex setup-tls --method tailscale muxplex setup-tls --method mkcert muxplex setup-tls --method selfsigned +muxplex setup-tls --method ca # persistent local CA + signed leaf # Show current TLS status and configuration muxplex setup-tls --status @@ -219,6 +221,16 @@ Auto-detection priority: **Tailscale** (if `tailscale` is installed and a cert i > **Note:** Tailscale certs have a 90-day expiry. Run `muxplex setup-tls --method tailscale` to renew when needed. +#### When to use `--method ca` + +The `ca` method is for the case where you want browser-trusted HTTPS on plain LAN names (e.g. `https://my-host:8088`, `https://192.168.1.5:8088`) but **can't** use Tailscale (no client install, blocked by IT policy, or the URL must be the bare LAN name) and **don't** want to buy a public domain. + +It generates a persistent root CA in `~/.config/muxplex/ca/` and signs a 13-month leaf with it. The leaf's SAN automatically includes the hostname, `.local`, `localhost`, the primary LAN IPv4 address, and the Tailscale MagicDNS name (if Tailscale is connected). Install the **CA** (not the leaf) once on each client; subsequent leaf rotations don't require re-trusting. + +Not part of the `auto` cascade — must be opted into explicitly. + +> **→ See [docs/TRUSTING_THE_LOCAL_CA.md](docs/TRUSTING_THE_LOCAL_CA.md)** for per-platform install instructions (Windows, macOS, Linux, iOS, Android, Firefox). + --- ## Configuration diff --git a/docs/TRUSTING_THE_LOCAL_CA.md b/docs/TRUSTING_THE_LOCAL_CA.md new file mode 100644 index 0000000..16ab616 --- /dev/null +++ b/docs/TRUSTING_THE_LOCAL_CA.md @@ -0,0 +1,226 @@ +# Trusting the local CA + +This guide walks through getting browser-trusted HTTPS for muxplex on plain LAN names (e.g. `https://my-host:8088`, `https://192.168.1.5:8088`) without requiring Tailscale on every client and without buying a public domain. + +## When this is the right approach + +Use `muxplex setup-tls --method ca` when **all** of the following apply: + +- You want to access muxplex by its plain LAN hostname (`my-host`) or IP address. +- Tailscale isn't an option on every client device (corporate IT policy blocks it; can't install on a phone; the URL must be the bare hostname; etc.). +- You don't want to buy and configure a public domain just to point it at a LAN IP. +- You can install a root certificate on each client device. A user-level (`CurrentUser`) trust install is enough; admin/elevation is **not** needed on most platforms. + +If Tailscale is acceptable on every client, prefer `--method tailscale` — it gives you a real Let's Encrypt certificate with zero per-client setup beyond the Tailscale app itself. + +## Why this fixes PWA install + reopen flakiness + +Browsers (Chromium, WebKit, Firefox) refuse to keep an installed PWA in standalone mode against an origin that doesn't present a trusted certificate. The behavior the user sees: + +1. First install: click through the cert warning, install the PWA, it works. +2. Reopen: the OS launches the PWA shell; the browser re-evaluates the cert; the click-through exception isn't re-applied to the standalone launch path; the PWA is kicked back into a regular browser tab to surface the warning. Once you click through there, the standalone shell isn't re-entered. + +A self-signed leaf in the trust store *can* fix this, but you have to re-import on every cert rotation. A persistent **CA** in the trust store fixes it permanently — leaf rotations don't break trust because clients trust the issuer, not the leaf. + +## How the `ca` method works + +``` +muxplex setup-tls --method ca +``` + +generates and persists a private CA, then signs a leaf TLS certificate with it. + +| Artifact | Path | Validity | +|---|---|---| +| CA cert | `~/.config/muxplex/ca/muxplex-ca.crt` | 10 years | +| CA private key | `~/.config/muxplex/ca/muxplex-ca.key` (mode `0600`) | 10 years | +| Leaf cert | `~/.config/muxplex/muxplex.crt` | 397 days | +| Leaf private key | `~/.config/muxplex/muxplex.key` (mode `0600`) | 397 days | + +The leaf's Subject Alternative Name (SAN) automatically includes: + +- `` — `socket.gethostname()` +- `.local` — for mDNS / Bonjour resolution +- `localhost` +- The Tailscale MagicDNS name (`..ts.net`) — only if `tailscale status` reports one +- IPs: `127.0.0.1`, `::1`, and the primary outbound IPv4 address of the host (auto-detected) + +The CA cert has the standard CA extensions (`BasicConstraints CA:TRUE, pathlen:0`, `KeyUsage keyCertSign+cRLSign`), so OS trust stores and browsers accept it as a Root. + +Re-running `muxplex setup-tls --method ca` is idempotent for the CA: if `~/.config/muxplex/ca/muxplex-ca.crt` and `.key` already exist, they're reused — only a fresh leaf is generated. Clients that already trust the CA don't need to re-import anything when the leaf rotates. + +## Per-platform install instructions + +### Windows (Chrome / Edge) + +PowerShell, **no admin needed**. Replace `` with the path you copied the file to. + +```powershell +Import-Certificate -FilePath -CertStoreLocation Cert:\CurrentUser\Root +``` + +If you'd rather not type the path, paste the PEM inline: + +```powershell +$pem = @' +-----BEGIN CERTIFICATE----- +... paste PEM contents from ~/.config/muxplex/ca/muxplex-ca.crt ... +-----END CERTIFICATE----- +'@ +$path = "$env:TEMP\muxplex-ca.crt" +$pem | Set-Content -Path $path -Encoding ASCII +Import-Certificate -FilePath $path -CertStoreLocation Cert:\CurrentUser\Root +``` + +Verify: + +```powershell +Get-ChildItem Cert:\CurrentUser\Root | Where-Object Subject -like "*muxplex Local CA*" +``` + +**Restart Edge / Chrome** (fully quit, not just close the window) for them to re-read the cert store. Firefox uses its own store — see the Firefox section below. + +### macOS (Safari / Chrome / Edge) + +```sh +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain \ + /path/to/muxplex-ca.crt +``` + +This adds the CA to the System keychain and marks it as trusted for SSL. Safari, Chrome, and Edge all use the system keychain. Firefox uses its own store — see below. + +Verify: + +```sh +security find-certificate -c "muxplex Local CA" /Library/Keychains/System.keychain +``` + +To remove later: + +```sh +sudo security delete-certificate -c "muxplex Local CA" /Library/Keychains/System.keychain +``` + +### Linux (system-wide) + +```sh +sudo cp /path/to/muxplex-ca.crt /usr/local/share/ca-certificates/muxplex-ca.crt +sudo update-ca-certificates +``` + +This covers the system trust store used by `curl`, `git`, etc., and Chrome / Chromium / Edge on most distros. + +For RPM-based systems (Fedora, RHEL): + +```sh +sudo cp /path/to/muxplex-ca.crt /etc/pki/ca-trust/source/anchors/muxplex-ca.crt +sudo update-ca-trust +``` + +Firefox on Linux uses its own store — see below. + +### iOS / iPadOS (Safari + Web Apps) + +1. Get the CA cert onto the device — easiest is AirDrop from a Mac, or email the `.crt` file to yourself. +2. Open the file in Mail or Files; iOS will offer to download a profile. +3. Go to **Settings → General → VPN & Device Management → Downloaded Profile** and tap **Install**. +4. **Critical second step:** go to **Settings → General → About → Certificate Trust Settings** and toggle **Enable Full Trust for Root Certificates** for the muxplex CA. Without this, Safari and WKWebView don't honor the cert. + +After both steps, Safari accepts the cert and PWAs added to the home screen launch in standalone mode without warnings. + +### Android (Chrome) + +Chrome on Android uses the system trust store, but user-installed CAs are flagged with a persistent "your traffic may be monitored" notice. The basic flow: + +1. Copy the `.crt` file to the device (USB, Drive, Files, etc.). +2. **Settings → Security & privacy → More security & privacy → Encryption & credentials → Install a certificate → CA certificate**. +3. Acknowledge the warning and select the file. + +Result varies by Android version and OEM. Some PWA features (service workers, push) may degrade in this state; basic browser HTTPS works. + +For a more reliable Android experience, prefer Tailscale or a public-domain Let's Encrypt path. + +### Firefox (any platform) + +Firefox maintains its own cert store — system trust install does **not** apply. + +1. **Tools → Settings → Privacy & Security → Certificates → View Certificates**. +2. **Authorities** tab → **Import**. +3. Pick `muxplex-ca.crt`. +4. Tick **Trust this CA to identify websites**, click OK. + +## Verification + +After installing, fully restart the browser, then visit `https://:/`. You should see a green padlock with no warnings. + +You can also verify from the command line on any client: + +```sh +# Linux/macOS +curl -v https://:/ 2>&1 | grep -i "verify\|certificate verify" +``` + +Look for `SSL certificate verify ok.` — if you see `SSL certificate verify failed` (and you're sure you imported the CA into the **right** store and restarted the browser), see the Troubleshooting section. + +## Cert rotation + +The leaf cert is valid for 397 days, which keeps it under the 398-day public-trust ceiling that some browsers enforce even for privately-installed CAs. To rotate: + +```sh +muxplex setup-tls --method ca +muxplex service restart +``` + +The CA is reused (clients keep trusting it), the leaf is regenerated, and the server picks up the new leaf on restart. **No client-side re-trust is required** — that's the whole point of using a CA structure instead of a bare self-signed cert. + +The CA itself is valid for 10 years; you only need to re-deploy a new CA + re-trust on clients once per decade. + +## Troubleshooting + +**"Browser still shows the warning after I imported the CA."** + +- Did you fully quit the browser and reopen it? (Closing the window isn't enough on Chrome/Edge.) +- Are you visiting a URL that's actually in the cert's SAN? Check `muxplex setup-tls --status` to see the SAN list. Common gotcha: the cert covers `my-host` but you're visiting `https://my-host.lan` — `.lan` isn't in the SAN. +- Did you import into the right store? + - Windows: `CurrentUser\Root` (not `My`/Personal). Verify with `Get-ChildItem Cert:\CurrentUser\Root | Where-Object Subject -like "*muxplex*"`. + - macOS: System keychain. Verify with `security find-certificate -c "muxplex Local CA"`. + - Linux: `/usr/local/share/ca-certificates/` then `update-ca-certificates`. + - Firefox: imported under **Authorities**, not **Your Certificates**. + +**"`curl` says `verify ok` but the browser still warns."** + +The browser uses a different trust store than the system CLI in some configurations. Most often this is a Firefox issue (separate store) or a stale browser session that hasn't reloaded the cert store. + +**"My LAN IP changed and now the cert doesn't cover the new IP."** + +The leaf SAN is fixed at generation time. Re-run `muxplex setup-tls --method ca` to regenerate the leaf with the new auto-detected LAN IP, then `muxplex service restart`. The CA stays the same, so clients don't need to re-trust. + +**"`muxplex setup-tls --method ca` fails with `cryptography` import errors."** + +Make sure you're running muxplex >= 0.5.0 (which introduced the `ca` method). Older versions accept `--method ca` only if it's been backported. + +## Security notes + +- The CA's private key (`~/.config/muxplex/ca/muxplex-ca.key`) is the secret that lets you mint TLS certs trusted by every device that's installed your CA. **Treat it like an SSH private key.** It's written with mode `0600`. Don't copy it off the host. Don't commit it to a repo. +- Anyone with that key can sign certs for *any* hostname that targets a client trusting your CA. The blast radius is "every device on which you installed this CA." That's much smaller than a public CA's blast radius, but worth understanding. +- The CA cert (`muxplex-ca.crt`) is public information — it goes onto every client device. Sharing it doesn't reduce your security. Sharing the `.key` does. +- The CA's pathlen is set to `0`, meaning it can sign leaf certificates but cannot issue intermediate sub-CAs. This limits its ability to be misused if the key is ever compromised. +- This CA is local to one host. If you run muxplex on multiple hosts and want browser-trusted certs on all of them, each host has its own CA — install all the CAs on each client. There's no harm in trusting multiple muxplex CAs; they're independent. + +## Comparison with other methods + +| Method | What it generates | Per-client setup | Browser-trusted? | +|---|---|---|---| +| `tailscale` | Real Let's Encrypt cert for `..ts.net` | Install + sign in to Tailscale | ✅ everywhere | +| `mkcert` | Cert signed by mkcert's local CA, installed into the *host's* trust store automatically | Manually install mkcert's CA into each *other* client | ✅ on the host; manual elsewhere | +| `ca` (this) | Cert signed by a persistent muxplex CA | Manually install muxplex's CA on each client (one-time) | ✅ after install | +| `selfsigned` | Self-issued leaf, no CA structure | None — but the cert must be re-imported on every rotation | ❌ unless re-imported | + +The `ca` method's key advantage over `mkcert` for this use case is that mkcert's `-install` step only configures the *host* it runs on. To get clients to trust mkcert certs, you'd still need to copy mkcert's root CA to each client and import it — exactly the same step as `--method ca`, but with mkcert as a dependency. `--method ca` cuts out the mkcert installation requirement and uses the same `cryptography` library that's already a muxplex dependency. + +## See also + +- [`muxplex setup-tls --help`](../README.md#https--tls-setup) — CLI reference. +- [`muxplex setup-tls --status`](../README.md#https--tls-setup) — inspect the current cert (subject, SAN, expiry, issuer). +- The [HTTPS / TLS section of the README](../README.md#https--tls-setup) — overview of all four cert methods. diff --git a/muxplex/cli.py b/muxplex/cli.py index ec2f66c..b3989e4 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -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 " + "-CertStoreLocation Cert:\\CurrentUser\\Root" + ) + print() + print(" macOS:") + print( + " sudo security add-trusted-cert -d -r trustRoot " + "-k /Library/Keychains/System.keychain " + ) + print() + print(" Linux (system-wide):") + print(" sudo cp /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", diff --git a/muxplex/tls.py b/muxplex/tls.py index b3b4bc9..2694208 100644 --- a/muxplex/tls.py +++ b/muxplex/tls.py @@ -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.