git / moonbite-network / deploy/setup-deploy-user.sh · raw
#!/usr/bin/env bash# Create the non-root, forced-command deploy user for the dashboard host# (audit 2026-10-09, finding M3). Run ONCE on the droplet as root; idempotent,# safe to re-run.## sudo bash deploy/setup-deploy-user.sh "<contents of the Actions deploy public key>"## Before this, GitHub Actions deployed as root with an unrestricted shell# (deploy.yml: SERVER_USER=root, git reset --hard, sudo systemctl restart).# A leaked DEPLOY_KEY therefore meant root on the host that serves the wallet# and /downloads. After this script:## * SSH as `deploy` can ONLY run /usr/local/bin/deploy-dashboard.sh <sha># (authorized_keys forced command; no pty, no port/agent/X11 forwarding).# * deploy-dashboard.sh fetches origin, hard-resets /opt/moonbite-dashboard to# the exact SHA CI validated, and restarts the service.# * sudo for `deploy` is limited to `systemctl restart moonbite-dashboard`# (and `status`, read-only).# * The old Actions key is removed from /root/.ssh/authorized_keys (matched by# its comment "moonbite-dashboard-deploy").## Then set the repo's deploy workflow to SERVER_USER=deploy (already done in# .github/workflows/deploy.yml) and keep the same DEPLOY_KEY secret -- the key# pair does not change, only which account accepts it and what it may run.set -euo pipefail[ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }DEPLOY_USER="deploy"PROJECT_PATH="/opt/moonbite-dashboard"SERVICE="moonbite-dashboard"KEY_COMMENT="moonbite-dashboard-deploy"FORCED_CMD="/usr/local/bin/deploy-dashboard.sh"PUBKEY="${1:-}"if [ -z "$PUBKEY" ]; then # Fall back to lifting the existing Actions key out of root's authorized_keys. PUBKEY="$(grep -F "$KEY_COMMENT" /root/.ssh/authorized_keys 2>/dev/null | head -1 || true)" [ -n "$PUBKEY" ] || { echo "usage: $0 '<ssh public key>' (no key with comment '$KEY_COMMENT' found in /root/.ssh/authorized_keys)" >&2; exit 1; }fi# Strip any options already on the key; we add our own.PUBKEY="$(printf '%s\n' "$PUBKEY" | sed -E 's/^.*(ssh-(ed25519|rsa)|ecdsa-sha2-nistp[0-9]+|sk-[a-z0-9-]+@openssh\.com) /\1 /')"case "$PUBKEY" in ssh-*|ecdsa-*|sk-*) ;; *) echo "not an ssh public key: $PUBKEY" >&2; exit 1;; esac# 1. userif ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then useradd --system --create-home --home-dir "/home/$DEPLOY_USER" --shell /bin/bash "$DEPLOY_USER" echo "created user $DEPLOY_USER"fi# 2. project checkout owned by deploy (the service itself may run as another# user; that is fine, it only needs read access to the tree)[ -d "$PROJECT_PATH/.git" ] || { echo "$PROJECT_PATH is not a git checkout" >&2; exit 1; }chown -R "$DEPLOY_USER:$DEPLOY_USER" "$PROJECT_PATH"sudo -u "$DEPLOY_USER" git -C "$PROJECT_PATH" config --global --add safe.directory "$PROJECT_PATH"# 3. forced commandcat > "$FORCED_CMD" <<EOF#!/usr/bin/env bash# Forced SSH command for the '$DEPLOY_USER' account (installed by deploy/setup-deploy-user.sh).# The only thing the GitHub Actions deploy key can do: check out ONE commit by# full SHA and restart the dashboard service. Arguments arrive via# SSH_ORIGINAL_COMMAND; anything that is not a 40-hex SHA is refused.set -euo pipefailPROJECT_PATH="$PROJECT_PATH"SERVICE="$SERVICE"read -r SHA _ <<<"\${SSH_ORIGINAL_COMMAND:-\${1:-}}"if ! [[ "\$SHA" =~ ^[0-9a-f]{40}\$ ]]; then echo "deploy-dashboard: expected a full 40-hex commit SHA, got: '\${SSH_ORIGINAL_COMMAND:-\${1:-}}'" >&2 exit 64ficd "\$PROJECT_PATH"echo "Fetching origin..."git fetch --quiet originecho "Checking out \$SHA..."git reset --hard --quiet "\$SHA"echo "Restarting \$SERVICE..."sudo -n /bin/systemctl restart "\$SERVICE"sudo -n /bin/systemctl status "\$SERVICE" --no-pager || trueecho "Deployment complete: \$(git rev-parse --short HEAD)"EOFchmod 755 "$FORCED_CMD"chown root:root "$FORCED_CMD"# 4. authorized_keys with forced command and no shell escapeinstall -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"AK="/home/$DEPLOY_USER/.ssh/authorized_keys"LINE="command=\"$FORCED_CMD\",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,restrict $PUBKEY $KEY_COMMENT"touch "$AK"grep -vF "$KEY_COMMENT" "$AK" > "$AK.tmp" || trueprintf '%s\n' "$LINE" >> "$AK.tmp"mv "$AK.tmp" "$AK"chmod 600 "$AK"; chown "$DEPLOY_USER:$DEPLOY_USER" "$AK"# 5. sudoers: only the restart (and read-only status)cat > /etc/sudoers.d/moonbite-deploy <<EOF# Installed by deploy/setup-deploy-user.sh. The deploy account may only restart# / inspect the dashboard service. No other sudo.$DEPLOY_USER ALL=(root) NOPASSWD: /bin/systemctl restart $SERVICE, /bin/systemctl status $SERVICE --no-pager, /bin/systemctl status $SERVICEEOFchmod 440 /etc/sudoers.d/moonbite-deployvisudo -cf /etc/sudoers.d/moonbite-deploy# 6. revoke the key from rootif [ -f /root/.ssh/authorized_keys ] && grep -qF "$KEY_COMMENT" /root/.ssh/authorized_keys; then cp -a /root/.ssh/authorized_keys "/root/.ssh/authorized_keys.bak.$(date +%s)" grep -vF "$KEY_COMMENT" /root/.ssh/authorized_keys > /root/.ssh/authorized_keys.tmp || true mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys echo "removed '$KEY_COMMENT' key from /root/.ssh/authorized_keys"fiechoecho "Done. Test from the runner's key:"echo " ssh -i deploy_key deploy@<host> \$(git rev-parse HEAD) # deploys that SHA"echo " ssh -i deploy_key deploy@<host> bash # must be refused"echo "Then ensure .github/workflows/deploy.yml uses SERVER_USER: deploy."