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.
This commit is contained in:
60
README.md
60
README.md
@@ -78,11 +78,16 @@ Every step is idempotent. In order:
|
|||||||
./install.sh --no-sunshine # client-only (install Moonlight only)
|
./install.sh --no-sunshine # client-only (install Moonlight only)
|
||||||
./install.sh --no-moonlight # host-only
|
./install.sh --no-moonlight # host-only
|
||||||
./install.sh --from-source # build sunshine from source (default uses sunshine-bin)
|
./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)
|
./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 # remove packages and udev rule, keep user data
|
||||||
./uninstall.sh --purge # also delete ~/.config/sunshine
|
./uninstall.sh --purge # also delete ~/.config/sunshine
|
||||||
./uninstall.sh --keep-moonlight
|
./uninstall.sh --keep-moonlight
|
||||||
|
./uninstall.sh --remove-ca-trust # also remove the omarchy-stream CA from system trust
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment overrides:
|
Environment overrides:
|
||||||
@@ -116,6 +121,61 @@ Per-vendor encoder picks:
|
|||||||
|
|
||||||
Everything else (bitrate, paired clients, app launchers) is set via the web UI.
|
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://<host>.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 `<hostname>.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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -51,6 +51,56 @@ to the same Apple ID.
|
|||||||
Use `moonlight-qt` from your distro's package manager (Flatpak on Steam Deck,
|
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.
|
`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://<host>.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
|
## First pair
|
||||||
|
|
||||||
The pairing flow is the same on every client.
|
The pairing flow is the same on every client.
|
||||||
|
|||||||
@@ -51,6 +51,43 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
step "Next steps"
|
||||||
info "App location: /Applications/Moonlight.app"
|
info "App location: /Applications/Moonlight.app"
|
||||||
info "Launch: open -a Moonlight"
|
info "Launch: open -a Moonlight"
|
||||||
|
|||||||
21
install.sh
21
install.sh
@@ -26,6 +26,8 @@ source "$SCRIPT_DIR/lib/service.sh"
|
|||||||
source "$SCRIPT_DIR/lib/verify.sh"
|
source "$SCRIPT_DIR/lib/verify.sh"
|
||||||
# shellcheck source=lib/headless.sh
|
# shellcheck source=lib/headless.sh
|
||||||
source "$SCRIPT_DIR/lib/headless.sh"
|
source "$SCRIPT_DIR/lib/headless.sh"
|
||||||
|
# shellcheck source=lib/certs.sh
|
||||||
|
source "$SCRIPT_DIR/lib/certs.sh"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -42,12 +44,16 @@ Options:
|
|||||||
--from-source Build Sunshine from source (equivalent to SUNSHINE_PKG=sunshine)
|
--from-source Build Sunshine from source (equivalent to SUNSHINE_PKG=sunshine)
|
||||||
--headless Force headless streaming mode (wlr capture of HEADLESS-1)
|
--headless Force headless streaming mode (wlr capture of HEADLESS-1)
|
||||||
--mirror Force mirror mode (KMS capture of the real display)
|
--mirror Force mirror mode (KMS capture of the real display)
|
||||||
|
--no-certs Skip the 1Password-backed cert step (use Sunshine's self-signed)
|
||||||
|
--force-certs Re-mint the host cert even if the current one is valid
|
||||||
--doctor Run only the post-install verification checks
|
--doctor Run only the post-install verification checks
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
|
|
||||||
Environment overrides:
|
Environment overrides:
|
||||||
SUNSHINE_PKG AUR package to use for Sunshine (default: sunshine-bin)
|
SUNSHINE_PKG AUR package to use for Sunshine (default: sunshine-bin)
|
||||||
Set to 'sunshine' to build from source instead.
|
Set to 'sunshine' to build from source instead.
|
||||||
|
OP_VAULT 1Password vault that holds the root CA (default: Private)
|
||||||
|
OP_CA_ITEM Item title in that vault (default: Omarchy-Stream Root CA)
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +64,7 @@ INSTALL_MOONLIGHT=1
|
|||||||
WRITE_CONFIG=1
|
WRITE_CONFIG=1
|
||||||
DOCTOR_ONLY=0
|
DOCTOR_ONLY=0
|
||||||
MODE_OVERRIDE=""
|
MODE_OVERRIDE=""
|
||||||
|
INSTALL_CERTS=1
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -66,6 +73,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--no-moonlight) INSTALL_MOONLIGHT=0 ;;
|
--no-moonlight) INSTALL_MOONLIGHT=0 ;;
|
||||||
--no-sunshine) INSTALL_SUNSHINE=0 ;;
|
--no-sunshine) INSTALL_SUNSHINE=0 ;;
|
||||||
--no-config) WRITE_CONFIG=0 ;;
|
--no-config) WRITE_CONFIG=0 ;;
|
||||||
|
--no-certs) INSTALL_CERTS=0 ;;
|
||||||
|
--force-certs) export FORCE_CERTS=1 ;;
|
||||||
--from-source) export SUNSHINE_PKG=sunshine ;;
|
--from-source) export SUNSHINE_PKG=sunshine ;;
|
||||||
--headless) MODE_OVERRIDE="headless" ;;
|
--headless) MODE_OVERRIDE="headless" ;;
|
||||||
--mirror) MODE_OVERRIDE="mirror" ;;
|
--mirror) MODE_OVERRIDE="mirror" ;;
|
||||||
@@ -133,6 +142,18 @@ main() {
|
|||||||
info "Skipping sunshine.conf (--no-config)"
|
info "Skipping sunshine.conf (--no-config)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ $INSTALL_CERTS -eq 1 ]]; then
|
||||||
|
step "Installing CA-signed Sunshine cert from 1Password"
|
||||||
|
if fetch_and_install_certs; then
|
||||||
|
CERTS_REPLACED=1
|
||||||
|
else
|
||||||
|
warn "Cert install failed — falling back to Sunshine's self-signed cert."
|
||||||
|
warn "Run scripts/cert-bootstrap.sh to create the CA item, then re-run install.sh."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Skipping cert step (--no-certs)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ $FIREWALL -eq 1 ]]; then
|
if [[ $FIREWALL -eq 1 ]]; then
|
||||||
step "Configuring firewall for Sunshine ports"
|
step "Configuring firewall for Sunshine ports"
|
||||||
open_sunshine_ports
|
open_sunshine_ports
|
||||||
|
|||||||
194
lib/certs.sh
Normal file
194
lib/certs.sh
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#!/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."
|
||||||
|
}
|
||||||
@@ -101,6 +101,30 @@ verify_install() {
|
|||||||
fi
|
fi
|
||||||
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
|
if systemctl --user is-active --quiet sunshine.service; then
|
||||||
ok "sunshine.service is active"
|
ok "sunshine.service is active"
|
||||||
if ss -ltn 2>/dev/null | grep -q ':47990 '; then
|
if ss -ltn 2>/dev/null | grep -q ':47990 '; then
|
||||||
|
|||||||
103
scripts/cert-bootstrap.sh
Executable file
103
scripts/cert-bootstrap.sh
Executable file
@@ -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 <<EOF
|
||||||
|
Usage: $(basename "$0") [--force]
|
||||||
|
|
||||||
|
Generates a new root CA and uploads it to 1Password.
|
||||||
|
|
||||||
|
--force Overwrite an existing CA item in the vault (DANGEROUS: invalidates
|
||||||
|
every host cert previously signed by the existing CA).
|
||||||
|
|
||||||
|
Configuration (env vars, all optional):
|
||||||
|
OP_VAULT 1Password vault (default: Private)
|
||||||
|
OP_CA_ITEM Item title in that vault (default: Omarchy-Stream Root CA)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--force) FORCE=1 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) err "Unknown option: $1"; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
require_not_root
|
||||||
|
op_require_signin
|
||||||
|
|
||||||
|
# Check whether the item already exists. `op item get` exits 0 if found.
|
||||||
|
if op item get "$OP_CA_ITEM" --vault "$OP_VAULT" >/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."
|
||||||
29
uninstall.sh
29
uninstall.sh
@@ -9,16 +9,20 @@ source "$SCRIPT_DIR/lib/common.sh"
|
|||||||
|
|
||||||
PURGE=0
|
PURGE=0
|
||||||
KEEP_MOONLIGHT=0
|
KEEP_MOONLIGHT=0
|
||||||
|
REMOVE_CA_TRUST=0
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--purge) PURGE=1 ;;
|
--purge) PURGE=1 ;;
|
||||||
--keep-moonlight) KEEP_MOONLIGHT=1 ;;
|
--keep-moonlight) KEEP_MOONLIGHT=1 ;;
|
||||||
|
--remove-ca-trust) REMOVE_CA_TRUST=1 ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [--purge] [--keep-moonlight]
|
Usage: $(basename "$0") [--purge] [--keep-moonlight] [--remove-ca-trust]
|
||||||
|
|
||||||
--purge Also delete ~/.config/sunshine and ~/.local/share/sunshine
|
--purge Also delete ~/.config/sunshine and ~/.local/share/sunshine
|
||||||
--keep-moonlight Do not uninstall moonlight-qt
|
--keep-moonlight Do not uninstall moonlight-qt
|
||||||
|
--remove-ca-trust Remove the omarchy-stream CA from /etc/ca-certificates
|
||||||
|
(default: leave it — other hosts/services may rely on it)
|
||||||
EOF
|
EOF
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
*) err "Unknown option: $1"; exit 2 ;;
|
*) err "Unknown option: $1"; exit 2 ;;
|
||||||
@@ -57,9 +61,24 @@ if [[ -f /etc/udev/rules.d/60-uinput.rules ]]; then
|
|||||||
as_root udevadm control --reload-rules
|
as_root udevadm control --reload-rules
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ $REMOVE_CA_TRUST -eq 1 ]]; then
|
||||||
|
step "Removing omarchy-stream CA from system trust store"
|
||||||
|
anchor="/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem"
|
||||||
|
if [[ -f "$anchor" ]]; then
|
||||||
|
as_root rm -f "$anchor"
|
||||||
|
as_root update-ca-trust extract >/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
|
if [[ $PURGE -eq 1 ]]; then
|
||||||
step "Purging Sunshine user data"
|
step "Purging Sunshine user data"
|
||||||
rm -rf "$HOME/.config/sunshine" "$HOME/.local/share/sunshine"
|
rm -rf "$HOME/.config/sunshine" "$HOME/.local/share/sunshine"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ok "Uninstall complete. Firewall rules and 'input' group membership were left in place."
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user