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
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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)
|
## v0.3.5 (2026-04-14)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -67,10 +67,11 @@
|
|||||||
### HTTPS / TLS
|
### HTTPS / TLS
|
||||||
|
|
||||||
- `muxplex setup-tls` — auto-detect and set up TLS certificates
|
- `muxplex setup-tls` — auto-detect and set up TLS certificates
|
||||||
- **Tailscale** — real Let's Encrypt certs via `tailscale cert` (recommended)
|
- **Tailscale** — real Let's Encrypt certs via `tailscale cert` (recommended when every client has Tailscale)
|
||||||
- **mkcert** — locally-trusted certs, zero browser warnings
|
- **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)
|
- **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 tailscale
|
||||||
muxplex setup-tls --method mkcert
|
muxplex setup-tls --method mkcert
|
||||||
muxplex setup-tls --method selfsigned
|
muxplex setup-tls --method selfsigned
|
||||||
|
muxplex setup-tls --method ca # persistent local CA + signed leaf
|
||||||
|
|
||||||
# Show current TLS status and configuration
|
# Show current TLS status and configuration
|
||||||
muxplex setup-tls --status
|
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.
|
> **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
|
## Configuration
|
||||||
|
|||||||
@@ -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
@@ -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.settings import SETTINGS_PATH, load_settings, patch_settings # noqa: PLC0415
|
||||||
from muxplex.tls import ( # noqa: PLC0415
|
from muxplex.tls import ( # noqa: PLC0415
|
||||||
|
_default_hostnames,
|
||||||
|
_default_lan_ip,
|
||||||
|
_default_tailnet_name,
|
||||||
detect_mkcert,
|
detect_mkcert,
|
||||||
detect_tailscale,
|
detect_tailscale,
|
||||||
|
generate_leaf_signed_by_ca,
|
||||||
|
generate_local_ca,
|
||||||
generate_mkcert,
|
generate_mkcert,
|
||||||
generate_self_signed,
|
generate_self_signed,
|
||||||
generate_tailscale,
|
generate_tailscale,
|
||||||
@@ -813,6 +818,46 @@ def setup_tls(method: str = "auto") -> None:
|
|||||||
if result is None and method in ("auto", "selfsigned"):
|
if result is None and method in ("auto", "selfsigned"):
|
||||||
result = generate_self_signed(cert_path, key_path)
|
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
|
# Step 4: Final failure check
|
||||||
if result is None:
|
if result is None:
|
||||||
print(
|
print(
|
||||||
@@ -851,6 +896,32 @@ def setup_tls(method: str = "auto") -> None:
|
|||||||
print(" Note: Tailscale certificates expire after 90 days.")
|
print(" Note: Tailscale certificates expire after 90 days.")
|
||||||
print(" Run 'muxplex setup-tls' to renew.")
|
print(" Run 'muxplex setup-tls' to renew.")
|
||||||
print()
|
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")
|
print(" Restart service to apply: muxplex service restart")
|
||||||
|
|
||||||
@@ -993,9 +1064,12 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
setup_tls_parser.add_argument(
|
setup_tls_parser.add_argument(
|
||||||
"--method",
|
"--method",
|
||||||
choices=["auto", "tailscale", "mkcert", "selfsigned"],
|
choices=["auto", "tailscale", "mkcert", "selfsigned", "ca"],
|
||||||
default="auto",
|
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(
|
setup_tls_parser.add_argument(
|
||||||
"--status",
|
"--status",
|
||||||
|
|||||||
+336
@@ -3,6 +3,10 @@ muxplex/tls.py — TLS certificate generation and inspection.
|
|||||||
|
|
||||||
Provides:
|
Provides:
|
||||||
generate_self_signed(cert_path, key_path, hostnames=None, days_valid=3650)
|
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)
|
get_cert_info(cert_path)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -17,6 +21,62 @@ def _default_hostnames() -> list[str]:
|
|||||||
return [hostname, f"{hostname}.local", "localhost"]
|
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(
|
def generate_self_signed(
|
||||||
cert_path,
|
cert_path,
|
||||||
key_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:
|
def detect_tailscale() -> dict | None:
|
||||||
"""Probe for Tailscale and return connection info if available.
|
"""Probe for Tailscale and return connection info if available.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user