Files
Omarchy-Stream/lib/certs.sh
Levi Woodard e878b392e4 Sign Sunshine certs with a 1Password-backed root CA
Replaces Sunshine's self-signed cert with one minted from a private root CA
whose key material lives in 1Password. Every host running install.sh fetches
the CA via 'op read', mints itself a host cert with SANs for <hostname>.lan
and the current LAN IP, and installs the CA into the system trust store.

Bootstrap (run once, anywhere)
- scripts/cert-bootstrap.sh: generates a 4096-bit RSA root CA (10y validity),
  uploads it as a Secure Note titled "Omarchy-Stream Root CA" in the Private
  vault with two fields: cert (text) and key (concealed). Refuses to overwrite
  an existing item without --force.

Per-host (lib/certs.sh)
- fetch_and_install_certs: reads op://Private/Omarchy-Stream Root CA/{cert,key}
  to a tmpfs-staged temp dir (XDG_RUNTIME_DIR), mints a host cert via openssl
  with serverAuth + clientAuth EKU, drops cert/key at ~/.config/sunshine/
  credentials/{cacert,cakey}.pem, installs the CA at
  /etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem and runs
  update-ca-trust.
- Idempotent: skips re-mint when on-disk cert is signed by the current CA,
  has the expected SANs, and isn't within 30 days of expiry. Override with
  FORCE_CERTS=1 or --force-certs.

install.sh
- Adds --no-certs, --force-certs flags; sources lib/certs.sh; runs cert step
  after permissions/config and before firewall so the service restart at the
  end of install picks up the new cert.

client/install-macos.sh
- After installing Moonlight, if `op` is available and signed in, fetches the
  CA and adds it as a trusted root to /Library/Keychains/System.keychain via
  `security add-trusted-cert -d -r trustRoot`. Skips cleanly when op isn't
  ready.

uninstall.sh
- Adds --remove-ca-trust to delete the system trust anchor. By default the
  CA is left in place since other tools may rely on it.

verify.sh
- Adds checks for: cert signed by omarchy-stream CA, cert >30 days from
  expiry, CA present in system trust store.

Docs
- README "Trusted TLS certs via 1Password" section: bootstrap flow, per-host
  flow, client trust matrix (Linux / macOS / iOS / Android / Apple TV),
  re-pairing note (first cert install on a host invalidates pinned Moonlight
  fingerprints), config env vars.
- client/README gains per-platform CA-trust install steps with concrete
  `op read` + platform-specific commands.
2026-05-18 10:43:33 -06:00

195 lines
6.6 KiB
Bash

#!/usr/bin/env bash
# Sunshine TLS cert management, backed by a root CA stored in 1Password.
#
# Lifecycle:
# 1. cert-bootstrap.sh (one time, anywhere) generates the CA and uploads it
# to 1Password.
# 2. install.sh on each host calls fetch_and_install_certs, which:
# - reads the CA cert + key from 1Password (op CLI must be signed in)
# - mints a host cert with SANs for <hostname>.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" <<EOF
[req]
distinguished_name = dn
req_extensions = v3_req
prompt = no
[dn]
CN = ${host_lc}.lan
O = omarchy-stream
[v3_req]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = ${host_lc}.lan
IP.1 = ${lan_ip}
EOF
openssl genrsa -out "$tmpdir/host-key.pem" 2048 2>/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."
}