457	function vvDoConfOnly() {
458	  vvSetBtn('Creating…', true);
459	  const params = new URLSearchParams({
460	    action:'save',
461	    host1: <?= json_encode($masterHost1) ?>,
462	    host2: <?= json_encode(trim(preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $_master, $_m2) ? $_m2[1] : '')) ?>,
463	    my_slot: <?= json_encode($myHostId) ?>,
464	    my_hostname: <?= json_encode($detectedHostname) ?>,
465	  });
466	  fetch('/plugins/varaverk/api/setup.php', {
467	    method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
468	  }).then(r=>r.json()).then(d => {
469	    if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>)); }
470	    else { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
471	  }).catch(() => { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
472	}
473	</script>
474

function vvDoConfOnly() {
  vvSetBtn('Creating…', true);
  const params = new URLSearchParams({
    action:'save',
    host1: <?= json_encode($masterHost1) ?>,
    host2: <?= json_encode(trim(preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $_master, $_m2) ? $_m2[1] : '')) ?>,
    my_slot: <?= json_encode($myHostId) ?>,
    my_hostname: <?= json_encode($detectedHostname) ?>,
  });
  fetch('/plugins/varaverk/api/setup.php', {
    method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
  }).then(r=>r.json()).then(d => {
    if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>)); }
    else { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
  }).catch(() => { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
}
</script>


Edit

    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
    else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
  }).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });

    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
    else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
  }).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });


The file /boot/config/plugins/varaverk/Plugin/unraid/pages/setup.php has been updated successfully. (file state is current in your context — no need to Read it back)

    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
    else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
  }).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });

    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
    else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
  }).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });

-    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }

+    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }


    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
    else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }

    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
    else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }


    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
    else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }

    if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
    else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }

}).catch(() => { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ Request failed', 'err'); });


if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>)); }

if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>), d.api_key); }


-    if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>)); }

+    if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>), d.api_key); }

else { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }

}).catch(() => { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ Request failed', 'err'); });


we could auto create unraid api and push automatically during initial


Done. Here's what changed across three files:

**`include/config.php`** — added `vv_auto_create_api_key(hostId, confFile)` as a shared function. Runs the `unraid-api` CLI, writes the key into the named conf, returns `['ok'=>true,'key_preview'=>...]` or an error.

**`api/setup.php`** — both `save` and `pull` actions now call `vv_auto_create_api_key()` after the conf is written, and include `api_key` in their JSON response.

**`api/create_api_key.php`** — now a 3-line wrapper around the shared function instead of 85 lines of duplicated logic. Still works standalone for the retry button.

**`pages/setup.php`** — `vvShowStep2()` accepts an optional `apiKey` argument. If the key was created server-side, it marks the button "Created ✓" and shows the confirmation before the user even sees step 2. All three callers pass `d.api_key` through. The button is still there as a fallback if the auto-creation failed (the CLI wasn't available, or first-run timing issue).


what else can the wizard automate.... im sure ill come across somthing


Thinking through what's left:

**Could fold into `conf_populate` (same detection pattern, more fields):**
- `DAILY_SYNC_SHARES` / `MEDIA_PERMISSION_SHARES` / `MEDIA_CLEAN_FOLDERS` — we already have the media roots from the arr API. They're almost always the same three paths. Could write all three arrays from what Radarr/Sonarr/Lidarr report.
- `SMART_IGNORE_DRIVES` — the boot device is already detected in the wizard banner. Could add the disk to the ignore list automatically (USB flash drives give meaningless SMART data anyway).
- `NETWORK_WATCHDOG_NPM_URL` — if an NPM container is running, `docker inspect` gives the admin port → `http://localhost:PORT`.
- `WATCHDOG_CONTAINER_URLS` for Emby/Jellyfin — we already have the URLs.

**Wizard-specific additions:**
- **SSH keygen button** — run `ssh-keygen` locally right there, write the path to `HOSTN_SSH_KEY` in conf. Doesn't need the partner to be up. Unblocks partnership without making the user open a terminal.
- **Emby API key** — can't read it from Emby's config (user-issued token), but could open the Emby WebUI in a new tab at the right path and show a paste field. Least friction for the one thing that genuinely can't be detected.

**Not automatable:**
- `DISCORD_WEBHOOK` — secret URL, requires user to create it in Discord
- `OWNER_EMAIL` — no source to read from
- Partnership/sync/fallback arrays — intentional topology decisions
- Watchdog memory limits — sizing calls

The SSH keygen button is probably the highest-value one — it's the only thing that currently requires dropping to terminal during first-run for a typical setup.


through. The button is still there as a fallback if the auto-creation failed (the CLI wasn't available, or first-run timing issue), these will for the most part also match other hosts.confs........ il ike the idea of automated ssh, but how does host2 get the conf filled if it has not ran yet, all others are good


through. The button is still there as a fallback if the auto-creation failed (the CLI wasn't available, or first-run timing issue), these will for the most part also match other hosts.confs........ il…


The SSH chicken-and-egg is the real problem: HOST2's pull action needs a key to SCP master.conf, but the key doesn't exist yet.

The fix is to generate the key **before** the pull, not after. The keygen step is purely local — no network needed. Then show the public key and gate the pull button behind "I've added this to HOST1":

**HOST2 wizard flow becomes:**

```
Step 0 (auto on load):  generate keypair locally if none exists
                        → writes HOSTN_SSH_KEY to conf
                        → displays pubkey with copy button
                        → "Add this to HOST1's ~/.ssh/authorized_keys, then continue"

Step 1:                 hostname + slot form
                        "Continue →" button only enabled after user checks a box
                        confirming the pubkey was added

Step 2 (on submit):     pull master.conf via SSH (key now works)
                        → create host2.conf from template (key path already set)
                        → auto-create Unraid API key
                        → conf_populate (reads HOST2's local services)
                        → step 2 UI
```

For HOST1 the key gen just happens silently as part of the save — no gate needed since it doesn't need to auth anywhere immediately.

The pubkey display could even have a one-liner copy:
```
ssh root@HOST1 "echo '<pubkey>' >> /root/.ssh/authorized_keys"
```
so it's a single paste into a terminal on HOST1 rather than an editor.

The "all others are good" point — `conf_populate` for HOST2 is entirely local (reads HOST2's own running containers), so that part has no dependency on HOST1 at all. It works fine the moment the pull completes.

Want me to build it?


we have a ssh script


Let me look at what it does before designing anything.


1	#!/bin/bash
2	# ==============================================================================================
3	# ============================= SSH Setup ======================================================
4	# ==============================================================================================
5	# Generates the SSH keypair for rsync automation and installs it on the remote server.
6	# Key named after this server: hostname lowercased, unraid- prefix stripped.
7	#   unRAID-Gmer4Lfe → gmer4lfe_rsync_automation
8	#   unRAID-Jayred365 → jayred365_rsync_automation
9	# Idempotent — skips generation if key already exists (use --force to regenerate).
10	# Updates host*.conf with key path on success.
11	#
12	# ── MODES ─────────────────────────────────────────────────────────────────────────────────────
13	#   (default)       — generate key if missing, copy to remote, update conf
14	#   --force         — regenerate key even if it exists, re-copy to remote
15	#   --validate      — test SSH auth to remote, track strikes, notify at limit
16	#   --status        — show key state, fingerprint, remote connectivity
17	#   --dry-run       — preview without creating, copying, or updating conf
18	#
19	# ── STRIKE SYSTEM (--validate) ────────────────────────────────────────────────────────────────
20	# Called during partnership --check cycles to detect broken SSH auth.
21	# Tracks consecutive SSH auth failures — not network unreachability.
22	# Remote Tailscale IP unreachable = network issue → not counted as SSH strike.
23	# Remote reachable but SSH auth fails = key issue → strike incremented.
24	# Strikes reset automatically after SSH_STRIKE_RESET_HRS of clean connectivity.
25	# At SSH_MAX_STRIKES: notify + return exit 2 (caller can escalate).
26	# State: DATA_DIR/ssh_strikes_{REMOTE_SERVER_NAME}.db
27	#
28	# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
29	#   SSH_MAX_STRIKES       — consecutive failures before notifying (default 5)
30	#   SSH_STRIKE_RESET_HRS  — hours since last failure before counter resets (default 24)
31	#
32	# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
33	#   Partnership/ssh_setup.sh                — initial setup (idempotent)
34	#   Partnership/ssh_setup.sh --force        — regenerate + re-copy
35	#   Partnership/ssh_setup.sh --validate     — health check + strike tracking
36	#   Partnership/ssh_setup.sh --status       — show key and connectivity state
37	#   Any mode supports --dry-run and --log
38	# ==============================================================================================
39
40	SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
41	SCRIPTS_ROOT="$SCRIPT_DIR/.."
42
43	source "$SCRIPTS_ROOT/load_config.sh"
44
45	# ── Parse --force before parse_args ───────────────────────────────────────────────────────────
46	MODE="setup"
47	FORCE=false
48	FILTERED_ARGS=()
49
50	for arg in "$@"; do
51	    case "$arg" in
52	        --force)    FORCE=true ;;
53	        --validate) MODE="validate" ;;
54	        --status)   MODE="status" ;;
55	        *)          FILTERED_ARGS+=("$arg") ;;
56	    esac
57	done
58
59	parse_args "${FILTERED_ARGS[@]}"
60
61	# ==============================================================================================
62	# ━━━ Setup ━━━
63	# ==============================================================================================
64	if [[ "$EUID" -ne 0 ]]; then
65	    error "Must be run as root"
66	    exit 1
67	fi
68
69	acquire_lock
70
71	platform_require_cmd \
72	    "/usr/local/emhttp/plugins/dynamix/scripts/notify" \
73	    "" "" \
74	    "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
75
76	detect_hosts
77
78	# ── Derive key name from hostname ─────────────────────────────────────────────────────────────
79	# Strip unraid- prefix (case-insensitive) if present → gmer4lfe_rsync_automation
80	SHORT_NAME="${LOCAL_SERVER_NAME,,}"
81	[[ "${SHORT_NAME}" == unraid-* ]] && SHORT_NAME="${SHORT_NAME:7}"
82	SSH_KEY_NAME="${SHORT_NAME}_rsync_automation"
83	SSH_KEY_PATH="/root/.ssh/${SSH_KEY_NAME}"
84	SSH_PUB_PATH="${SSH_KEY_PATH}.pub"
85
86	# ── Host conf path ────────────────────────────────────────────────────────────────────────────
87	HOST_NUM="${MY_ID#HOST}"   # "1" or "2"
88	HOST_CONF="$SCRIPTS_ROOT/Configurations/host${HOST_NUM}.conf"
89	KEY_CONF_VAR="${MY_ID}_SSH_KEY"
90
91	# ── Strike state file ─────────────────────────────────────────────────────────────────────────
92	SSH_STRIKE_FILE="$DATA_DIR/ssh_strikes_${REMOTE_SERVER_NAME}.db"
93
94	# ==============================================================================================
95	# ─── HELPERS ──────────────────────────────────────────────────────────────────────────────────
96	# ==============================================================================================
97
98	test_ssh_auth() {
99	    local remote_ip="$1"
100	    timeout "${SSH_TIMEOUT:-15}" ssh \
101	        -i "$SSH_KEY_PATH" \
102	        -o BatchMode=yes \
103	        -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
104	        -o StrictHostKeyChecking=no \
105	        root@"$remote_ip" "echo ok" >/dev/null 2>&1
106	}
107
108	read_strikes() {
109	    grep "^strikes=" "$SSH_STRIKE_FILE" 2>/dev/null | cut -d= -f2 || echo 0
110	}
111
112	read_last_strike_epoch() {
113	    local ts
114	    ts=$(grep "^last_strike=" "$SSH_STRIKE_FILE" 2>/dev/null | cut -d= -f2)
115	    [[ -n "$ts" ]] && date -d "$ts" +%s 2>/dev/null || echo 0
116	}
117
118	write_strike_file() {
119	    local strikes="$1" last_strike="${2:-}" last_success="${3:-}"
120	    mkdir -p "$(dirname "$SSH_STRIKE_FILE")"
121	    cat > "$SSH_STRIKE_FILE" <<EOF
122	strikes=${strikes}
123	last_strike=${last_strike}
124	last_success=${last_success}
125	updated=$(date '+%Y-%m-%d %H:%M:%S')
126	EOF
127	}
128
129	update_conf_key_path() {
130	    if [[ ! -f "$HOST_CONF" ]]; then
131	        warn "$(basename "$HOST_CONF") not found — update ${KEY_CONF_VAR} manually to: $SSH_KEY_PATH"
132	        return 1
133	    fi
134
135	    local current
136	    current=$(grep "^[[:space:]]*${KEY_CONF_VAR}=" "$HOST_CONF" 2>/dev/null | \
137	              sed 's/.*="\?\([^"]*\)"\?.*/\1/')
138
139	    if [[ "$current" == "$SSH_KEY_PATH" ]]; then
140	        log "${KEY_CONF_VAR} already correct in $(basename "$HOST_CONF")"
141	        return 0
142	    fi
143
144	    if grep -q "^[[:space:]]*${KEY_CONF_VAR}=" "$HOST_CONF" 2>/dev/null; then
145	        sed -i "s|^[[:space:]]*${KEY_CONF_VAR}=.*|    ${KEY_CONF_VAR}=\"${SSH_KEY_PATH}\"|" "$HOST_CONF" && \
146	            log "${KEY_CONF_VAR} updated in $(basename "$HOST_CONF") ✅" || \
147	            warn "Failed to update ${KEY_CONF_VAR} in $(basename "$HOST_CONF") — update manually"
148	    else
149	        warn "${KEY_CONF_VAR} not found in $(basename "$HOST_CONF") — add manually:"
150	        warn "    ${KEY_CONF_VAR}=\"${SSH_KEY_PATH}\""
151	    fi
152	}
153
154	# ==============================================================================================
155	# ━━━ Status ━━━
156	# ==============================================================================================
157	if [[ "$MODE" == "status" ]]; then
158	    echo ""
159	    echo "━━━━━ $ICON_SUMMARY SSH SETUP STATUS ━━━━━"
160	    echo "$ICON_HOST  Identity: $MY_ID ($LOCAL_SERVER_NAME)"
161	    echo "$ICON_NET   Remote:   $REMOTE_SERVER_NAME"
162	    echo ""
163	    echo "  Key name: $SSH_KEY_NAME"
164	    echo "  Key path: $SSH_KEY_PATH"
165	    echo ""
166
167	    if [[ -f "$SSH_KEY_PATH" ]]; then
168	        local_fp=$(ssh-keygen -lf "$SSH_KEY_PATH" 2>/dev/null || echo "unreadable")
169	        echo "  $ICON_DONE  Private key: present"
170	        echo "         Fingerprint: $local_fp"
171	    else
172	        echo "  $ICON_ERROR Private key: NOT found"
173	    fi
174
175	    if [[ -f "$SSH_PUB_PATH" ]]; then
176	        echo "  $ICON_DONE  Public key:  present"
177	    else
178	        echo "  $ICON_ERROR Public key:  NOT found"
179	    fi
180
181	    # Check conf
182	    if [[ -f "$HOST_CONF" ]]; then
183	        current_conf=$(grep "^[[:space:]]*${KEY_CONF_VAR}=" "$HOST_CONF" 2>/dev/null | \
184	                       sed 's/.*="\?\([^"]*\)"\?.*/\1/')
185	        if [[ "$current_conf" == "$SSH_KEY_PATH" ]]; then
186	            echo "  $ICON_DONE  Conf ($(basename "$HOST_CONF")): ${KEY_CONF_VAR} ✅"
187	        else
188	            echo "  $ICON_ERROR Conf ($(basename "$HOST_CONF")): ${KEY_CONF_VAR}=${current_conf:-not set}"
189	        fi
190	    fi
191
192	    # Remote connectivity
193	    echo ""
194	    REMOTE_IP=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")
195	    if [[ -z "$REMOTE_IP" ]]; then
196	        echo "  $ICON_ERROR Remote ($REMOTE_SERVER_NAME): Tailscale unreachable"
197	    elif [[ -f "$SSH_KEY_PATH" ]] && test_ssh_auth "$REMOTE_IP"; then
198	        echo "  $ICON_DONE  Remote ($REMOTE_SERVER_NAME [$REMOTE_IP]): SSH auth OK ✅"
199	    else
200	        echo "  $ICON_ERROR Remote ($REMOTE_SERVER_NAME [$REMOTE_IP]): SSH auth FAILED"
201	    fi
202
203	    # Strike state
204	    if [[ -f "$SSH_STRIKE_FILE" ]]; then
205	        echo ""
206	        echo "  Strike state:"
207	        while IFS= read -r line; do
208	            echo "    $line"
209	        done < "$SSH_STRIKE_FILE"
210	    fi
211
212	    echo "━━━━━━━━━━━━━━━━━━━━━━━"
213	    exit 0
214	fi
215
216	# ==============================================================================================
217	# ━━━ Validate (SSH health check + strike tracking) ━━━
218	# ==============================================================================================
219	if [[ "$MODE" == "validate" ]]; then
220	    MAX_STRIKES="${SSH_MAX_STRIKES:-5}"
221	    RESET_HRS="${SSH_STRIKE_RESET_HRS:-24}"
222
223	    REMOTE_IP=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")
224	    if [[ -z "$REMOTE_IP" ]]; then
225	        log "SSH validate — $REMOTE_SERVER_NAME Tailscale unreachable, not an SSH issue"
226	        exit 0
227	    fi
228
229	    if [[ ! -f "$SSH_KEY_PATH" ]]; then
230	        warn "SSH validate — key not found at $SSH_KEY_PATH — run ssh_setup.sh to create"
231	        exit 1
232	    fi
233
234	    NOW=$(date '+%Y-%m-%d %H:%M:%S')
235	    NOW_EPOCH=$(date +%s)
236
237	    if test_ssh_auth "$REMOTE_IP"; then
238	        # SSH works — reset strikes if they were > 0
239	        STRIKES=$(read_strikes)
240	        if [[ "$STRIKES" -gt 0 ]]; then
241	            write_strike_file 0 "" "$NOW"
242	            log "SSH validate — auth restored to $REMOTE_SERVER_NAME ✅ (strikes reset)"
243	        else
244	            log "SSH validate — $REMOTE_SERVER_NAME SSH auth OK ✅"
245	        fi
246	        exit 0
247	    fi
248
249	    # SSH auth failed — check if strikes should reset first
250	    STRIKES=$(read_strikes)
251	    LAST_STRIKE_EPOCH=$(read_last_strike_epoch)
252	    RESET_SECS=$(( RESET_HRS * 3600 ))
253
254	    if [[ "$STRIKES" -gt 0 ]] && \
255	       [[ "$LAST_STRIKE_EPOCH" -gt 0 ]] && \
256	       [[ $(( NOW_EPOCH - LAST_STRIKE_EPOCH )) -gt "$RESET_SECS" ]]; then
257	        warn "SSH validate — strike counter reset after ${RESET_HRS}hr gap"
258	        STRIKES=0
259	    fi
260
261	    STRIKES=$(( STRIKES + 1 ))
262	    write_strike_file "$STRIKES" "$NOW" ""
263	    warn "SSH validate — $REMOTE_SERVER_NAME auth FAILED (strike $STRIKES/$MAX_STRIKES)"
264
265	    if [[ "$STRIKES" -ge "$MAX_STRIKES" ]]; then
266	        error "SSH auth to $REMOTE_SERVER_NAME broken — ${STRIKES} consecutive failures"
267	        error "Repair: run 'Partnership/ssh_setup.sh --force' to regenerate and re-copy key"
268	        notify "SSH auth broken to $REMOTE_SERVER_NAME ($MY_ID) — ${STRIKES} strikes, manual repair needed. Run: Partnership/ssh_setup.sh --force" \
269	            "SSH Setup" "warning"
270	        exit 2
271	    fi
272
273	    exit 1
274	fi
275
276	# ==============================================================================================
277	# ━━━ Setup (default) or --force ━━━
278	# ==============================================================================================
279	echo ""
280	echo "━━━ $ICON_GEAR SSH Key Setup — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
281	echo ""
282	echo "  Key name: $SSH_KEY_NAME"
283	echo "  Key path: $SSH_KEY_PATH"
284
285	[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no keys will be created or copied"
286
287	# ── Key generation ────────────────────────────────────────────────────────────────────────────
288	echo ""
289	echo "━━━ $ICON_GEAR Key Generation ━━━"
290
291	if [[ -f "$SSH_KEY_PATH" ]] && [[ "$FORCE" == false ]]; then
292	    local_fp=$(ssh-keygen -lf "$SSH_KEY_PATH" 2>/dev/null || echo "unreadable")
293	    echo "Key already exists — skipping generation (--force to regenerate)"
294	    echo "  $local_fp"
295	else
296	    if [[ "$FORCE" == true ]] && [[ -f "$SSH_KEY_PATH" ]]; then
297	        warn "Regenerating key (--force) — existing key will be replaced"
298	        if [[ "$DRY_RUN" == false ]]; then
299	            rm -f "$SSH_KEY_PATH" "$SSH_PUB_PATH"
300	        fi
301	    fi
302
303	    warn "Generating ed25519 keypair: $SSH_KEY_PATH"
304	    if [[ "$DRY_RUN" == false ]]; then
305	        ssh-keygen -t ed25519 -N "" -f "$SSH_KEY_PATH" -C "${SSH_KEY_NAME}@${LOCAL_SERVER_NAME}" && \
306	            log "Keypair generated ✅" || {
307	            error "Failed to generate keypair"
308	            exit 1
309	        }
310	        chmod 600 "$SSH_KEY_PATH"
311	        chmod 644 "$SSH_PUB_PATH"
312	    else
313	        warn "DRY RUN — would generate: $SSH_KEY_PATH"
314	    fi
315	fi
316
317	# ── Update conf ───────────────────────────────────────────────────────────────────────────────
318	echo ""
319	echo "━━━ $ICON_GEAR Update host${HOST_NUM}.conf ━━━"
320
321	if [[ "$DRY_RUN" == false ]]; then
322	    update_conf_key_path
323	else
324	    warn "DRY RUN — would set ${KEY_CONF_VAR}=\"${SSH_KEY_PATH}\" in host${HOST_NUM}.conf"
325	fi
326
327	# ── Copy to remote ────────────────────────────────────────────────────────────────────────────
328	echo ""
329	echo "━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━"
330
331	resolve_remote_ip
332
333	if [[ "$DRY_RUN" == true ]]; then
334	    warn "DRY RUN — would ssh-copy-id to root@$REMOTE_SERVER"
335	    warn "DRY RUN — would require password for root@$REMOTE_SERVER if key auth not yet set up"
336	else
337	    warn "Installing public key on $REMOTE_SERVER_NAME ($REMOTE_SERVER)..."
338	    warn "(Password prompt for root@$REMOTE_SERVER is expected on first setup)"
339
340	    if ssh-copy-id -i "$SSH_PUB_PATH" -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
341	        root@"$REMOTE_SERVER" 2>/dev/null; then
342	        echo "Public key installed on $REMOTE_SERVER_NAME ✅"
343	    else
344	        error "ssh-copy-id failed — check that:"
345	        error "  1. Remote server is reachable: tailscale status"
346	        error "  2. Password auth is enabled on remote: grep PasswordAuthentication /etc/ssh/sshd_config"
347	        error "  3. The correct password for root@$REMOTE_SERVER_NAME is used"
348	        exit 1
349	    fi
350	fi
351
352	# ── Verify ────────────────────────────────────────────────────────────────────────────────────
353	echo ""
354	echo "━━━ $ICON_VERIFY Verify SSH Auth ━━━"
355
356	if [[ "$DRY_RUN" == false ]]; then
357	    if test_ssh_auth "$REMOTE_SERVER"; then
358	        echo "SSH auth to $REMOTE_SERVER_NAME working ✅"
359	        # Reset any existing strikes
360	        if [[ -f "$SSH_STRIKE_FILE" ]]; then
361	            write_strike_file 0 "" "$(date '+%Y-%m-%d %H:%M:%S')"
362	        fi
363	    else
364	        warn "SSH auth test failed — key may need a moment to propagate"
365	        warn "Verify manually: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER"
366	    fi
367	else
368	    warn "DRY RUN — would test: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER echo ok"
369	fi
370
371	# ── Summary ───────────────────────────────────────────────────────────────────────────────────
372	echo ""
373	echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY ━━━━━"
374	echo "  Key:    $SSH_KEY_PATH"
375	echo "  Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)"
376	echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
377	echo ""
378	[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
379	    warn "$ICON_DONE DONE — SSH key ready for rsync automation ✅"
380	echo "━━━━━━━━━━━━━━━━━━━━━━━"
381	exit 0
382

#!/bin/bash
# ==============================================================================================
# ============================= SSH Setup ======================================================
# ==============================================================================================
# Generates the SSH keypair for rsync automation and installs it on the remote server.
# Key named after this server: hostname lowercased, unraid- prefix stripped.
#   unRAID-Gmer4Lfe → gmer4lfe_rsync_automation
#   unRAID-Jayred365 → jayred365_rsync_automation
# Idempotent — skips generation if key already exists (use --force to regenerate).
# Updates host*.conf with key path on success.
#
# ── MODES ─────────────────────────────────────────────────────────────────────────────────────
#   (default)       — generate key if missing, copy to remote, update conf
#   --force         — regenerate key even if it exists, re-copy to remote
#   --validate      — test SSH auth to remote, track strikes, notify at limit
#   --status        — show key state, fingerprint, remote connectivity
#   --dry-run       — preview without creating, copying, or updating conf
#
# ── STRIKE SYSTEM (--validate) ────────────────────────────────────────────────────────────────
# Called during partnership --check cycles to detect broken SSH auth.
# Tracks consecutive SSH auth failures — not network unreachability.
# Remote Tailscale IP unreachable = network issue → not counted as SSH strike.
# Remote reachable but SSH auth fails = key issue → strike incremented.
# Strikes reset automatically after SSH_STRIKE_RESET_HRS of clean connectivity.
# At SSH_MAX_STRIKES: notify + return exit 2 (caller can escalate).
# State: DATA_DIR/ssh_strikes_{REMOTE_SERVER_NAME}.db
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
#   SSH_MAX_STRIKES       — consecutive failures before notifying (default 5)
#   SSH_STRIKE_RESET_HRS  — hours since last failure before counter resets (default 24)
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
#   Partnership/ssh_setup.sh                — initial setup (idempotent)
#   Partnership/ssh_setup.sh --force        — regenerate + re-copy
#   Partnership/ssh_setup.sh --validate     — health check + strike tracking
#   Partnership/ssh_setup.sh --status       — show key and connectivity state
#   Any mode supports --dry-run and --log
# ==============================================================================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."

source "$SCRIPTS_ROOT/load_config.sh"

# ── Parse --force before parse_args ───────────────────────────────────────────────────────────
MODE="setup"
FORCE=false
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --force)    FORCE=true ;;
        --validate) MODE="validate" ;;
        --status)   MODE="status" ;;
        *)          FILTERED_ARGS+=("$arg") ;;
    esac
done

parse_args "${FILTERED_ARGS[@]}"

# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
    error "Must be run as root"
    exit 1
fi

acquire_lock

platform_require_cmd \
    "/usr/local/emhttp/plugins/dynamix/scripts/notify" \
    "" "" \
    "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"

detect_hosts

# ── Derive key name from hostname ─────────────────────────────────────────────────────────────
# Strip unraid- prefix (case-insensitive) if present → gmer4lfe_rsync_automation
SHORT_NAME="${LOCAL_SERVER_NAME,,}"
[[ "${SHORT_NAME}" == unraid-* ]] && SHORT_NAME="${SHORT_NAME:7}"
SSH_KEY_NAME="${SHORT_NAME}_rsync_automation"
SSH_KEY_PATH="/root/.ssh/${SSH_KEY_NAME}"
SSH_PUB_PATH="${SSH_KEY_PATH}.pub"

# ── Host conf path ────────────────────────────────────────────────────────────────────────────
HOST_NUM="${MY_ID#HOST}"   # "1" or "2"
HOST_CONF="$SCRIPTS_ROOT/Configurations/host${HOST_NUM}.conf"
KEY_CONF_VAR="${MY_ID}_SSH_KEY"

# ── Strike state file ─────────────────────────────────────────────────────────────────────────
SSH_STRIKE_FILE="$DATA_DIR/ssh_strikes_${REMOTE_SERVER_NAME}.db"

# ==============================================================================================
# ─── HELPERS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================

test_ssh_auth() {
    local remote_ip="$1"
    timeout "${SSH_TIMEOUT:-15}" ssh \
        -i "$SSH_KEY_PATH" \
        -o BatchMode=yes \
        -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
        -o StrictHostKeyChecking=no \
        root@"$remote_ip" "echo ok" >/dev/null 2>&1
}

read_strikes() {
    grep "^strikes=" "$SSH_STRIKE_FILE" 2>/dev/null | cut -d= -f2 || echo 0
}

read_last_strike_epoch() {
    local ts
    ts=$(grep "^last_strike=" "$SSH_STRIKE_FILE" 2>/dev/null | cut -d= -f2)
    [[ -n "$ts" ]] && date -d "$ts" +%s 2>/dev/null || echo 0
}

write_strike_file() {
    local strikes="$1" last_strike="${2:-}" last_success="${3:-}"
    mkdir -p "$(dirname "$SSH_STRIKE_FILE")"
    cat > "$SSH_STRIKE_FILE" <<EOF
strikes=${strikes}
last_strike=${last_strike}
last_success=${last_success}
updated=$(date '+%Y-%m-%d %H:%M:%S')
EOF
}

update_conf_key_path() {
    if [[ ! -f "$HOST_CONF" ]]; then
        warn "$(basename "$HOST_CONF") not found — update ${KEY_CONF_VAR} manually to: $SSH_KEY_PATH"
        return 1
    fi

    local current
    current=$(grep "^[[:space:]]*${KEY_CONF_VAR}=" "$HOST_CONF" 2>/dev/null | \
              sed 's/.*="\?\([^"]*\)"\?.*/\1/')

    if [[ "$current" == "$SSH_KEY_PATH" ]]; then
        log "${KEY_CONF_VAR} already correct in $(basename "$HOST_CONF")"
        return 0
    fi

    if grep -q "^[[:space:]]*${KEY_CONF_VAR}=" "$HOST_CONF" 2>/dev/null; then
        sed -i "s|^[[:space:]]*${KEY_CONF_VAR}=.*|    ${KEY_CONF_VAR}=\"${SSH_KEY_PATH}\"|" "$HOST_CONF" && \
            log "${KEY_CONF_VAR} updated in $(basename "$HOST_CONF") ✅" || \
            warn "Failed to update ${KEY_CONF_VAR} in $(basename "$HOST_CONF") — update manually"
    else
        warn "${KEY_CONF_VAR} not found in $(basename "$HOST_CONF") — add manually:"
        warn "    ${KEY_CONF_VAR}=\"${SSH_KEY_PATH}\""
    fi
}

# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$MODE" == "status" ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY SSH SETUP STATUS ━━━━━"
    echo "$ICON_HOST  Identity: $MY_ID ($LOCAL_SERVER_NAME)"
    echo "$ICON_NET   Remote:   $REMOTE_SERVER_NAME"
    echo ""
    echo "  Key name: $SSH_KEY_NAME"
    echo "  Key path: $SSH_KEY_PATH"
    echo ""

    if [[ -f "$SSH_KEY_PATH" ]]; then
        local_fp=$(ssh-keygen -lf "$SSH_KEY_PATH" 2>/dev/null || echo "unreadable")
        echo "  $ICON_DONE  Private key: present"
        echo "         Fingerprint: $local_fp"
    else
        echo "  $ICON_ERROR Private key: NOT found"
    fi

    if [[ -f "$SSH_PUB_PATH" ]]; then
        echo "  $ICON_DONE  Public key:  present"
    else
        echo "  $ICON_ERROR Public key:  NOT found"
    fi

    # Check conf
    if [[ -f "$HOST_CONF" ]]; then
        current_conf=$(grep "^[[:space:]]*${KEY_CONF_VAR}=" "$HOST_CONF" 2>/dev/null | \
                       sed 's/.*="\?\([^"]*\)"\?.*/\1/')
        if [[ "$current_conf" == "$SSH_KEY_PATH" ]]; then
            echo "  $ICON_DONE  Conf ($(basename "$HOST_CONF")): ${KEY_CONF_VAR} ✅"
        else
            echo "  $ICON_ERROR Conf ($(basename "$HOST_CONF")): ${KEY_CONF_VAR}=${current_conf:-not set}"
        fi
    fi

    # Remote connectivity
    echo ""
    REMOTE_IP=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")
    if [[ -z "$REMOTE_IP" ]]; then
        echo "  $ICON_ERROR Remote ($REMOTE_SERVER_NAME): Tailscale unreachable"
    elif [[ -f "$SSH_KEY_PATH" ]] && test_ssh_auth "$REMOTE_IP"; then
        echo "  $ICON_DONE  Remote ($REMOTE_SERVER_NAME [$REMOTE_IP]): SSH auth OK ✅"
    else
        echo "  $ICON_ERROR Remote ($REMOTE_SERVER_NAME [$REMOTE_IP]): SSH auth FAILED"
    fi

    # Strike state
    if [[ -f "$SSH_STRIKE_FILE" ]]; then
        echo ""
        echo "  Strike state:"
        while IFS= read -r line; do
            echo "    $line"
        done < "$SSH_STRIKE_FILE"
    fi

    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

# ==============================================================================================
# ━━━ Validate (SSH health check + strike tracking) ━━━
# ==============================================================================================
if [[ "$MODE" == "validate" ]]; then
    MAX_STRIKES="${SSH_MAX_STRIKES:-5}"
    RESET_HRS="${SSH_STRIKE_RESET_HRS:-24}"

    REMOTE_IP=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")
    if [[ -z "$REMOTE_IP" ]]; then
        log "SSH validate — $REMOTE_SERVER_NAME Tailscale unreachable, not an SSH issue"
        exit 0
    fi

    if [[ ! -f "$SSH_KEY_PATH" ]]; then
        warn "SSH validate — key not found at $SSH_KEY_PATH — run ssh_setup.sh to create"
        exit 1
    fi

    NOW=$(date '+%Y-%m-%d %H:%M:%S')
    NOW_EPOCH=$(date +%s)

    if test_ssh_auth "$REMOTE_IP"; then
        # SSH works — reset strikes if they were > 0
        STRIKES=$(read_strikes)
        if [[ "$STRIKES" -gt 0 ]]; then
            write_strike_file 0 "" "$NOW"
            log "SSH validate — auth restored to $REMOTE_SERVER_NAME ✅ (strikes reset)"
        else
            log "SSH validate — $REMOTE_SERVER_NAME SSH auth OK ✅"
        fi
        exit 0
    fi

    # SSH auth failed — check if strikes should reset first
    STRIKES=$(read_strikes)
    LAST_STRIKE_EPOCH=$(read_last_strike_epoch)
    RESET_SECS=$(( RESET_HRS * 3600 ))

    if [[ "$STRIKES" -gt 0 ]] && \
       [[ "$LAST_STRIKE_EPOCH" -gt 0 ]] && \
       [[ $(( NOW_EPOCH - LAST_STRIKE_EPOCH )) -gt "$RESET_SECS" ]]; then
        warn "SSH validate — strike counter reset after ${RESET_HRS}hr gap"
        STRIKES=0
    fi

    STRIKES=$(( STRIKES + 1 ))
    write_strike_file "$STRIKES" "$NOW" ""
    warn "SSH validate — $REMOTE_SERVER_NAME auth FAILED (strike $STRIKES/$MAX_STRIKES)"

    if [[ "$STRIKES" -ge "$MAX_STRIKES" ]]; then
        error "SSH auth to $REMOTE_SERVER_NAME broken — ${STRIKES} consecutive failures"
        error "Repair: run 'Partnership/ssh_setup.sh --force' to regenerate and re-copy key"
        notify "SSH auth broken to $REMOTE_SERVER_NAME ($MY_ID) — ${STRIKES} strikes, manual repair needed. Run: Partnership/ssh_setup.sh --force" \
            "SSH Setup" "warning"
        exit 2
    fi

    exit 1
fi

# ==============================================================================================
# ━━━ Setup (default) or --force ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR SSH Key Setup — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo "  Key name: $SSH_KEY_NAME"
echo "  Key path: $SSH_KEY_PATH"

[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no keys will be created or copied"

# ── Key generation ────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Key Generation ━━━"

if [[ -f "$SSH_KEY_PATH" ]] && [[ "$FORCE" == false ]]; then
    local_fp=$(ssh-keygen -lf "$SSH_KEY_PATH" 2>/dev/null || echo "unreadable")
    echo "Key already exists — skipping generation (--force to regenerate)"
    echo "  $local_fp"
else
    if [[ "$FORCE" == true ]] && [[ -f "$SSH_KEY_PATH" ]]; then
        warn "Regenerating key (--force) — existing key will be replaced"
        if [[ "$DRY_RUN" == false ]]; then
            rm -f "$SSH_KEY_PATH" "$SSH_PUB_PATH"
        fi
    fi

    warn "Generating ed25519 keypair: $SSH_KEY_PATH"
    if [[ "$DRY_RUN" == false ]]; then
        ssh-keygen -t ed25519 -N "" -f "$SSH_KEY_PATH" -C "${SSH_KEY_NAME}@${LOCAL_SERVER_NAME}" && \
            log "Keypair generated ✅" || {
            error "Failed to generate keypair"
            exit 1
        }
        chmod 600 "$SSH_KEY_PATH"
        chmod 644 "$SSH_PUB_PATH"
    else
        warn "DRY RUN — would generate: $SSH_KEY_PATH"
    fi
fi

# ── Update conf ───────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Update host${HOST_NUM}.conf ━━━"

if [[ "$DRY_RUN" == false ]]; then
    update_conf_key_path
else
    warn "DRY RUN — would set ${KEY_CONF_VAR}=\"${SSH_KEY_PATH}\" in host${HOST_NUM}.conf"
fi

# ── Copy to remote ────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━"

resolve_remote_ip

if [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — would ssh-copy-id to root@$REMOTE_SERVER"
    warn "DRY RUN — would require password for root@$REMOTE_SERVER if key auth not yet set up"
else
    warn "Installing public key on $REMOTE_SERVER_NAME ($REMOTE_SERVER)..."
    warn "(Password prompt for root@$REMOTE_SERVER is expected on first setup)"

    if ssh-copy-id -i "$SSH_PUB_PATH" -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
        root@"$REMOTE_SERVER" 2>/dev/null; then
        echo "Public key installed on $REMOTE_SERVER_NAME ✅"
    else
        error "ssh-copy-id failed — check that:"
        error "  1. Remote server is reachable: tailscale status"
        error "  2. Password auth is enabled on remote: grep PasswordAuthentication /etc/ssh/sshd_config"
        error "  3. The correct password for root@$REMOTE_SERVER_NAME is used"
        exit 1
    fi
fi

# ── Verify ────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_VERIFY Verify SSH Auth ━━━"

if [[ "$DRY_RUN" == false ]]; then
    if test_ssh_auth "$REMOTE_SERVER"; then
        echo "SSH auth to $REMOTE_SERVER_NAME working ✅"
        # Reset any existing strikes
        if [[ -f "$SSH_STRIKE_FILE" ]]; then
            write_strike_file 0 "" "$(date '+%Y-%m-%d %H:%M:%S')"
        fi
    else
        warn "SSH auth test failed — key may need a moment to propagate"
        warn "Verify manually: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER"
    fi
else
    warn "DRY RUN — would test: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER echo ok"
fi

# ── Summary ───────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY ━━━━━"
echo "  Key:    $SSH_KEY_PATH"
echo "  Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)"
echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
    warn "$ICON_DONE DONE — SSH key ready for rsync automation ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0


The script does two distinct phases:
1. **Local** — keygen + conf update (no network needed)
2. **Remote** — `ssh-copy-id` to partner (needs password or existing key)

For the wizard the problem is phase 2 can't prompt for a password through a web UI. But phase 1 is enough to unblock HOST2's pull — generate the key locally, show the pubkey, give the user a one-liner to paste on HOST1, then the pull button works.

`ssh_setup.sh` doesn't have a `--local-only` flag today. We'd need to add one, then the wizard calls it via an API endpoint:

```
HOST2 wizard flow:
  [Generate Key] button
    → api/setup.php?action=ssh_generate
    → runs ssh_setup.sh --local-only
    → generates keypair, writes HOSTN_SSH_KEY to conf
    → returns pubkey

  Displays:
    Public key: ssh-ed25519 AAAA...
    ┌──────────────────────────────────────────────────────────┐
    │ ssh root@HOST1 "cat >> ~/.ssh/authorized_keys" <<< '...' │
    └──────────────────────────────────────────────────────────┘
    [Copy] button

  [I've added it — Pull config from HOST1 →] button
    → existing pull action (now has the key)
```

For HOST1 the full `ssh_setup.sh` (including remote copy to HOST2) still runs during `partnership_onboard.sh` — nothing changes there. First-run wizard only needs the local half.

Want me to add `--local-only` to `ssh_setup.sh` and wire it into the wizard?


yeah do it


100	    else                  echo '— First Run';
101	  ?>

102
<?php
103	    if ($isConfOnlyFlow)  echo 'master.conf received. Create your local configuration to continue.';
104	    elseif ($isHost2Flow) echo 'HOST1 is configured. Pull their settings to connect.';
105	    else                  echo 'Set up your server before the plugin can start.';
106	  ?>

107
108
109

110
111
112

113
Detecting environment…

114

115
116	    <?php if ($isConfOnlyFlow): ?>
117
118

119
HOST1: <?= htmlspecialchars($masterHost1) ?>

120
This server: <?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?>

121
Creating: <?= htmlspecialchars($myHostId) ?>.conf

122

123	      <button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
124
125	    <?php elseif ($isHost2Flow): ?>
126
127

128	        This server's hostname
129	        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
130
Must match Unraid Settings → Identification exactly

131

132

133	        Your slot
134	        <select id="vv-slot">
135	          <option value="host2">HOST2</option>
136	          <option value="host3">HOST3</option>
137	          <option value="host4">HOST4</option>
138	        </select>
139

140

141	        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

142	        Requires SSH keys to be exchanged — run Partnership/ssh_setup.sh first if keys aren't set up.
143

144	      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
145
146	    <?php else: ?>
147
148

149	        This server's hostname
150	        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
151
Must match Unraid Settings → Identification exactly (case-sensitive)

152

153
154

155	      Server role
156

157

158	          Primary
HOST1 · first server
159

else                  echo '— First Run';
  ?>

<?php
    if ($isConfOnlyFlow)  echo 'master.conf received. Create your local configuration to continue.';
    elseif ($isHost2Flow) echo 'HOST1 is configured. Pull their settings to connect.';
    else                  echo 'Set up your server before the plugin can start.';
  ?>

Detecting environment…

    <?php if ($isConfOnlyFlow): ?>

HOST1: <?= htmlspecialchars($masterHost1) ?>

This server: <?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?>

Creating: <?= htmlspecialchars($myHostId) ?>.conf

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>

    <?php elseif ($isHost2Flow): ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly

        Your slot
        <select id="vv-slot">
          <option value="host2">HOST2</option>
          <option value="host3">HOST3</option>
          <option value="host4">HOST4</option>
        </select>

        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

        Requires SSH keys to be exchanged — run Partnership/ssh_setup.sh first if keys aren't set up.

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>

    <?php else: ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly (case-sensitive)

      Server role

          Primary
HOST1 · first server


Good, I have what I need. Making all three changes in parallel:


MODE="setup"
FORCE=false
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --force)    FORCE=true ;;
        --validate) MODE="validate" ;;
        --status)   MODE="status" ;;
        *)          FILTERED_ARGS+=("$arg") ;;
    esac
done

MODE="setup"
FORCE=false
LOCAL_ONLY=false
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --force)      FORCE=true ;;
        --validate)   MODE="validate" ;;
        --status)     MODE="status" ;;
        --local-only) LOCAL_ONLY=true ;;
        *)            FILTERED_ARGS+=("$arg") ;;
    esac
done


The file /boot/config/plugins/varaverk/Partnership/ssh_setup.sh has been updated successfully. (file state is current in your context — no need to Read it back)

MODE="setup"
FORCE=false
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --force)    FORCE=true ;;
        --validate) MODE="validate" ;;
        --status)   MODE="status" ;;
        *)          FILTERED_ARGS+=("$arg") ;;
    esac
done

MODE="setup"
FORCE=false
LOCAL_ONLY=false
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --force)      FORCE=true ;;
        --validate)   MODE="validate" ;;
        --status)     MODE="status" ;;
        --local-only) LOCAL_ONLY=true ;;
        *)            FILTERED_ARGS+=("$arg") ;;
    esac
done

# ── Parse --force before parse_args ───────────────────────────────────────────────────────────

-        *)          FILTERED_ARGS+=("$arg") ;;

+        --validate)   MODE="validate" ;;

+        --local-only) LOCAL_ONLY=true ;;

+        *)            FILTERED_ARGS+=("$arg") ;;


# ── Copy to remote ────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━"

resolve_remote_ip

if [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — would ssh-copy-id to root@$REMOTE_SERVER"
    warn "DRY RUN — would require password for root@$REMOTE_SERVER if key auth not yet set up"
else
    warn "Installing public key on $REMOTE_SERVER_NAME ($REMOTE_SERVER)..."
    warn "(Password prompt for root@$REMOTE_SERVER is expected on first setup)"

    if ssh-copy-id -i "$SSH_PUB_PATH" -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
        root@"$REMOTE_SERVER" 2>/dev/null; then
        echo "Public key installed on $REMOTE_SERVER_NAME ✅"
    else
        error "ssh-copy-id failed — check that:"
        error "  1. Remote server is reachable: tailscale status"
        error "  2. Password auth is enabled on remote: grep PasswordAuthentication /etc/ssh/sshd_config"
        error "  3. The correct password for root@$REMOTE_SERVER_NAME is used"
        exit 1
    fi
fi

# ── Verify ────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_VERIFY Verify SSH Auth ━━━"

if [[ "$DRY_RUN" == false ]]; then
    if test_ssh_auth "$REMOTE_SERVER"; then
        echo "SSH auth to $REMOTE_SERVER_NAME working ✅"
        # Reset any existing strikes
        if [[ -f "$SSH_STRIKE_FILE" ]]; then
            write_strike_file 0 "" "$(date '+%Y-%m-%d %H:%M:%S')"
        fi
    else
        warn "SSH auth test failed — key may need a moment to propagate"
        warn "Verify manually: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER"
    fi
else
    warn "DRY RUN — would test: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER echo ok"
fi

# ── Summary ───────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY ━━━━━"
echo "  Key:    $SSH_KEY_PATH"
echo "  Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)"
echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
    warn "$ICON_DONE DONE — SSH key ready for rsync automation ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0

# ── Copy to remote ────────────────────────────────────────────────────────────────────────────
if [[ "$LOCAL_ONLY" == true ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY (local) ━━━━━"
    echo "  Key:    $SSH_KEY_PATH"
    echo "  Pubkey: $SSH_PUB_PATH"
    echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
    echo ""
    warn "Local setup complete — copy public key to remote manually:"
    warn "  ssh-copy-id -i $SSH_PUB_PATH root@<remote>"
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

echo ""
echo "━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━"

resolve_remote_ip

if [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — would ssh-copy-id to root@$REMOTE_SERVER"
    warn "DRY RUN — would require password for root@$REMOTE_SERVER if key auth not yet set up"
else
    warn "Installing public key on $REMOTE_SERVER_NAME ($REMOTE_SERVER)..."
    warn "(Password prompt for root@$REMOTE_SERVER is expected on first setup)"

    if ssh-copy-id -i "$SSH_PUB_PATH" -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
        root@"$REMOTE_SERVER" 2>/dev/null; then
        echo "Public key installed on $REMOTE_SERVER_NAME ✅"
    else
        error "ssh-copy-id failed — check that:"
        error "  1. Remote server is reachable: tailscale status"
        error "  2. Password auth is enabled on remote: grep PasswordAuthentication /etc/ssh/sshd_config"
        error "  3. The correct password for root@$REMOTE_SERVER_NAME is used"
        exit 1
    fi
fi

# ── Verify ────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_VERIFY Verify SSH Auth ━━━"

if [[ "$DRY_RUN" == false ]]; then
    if test_ssh_auth "$REMOTE_SERVER"; then
        echo "SSH auth to $REMOTE_SERVER_NAME working ✅"
        if [[ -f "$SSH_STRIKE_FILE" ]]; then
            write_strike_file 0 "" "$(date '+%Y-%m-%d %H:%M:%S')"
        fi
    else
        warn "SSH auth test failed — key may need a moment to propagate"
        warn "Verify manually: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER"
    fi
else
    warn "DRY RUN — would test: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER echo ok"
fi

# ── Summary ───────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY ━━━━━"
echo "  Key:    $SSH_KEY_PATH"
echo "  Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)"
echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
    warn "$ICON_DONE DONE — SSH key ready for rsync automation ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0


# ── Copy to remote ────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━"

resolve_remote_ip

if [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — would ssh-copy-id to root@$REMOTE_SERVER"
    warn "DRY RUN — would require password for root@$REMOTE_SERVER if key auth not yet set up"
else
    warn "Installing public key on $REMOTE_SERVER_NAME ($REMOTE_SERVER)..."
    warn "(Password prompt for root@$REMOTE_SERVER is expected on first setup)"

    if ssh-copy-id -i "$SSH_PUB_PATH" -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
        root@"$REMOTE_SERVER" 2>/dev/null; then
        echo "Public key installed on $REMOTE_SERVER_NAME ✅"
    else
        error "ssh-copy-id failed — check that:"
        error "  1. Remote server is reachable: tailscale status"
        error "  2. Password auth is enabled on remote: grep PasswordAuthentication /etc/ssh/sshd_config"
        error "  3. The correct password for root@$REMOTE_SERVER_NAME is used"
        exit 1
    fi
fi

# ── Verify ────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_VERIFY Verify SSH Auth ━━━"

if [[ "$DRY_RUN" == false ]]; then
    if test_ssh_auth "$REMOTE_SERVER"; then
        echo "SSH auth to $REMOTE_SERVER_NAME working ✅"
        # Reset any existing strikes
        if [[ -f "$SSH_STRIKE_FILE" ]]; then
            write_strike_file 0 "" "$(date '+%Y-%m-%d %H:%M:%S')"
        fi
    else
        warn "SSH auth test failed — key may need a moment to propagate"
        warn "Verify manually: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER"
    fi
else
    warn "DRY RUN — would test: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER echo ok"
fi

# ── Summary ───────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY ━━━━━"
echo "  Key:    $SSH_KEY_PATH"
echo "  Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)"
echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
    warn "$ICON_DONE DONE — SSH key ready for rsync automation ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0

# ── Copy to remote ────────────────────────────────────────────────────────────────────────────
if [[ "$LOCAL_ONLY" == true ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY (local) ━━━━━"
    echo "  Key:    $SSH_KEY_PATH"
    echo "  Pubkey: $SSH_PUB_PATH"
    echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
    echo ""
    warn "Local setup complete — copy public key to remote manually:"
    warn "  ssh-copy-id -i $SSH_PUB_PATH root@<remote>"
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

echo ""
echo "━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━"

resolve_remote_ip

if [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — would ssh-copy-id to root@$REMOTE_SERVER"
    warn "DRY RUN — would require password for root@$REMOTE_SERVER if key auth not yet set up"
else
    warn "Installing public key on $REMOTE_SERVER_NAME ($REMOTE_SERVER)..."
    warn "(Password prompt for root@$REMOTE_SERVER is expected on first setup)"

    if ssh-copy-id -i "$SSH_PUB_PATH" -o ConnectTimeout="${SSH_TIMEOUT:-15}" \
        root@"$REMOTE_SERVER" 2>/dev/null; then
        echo "Public key installed on $REMOTE_SERVER_NAME ✅"
    else
        error "ssh-copy-id failed — check that:"
        error "  1. Remote server is reachable: tailscale status"
        error "  2. Password auth is enabled on remote: grep PasswordAuthentication /etc/ssh/sshd_config"
        error "  3. The correct password for root@$REMOTE_SERVER_NAME is used"
        exit 1
    fi
fi

# ── Verify ────────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_VERIFY Verify SSH Auth ━━━"

if [[ "$DRY_RUN" == false ]]; then
    if test_ssh_auth "$REMOTE_SERVER"; then
        echo "SSH auth to $REMOTE_SERVER_NAME working ✅"
        if [[ -f "$SSH_STRIKE_FILE" ]]; then
            write_strike_file 0 "" "$(date '+%Y-%m-%d %H:%M:%S')"
        fi
    else
        warn "SSH auth test failed — key may need a moment to propagate"
        warn "Verify manually: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER"
    fi
else
    warn "DRY RUN — would test: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER echo ok"
fi

# ── Summary ───────────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY ━━━━━"
echo "  Key:    $SSH_KEY_PATH"
echo "  Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)"
echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
    warn "$ICON_DONE DONE — SSH key ready for rsync automation ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0

+    echo "━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY (local) ━━━━━"

+    echo "  Conf:   ${KEY_CONF_VAR} in host${HOST_NUM}.conf"

+    warn "Local setup complete — copy public key to remote manually:"

+    warn "  ssh-copy-id -i $SSH_PUB_PATH root@<remote>"

+    echo "━━━━━━━━━━━━━━━━━━━━━━━"


Now add the `ssh_generate` action to `api/setup.php`:


// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {

// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
if ($action === 'ssh_generate') {
    $script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
    // Derive pubkey path from hostname
    $hostname  = vv_get_hostname();
    $shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
    $pubPath   = '/root/.ssh/' . $shortName . '_rsync_automation.pub';
    $pubKey    = trim(@file_get_contents($pubPath) ?: '');
    echo json_encode([
        'ok'     => $rc === 0 && !empty($pubKey),
        'pubkey' => $pubKey,
        'error'  => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,
    ]);
    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {


The file /boot/config/plugins/varaverk/Plugin/unraid/api/setup.php has been updated successfully. (file state is current in your context — no need to Read it back)

// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
if ($action === 'ssh_generate') {
    $script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
    // Derive pubkey path from hostname
    $hostname  = vv_get_hostname();
    $shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
    $pubPath   = '/root/.ssh/' . $shortName . '_rsync_automation.pub';
    $pubKey    = trim(@file_get_contents($pubPath) ?: '');
    echo json_encode([
        'ok'     => $rc === 0 && !empty($pubKey),
        'pubkey' => $pubKey,
        'error'  => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,
    ]);
    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
    ? trim($_GET['action'] ?? '')
    : trim($_POST['action'] ?? 'save');

// ── GET: detect environment ────────────────────────────────────────────────────────────────────
if ($action === 'detect') {
    $bootPart  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
    $bootDisk  = $bootPart
        ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')
        : '';
    $transport = $bootDisk
        ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))
        : 'unknown';

    $isUsb = ($transport === 'usb');

    preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);

    echo json_encode([
        'ok'           => true,
        'hostname'     => vv_get_hostname(),
        'unraid_ver'   => $vm[1] ?? 'unknown',
        'transport'    => $transport,
        'boot_device'  => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
        'mode'         => $isUsb ? 'flash' : 'internal',
        'scripts_dir'  => SCRIPTS_DIR,
    ]);
    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {
    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
    $lines = array_values(array_filter(array_map('trim', $out)));
    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
if ($action === 'pull') {
    $host1Hostname = trim($_POST['host1_hostname'] ?? '');
    $mySlot        = trim($_POST['my_slot'] ?? 'host2');
    $myHostname    = trim($_POST['my_hostname'] ?? '');

    if (!$host1Hostname) {
        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
        exit;
    }
    if (!preg_match('/^host\d+$/', $mySlot)) {
        echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
        exit;
    }

    $hostId    = strtoupper($mySlot);
    $hostIdLow = strtolower($mySlot);

    // Derive SSH key path from this server's hostname
    $sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
    $sshKey   = '/root/.ssh/' . $sshOwner . '_rsync_automation';

    if (!file_exists($sshKey)) {
        echo json_encode(['ok' => false, 'error' =>
            "SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
        exit;
    }

    // Resolve HOST1 Tailscale IP
    $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
    if (!$ip) {
        echo json_encode(['ok' => false, 'error' =>
            "Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
        exit;
    }

    // Get HOST1's SCRIPTS_DIR from their varaverk.cfg
    $sshBase   = 'ssh -i ' . escapeshellarg($sshKey)
               . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
    $remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
    preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
    $remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';

    // SCP master.conf from HOST1
    $localMaster = CONF_DIR . '/master.conf';
    $src  = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
    $cmd  = 'scp -i ' . escapeshellarg($sshKey)
          . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
          . ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
    exec($cmd, $out, $rc);
    if ($rc !== 0) {
        echo json_encode(['ok' => false, 'error' =>
            'SCP failed: ' . implode('; ', $out) .
            ' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
        exit;
    }

    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $bootPart2  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
            $bootDisk2  = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
            $transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
            $storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKey . '"', $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
                '${1}' . $storageInternal2, $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'api_key'  => $apiKeyResult,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;
}

// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
$host1      = trim($_POST['host1']      ?? '');
$host2      = trim($_POST['host2']      ?? '');
$mySlot     = trim($_POST['my_slot']    ?? 'host1');
$myHostname = trim($_POST['my_hostname'] ?? '');

if (empty($host1)) {
    echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
    exit;
}
if (!preg_match('/^host\d+$/', $mySlot)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
    exit;
}

// Write HOST1 / HOST2 into master.conf
$master = vv_read_conf_raw('master.conf');
if ($master === '') {
    echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
    exit;
}

$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);

$slotNum = (int) preg_replace('/\D/', '', $mySlot);
if ($slotNum > 2 && !empty($myHostname)) {
    $hostKey = 'HOST' . $slotNum;
    if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
        $master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
            '$1' . "\n    {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
    } else {
        $master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
            '${1}"' . addslashes($myHostname) . '"', $master);
    }
}

if (!vv_write_conf_raw('master.conf', $master)) {
    echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
    exit;
}

// Create host*.conf from template
$hostId    = strtoupper($mySlot);
$hostIdLow = strtolower($mySlot);
$confFile  = $hostIdLow . '.conf';

if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';

        // Auto-detect storage mode from boot device transport
        $bootPart  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
        $bootDisk  = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
        $transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
        $storageInternal = ($transport !== 'usb') ? 'true' : 'false';

        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
            '${1}' . $storageInternal, $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }
    }
}

// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

// Auto-create Unraid API key and write into the fresh conf
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

echo json_encode([
    'ok'       => true,
    'host_id'  => $hostId,
    'api_key'  => $apiKeyResult,
    'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);

+// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────

+    $script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';

+        echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);

+    exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);

+    // Derive pubkey path from hostname

+    $shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));

+    $pubPath   = '/root/.ssh/' . $shortName . '_rsync_automation.pub';

+    $pubKey    = trim(@file_get_contents($pubPath) ?: '');

+        'error'  => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,


Now update the HOST2 section of the wizard:


<?php elseif ($isHost2Flow): ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly

        Your slot
        <select id="vv-slot">
          <option value="host2">HOST2</option>
          <option value="host3">HOST3</option>
          <option value="host4">HOST4</option>
        </select>

        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

        Requires SSH keys to be exchanged — run Partnership/ssh_setup.sh first if keys aren't set up.

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>

<?php elseif ($isHost2Flow): ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly

        Your slot
        <select id="vv-slot">
          <option value="host2">HOST2</option>
          <option value="host3">HOST3</option>
          <option value="host4">HOST4</option>
        </select>

        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

SSH key

Required to pull config from HOST1

        <button class="vv-btn vv-btn-sm" id="vv-ssh-gen-btn" onclick="vvGenerateSshKey(this)">Generate key</button>

Add to HOST1's authorized_keys — paste this on HOST1:

            <textarea id="vv-ssh-cmd" readonly rows="2"
              style="width:100%;box-sizing:border-box;background:#060606;border:1px solid #2a2a2a;color:#777;font-family:monospace;font-size:10px;padding:7px 36px 7px 8px;border-radius:2px;resize:none;line-height:1.5;"></textarea>
            <button onclick="vvCopyCmd(this)" title="Copy"
              style="position:absolute;right:6px;top:6px;background:none;border:none;color:#555;cursor:pointer;font-size:12px;padding:0;">⎘</button>

Once added, the pull button below will work.

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()" disabled>Pull configuration from HOST1 →</button>


<?php elseif ($isHost2Flow): ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly

        Your slot
        <select id="vv-slot">
          <option value="host2">HOST2</option>
          <option value="host3">HOST3</option>
          <option value="host4">HOST4</option>
        </select>

        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

        Requires SSH keys to be exchanged — run Partnership/ssh_setup.sh first if keys aren't set up.

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>

<?php elseif ($isHost2Flow): ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly

        Your slot
        <select id="vv-slot">
          <option value="host2">HOST2</option>
          <option value="host3">HOST3</option>
          <option value="host4">HOST4</option>
        </select>

        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

SSH key

Required to pull config from HOST1

        <button class="vv-btn vv-btn-sm" id="vv-ssh-gen-btn" onclick="vvGenerateSshKey(this)">Generate key</button>

Add to HOST1's authorized_keys — paste this on HOST1:

            <textarea id="vv-ssh-cmd" readonly rows="2"
              style="width:100%;box-sizing:border-box;background:#060606;border:1px solid #2a2a2a;color:#777;font-family:monospace;font-size:10px;padding:7px 36px 7px 8px;border-radius:2px;resize:none;line-height:1.5;"></textarea>
            <button onclick="vvCopyCmd(this)" title="Copy"
              style="position:absolute;right:6px;top:6px;background:none;border:none;color:#555;cursor:pointer;font-size:12px;padding:0;">⎘</button>

Once added, the pull button below will work.

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()" disabled>Pull configuration from HOST1 →</button>

-      <div style="font-size:11px;color:#555;margin-bottom:16px;">

-        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

-        Requires SSH keys to be exchanged — run Partnership/ssh_setup.sh first if keys aren't set up.

+      <div style="font-size:11px;color:#555;margin-bottom:14px;">

+        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

-      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>

+      <div id="vv-ssh-block" style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:12px 14px;margin-bottom:16px;">

+        <button class="vv-btn vv-btn-sm" id="vv-ssh-gen-btn" onclick="vvGenerateSshKey(this)">Generate key</button>

+        <div id="vv-ssh-pubkey-block" style="display:none;margin-top:12px;">

+
Add to HOST1's authorized_keys — paste this on HOST1:

+          <div style="position:relative;">

+            <textarea id="vv-ssh-cmd" readonly rows="2"

+              style="width:100%;box-sizing:border-box;background:#060606;border:1px solid #2a2a2a;color:#777;font-family:monospace;font-size:10px;padding:7px 36px 7px 8px;border-radius:2px;resize:none;line-height:1.5;"></textarea>

+            <button onclick="vvCopyCmd(this)" title="Copy"

+              style="position:absolute;right:6px;top:6px;background:none;border:none;color:#555;cursor:pointer;font-size:12px;padding:0;">⎘</button>

+
Once added, the pull button below will work.

+      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()" disabled>Pull configuration from HOST1 →</button>


Now add the `vvGenerateSshKey` and `vvCopyCmd` JS functions:


// ── Navigation ────────────────────────────────────────────────────────────────
function vvGoScheduler(e) {

// ── SSH key generation (HOST2 flow) ──────────────────────────────────────────
function vvGenerateSshKey(btn) {
  btn.disabled = true; btn.textContent = '⟳ Generating…';
  const state = document.getElementById('vv-ssh-state');
  fetch('/plugins/varaverk/api/setup.php', {
    method: 'POST',
    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
    body: new URLSearchParams({action: 'ssh_generate'})
  }).then(r => r.json()).then(d => {
    if (d.ok && d.pubkey) {
      state.textContent = '✓ Key generated';
      state.style.color = '#4a8';
      btn.textContent = 'Regenerate';
      btn.disabled = false;
      const host1 = <?= json_encode($host1FromState) ?>;
      const cmd = `ssh root@${host1} "cat >> ~/.ssh/authorized_keys" <<< '${d.pubkey}'`;
      document.getElementById('vv-ssh-cmd').value = cmd;
      document.getElementById('vv-ssh-pubkey-block').style.display = 'block';
      document.getElementById('vv-main-btn').disabled = false;
    } else {
      state.textContent = '✗ ' + (d.error || 'Key generation failed');
      state.style.color = '#a44';
      btn.disabled = false; btn.textContent = 'Retry';
    }
  }).catch(() => {
    state.textContent = '✗ Request failed';
    state.style.color = '#a44';
    btn.disabled = false; btn.textContent = 'Retry';
  });
}

function vvCopyCmd(btn) {
  const ta = document.getElementById('vv-ssh-cmd');
  navigator.clipboard?.writeText(ta.value).then(() => {
    const orig = btn.textContent; btn.textContent = '✓';
    setTimeout(() => btn.textContent = orig, 1200);
  });
}

// ── Navigation ────────────────────────────────────────────────────────────────
function vvGoScheduler(e) {


// ── SSH key generation (HOST2 flow) ──────────────────────────────────────────
function vvGenerateSshKey(btn) {
  btn.disabled = true; btn.textContent = '⟳ Generating…';
  const state = document.getElementById('vv-ssh-state');
  fetch('/plugins/varaverk/api/setup.php', {
    method: 'POST',
    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
    body: new URLSearchParams({action: 'ssh_generate'})
  }).then(r => r.json()).then(d => {
    if (d.ok && d.pubkey) {
      state.textContent = '✓ Key generated';
      state.style.color = '#4a8';
      btn.textContent = 'Regenerate';
      btn.disabled = false;
      const host1 = <?= json_encode($host1FromState) ?>;
      const cmd = `ssh root@${host1} "cat >> ~/.ssh/authorized_keys" <<< '${d.pubkey}'`;
      document.getElementById('vv-ssh-cmd').value = cmd;
      document.getElementById('vv-ssh-pubkey-block').style.display = 'block';
      document.getElementById('vv-main-btn').disabled = false;
    } else {
      state.textContent = '✗ ' + (d.error || 'Key generation failed');
      state.style.color = '#a44';
      btn.disabled = false; btn.textContent = 'Retry';
    }
  }).catch(() => {
    state.textContent = '✗ Request failed';
    state.style.color = '#a44';
    btn.disabled = false; btn.textContent = 'Retry';
  });
}

function vvCopyCmd(btn) {
  const ta = document.getElementById('vv-ssh-cmd');
  navigator.clipboard?.writeText(ta.value).then(() => {
    const orig = btn.textContent; btn.textContent = '✓';
    setTimeout(() => btn.textContent = orig, 1200);
  });
}

// ── Navigation ────────────────────────────────────────────────────────────────
function vvGoScheduler(e) {

+// ── SSH key generation (HOST2 flow) ──────────────────────────────────────────

+  btn.disabled = true; btn.textContent = '⟳ Generating…';

+  const state = document.getElementById('vv-ssh-state');

+  fetch('/plugins/varaverk/api/setup.php', {

+    headers: {'Content-Type': 'application/x-www-form-urlencoded'},

+    body: new URLSearchParams({action: 'ssh_generate'})

+      state.textContent = '✓ Key generated';

+      const host1 = <?= json_encode($host1FromState) ?>;

+      const cmd = `ssh root@${host1} "cat >> ~/.ssh/authorized_keys" <<< '${d.pubkey}'`;

+      document.getElementById('vv-ssh-cmd').value = cmd;

+      document.getElementById('vv-ssh-pubkey-block').style.display = 'block';

+      document.getElementById('vv-main-btn').disabled = false;

+      state.textContent = '✗ ' + (d.error || 'Key generation failed');

+      btn.disabled = false; btn.textContent = 'Retry';

+    state.textContent = '✗ Request failed';

+  const ta = document.getElementById('vv-ssh-cmd');

+  navigator.clipboard?.writeText(ta.value).then(() => {

+    const orig = btn.textContent; btn.textContent = '✓';

+    setTimeout(() => btn.textContent = orig, 1200);


Let me do a quick sanity check on the ssh_setup.sh change:


Bash

bash /boot/config/plugins/varaverk/Partnership/ssh_setup.sh --local-only --dry-run 2>&1

Test --local-only --dry-run flag


━━━ ⚙ SSH Key Setup — HOST1 (unRAID-Gmer4Lfe) — 2026-06-05 22:29:48 ━━━

  Key name: gmer4lfe_rsync_automation
  Key path: /root/.ssh/gmer4lfe_rsync_automation
⚠  [WARN]  DRY RUN — no keys will be created or copied

━━━ ⚙ Key Generation ━━━
Key already exists — skipping generation (--force to regenerate)
  256 SHA256:Z7rXqvoWS3tf9MmDL82qJ9h8Uh3znQhKyOlwpUUrbTM gmer4lfe_rsync_automation@unRAID-Gmer4Lfe (ED25519)

━━━ ⚙ Update host1.conf ━━━
⚠  [WARN]  DRY RUN — would set HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation" in host1.conf

━━━━━ 📋 SSH SETUP SUMMARY (local) ━━━━━
  Key:    /root/.ssh/gmer4lfe_rsync_automation
  Pubkey: /root/.ssh/gmer4lfe_rsync_automation.pub
  Conf:   HOST1_SSH_KEY in host1.conf

⚠  [WARN]  Local setup complete — copy public key to remote manually:
⚠  [WARN]    ssh-copy-id -i /root/.ssh/gmer4lfe_rsync_automation.pub root@<remote>
━━━━━━━━━━━━━━━━━━━━━━━


Works cleanly — exits at the local summary, never touches the remote. Here's what was built across the three files:

**`ssh_setup.sh`** — `--local-only` flag exits after keygen + conf update, before the `ssh-copy-id` and verify steps. Prints the pubkey path and a manual copy command.

**`api/setup.php`** — new `ssh_generate` action runs `ssh_setup.sh --local-only`, reads the generated `.pub` file, returns `{ok, pubkey}`.

**`pages/setup.php`** — HOST2 flow now has an SSH key block before the pull button:
- "Generate key" button → calls `ssh_generate`, shows the pubkey as a ready-to-paste heredoc command targeting HOST1's `authorized_keys`
- Copy button (⎘) copies the command to clipboard
- Pull button starts **disabled**, only enables after key generation succeeds
- Regenerate is available if they need a fresh key

HOST1 flow unchanged — its full SSH setup (including remote copy) still runs through `partnership_onboard.sh` as before.


ok so whats the check list look like aftr the wizard


Walking through each item's state after a typical HOST1 wizard run:

```
✓  Server identity     HOST1 set in master.conf — wizard wrote it
✓  Host configuration  host1.conf exists — wizard created it
✓  Unraid API key      auto-created during save
✗  SSH key             path written to conf, but key file never generated
✓  Auto-populate       services detected (if arr is running)
✗  Partnership         "Not started" if HOST2 was set — action: partnership tab
```

The SSH key item is the gap. The wizard writes the path to conf (`/root/.ssh/gmer4lfe_rsync_automation`) but never actually runs `ssh-keygen` for HOST1. HOST2 gets the generate button — HOST1 doesn't.

The fix is the same thing we did for the API key: call `ssh_setup.sh --local-only` as part of the `save` action, right after the host.conf is created. It's idempotent, local-only, and the path is already being derived from the hostname at that point. Then after the wizard the checklist would be:

```
✓  Server identity
✓  Host configuration
✓  Unraid API key
✓  SSH key
✓  Auto-populate
✗  Partnership         (only remaining — intentional, needs both servers)
```

Want me to add that?


One edit to `api/setup.php` — add `ssh_setup.sh --local-only` to both `save` and `pull` right alongside the API key call:


Edit

// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

// Auto-create Unraid API key and write into the fresh conf
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
if (file_exists($sshScript)) {
    exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
}

// Auto-create Unraid API key and write into the fresh conf
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);


// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

// Auto-create Unraid API key and write into the fresh conf
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
if (file_exists($sshScript)) {
    exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
}

// Auto-create Unraid API key and write into the fresh conf
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

+// Auto-generate SSH keypair (local only — remote copy happens during onboarding)

+$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';

+    exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');


    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'api_key'  => $apiKeyResult,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;

    if (file_exists($sshScript)) {
        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
    }
    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'api_key'  => $apiKeyResult,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;


    if (file_exists($sshScript)) {
        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
    }
    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'api_key'  => $apiKeyResult,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;

+        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');


The `pull` action references `$sshScript` which is defined just before it in the file — let me verify that variable is in scope there:


53	    exit;
54	}
55
56	// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
57	if ($action === 'populate') {
58	    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
59	    if (!file_exists($script)) {
60	        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
61	        exit;
62	    }
63	    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
64	    $lines = array_values(array_filter(array_map('trim', $out)));
65	    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
66	    exit;
67	}
68
69	if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
70	    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
71	    exit;
72	}
73
74	// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
75	if ($action === 'pull') {
76	    $host1Hostname = trim($_POST['host1_hostname'] ?? '');
77	    $mySlot        = trim($_POST['my_slot'] ?? 'host2');
78	    $myHostname    = trim($_POST['my_hostname'] ?? '');
79
80	    if (!$host1Hostname) {
81	        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
82	        exit;

    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {
    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
    $lines = array_values(array_filter(array_map('trim', $out)));
    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
if ($action === 'pull') {
    $host1Hostname = trim($_POST['host1_hostname'] ?? '');
    $mySlot        = trim($_POST['my_slot'] ?? 'host2');
    $myHostname    = trim($_POST['my_hostname'] ?? '');

    if (!$host1Hostname) {
        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
        exit;


125	        echo json_encode(['ok' => false, 'error' =>
126	            'SCP failed: ' . implode('; ', $out) .
127	            ' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
128	        exit;
129	    }
130
131	    // Create host conf from template if it doesn't exist
132	    $confFile = $hostIdLow . '.conf';
133	    if (!file_exists(CONF_DIR . '/' . $confFile)) {
134	        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
135	        if ($template) {
136	            $bootPart2  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
137	            $bootDisk2  = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
138	            $transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
139	            $storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
140	            $conf = str_replace('HOSTN', $hostId,    $template);
141	            $conf = str_replace('hostn', $hostIdLow, $conf);
142	            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
143	                '${1}"' . $sshKey . '"', $conf);
144	            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',

        echo json_encode(['ok' => false, 'error' =>
            'SCP failed: ' . implode('; ', $out) .
            ' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
        exit;
    }

    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $bootPart2  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
            $bootDisk2  = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
            $transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
            $storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKey . '"', $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',


144	            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
145	                '${1}' . $storageInternal2, $conf);
146	            vv_write_conf_raw($confFile, $conf);
147	        }
148	    }
149
150	    if (file_exists($sshScript)) {
151	        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
152	    }
153	    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
154
155	    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
156	                      'api_key'  => $apiKeyResult,
157	                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
158	    exit;
159	}
160
161	// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
162	$host1      = trim($_POST['host1']      ?? '');
163	$host2      = trim($_POST['host2']      ?? '');

            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
                '${1}' . $storageInternal2, $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

    if (file_exists($sshScript)) {
        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
    }
    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'api_key'  => $apiKeyResult,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;
}

// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
$host1      = trim($_POST['host1']      ?? '');
$host2      = trim($_POST['host2']      ?? '');


`$sshScript` is defined inside the `save` block but the `pull` block runs and exits before reaching it. Need to define it once at the top, before both actions:


if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────


if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────

$host1Hostname = trim($_POST['host1_hostname'] ?? '');


Now remove the duplicate definition that got added inside the `save` block:


// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
if (file_exists($sshScript)) {

// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
if (file_exists($sshScript)) {


// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
if (file_exists($sshScript)) {

// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
if (file_exists($sshScript)) {

-$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';

exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');


Quick verify the structure is clean:


Bash

grep -n "sshScript" /boot/config/plugins/varaverk/Plugin/unraid/api/setup.php

Check sshScript references in setup.php


74:$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
152:    if (file_exists($sshScript)) {
153:        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
239:if (file_exists($sshScript)) {
240:    exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');


Defined once at line 74, used at lines 152 (pull) and 239 (save). Both in scope. Done.

After the wizard the checklist will be:

```
✓  Server identity
✓  Host configuration
✓  Unraid API key
✓  SSH key
✓  Auto-populate       (if arr services up)
✗  Partnership         intentional — needs both servers ready
```

The only ✗ left is partnership, which can't be automated in the wizard by definition — it requires both servers to be independently set up first.


so the pull in the wizard... isnt that handled on partnership page, would that all be better in the wizard or as a part of the check list


Yeah, it doesn't belong in the wizard. The wizard is identity — who is this server, what slot, generate its key. The pull is a join action — it's partnership, not identity.

Problems with it in the wizard:
- HOST2 might open the plugin before HOST1 has run their wizard — state file doesn't exist yet, they get the wrong flow
- If the pull fails they're stuck mid-wizard with no clean retry path
- The SSH key block in the HOST2 wizard is now redundant — the key gets generated by the save action for everyone

Cleaner split:

**Wizard** — same flow for all hosts:
- Detect environment, set identity, generate SSH key, generate API key, populate
- No HOST2 branch at all

**Checklist** — gets a `master_conf` item:
```
✗  master.conf    HOST1 not set — [Pull from HOST1] button
```
The pull button is inline, can retry, shows result in place. Or it lives on the partnership page with the rest of the join flow — either works, but checklist keeps it visible without requiring navigation.

The `$isHost2Flow` and `$isConfOnlyFlow` branches in the wizard can be removed entirely. `vv_push_setup_state()` / the state file detection was always fragile for the same reason — it assumes HOST1 has already run and successfully pushed to HOST2, which requires SSH to be set up first, which is the thing we're trying to set up.

Want me to strip the HOST2 pull branch from the wizard and add the `master_conf` item to the checklist with an inline pull action?


yeah do it,


This touches three files. Let me read the current state of `pages/setup.php` fully first, then rewrite it and patch the other two.