Adds a parallel install path for Debian/Ubuntu hosts alongside the existing
Arch/Omarchy/Hyprland one. The Arch path is untouched at runtime; everything
new is gated on $DISTRO and (for headless) $COMPOSITOR.
Highlights:
- lib/distro.sh: detect_distro + pkg_install/pkg_remove/ca_anchor_path/
ca_update_trust dispatch helpers
- lib/packages.sh: Ubuntu sunshine install pulls LizardByte's official .deb
from GitHub releases (override via SUNSHINE_DEB_URL/SUNSHINE_DEB_VERSION);
GPU encoder packages branch per $DISTRO:$GPU_VENDOR
- bin/sunshine-stream-{do,undo,prestart}-sway.sh + files/sway-headless.*:
swaymsg-based headless capture path for hosts without Hyprland. sway runs
under a systemd-user unit that sunshine.service depends on via drop-in.
- lib/preflight.sh: clearer NVIDIA driver guidance on Ubuntu (we don't install
the driver - too many branch/kernel/Secure-Boot variants); sway-aware
headless preflight
- lib/certs.sh + lib/verify.sh + uninstall.sh: distro-aware CA trust anchor
(Arch: /etc/ca-certificates/trust-source/anchors + update-ca-trust;
Debian: /usr/local/share/ca-certificates + update-ca-certificates)
Verified on Ubuntu 24.04: ./install.sh --doctor --headless loads cleanly,
distro/GPU/compositor detection report the right values, all pre-install
failures correspond to the actual missing pieces.
215 lines
7.5 KiB
Bash
215 lines
7.5 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"
|
|
# Resolved per-distro via lib/distro.sh:
|
|
# Arch: /etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem
|
|
# Debian: /usr/local/share/ca-certificates/omarchy-stream-ca.crt
|
|
# Read it via ca_anchor_path; do not hard-code here.
|
|
|
|
# --- 1Password helpers ----------------------------------------------------
|
|
|
|
op_require_signin() {
|
|
if ! command -v op >/dev/null 2>&1; then
|
|
err "1Password CLI ('op') not found on PATH."
|
|
case "$DISTRO" in
|
|
arch) err "Install it: yay -S 1password-cli" ;;
|
|
debian) err "Install it: https://developer.1password.com/docs/cli/get-started/ (apt repo or .deb)" ;;
|
|
*) err "Install the 1Password CLI from https://developer.1password.com/docs/cli/get-started/" ;;
|
|
esac
|
|
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
|
|
DNS.2 = localhost
|
|
IP.1 = ${lan_ip}
|
|
IP.2 = 127.0.0.1
|
|
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"
|
|
local anchor
|
|
anchor="$(ca_anchor_path)"
|
|
if [[ -z "$anchor" ]]; then
|
|
warn "Don't know how to install CA on distro '$DISTRO' — skipping system trust step."
|
|
return 0
|
|
fi
|
|
# Idempotent: compare sha256 first to avoid pointless update-ca-* runs.
|
|
if [[ -f "$anchor" ]] && cmp -s "$ca_pem" "$anchor"; then
|
|
ok "CA already in system trust store"
|
|
return 0
|
|
fi
|
|
# Debian's update-ca-certificates only picks up files under
|
|
# /usr/local/share/ca-certificates/ that end in .crt. The path returned by
|
|
# ca_anchor_path already accounts for that.
|
|
info "Installing CA into $anchor"
|
|
as_root mkdir -p "$(dirname "$anchor")"
|
|
as_root install -m 0644 "$ca_pem" "$anchor"
|
|
ca_update_trust
|
|
ok "System trust store refreshed"
|
|
}
|
|
|
|
# --- 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}" \
|
|
&& cert_has_san_for "localhost" \
|
|
&& cert_has_san_for "127.0.0.1"; 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."
|
|
}
|