diff --git a/README.md b/README.md index eaee413..e55aedb 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,16 @@ Every step is idempotent. In order: ./install.sh --no-sunshine # client-only (install Moonlight only) ./install.sh --no-moonlight # host-only ./install.sh --from-source # build sunshine from source (default uses sunshine-bin) +./install.sh --no-certs # skip the 1Password-backed cert step +./install.sh --force-certs # re-mint the host cert even if current ./install.sh --doctor # verification only (no install) +./scripts/cert-bootstrap.sh # one-time: generate root CA, push to 1Password + ./uninstall.sh # remove packages and udev rule, keep user data ./uninstall.sh --purge # also delete ~/.config/sunshine ./uninstall.sh --keep-moonlight +./uninstall.sh --remove-ca-trust # also remove the omarchy-stream CA from system trust ``` Environment overrides: @@ -116,6 +121,61 @@ Per-vendor encoder picks: Everything else (bitrate, paired clients, app launchers) is set via the web UI. +## Trusted TLS certs via 1Password + +The installer can replace Sunshine's default self-signed cert with one minted from a private root CA whose key material lives in 1Password. Result: no more browser warning on `https://.lan:47990`, and any tool that respects the system trust store (curl, openssl, browsers using NSS) trusts the host directly. + +### One-time bootstrap (run on any one machine) + +```bash +./scripts/cert-bootstrap.sh +``` + +This: +1. Generates a 4096-bit RSA root CA (10-year validity). +2. Uploads it to 1Password as a Secure Note titled **Omarchy-Stream Root CA** in the **Private** vault, with two fields: + - `cert` (text): the PEM cert. + - `key` (concealed): the PEM private key. +3. Prints the `op://Private/Omarchy-Stream Root CA/{cert,key}` references for confirmation. + +Refuses to overwrite an existing CA item unless you pass `--force` — replacing the CA invalidates every host cert previously minted from it. + +### Per-host (built into install.sh) + +`./install.sh` runs a cert step that: + +1. Reads the CA from 1Password (the `op` CLI must be signed in, `eval $(op signin)` first). +2. Mints a host cert with SAN entries for `.lan` and the host's current LAN IP, signed by the CA, valid 365 days. +3. Writes the cert / key into `~/.config/sunshine/credentials/{cacert,cakey}.pem` (the same paths Sunshine uses by default). +4. Installs the CA cert into `/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem` and runs `update-ca-trust`. +5. The next service restart picks up the new cert. + +The step is idempotent: if the on-disk cert is signed by the current CA, has the right SANs, and isn't expiring within 30 days, it's left alone. Force a re-mint with `--force-certs`. + +Skip the cert step entirely with `--no-certs` — Sunshine will fall back to generating its own self-signed cert as before. + +### Clients trust the CA too + +| Client | How | +|---|---| +| Another Linux host | Same `install.sh` — the cert step installs the CA into `/etc/ca-certificates` regardless of whether the host runs Sunshine. | +| macOS | `client/install-macos.sh` fetches the CA from 1Password and runs `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain`. | +| iOS / iPadOS | Email yourself the CA PEM, install as a profile, then enable full trust in Settings → General → About → Certificate Trust Settings. Documented in `client/README.md`. | +| Android | Settings → Security → Encryption & credentials → Install from storage → CA certificate. Documented in `client/README.md`. | + +### Cert replacement and Moonlight re-pairing + +Sunshine uses one cert for both the web UI and the pairing handshake. Replacing the cert invalidates the fingerprint pinned by previously-paired Moonlight clients. **After the first cert install on a host, re-pair every Moonlight client once** via the web UI. After that, the cert is stable and re-runs of `install.sh` don't re-mint unless the cert is expiring. + +### Configuration + +| Variable | Default | Effect | +|---|---|---| +| `OP_VAULT` | `Private` | 1Password vault that holds the CA item | +| `OP_CA_ITEM` | `Omarchy-Stream Root CA` | Title of the CA item | +| `CERT_DAYS` | `365` | Host cert validity (days) | +| `FORCE_CERTS` | `0` | Set to `1` to re-mint even when the existing cert is current | + ## Clients The host-side installer handles Linux clients via `moonlight-qt`. For everything else, see `client/README.md` for per-platform install plus the first-pair walkthrough. diff --git a/client/README.md b/client/README.md index c0c8f7f..6144536 100644 --- a/client/README.md +++ b/client/README.md @@ -51,6 +51,56 @@ to the same Apple ID. Use `moonlight-qt` from your distro's package manager (Flatpak on Steam Deck, `pacman`/`apt` elsewhere). It is the same Qt-based client as macOS and Windows. +## Trusting the omarchy-stream CA + +If the host was installed with the cert step (default), its Sunshine web UI +uses a cert signed by a private root CA whose key is stored in 1Password. To +avoid the browser warning on `https://.lan:47990`, install the CA on each +client device. This is a one-time step per device. + +### macOS + +`./install-macos.sh` handles this automatically if `op` is signed in. To do it +manually: + +```bash +op read --no-newline "op://Private/Omarchy-Stream Root CA/cert" > /tmp/ca.pem +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain /tmp/ca.pem +rm /tmp/ca.pem +``` + +### iOS / iPadOS + +1. In the 1Password app, open the **Omarchy-Stream Root CA** item in the + **Private** vault. Copy the `cert` field into the body of an email and send + it to yourself with the file extension `.crt` or `.pem` (Mail handles inline + PEM oddly; an attachment is cleanest). +2. Open the email on the iOS device and tap the attachment. iOS will offer to + install a configuration profile. +3. Settings → General → VPN & Device Management → Downloaded Profile → Install. +4. **Important second step**: Settings → General → About → Certificate Trust + Settings → enable full trust for the omarchy-stream Root CA. Without this, + Safari still warns. + +### Android + +1. From 1Password, save the CA `cert` field to a `.crt` file on the device + (Downloads folder is fine). +2. Settings → Security → Encryption & credentials → Install a certificate → CA + certificate. +3. Acknowledge the warning, select the file, give it a name. + +(Path varies slightly by Android version / OEM skin — search Settings for "CA +certificate" if the path above doesn't match.) + +### Apple TV + +Cert profiles can be installed by emailing the cert to an account on the +Apple TV and tapping it, or by using Apple Configurator from a Mac. For +LAN-only use the self-signed warning is also tolerable — Apple TV has no +browser, so the cert is only relevant if you ever inspect the host directly. + ## First pair The pairing flow is the same on every client. diff --git a/client/install-macos.sh b/client/install-macos.sh index 85d7755..85d09cb 100755 --- a/client/install-macos.sh +++ b/client/install-macos.sh @@ -51,6 +51,43 @@ else exit 1 fi +# --- CA trust install (1Password-backed) ------------------------------------ +# If `op` is available and signed in, fetch the omarchy-stream Root CA from +# 1Password and add it to the System keychain as a trusted root. This makes +# Safari / Chrome / curl trust the Sunshine web UI on every host without the +# self-signed warning. Skip cleanly if op isn't available or not signed in; +# the user can re-run after `eval $(op signin)`. +SKIP_CA="${SKIP_CA:-0}" +OP_VAULT="${OP_VAULT:-Private}" +OP_CA_ITEM="${OP_CA_ITEM:-Omarchy-Stream Root CA}" + +if [[ $SKIP_CA -eq 1 ]]; then + info "Skipping CA install (SKIP_CA=1)" +elif ! command -v op >/dev/null 2>&1; then + info "1Password CLI ('op') not found. Skipping CA trust install." + info "Install it from https://1password.com/downloads/command-line/ and re-run for trusted certs." +elif ! op whoami >/dev/null 2>&1; then + info "1Password CLI is not signed in. Skipping CA trust install." + info "Sign in with 'eval \$(op signin)' and re-run for trusted certs." +else + step "Installing omarchy-stream CA into System keychain" + ca_tmp="$(mktemp -t omarchy-ca.XXXXXX.pem)" + trap 'rm -f "$ca_tmp"' EXIT + if op read --no-newline "op://${OP_VAULT}/${OP_CA_ITEM}/cert" >"$ca_tmp" 2>/dev/null && [[ -s "$ca_tmp" ]]; then + info "Fetched CA from 1Password (vault: ${OP_VAULT})" + info "Adding to /Library/Keychains/System.keychain — you may be prompted for your password." + if sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain "$ca_tmp"; then + ok "CA installed as trusted root." + else + warn "security add-trusted-cert failed. You can add the cert manually via Keychain Access." + fi + else + warn "Could not read 'cert' field from item '${OP_CA_ITEM}' in vault '${OP_VAULT}'." + warn "Run scripts/cert-bootstrap.sh on a Linux host first, then re-run this script." + fi +fi + step "Next steps" info "App location: /Applications/Moonlight.app" info "Launch: open -a Moonlight" diff --git a/install.sh b/install.sh index 568a797..7bc7f36 100755 --- a/install.sh +++ b/install.sh @@ -26,6 +26,8 @@ source "$SCRIPT_DIR/lib/service.sh" source "$SCRIPT_DIR/lib/verify.sh" # shellcheck source=lib/headless.sh source "$SCRIPT_DIR/lib/headless.sh" +# shellcheck source=lib/certs.sh +source "$SCRIPT_DIR/lib/certs.sh" usage() { cat <.lan and the LAN IP +# - drops cert/key into ~/.config/sunshine/credentials/{cacert,cakey}.pem +# - installs the CA into /etc/ca-certificates so the host trusts itself +# +# Replacing Sunshine's cert invalidates the fingerprint that previously-paired +# Moonlight clients pinned. After first cert install, re-pair each client once. + +: "${OP_VAULT:=Private}" +: "${OP_CA_ITEM:=Omarchy-Stream Root CA}" +: "${CERT_DAYS:=365}" +: "${CERT_RENEW_THRESHOLD_DAYS:=30}" + +SUNSHINE_CRED_DIR="$HOME/.config/sunshine/credentials" +SUNSHINE_CERT="$SUNSHINE_CRED_DIR/cacert.pem" +SUNSHINE_KEY="$SUNSHINE_CRED_DIR/cakey.pem" +SYSTEM_TRUST_ANCHOR="/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem" + +# --- 1Password helpers ---------------------------------------------------- + +op_require_signin() { + if ! command -v op >/dev/null 2>&1; then + err "1Password CLI ('op') not found on PATH." + err "Install it: yay -S 1password-cli" + return 1 + fi + if ! op whoami >/dev/null 2>&1; then + err "Not signed in to 1Password CLI." + err "Sign in first (in this shell): ${BOLD}eval \$(op signin)${RESET}" + err "Or set OP_SERVICE_ACCOUNT_TOKEN in the environment for non-interactive use." + return 1 + fi + return 0 +} + +# Read a single field from the CA item. Returns 0 if the field exists. +op_read_ca_field() { + local field="$1" + op read --no-newline "op://${OP_VAULT}/${OP_CA_ITEM}/${field}" 2>/dev/null +} + +# --- Cert state inspection ------------------------------------------------ + +# True if the on-disk cert is valid and not expiring within the renew threshold. +cert_is_current() { + [[ -f "$SUNSHINE_CERT" ]] || return 1 + local expires_in_seconds + expires_in_seconds=$(( CERT_RENEW_THRESHOLD_DAYS * 86400 )) + openssl x509 -in "$SUNSHINE_CERT" -checkend "$expires_in_seconds" -noout >/dev/null 2>&1 +} + +# True if the on-disk cert is signed by the CA at $1 (pem path). +cert_signed_by_ca() { + local ca_pem="$1" + [[ -f "$SUNSHINE_CERT" && -f "$ca_pem" ]] || return 1 + openssl verify -CAfile "$ca_pem" "$SUNSHINE_CERT" >/dev/null 2>&1 +} + +cert_has_san_for() { + local needle="$1" + [[ -f "$SUNSHINE_CERT" ]] || return 1 + openssl x509 -in "$SUNSHINE_CERT" -noout -ext subjectAltName 2>/dev/null \ + | grep -qE "(DNS:${needle}\b|IP Address:${needle}\b)" +} + +# --- Cert minting --------------------------------------------------------- + +# Pick a non-link-local IPv4 on a globally-scoped interface. +detect_lan_ip() { + ip -4 -o addr show scope global 2>/dev/null \ + | awk '{print $4}' | cut -d/ -f1 | head -n1 +} + +# Mint a host cert under a temp dir using the CA files passed in. +# Args: tmpdir ca_cert ca_key hostname_lc lan_ip +# Writes: $tmpdir/host-cert.pem, $tmpdir/host-key.pem +mint_host_cert() { + local tmpdir="$1" ca_cert="$2" ca_key="$3" host_lc="$4" lan_ip="$5" + + cat >"$tmpdir/host.cnf" </dev/null + openssl req -new \ + -key "$tmpdir/host-key.pem" \ + -out "$tmpdir/host.csr" \ + -config "$tmpdir/host.cnf" 2>/dev/null + openssl x509 -req \ + -in "$tmpdir/host.csr" \ + -CA "$ca_cert" -CAkey "$ca_key" -CAcreateserial \ + -out "$tmpdir/host-cert.pem" \ + -days "$CERT_DAYS" -sha256 \ + -extensions v3_req -extfile "$tmpdir/host.cnf" 2>/dev/null +} + +# --- System trust store --------------------------------------------------- + +install_ca_to_system_trust() { + local ca_pem="$1" + # Idempotent: compare sha256 first to avoid pointless update-ca-trust runs. + if [[ -f "$SYSTEM_TRUST_ANCHOR" ]] \ + && cmp -s "$ca_pem" "$SYSTEM_TRUST_ANCHOR"; then + ok "CA already in system trust store" + return 0 + fi + info "Installing CA into $SYSTEM_TRUST_ANCHOR" + as_root install -m 0644 "$ca_pem" "$SYSTEM_TRUST_ANCHOR" + as_root update-ca-trust extract >/dev/null + ok "System trust store refreshed (update-ca-trust)" +} + +# --- Top-level orchestration --------------------------------------------- + +# Called from install.sh. Honors FORCE_CERTS=1 to bypass freshness check. +fetch_and_install_certs() { + op_require_signin || return 1 + + local host_lc lan_ip + host_lc="${HOSTNAME_SHORT,,}" + lan_ip="$(detect_lan_ip)" + if [[ -z "$lan_ip" ]]; then + err "Could not detect a LAN IP — no globally-scoped IPv4 found." + return 1 + fi + info "Cert subject: CN=${host_lc}.lan, SAN: DNS:${host_lc}.lan, IP:${lan_ip}" + + # Stage CA in tmpfs (XDG_RUNTIME_DIR is tmpfs on Arch/systemd). + local tmpdir + tmpdir="$(mktemp -d "${XDG_RUNTIME_DIR:-/tmp}/omarchy-certs.XXXXXX")" + chmod 700 "$tmpdir" + # shellcheck disable=SC2064 + trap "rm -rf '$tmpdir'" EXIT + + info "Fetching CA from 1Password (vault: ${OP_VAULT}, item: ${OP_CA_ITEM})" + if ! op_read_ca_field cert >"$tmpdir/ca-cert.pem" || [[ ! -s "$tmpdir/ca-cert.pem" ]]; then + err "Failed to read 'cert' field from 1Password item ${OP_CA_ITEM}." + err "Run scripts/cert-bootstrap.sh first to create the CA item." + return 1 + fi + if ! op_read_ca_field key >"$tmpdir/ca-key.pem" || [[ ! -s "$tmpdir/ca-key.pem" ]]; then + err "Failed to read 'key' field from 1Password item ${OP_CA_ITEM}." + return 1 + fi + chmod 600 "$tmpdir/ca-key.pem" + + # Always install CA into system trust regardless of host-cert freshness. + install_ca_to_system_trust "$tmpdir/ca-cert.pem" + + # Decide whether to mint a new host cert. + if [[ "${FORCE_CERTS:-0}" -eq 0 ]] \ + && cert_is_current \ + && cert_signed_by_ca "$tmpdir/ca-cert.pem" \ + && cert_has_san_for "${host_lc}.lan" \ + && cert_has_san_for "${lan_ip}"; then + ok "Sunshine cert is current, signed by CA, and matches expected SANs — skipping mint" + return 0 + fi + + info "Minting host cert (${CERT_DAYS} days)" + mint_host_cert "$tmpdir" "$tmpdir/ca-cert.pem" "$tmpdir/ca-key.pem" "$host_lc" "$lan_ip" + + mkdir -p "$SUNSHINE_CRED_DIR" + install -m 0644 "$tmpdir/host-cert.pem" "$SUNSHINE_CERT" + install -m 0600 "$tmpdir/host-key.pem" "$SUNSHINE_KEY" + ok "Installed Sunshine cert at $SUNSHINE_CERT" + ok "Installed Sunshine key at $SUNSHINE_KEY" + + warn "Cert replaced. Previously-paired Moonlight clients must re-pair via https://localhost:47990." +} diff --git a/lib/verify.sh b/lib/verify.sh index 00b0d0a..32c4269 100644 --- a/lib/verify.sh +++ b/lib/verify.sh @@ -101,6 +101,30 @@ verify_install() { fi fi + local sunshine_cert="$HOME/.config/sunshine/credentials/cacert.pem" + if [[ -f "$sunshine_cert" ]]; then + local issuer + issuer="$(openssl x509 -in "$sunshine_cert" -noout -issuer 2>/dev/null || true)" + if grep -qF 'omarchy-stream' <<<"$issuer"; then + ok "Sunshine cert is signed by the omarchy-stream CA" + if openssl x509 -in "$sunshine_cert" -checkend $((30 * 86400)) -noout >/dev/null 2>&1; then + ok "Sunshine cert valid for >30 days" + else + warn "Sunshine cert expires within 30 days — re-run install.sh to renew" + fi + else + info "Sunshine cert is Sunshine's self-signed default (no CA chain)" + fi + else + info "Sunshine cert not present yet (will be generated on first start)" + fi + + if [[ -f /etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem ]]; then + ok "omarchy-stream CA installed in system trust store" + else + info "omarchy-stream CA not in system trust store (only matters if --no-certs was used)" + fi + if systemctl --user is-active --quiet sunshine.service; then ok "sunshine.service is active" if ss -ltn 2>/dev/null | grep -q ':47990 '; then diff --git a/scripts/cert-bootstrap.sh b/scripts/cert-bootstrap.sh new file mode 100755 index 0000000..6f00b44 --- /dev/null +++ b/scripts/cert-bootstrap.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# One-time bootstrap: generate a root CA for omarchy-stream and upload it to +# 1Password. Run this on ONE machine; every host install.sh thereafter pulls +# the CA from 1Password to mint per-host certs. +# +# Re-running this script will refuse to overwrite an existing item unless +# --force is passed. The CA's private key is the trust root for every paired +# host; replacing it forces re-trust on every device. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/certs.sh +source "$SCRIPT_DIR/../lib/certs.sh" + +FORCE=0 +CA_VALID_DAYS=3650 +CA_CN="omarchy-stream Root CA" +CA_O="omarchy-stream" + +usage() { + cat </dev/null 2>&1; then + if [[ $FORCE -eq 0 ]]; then + err "Item '$OP_CA_ITEM' already exists in vault '$OP_VAULT'." + err "Re-running cert-bootstrap with --force will replace it, which invalidates" + err "every host cert minted from the existing CA. If you just want to refresh" + err "host certs on this machine, run install.sh (with FORCE_CERTS=1 to mint)." + exit 1 + fi + warn "Overwriting existing CA item (--force given)" + op item delete "$OP_CA_ITEM" --vault "$OP_VAULT" >/dev/null +fi + +tmpdir="$(mktemp -d "${XDG_RUNTIME_DIR:-/tmp}/omarchy-ca-bootstrap.XXXXXX")" +chmod 700 "$tmpdir" +trap "rm -rf '$tmpdir'" EXIT + +step "Generating root CA (4096-bit RSA, ${CA_VALID_DAYS} days)" +openssl genrsa -out "$tmpdir/ca-key.pem" 4096 2>/dev/null +chmod 600 "$tmpdir/ca-key.pem" + +openssl req -new -x509 \ + -key "$tmpdir/ca-key.pem" \ + -out "$tmpdir/ca-cert.pem" \ + -days "$CA_VALID_DAYS" \ + -sha256 \ + -subj "/CN=${CA_CN}/O=${CA_O}" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null + +ok "Generated CA cert and key" +info "CA fingerprint (SHA256):" +openssl x509 -in "$tmpdir/ca-cert.pem" -noout -fingerprint -sha256 \ + | sed 's/^/ /' + +step "Uploading to 1Password (vault: $OP_VAULT, item: $OP_CA_ITEM)" +op item create \ + --category "Secure Note" \ + --vault "$OP_VAULT" \ + --title "$OP_CA_ITEM" \ + "notesPlain=Root CA for omarchy-stream Sunshine certs. Bootstrapped $(date -Iseconds) on $(hostname -s)." \ + "cert[text]=$(cat "$tmpdir/ca-cert.pem")" \ + "key[concealed]=$(cat "$tmpdir/ca-key.pem")" \ + >/dev/null + +ok "Uploaded CA to 1Password" +info "" +info "References for install.sh / lib/certs.sh:" +info " op://${OP_VAULT}/${OP_CA_ITEM}/cert" +info " op://${OP_VAULT}/${OP_CA_ITEM}/key" +info "" +info "Next: on each host (including this one if it'll be a Sunshine host), run:" +info " ./install.sh" +info "and the cert step will pull the CA from 1Password and mint a host cert." diff --git a/uninstall.sh b/uninstall.sh index 3f2b879..5f1be2f 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -9,16 +9,20 @@ source "$SCRIPT_DIR/lib/common.sh" PURGE=0 KEEP_MOONLIGHT=0 +REMOVE_CA_TRUST=0 while [[ $# -gt 0 ]]; do case "$1" in - --purge) PURGE=1 ;; - --keep-moonlight) KEEP_MOONLIGHT=1 ;; + --purge) PURGE=1 ;; + --keep-moonlight) KEEP_MOONLIGHT=1 ;; + --remove-ca-trust) REMOVE_CA_TRUST=1 ;; -h|--help) cat </dev/null + ok "Removed $anchor and refreshed trust store" + else + info "CA anchor not present; nothing to remove" + fi +fi + if [[ $PURGE -eq 1 ]]; then step "Purging Sunshine user data" rm -rf "$HOME/.config/sunshine" "$HOME/.local/share/sunshine" fi ok "Uninstall complete. Firewall rules and 'input' group membership were left in place." +if [[ $REMOVE_CA_TRUST -eq 0 ]]; then + info "The omarchy-stream CA was left in /etc/ca-certificates (--remove-ca-trust to drop it)." +fi