morphit/scripts/canary/setup.sh
Morphit Team 7a84bc325b
Some checks failed
morphit-release / Build + publish release tarball (push) Has been cancelled
true-offline install, honest home bring-up, Tor/I2P serve the marketplace
2026-08-09 21:04:17 -07:00

321 lines
15 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# scripts/canary/setup.sh
#
# One-time, guided setup for this Morphit instance's warrant canary. Run this
# ONCE on the machine that will SIGN the canary (your "admin" machine); after
# that a weekly timer keeps the canary fresh on its own.
#
# It handles BOTH kinds of Morphit deployment, and asks you which you have:
#
# • HOME HOSTING (local) — your Morphit runs on THIS computer (e.g. a Mint
# box at home). The canary is signed here and placed straight into the
# served build/ dir. Simplest; nothing to upload.
#
# • REMOTE SERVER (VPS) — your Morphit runs on a separate server. You sign
# the canary HERE (so the signing key never sits on the internet-facing
# box) and this script uploads the signed file to the server. This is the
# stronger arrangement OPERATIONS.md §36 recommends.
#
# Why signing lives OFF the served box (for the remote case): a warrant canary
# has to go stale exactly when something is wrong — if you're compelled and
# gagged, if the box is seized, if something happens to you. A signer that runs
# on the served box would keep stamping "all-clear" forever, turning the canary
# into a lie. Keeping the key on your own machine makes silence mean something.
#
# This script is intentionally plain shell (gpg, curl, ssh, systemctl) so you
# can read every line before trusting it with a signing key.
set -euo pipefail
# ─── tiny UI helpers (prompts go to stderr so $(...) capture stays clean) ──
say() { printf '%s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf 'canary-setup: %s\n' "$*" >&2; }
die() { printf 'canary-setup: %s\n' "$*" >&2; exit 1; }
ask() { # $1=prompt $2=default(optional) -> echoes the answer
local prompt="$1" def="${2:-}" ans
if [ -n "$def" ]; then printf '%s [%s]: ' "$prompt" "$def" >&2
else printf '%s: ' "$prompt" >&2; fi
read -r ans || true
printf '%s' "${ans:-$def}"
}
confirm() { # $1=prompt -> 0 if yes (default yes)
local ans
printf '%s [Y/n]: ' "$1" >&2
read -r ans || true
case "${ans:-y}" in [Yy]*) return 0 ;; *) return 1 ;; esac
}
# ─── 0. Banner + locate the repo ─────────────────────────────────
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
GENERATE="$REPO_ROOT/scripts/canary/generate.sh"
STATIC_DIR="$REPO_ROOT/apps/web/static"
BUILD_DIR="$REPO_ROOT/apps/web/build"
# cp693 — the SERVED build dir. On a manual/source install the source tree IS
# what nginx serves, so this defaults to $REPO_ROOT/apps/web/build. But on an
# ansible/home install the wizard runs this script from the SOURCE tarball
# (~/Downloads/morphit) while the frontend container serves the DEPLOYED tree
# (/opt/morphit/apps/web/build). The wizard passes MORPHIT_CANARY_SERVE_DIR so
# the signed canary + weekly refresh land where it's actually served, not in the
# source tree where nothing reads it.
SERVE_DIR="${MORPHIT_CANARY_SERVE_DIR:-$REPO_ROOT/apps/web/build}"
say ""
say "── Morphit warrant-canary setup ───────────────────────────────"
say ""
say "This sets up a PGP-signed warrant canary for your instance and keeps it"
say "refreshed automatically every week. It takes a couple of minutes."
say ""
[ -f "$GENERATE" ] || die "can't find generate.sh at $GENERATE — run this from inside your Morphit checkout."
# ─── 1. Tools ────────────────────────────────────────────────────
for tool in gpg curl node; do
command -v "$tool" >/dev/null 2>&1 || die "required tool '$tool' is not installed."
done
[ -x "$REPO_ROOT/node_modules/.bin/tsx" ] || die "tsx not found — run 'npm ci' in $REPO_ROOT first (the canary's chain-head fetchers need it)."
# ─── 2. Which deployment? ────────────────────────────────────────
say "Where does your Morphit instance run?"
say " 1) On THIS computer — home hosting (sign + serve right here)"
say " 2) On a remote server / VPS (sign here, upload the canary there)"
MODE=""
while [ -z "$MODE" ]; do
case "$(ask 'Enter 1 or 2' '1')" in
1) MODE=local ;;
2) MODE=remote ;;
*) say "Please enter 1 or 2." ;;
esac
done
say ""
REMOTE_SSH=""
REMOTE_PATH=""
if [ "$MODE" = remote ]; then
command -v ssh >/dev/null 2>&1 || die "'ssh' is required for the remote/VPS mode."
command -v scp >/dev/null 2>&1 || die "'scp' is required for the remote/VPS mode."
REMOTE_SSH="$(ask 'Server SSH login (e.g. root@morphit.io or user@1.2.3.4)' '')"
[ -n "$REMOTE_SSH" ] || die "a server SSH login is required for the remote mode."
REMOTE_PATH="$(ask 'Path to Morphit on the server' '/opt/morphit')"
say "Checking SSH access to $REMOTE_SSH ..."
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_SSH" 'true' 2>/dev/null \
|| die "couldn't connect to $REMOTE_SSH without a password. Set up an SSH key first (ssh-copy-id $REMOTE_SSH), then re-run."
info "SSH OK."
# cp622 — a fresh root install leaves the served build/ dir root-owned, so the
# very FIRST canary upload from this (non-root) SSH login would hit "Permission
# denied." If this login has passwordless sudo on the server, hand build/ to it
# now so uploads just work — no manual chown. Best-effort: if sudo isn't
# available we skip quietly (the one-time chown is in RUN-A-MORPHIT-NODE.md §9).
_build_remote="$REMOTE_PATH/apps/web/build"
if ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_SSH" 'sudo -n true' 2>/dev/null; then
if ssh -o BatchMode=yes "$REMOTE_SSH" "sudo -n mkdir -p '$_build_remote' && sudo -n chown -R \"\$(id -un):\$(id -gn)\" '$_build_remote'" 2>/dev/null; then
info "Made the served build/ dir writable for your canary uploads (via sudo)."
else
info "(Couldn't auto-adjust the served dir; if your first upload hits"
info " 'Permission denied', see the one-time chown in RUN-A-MORPHIT-NODE.md §9.)"
fi
fi
say ""
fi
# ─── 3. Signing key (find, or offer to create one) ───────────────
# Existing secret-key fingerprints, newest last.
KEY_ID="${MORPHIT_CANARY_PGP_KEY_ID:-}"
EXISTING=()
while IFS= read -r fpr; do [ -n "$fpr" ] && EXISTING+=("$fpr"); done < <(
gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1=="fpr"{print $10}'
)
if [ -n "$KEY_ID" ]; then
info "Using signing key from \$MORPHIT_CANARY_PGP_KEY_ID: $KEY_ID"
elif [ "${#EXISTING[@]}" -gt 0 ]; then
say "Found these PGP signing keys on this machine:"
i=1
for fpr in "${EXISTING[@]}"; do
uid="$(gpg --list-keys --with-colons "$fpr" 2>/dev/null | awk -F: '$1=="uid"{print $10; exit}')"
info "$i) $fpr ${uid:-}"
i=$((i + 1))
done
pick="$(ask "Which key should sign the canary? (1-${#EXISTING[@]})" '1')"
case "$pick" in ''|*[!0-9]*) pick=1 ;; esac
[ "$pick" -ge 1 ] && [ "$pick" -le "${#EXISTING[@]}" ] || pick=1
KEY_ID="${EXISTING[$((pick - 1))]}"
info "Using $KEY_ID."
else
say "You don't have a PGP signing key on this machine yet — that's fine, I can"
say "create one for you now. It's what signs your canary so readers can verify it."
confirm "Create a signing key now?" || die "a signing key is required; re-run once you have one."
kname="$(ask 'A name for the key (e.g. Jane, or your instance name)' "${MORPHIT_CANARY_OPERATOR_NAME:-}")"
kemail="$(ask 'An email for the key (e.g. you@example.com)' '')"
[ -n "$kname" ] && [ -n "$kemail" ] || die "a name and email are needed to create the key."
say "Creating your key (no passphrase, so the weekly refresh can run on its own)..."
# A passphrase-less key lets the timer sign unattended. On a home box this is
# a deliberate trade-off; the security note at the end explains it.
gpg --batch --passphrase '' --quick-generate-key "$kname <$kemail>" default sign 2y
KEY_ID="$(gpg --list-secret-keys --with-colons "$kemail" 2>/dev/null | awk -F: '$1=="fpr"{print $10; exit}')"
[ -n "$KEY_ID" ] || die "key creation appears to have failed."
info "Created signing key $KEY_ID."
fi
# Confirm the key can actually sign without prompting (needed for the timer).
if ! printf 'canary-selftest' | gpg --batch --yes --local-user "$KEY_ID" --clearsign >/dev/null 2>&1; then
warn "the chosen key asked for a passphrase (or failed to sign). For the weekly"
warn "timer to run unattended, cache the passphrase in your gpg-agent, or use a"
warn "passphrase-less key. Continuing — the first refresh below may prompt you."
fi
# Publish the PUBLIC key so readers can verify the canary (served at /pgp_keys.asc).
gpg --armor --export "$KEY_ID" > "$STATIC_DIR/pgp_keys.asc"
info "Exported your public key to apps/web/static/pgp_keys.asc (served at /pgp_keys.asc)."
say ""
# ─── 4. Instance identity (what the canary declares) ─────────────
say "A few details that appear in the signed canary:"
OPERATOR_NAME="$(ask 'Operator name (e.g. morphit.io)' "${MORPHIT_CANARY_OPERATOR_NAME:-}")"
INSTANCE_ORIGIN="$(ask 'Your instance URL (e.g. https://morphit.io)' "${MORPHIT_CANARY_INSTANCE_ORIGIN:-}")"
OPERATOR_ACCOUNT="$(ask 'Your Blurt operator account, no @ (e.g. morphit)' "${MORPHIT_CANARY_OPERATOR_ACCOUNT:-}")"
[ -n "$OPERATOR_NAME" ] && [ -n "$INSTANCE_ORIGIN" ] && [ -n "$OPERATOR_ACCOUNT" ] \
|| die "operator name, instance URL, and Blurt account are all required."
OPERATOR_ACCOUNT="${OPERATOR_ACCOUNT#@}"
say ""
# ─── 5. Write the weekly refresh script ──────────────────────────
MORPHIT_HOME="$HOME/.morphit"
REFRESH="$MORPHIT_HOME/update-canary.sh"
mkdir -p "$MORPHIT_HOME"
{
printf '#!/usr/bin/env bash\n'
printf '# Auto-generated by scripts/canary/setup.sh — refreshes the Morphit warrant canary.\n'
printf '# Re-run setup.sh to change anything here.\n'
printf 'set -euo pipefail\n'
printf "export MORPHIT_CANARY_PGP_KEY_ID='%s'\n" "$KEY_ID"
printf "export MORPHIT_CANARY_OPERATOR_NAME='%s'\n" "$OPERATOR_NAME"
printf "export MORPHIT_CANARY_INSTANCE_ORIGIN='%s'\n" "$INSTANCE_ORIGIN"
printf "export MORPHIT_CANARY_OPERATOR_ACCOUNT='%s'\n" "$OPERATOR_ACCOUNT"
printf "REPO='%s'\n" "$REPO_ROOT"
printf "SERVE='%s'\n" "$SERVE_DIR"
printf 'cd "$REPO"\n'
printf 'bash scripts/canary/generate.sh\n'
printf 'SIGNED="$REPO/apps/web/static/canary.txt"\n'
printf 'PUBKEY="$REPO/apps/web/static/pgp_keys.asc"\n'
if [ "$MODE" = local ]; then
printf '# Home hosting: place the freshly-signed canary where nginx serves it.\n'
printf 'DEST="$SERVE"\n'
printf 'mkdir -p "$DEST"\n'
printf '# Self-heal: an upgrade may re-root build/; take it back (best-effort sudo) before writing.\n'
printf 'if [ ! -w "$DEST" ] && command -v sudo >/dev/null 2>&1; then sudo -n chown -R "$(id -un):$(id -gn)" "$DEST" 2>/dev/null || true; fi\n'
printf 'install -m 0644 "$SIGNED" "$DEST/canary.txt"\n'
printf 'install -m 0644 "$PUBKEY" "$DEST/pgp_keys.asc"\n'
printf 'echo "canary: placed in $DEST/ (served at /canary.txt)"\n'
else
printf '# Remote server: upload the freshly-signed canary to the served build/ dir.\n'
printf "REMOTE_SSH='%s'\n" "$REMOTE_SSH"
printf "REMOTE_PATH='%s'\n" "$REMOTE_PATH"
# Emit the self-heal (below) into the refresh script instead of a bare mkdir,
# so an upgrade re-rooting build/ can never break the canary upload.
cat <<'SELFHEAL'
# Self-heal: an in-place `morphit-ops upgrade` re-roots the served build/ dir, so
# make it ours (best-effort, via passwordless sudo) before uploading — a re-rooted
# dir can then never break the canary, on any version. No passwordless sudo → just
# ensure the dir exists and let the scp below surface any real permission problem.
if ssh -o BatchMode=yes "$REMOTE_SSH" 'sudo -n true' 2>/dev/null; then
ssh -o BatchMode=yes "$REMOTE_SSH" "sudo -n mkdir -p '$REMOTE_PATH/apps/web/build' && sudo -n chown -R \"\$(id -un):\$(id -gn)\" '$REMOTE_PATH/apps/web/build'" 2>/dev/null || true
else
ssh "$REMOTE_SSH" "mkdir -p '$REMOTE_PATH/apps/web/build'"
fi
SELFHEAL
printf 'scp -q "$SIGNED" "$REMOTE_SSH:$REMOTE_PATH/apps/web/build/canary.txt"\n'
printf 'scp -q "$PUBKEY" "$REMOTE_SSH:$REMOTE_PATH/apps/web/build/pgp_keys.asc"\n'
printf 'echo "canary: uploaded to $REMOTE_SSH:$REMOTE_PATH/apps/web/build/"\n'
fi
} > "$REFRESH"
chmod +x "$REFRESH"
info "Wrote your refresh script: $REFRESH"
say ""
# ─── 6. Install the weekly timer (systemd user timer, or cron) ───
TIMER_OK=0
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
UNIT_DIR="$HOME/.config/systemd/user"
mkdir -p "$UNIT_DIR"
cat > "$UNIT_DIR/morphit-canary.service" <<UEOF
[Unit]
Description=Refresh the Morphit warrant canary
[Service]
Type=oneshot
ExecStart=$REFRESH
UEOF
cat > "$UNIT_DIR/morphit-canary.timer" <<'UEOF'
[Unit]
Description=Weekly Morphit warrant-canary refresh
[Timer]
OnCalendar=Sun *-*-* 03:14:00 UTC
Persistent=true
[Install]
WantedBy=timers.target
UEOF
systemctl --user daemon-reload
systemctl --user enable --now morphit-canary.timer >/dev/null 2>&1 && TIMER_OK=1
# Keep the timer running even when you're logged out.
loginctl enable-linger "$USER" >/dev/null 2>&1 \
|| info "(couldn't enable 'linger'; the timer runs while you're logged in)"
[ "$TIMER_OK" = 1 ] && info "Weekly systemd timer armed (Sundays, 03:14 UTC)."
fi
if [ "$TIMER_OK" != 1 ]; then
# Fall back to a cron line the operator can paste.
CRON_LINE="14 3 * * 0 $REFRESH >> $MORPHIT_HOME/canary.log 2>&1"
warn "no systemd user timer available. Add this weekly cron line yourself:"
printf '\n %s\n\n' "$CRON_LINE" >&2
printf '%s\n' "$CRON_LINE" > "$MORPHIT_HOME/canary.cron"
info "(also saved to $MORPHIT_HOME/canary.cron)"
fi
say ""
# ─── 7. First run (sign + place/upload now) ──────────────────────
say "Signing and publishing your first canary now..."
if bash "$REFRESH"; then
info "First canary published. Check ${INSTANCE_ORIGIN%/}/canary.txt in a browser."
else
die "the first refresh failed — see the messages above. Fix, then re-run: bash $REFRESH"
fi
say ""
# ─── 8. Summary + honest security note ───────────────────────────
say "── Done ───────────────────────────────────────────────────────"
info "Canary signed with key: $KEY_ID"
info "Refreshes weekly via: $REFRESH"
if [ "$MODE" = remote ]; then
info "Uploads to: $REMOTE_SSH:$REMOTE_PATH/apps/web/build/"
else
info "Served from: $SERVE_DIR/"
fi
say ""
say "IMPORTANT — after every 'morphit-ops' UPGRADE, the rebuild wipes the served"
say "build/ dir, so re-run your refresh once to restore the canary:"
info "bash $REFRESH"
say ""
if [ "$MODE" = local ]; then
say "SECURITY NOTE: your canary is signed on the SAME machine that serves it."
say "That's fine for home hosting, but the strongest canary is signed on a"
say "SEPARATE computer, so that a seizure of this box can't forge future"
say '"all-clear" canaries. If you add a second machine later, re-run this in'
say "remote mode from it."
fi