#!/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 .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." }