diff --git a/Partnership/onboard_cancel.sh b/Partnership/onboard_cancel.sh
new file mode 100755
index 0000000..e8d3d7d
--- /dev/null
+++ b/Partnership/onboard_cancel.sh
@@ -0,0 +1,141 @@
+#!/bin/bash
+# ==============================================================================================
+# ============================= Onboard Cancel =================================================
+# ==============================================================================================
+#
+# PURPOSE
+# ─────────────────────────────────────────────────────────────────────────────
+# Undoes Phase 1 of the onboard process:
+# 1. Remove HOST1's public key from HOST2's authorized_keys (while key still works)
+# 2. Remove phase flags from HOST2's varaverk_setup.db
+# 3. Delete local SSH key pair
+# 4. Clear phase flags from local varaverk_setup.db
+#
+# Run on the OWNER. If HOST2 is unreachable the local side is still cleaned up.
+#
+# ==============================================================================================
+# RUNTIME MODES
+# ==============================================================================================
+#
+# Partnership/onboard_cancel.sh
+# Cancel Phase 1 — remove keys and reset state
+#
+# Partnership/onboard_cancel.sh --dry-run
+# Preview all steps without making changes
+#
+# ==============================================================================================
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SCRIPTS_ROOT="$SCRIPT_DIR/.."
+SSH_TIMEOUT=15
+
+source "$SCRIPTS_ROOT/load_config.sh"
+parse_args "$@"
+
+[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
+
+acquire_lock
+
+detect_hosts
+
+OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
+MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
+MIRROR="${!MIRROR_ID}"
+SSH_KEY_PUB="${SSH_KEY}.pub"
+STATE_FILE="/boot/config/varaverk_setup.db"
+
+START=$(date +%s)
+
+echo ""
+echo "━━━ Cancel Phase 1 — $MY_ID → $MIRROR_ID ($MIRROR) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
+echo ""
+[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
+echo ""
+
+REMOTE_CLEANED=false
+LOCAL_KEY_GONE=false
+STATE_CLEARED=false
+
+# ── Step 1: Remove HOST1's public key from HOST2 ──────────────────────────────
+echo "━━━ Step 1 — Remove Public Key from $MIRROR_ID ━━━"
+
+if [[ ! -f "$SSH_KEY_PUB" ]]; then
+ log "No local public key at $SSH_KEY_PUB — nothing to remove from $MIRROR_ID"
+ REMOTE_CLEANED=true
+else
+ # Extract the key blob (middle field of pub key) — used as a unique identifier.
+ # Use | as sed delimiter to avoid clashing with base64 / characters in the blob.
+ KEY_BLOB=$(awk '{print $2}' "$SSH_KEY_PUB")
+ MIRROR_IP=$(resolve_tailscale_ip "$MIRROR" 2>/dev/null || true)
+
+ if [[ -z "$MIRROR_IP" ]]; then
+ warn "Cannot resolve $MIRROR Tailscale IP — skipping remote cleanup"
+ warn "Remove HOST1's public key from $MIRROR:/root/.ssh/authorized_keys manually"
+ elif [[ "$DRY_RUN" == true ]]; then
+ warn "DRY RUN — would remove key blob from $MIRROR:/root/.ssh/authorized_keys"
+ warn "DRY RUN — would clear ${MIRROR_ID}_PHASE* from $MIRROR:/boot/config/varaverk_setup.db"
+ REMOTE_CLEANED=true
+ else
+ timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
+ -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
+ "sed -i \"|${KEY_BLOB}|d\" /root/.ssh/authorized_keys 2>/dev/null
+ sed -i \"/^${MIRROR_ID}_PHASE/d\" /boot/config/varaverk_setup.db 2>/dev/null
+ echo ok" 2>/dev/null | grep -q ok && {
+ log "HOST1 key removed from $MIRROR authorized_keys ✅"
+ log "Phase flags cleared on $MIRROR ✅"
+ REMOTE_CLEANED=true
+ } || warn "Could not SSH to $MIRROR — remove HOST1 key and phase flags there manually"
+ fi
+fi
+
+# ── Step 2: Delete local SSH key pair ─────────────────────────────────────────
+echo ""
+echo "━━━ Step 2 — Delete Local SSH Key ━━━"
+
+if [[ ! -f "$SSH_KEY" && ! -f "$SSH_KEY_PUB" ]]; then
+ log "Local key already gone"
+ LOCAL_KEY_GONE=true
+elif [[ "$DRY_RUN" == true ]]; then
+ warn "DRY RUN — would delete: $SSH_KEY"
+ warn "DRY RUN — would delete: $SSH_KEY_PUB"
+ LOCAL_KEY_GONE=true
+else
+ rm -f "$SSH_KEY" "$SSH_KEY_PUB" && {
+ log "Local key pair deleted ✅"
+ LOCAL_KEY_GONE=true
+ } || warn "Failed to delete $SSH_KEY — check permissions"
+fi
+
+# ── Step 3: Clear phase flags from local setup.db ─────────────────────────────
+echo ""
+echo "━━━ Step 3 — Clear Phase State ━━━"
+
+if [[ ! -f "$STATE_FILE" ]]; then
+ log "No varaverk_setup.db — nothing to clear"
+ STATE_CLEARED=true
+elif [[ "$DRY_RUN" == true ]]; then
+ warn "DRY RUN — would remove ${MIRROR_ID}_PHASE* from $STATE_FILE"
+ STATE_CLEARED=true
+else
+ sed -i "/^${MIRROR_ID}_PHASE/d" "$STATE_FILE" && {
+ log "Phase flags cleared from local setup.db ✅"
+ STATE_CLEARED=true
+ }
+fi
+
+# ── Summary ───────────────────────────────────────────────────────────────────
+END=$(date +%s)
+echo ""
+echo "━━━━━ CANCEL SUMMARY ━━━━━"
+echo " $MIRROR_ID key removed: $( [[ "$REMOTE_CLEANED" == true ]] && echo "✅" || echo "⚠ manual cleanup needed" )"
+echo " Local key deleted: $( [[ "$LOCAL_KEY_GONE" == true ]] && echo "✅" || echo "⚠ still present" )"
+echo " Phase state cleared: $( [[ "$STATE_CLEARED" == true ]] && echo "✅" || echo "⚠" )"
+echo " Duration: $(format_duration $(( END - START )))"
+echo ""
+if [[ "$REMOTE_CLEANED" == false ]]; then
+ echo " Manual cleanup on $MIRROR:"
+ echo " sed -i '/$(awk "{print \$3}" "$SSH_KEY_PUB" 2>/dev/null || echo "HOST1_key_comment")//d' /root/.ssh/authorized_keys"
+fi
+echo " Run Phase 1 again to restart the onboard process."
+echo "━━━━━━━━━━━━━━━━━━━━━━━"
+exit 0
diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php
index 177463a..302168a 100644
--- a/Plugin/unraid/pages/partnership.php
+++ b/Plugin/unraid/pages/partnership.php
@@ -96,6 +96,16 @@ function vvPtOnboard(btn) {
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Onboard (Mirror)'; }, 4000));
}
+function vvPtCancel(btn, hostId) {
+ if (!confirm(`Cancel Phase 1 for ${hostId}?\n\nThis will:\n• Remove HOST1's SSH key from ${hostId}'s authorized_keys\n• Delete the local key pair\n• Reset phase state on both hosts\n\nContinue?`)) return;
+ btn.disabled = true;
+ btn.textContent = '⟳ Cancelling…';
+ _vvPtRun('Partnership/onboard_cancel.sh')
+ .then(d => { if (!d.ok) alert('Failed: ' + (d.error ?? 'Unknown error')); })
+ .catch(e => alert('Error: ' + e))
+ .finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '✕ Cancel'; }, 4000));
+}
+
function vvPtOffboard(btn) {
if (btn.style.opacity === '0.35' || btn.style.cursor === 'default') return;
if (!confirm('Run partnership_offboard.sh?\n\nThis will end the partnership, reconfigure WebUIs, and revoke SSH access.\n\nContinue?')) return;
@@ -285,6 +295,11 @@ function _renderActions(nodes, cfg) {
style="opacity:.5;">
▶ Full Onboard
`;
+ html += ``;
} else if (phase === 1) {
// Phase 1 done — waiting for HOST2 to onboard and trigger Phase 2
html += `⏳ Waiting for ${remote.id} to onboard…`;
@@ -292,6 +307,10 @@ function _renderActions(nodes, cfg) {
title="Manually trigger Phase 2 — deploy containers, arr stack, and establish partnership on ${remote.hostname}. Normally triggered automatically when ${remote.id} completes its onboard.">
▶ Run Phase 2 Manually
`;
+ html += ``;
} else {
// Phase 2 done — fully onboarded
html += `✅ Partnership established`;