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
+13
View File
@@ -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 `<hostname>`, `<hostname>.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
+15 -3
View File
@@ -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, `<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
+226
View File
@@ -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:
- `<hostname>``socket.gethostname()`
- `<hostname>.local` — for mDNS / Bonjour resolution
- `localhost`
- The Tailscale MagicDNS name (`<host>.<tailnet>.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 `<path-to-ca.crt>` with the path you copied the file to.
```powershell
Import-Certificate -FilePath <path-to-ca.crt> -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://<host>:<port>/`. 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://<host>:<port>/ 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 `<host>.<tailnet>.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.
+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.